package memory import ( "context" "database/sql" "errors" "fmt" "time" ) // 本文件是记忆整理**应用路径**(切片 TIDY-1)的存储层:候选认领、软合并 / // 软删 / 改 kind 的事务写入,以及回滚。它复用 memory 包既有的 *sql.DB、 // 分区口径(tidyPartition / tidyStatusActive)与软合并且已存在的辅助 // (preinsert_store.go 的 bumpSurvivor / dropSurvivorLink / revertMergedSQL / // selectMergedIntoSQL —— 那是 design/23 §8 的**同一套**软合并列语义,不是第二套)。 // // 与 D3(入库前审查)那套的关系:语义同源(status/merged_into/merged_from/ // merge_count),但**闸口不同** —— D3 的顺序队列保证一条记忆只审一次,因此它的 // markAbsorbed 不带 `AND status='active'`;整理的候选是**事后**执行的,可能 // 过时(源行已被别的动作改过),所以这里每一条 UPDATE 都带状态条件,并把它 // 当作幂等/过时的判据(RowsAffected == 0)。 const ( // tidyCandidateApplied 是候选「已应用」状态(design/23 §10 的 status 闭集)。 tidyCandidateApplied = "applied" // tidyStatusArchived 是软删后的行状态(design/23 §10:active/merged/archived)。 // 归档行不再参与检索与整理,但**行与原文都还在**,因此可 revert(§8)。 tidyStatusArchived = "archived" ) // claimTidyCandidateSQL 是**执行闸**:只有把候选从 pending 原子地推到 // applied 的那一次调用才继续往下改记忆。WHERE 里的状态条件就是幂等性的根, // 因此它绝不能省(少了它,重复应用会再写一次 memory_items)。 const claimTidyCandidateSQL = `UPDATE memory_tidy_candidates SET status = ?, decided_at = ?, decided_by = ? WHERE id = ? AND status = ?` // markTidyAbsorbedSQL 标记被吸收行:status='merged' + merged_into=存活 id。 // `AND status='active'` 是第二道闸:源行已被别的动作改过时 RowsAffected==0, // 调用方据此回滚整个事务(不产生第二次合并计数)。 const markTidyAbsorbedSQL = `UPDATE memory_items SET status = ?, merged_into = ?, updated_at = ? WHERE id = ? AND status = ?` // setTidyItemStatusSQL 是软删 / 复原共用的一条:只改 status 与 updated_at, // 带 from 状态条件。 const setTidyItemStatusSQL = `UPDATE memory_items SET status = ?, updated_at = ? WHERE id = ? AND status = ?` // reclassifyTidyItemSQL 只改 kind 一列(design/23 §5.1 T0-6),带状态条件。 const reclassifyTidyItemSQL = `UPDATE memory_items SET kind = ?, updated_at = ? WHERE id = ? AND status = ?` // undoTidyReclassifySQL 撤回一次 kind 修正:只在当前 kind 仍是当时写的建议值、 // 且行仍在册时生效(否则说明有人后来手动改过,不该被回滚覆盖)。 const undoTidyReclassifySQL = `UPDATE memory_items SET kind = ?, updated_at = ? WHERE id = ? AND status = ? AND kind = ?` // tidyApplyTx 在**一个事务**里跑一次应用:先认领候选(条件更新 = 执行闸), // 再执行 mutate,最后提交。任何一步失败都回滚 —— 因此「候选被标 applied 但 // 记忆没改」这种半成品状态不可能落库。 func (s *Store) tidyApplyTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time, mutate func(context.Context, *sql.Tx) error) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("memory/tidy: apply candidate begin: %w", err) } defer func() { _ = tx.Rollback() }() claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidatePending, tidyCandidateApplied, opts.Actor, now) if err != nil { return err } if !claimed { return errTidyCandidateDecided } if err := mutate(ctx, tx); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("memory/tidy: apply candidate commit: %w", err) } return nil } // tidyRevertTx 与 tidyApplyTx 同构,只是认领的方向相反(applied → pending)。 func (s *Store) tidyRevertTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, mutate func(context.Context, *sql.Tx) error) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("memory/tidy: revert candidate begin: %w", err) } defer func() { _ = tx.Rollback() }() claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidateApplied, tidyCandidatePending, opts.Actor, time.Now().UTC()) if err != nil { return err } if !claimed { return errTidyCandidateDecided } if err := mutate(ctx, tx); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("memory/tidy: revert candidate commit: %w", err) } return nil } // claimTidyCandidate 原子地把候选从**当前状态 from** 推到 to。返回是否真的改了行: // false 表示它已经被决定过(幂等命中,不是错误)。 // // from 参数是这道闸的关键:应用要求 from='pending',回滚要求 from='applied'。 // 两者共用一个语句,但**不能共用一个硬编码的 from** —— 那会让回滚永远匹配不到行。 func claimTidyCandidate(ctx context.Context, tx *sql.Tx, id, from, to, actor string, now time.Time) (bool, error) { res, err := tx.ExecContext(ctx, claimTidyCandidateSQL, to, formatTime(now), actor, id, from) if err != nil { return false, fmt.Errorf("memory/tidy: claim candidate %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("memory/tidy: claim candidate %s rows: %w", id, err) } return n > 0, nil } // markTidyAbsorbed 标记被吸收行并刷新存活行的合并元信息。源行不在册时返回 // errTidyItemNotActive(事务因此回滚)。 func markTidyAbsorbed(ctx context.Context, tx *sql.Tx, absorbedID, survivorID string, now time.Time) error { res, err := tx.ExecContext(ctx, markTidyAbsorbedSQL, string(ingestStatusMerged), survivorID, formatTime(now), absorbedID, tidyStatusActive) if err != nil { return fmt.Errorf("memory/tidy: mark absorbed item: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("memory/tidy: mark absorbed item rows: %w", err) } if n == 0 { return errTidyItemNotActive } return nil } // setTidyItemStatus 在 from → to 之间改一行的 status。返回是否真的改了行。 func setTidyItemStatus(ctx context.Context, tx *sql.Tx, id, from, to string, now time.Time) (bool, error) { res, err := tx.ExecContext(ctx, setTidyItemStatusSQL, to, formatTime(now), id, from) if err != nil { return false, fmt.Errorf("memory/tidy: set item status %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("memory/tidy: set item status rows: %w", err) } return n > 0, nil } // archiveTidyItem 把一条在册记忆软删(active → archived)。不在册时返回 // errTidyItemNotActive。它**只改 status/updated_at**,绝不碰 content。 func archiveTidyItem(ctx context.Context, tx *sql.Tx, id string, now time.Time) error { changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusActive, tidyStatusArchived, now) if err != nil { return err } if !changed { return errTidyItemNotActive } return nil } // reclassifyTidyItem 把一条在册记忆的 kind 改为 kind。不在册时返回 // errTidyItemNotActive。 func reclassifyTidyItem(ctx context.Context, tx *sql.Tx, id string, kind Kind, now time.Time) error { res, err := tx.ExecContext(ctx, reclassifyTidyItemSQL, string(kind), formatTime(now), id, tidyStatusActive) if err != nil { return fmt.Errorf("memory/tidy: reclassify item %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("memory/tidy: reclassify item rows: %w", err) } if n == 0 { return errTidyItemNotActive } return nil } // addTidyRunApplied 把该轮留痕的 applied 计数累加(design/23 §10)。 func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int) error { if _, err := s.db.ExecContext(ctx, incrementTidyRunAppliedSQL, applied, runID); err != nil { return fmt.Errorf("memory/tidy: bump run applied %s: %w", runID, err) } return nil } // revertTidyAction 在事务里撤销一条已应用候选对记忆的改动,返回真正被复原的 // item id(一条都没复原时返回空切片 —— 那说明用户已经用 /memories/{id}/revert // 手工撤过了,此时只把候选退回 pending,不假装改过东西)。 func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) { switch cand.Action { case TidyMerge, TidyDedupeGroup: // dedupe_group 与 merge 是**同一套软合并**(被吸收行 → merged + merged_into), // 因此回滚逐字复用 revertTidyMerge:它本来就按 SourceIDs 逐条复原。 return revertTidyMerge(ctx, tx, cand, now) case TidyArchive: return revertTidyArchive(ctx, tx, cand, now) case TidyReclassify: return revertTidyReclassify(ctx, tx, cand, now) default: return []string{}, nil } } // revertTidyMerge 复原一次软合并:逐条把被吸收行从 merged 复原为 active,并从 // 存活行的 merged_from 里摘掉它(dropSurvivorLink)。只复原**这条候选做的那次** // 合并(merged_into 必须等于候选的 target)。 func revertTidyMerge(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) { out := make([]string, 0, len(cand.SourceIDs)) for _, id := range cand.SourceIDs { ok, err := revertTidyMergeInTx(ctx, tx, id, cand.TargetID, now) if err != nil { return nil, err } if ok { out = append(out, id) } } return out, nil } // revertTidyMergeInTx 复原单条被吸收行。它只认 `status='merged'` 且 // `merged_into=<该候选的存活 id>` 的行;targetID 为空时接受任意存活 id // (容错:老候选可能没落 target)。 func revertTidyMergeInTx(ctx context.Context, tx *sql.Tx, absorbedID, targetID string, now time.Time) (bool, error) { var survivorID string err := tx.QueryRowContext(ctx, selectMergedIntoSQL, absorbedID, string(ingestStatusMerged)).Scan(&survivorID) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("memory/tidy: read merged item %s: %w", absorbedID, err) } if targetID != "" && survivorID != targetID { // 它现在挂在**别的**存活行上(后来又被合并过一次):这条候选的回滚 // 不该把它从新关系里拽出来。 return false, nil } if _, err := tx.ExecContext(ctx, revertMergedSQL, string(tidyStatusActive), formatTime(now), absorbedID, string(ingestStatusMerged)); err != nil { return false, fmt.Errorf("memory/tidy: restore merged item %s: %w", absorbedID, err) } if survivorID != "" { if err := dropSurvivorLink(ctx, tx, survivorID, absorbedID, now); err != nil { return false, err } } return true, nil } // revertTidyArchive 复原被软删(archived)的行:archived → active。 func revertTidyArchive(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) { out := make([]string, 0, len(cand.SourceIDs)) for _, id := range cand.SourceIDs { changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusArchived, tidyStatusActive, now) if err != nil { return nil, err } if changed { out = append(out, id) } } return out, nil } // revertTidyReclassify 撤回一次 kind 修正:只在当前 kind 仍等于当时写的建议值、 // 且行仍在册时生效(有人后来手动改过就保持不动)。旧 kind 来自候选证据 KindA。 func revertTidyReclassify(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) { kind := Kind(cand.Evidence.ProposedKind) previous := Kind(cand.Evidence.KindA) if !previous.valid() || !kind.valid() || len(cand.SourceIDs) != 1 { return []string{}, nil } res, err := tx.ExecContext(ctx, undoTidyReclassifySQL, string(previous), formatTime(now), cand.SourceIDs[0], tidyStatusActive, string(kind)) if err != nil { return nil, fmt.Errorf("memory/tidy: undo reclassify %s: %w", cand.SourceIDs[0], err) } n, err := res.RowsAffected() if err != nil { return nil, fmt.Errorf("memory/tidy: undo reclassify rows: %w", err) } if n == 0 { return []string{}, nil } return []string{cand.SourceIDs[0]}, nil }