主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
474 lines
20 KiB
Go
474 lines
20 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。
|
||
//
|
||
// 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、
|
||
// 两天一条没应用。本文件把候选真正落地,五条不可走样的口径:
|
||
//
|
||
// 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活
|
||
// id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删
|
||
// (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、
|
||
// **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert);
|
||
// 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` +
|
||
// RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸
|
||
// —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用;
|
||
// 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把
|
||
// archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending;
|
||
// 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑
|
||
// (design/23 §9 的失败隔离同口径);
|
||
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
|
||
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
|
||
// 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go);
|
||
// 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」,
|
||
// 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。
|
||
//
|
||
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
|
||
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
|
||
|
||
// TidyApplyStatus 是一条候选应用结果的状态类别。
|
||
type TidyApplyStatus string
|
||
|
||
const (
|
||
// TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。
|
||
TidyApplyApplied TidyApplyStatus = "applied"
|
||
// TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、
|
||
// 动作不可应用、候选形状不完整。
|
||
TidyApplySkipped TidyApplyStatus = "skipped"
|
||
// TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。
|
||
TidyApplyFailed TidyApplyStatus = "failed"
|
||
)
|
||
|
||
// 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。
|
||
const (
|
||
// tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。
|
||
tidySkipAlreadyDecided = "already_decided"
|
||
// tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。
|
||
tidySkipNotApplied = "not_applied"
|
||
// tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。
|
||
tidySkipActionNotApplyable = "action_not_applyable"
|
||
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
|
||
tidySkipSourceNotActive = "source_not_active"
|
||
// tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源
|
||
// (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御:
|
||
// 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」
|
||
// 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。
|
||
tidySkipSourceRestricted = "restricted_source"
|
||
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
|
||
tidySkipSourceMissing = "source_missing"
|
||
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
|
||
tidySkipPartitionMismatch = "partition_mismatch"
|
||
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
|
||
tidySkipInvalidCandidate = "invalid_candidate"
|
||
)
|
||
|
||
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
|
||
// 调用方用 errors.Is 判断。
|
||
var (
|
||
// ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。
|
||
ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found")
|
||
// ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 ——
|
||
// 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。
|
||
// (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。)
|
||
ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found")
|
||
// errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中,
|
||
// 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。
|
||
errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided")
|
||
// errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册
|
||
// (已被别的动作改过)。同样不越过本包。
|
||
errTidyItemNotActive = errors.New("memory/tidy: item is not active")
|
||
)
|
||
|
||
// TidyApplyOptions 是一次应用/回滚的请求口径。
|
||
type TidyApplyOptions struct {
|
||
// Actor 是决定者标签,落 memory_tidy_candidates.decided_by。
|
||
Actor string
|
||
// Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit,
|
||
// 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。
|
||
Limit int
|
||
}
|
||
|
||
// normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的
|
||
// limit 约定一致:钳位而不是报错)。
|
||
func (o TidyApplyOptions) normalizedLimit() int {
|
||
if o.Limit <= 0 {
|
||
return defaultTidyApplyBatchLimit
|
||
}
|
||
if o.Limit > maxTidyApplyBatchLimit {
|
||
return maxTidyApplyBatchLimit
|
||
}
|
||
return o.Limit
|
||
}
|
||
|
||
// TidyApplyResult 是一条候选的应用/回滚结果。
|
||
type TidyApplyResult struct {
|
||
CandidateID string `json:"candidateId"`
|
||
Action TidyAction `json:"action"`
|
||
Status TidyApplyStatus `json:"status"`
|
||
Reason string `json:"reason,omitempty"`
|
||
TargetID string `json:"targetId,omitempty"`
|
||
// AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行;
|
||
// archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手,
|
||
// 也是「软删除后总行数不变」这条断言的证据面。
|
||
AffectedIDs []string `json:"affectedIds"`
|
||
// Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是
|
||
// len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的
|
||
// 诚实口径一致(超限绝不静默)。
|
||
type TidyApplyBatch struct {
|
||
RunID string `json:"runId"`
|
||
Limit int `json:"limit"`
|
||
Total int `json:"total"`
|
||
Truncated bool `json:"truncated"`
|
||
Results []TidyApplyResult `json:"results"`
|
||
Applied int `json:"applied"`
|
||
Skipped int `json:"skipped"`
|
||
Failed int `json:"failed"`
|
||
// Merged / Archived / Reclassified 是**成功应用**的候选按动作分解
|
||
// (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。
|
||
Merged int `json:"merged"`
|
||
Archived int `json:"archived"`
|
||
Reclassified int `json:"reclassified"`
|
||
}
|
||
|
||
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
|
||
// 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以
|
||
// TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。
|
||
func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
cand, err := s.GetTidyCandidate(ctx, id)
|
||
if err != nil {
|
||
return TidyApplyResult{}, err
|
||
}
|
||
return s.applyCandidate(ctx, cand, opts)
|
||
}
|
||
|
||
// applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。
|
||
func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID,
|
||
Action: cand.Action,
|
||
TargetID: cand.TargetID,
|
||
AffectedIDs: []string{},
|
||
}
|
||
if !cand.Action.applyable() {
|
||
// link / keep 没有执行语义(design/23 §14;2026-10-07 起 link 也不再产出,
|
||
// 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
now := time.Now().UTC()
|
||
switch cand.Action {
|
||
case TidyMerge:
|
||
return s.applyTidyMerge(ctx, cand, opts, now)
|
||
case TidyArchive:
|
||
return s.applyTidyArchive(ctx, cand, opts, now)
|
||
case TidyReclassify:
|
||
return s.applyTidyReclassify(ctx, cand, opts, now)
|
||
default:
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
}
|
||
|
||
// applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false:
|
||
// 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务
|
||
// 库里既有的历史行与「不许假装做过」的诚实口径。
|
||
func (a TidyAction) applyable() bool {
|
||
switch a {
|
||
case TidyMerge, TidyArchive, TidyReclassify:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。
|
||
func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand)
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil {
|
||
return err
|
||
}
|
||
return bumpSurvivor(ctx, tx, survivor, absorbed, now)
|
||
})
|
||
return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改,
|
||
// 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。
|
||
func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if nonOwnerSource(item.SourceConversation) {
|
||
// 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御,
|
||
// 规则层本就把这类条目排除在扫描范围外。
|
||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
return archiveTidyItem(ctx, tx, item.ID, now)
|
||
})
|
||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):只改 kind 一列,
|
||
// 建议值来自候选证据里的 ProposedKind。
|
||
func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) != 1 {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
kind := Kind(cand.Evidence.ProposedKind)
|
||
if !kind.valid() {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if item.Kind == kind {
|
||
// 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里
|
||
// 用 already_decided 更像「无需再做」。
|
||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
return reclassifyTidyItem(ctx, tx, item.ID, kind, now)
|
||
})
|
||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||
}
|
||
|
||
// loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过
|
||
// (原因由它给出),此时两行不可用。
|
||
func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) {
|
||
absorbed, err := s.Get(ctx, cand.SourceIDs[0])
|
||
if errors.Is(err, ErrNotFound) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||
}
|
||
if err != nil {
|
||
return MemoryItem{}, MemoryItem{}, "", err
|
||
}
|
||
survivor, err := s.Get(ctx, cand.TargetID)
|
||
if errors.Is(err, ErrNotFound) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||
}
|
||
if err != nil {
|
||
return MemoryItem{}, MemoryItem{}, "", err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, absorbed); skip != "" {
|
||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
|
||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||
}
|
||
// 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。
|
||
// 规则层已把这类条目排除在扫描外,这里是纵深防御。
|
||
if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, nil
|
||
}
|
||
if absorbed.ID == survivor.ID {
|
||
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
|
||
}
|
||
return absorbed, survivor, "", nil
|
||
}
|
||
|
||
// tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。
|
||
// 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据,
|
||
// 而落库数据可能来自更早的规则版本或手工改动。
|
||
func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string {
|
||
if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID {
|
||
return tidySkipPartitionMismatch
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的
|
||
// candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定),
|
||
// 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。
|
||
func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||
cand, err := s.GetTidyCandidate(ctx, id)
|
||
if err != nil {
|
||
return TidyApplyResult{}, err
|
||
}
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||
}
|
||
if !cand.Action.applyable() {
|
||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||
}
|
||
if cand.Status != tidyCandidateApplied {
|
||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||
}
|
||
now := time.Now().UTC()
|
||
var reverted []string
|
||
err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error {
|
||
out, err := revertTidyAction(ctx, tx, cand, now)
|
||
reverted = out
|
||
return err
|
||
})
|
||
if errors.Is(err, errTidyCandidateDecided) {
|
||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||
}
|
||
if err != nil {
|
||
return tidyFailed(base, err), nil
|
||
}
|
||
base.Status = TidyApplyApplied
|
||
base.AffectedIDs = reverted
|
||
return base, nil
|
||
}
|
||
|
||
// ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三):
|
||
// runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒
|
||
// 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报
|
||
// Truncated + Total(绝不静默少应用)。
|
||
//
|
||
// 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。
|
||
func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) {
|
||
limit := opts.normalizedLimit()
|
||
if strings.TrimSpace(runID) != "" {
|
||
if _, err := s.GetTidyRun(ctx, runID); err != nil {
|
||
return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err)
|
||
}
|
||
}
|
||
total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID)
|
||
if err != nil {
|
||
return TidyApplyBatch{}, err
|
||
}
|
||
cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit)
|
||
if err != nil {
|
||
return TidyApplyBatch{}, err
|
||
}
|
||
batch := TidyApplyBatch{
|
||
RunID: runID,
|
||
Limit: limit,
|
||
Total: total,
|
||
Truncated: total > len(cands),
|
||
Results: make([]TidyApplyResult, 0, len(cands)),
|
||
}
|
||
for _, cand := range cands {
|
||
if err := ctx.Err(); err != nil {
|
||
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
|
||
break
|
||
}
|
||
res, err := s.applyCandidate(ctx, cand, opts)
|
||
if err != nil {
|
||
res = tidyFailed(TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
|
||
AffectedIDs: []string{},
|
||
}, err)
|
||
}
|
||
batch.Results = append(batch.Results, res)
|
||
tallyTidyApply(&batch, res)
|
||
}
|
||
if batch.RunID != "" && batch.Applied > 0 {
|
||
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
|
||
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
|
||
if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil {
|
||
return batch, err
|
||
}
|
||
}
|
||
return batch, nil
|
||
}
|
||
|
||
// tallyTidyApply 把一条结果计入批量回执的计数器。
|
||
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
|
||
switch res.Status {
|
||
case TidyApplyApplied:
|
||
batch.Applied++
|
||
switch res.Action {
|
||
case TidyMerge:
|
||
batch.Merged++
|
||
case TidyArchive:
|
||
batch.Archived++
|
||
case TidyReclassify:
|
||
batch.Reclassified++
|
||
}
|
||
case TidyApplyFailed:
|
||
batch.Failed++
|
||
default:
|
||
batch.Skipped++
|
||
}
|
||
}
|
||
|
||
// skipTidyApply 造一条 skipped 结果。
|
||
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
|
||
base.Status = TidyApplySkipped
|
||
base.Reason = reason
|
||
if base.AffectedIDs == nil {
|
||
base.AffectedIDs = []string{}
|
||
}
|
||
return base
|
||
}
|
||
|
||
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
|
||
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
|
||
base.Status = TidyApplyFailed
|
||
base.Reason = "apply_failed"
|
||
base.Error = err.Error()
|
||
if base.AffectedIDs == nil {
|
||
base.AffectedIDs = []string{}
|
||
}
|
||
return base
|
||
}
|
||
|
||
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
|
||
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
|
||
//
|
||
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
|
||
// (那是把「没做」说成「做了」)。
|
||
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
|
||
switch {
|
||
case err == nil:
|
||
base.Status = TidyApplyApplied
|
||
base.AffectedIDs = affected
|
||
return base, nil
|
||
case errors.Is(err, errTidyCandidateDecided):
|
||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||
case errors.Is(err, errTidyItemNotActive):
|
||
return skipTidyApply(base, notActiveReason), nil
|
||
default:
|
||
return tidyFailed(base, err), nil
|
||
}
|
||
}
|