Files
AskaEth ffe2518bf4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 留痕表正式迁移 0025 + 保留期清扫(W5)
- 迁移 0025_memory_ingest_reviews(幂等,既有部署重跑不炸/不丢行)
- GC:保留期 720h + 条数上限 10 万行,挂进审查服务生命周期(Start 起 / Stop 停 / 启动先扫)
- 过期即硬删(RevertMerged 只读 memory_items,删留痕不损失回滚能力)
- 偏离一处并给理由:表只留 created_at(先例 0017;append-only;加 updated_at 需三份 DDL 真源)
- 3 个变异自检(边界 <=/元组比较方向/Retention=0)
2026-10-06 15:59:16 +08:00

260 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// 本文件是入库前审查留痕(design/23 §15 D3-8 的 `memory_ingest_reviews`)的
// **保留期清扫(Sweep)**(切片 W5)。动因是一条实测口径:审查**每写一条新记忆
// 就落一行留痕**(现网节奏 ≈ 700–1000 行/天),而 MTB 落地时这张表既没有迁移号、
// 也没有 TTL ⇒ 无界增长(DECISIONS-2026-10-06 §D 已裁「补」)。
//
// 范式逐条照 `internal/runtime/events/gc.go` 与 `internal/runtime/flowgraph/gc.go`
// (design/26 §4.4 明写「不另造」),三条取舍完全一致:
//
// 1. **启动期扫一次、此后按间隔定时扫**(间隔默认 DefaultIngestReviewGCInterval)。
// 循环住在**入库前审查服务**的生命周期里(Start 起来、Stop 停掉):它是本包内
// 唯一既持有 context、又有确定启停点的记忆后台件。不新开监听端口、不新起
// 独立的组合根接线(AGENTS §9 单二进制、单端口)。
// 2. **失败只记 WARN、绝不让进程退出**:Sweep 把查询/删除失败**上抛**给调用方
// (调用方记 WARN 后继续等下一轮);已删的行不会因为后续批次失败而「回滚」,
// 未删的行也不会被清空 —— 一轮失败就只是「这一轮没扫干净」。
// 3. **批数有界**:一轮清扫最多 maxIngestReviewSweepBatches ×
// ingestReviewSweepBatchSize 行(积压时不一次 DELETE 全表,剩下的留给下一轮),
// 与 AGENTS §10 的压测纪律同口径:有界,不靠堆资源。
//
// 两条清理判据(**过期即删**,理由见常量注释):
//
// - **保留期**:`created_at < now − 保留期` 的行逐批删除(默认 720h);
// - **条数上限**:超过 DefaultIngestReviewMaxRows 行时,按 `(created_at, rowid)`
// 元组裁掉最旧的尾巴 —— 保留期管「日常节奏」,上限管「异常爆发」
// (一次回填/一个 bug 可以在保留期内灌进百万行,只有条数能把它挡住)。
//
// **为什么是硬删而不是软删**:留痕描述的那次软合并**早已落在 `memory_items` 上**
// (`status`/`merged_into`/`merged_from`/`merge_count`),`RevertMerged` 只读
// `memory_items`(preinsert_store.go 的 selectMergedIntoSQL),从不读本表
// ⇒ 删留痕不损失任何回滚能力。反过来,软删既要加列(shape 变更)又保留了这份
// 无界增长本身 —— 等于把问题换个名字留着。故过期即**物理删除**(design/23 §8 的
// 「软删除」是对 `memory_items` 正文的口径,不是对观测留痕的口径)。
const (
// DefaultIngestReviewRetention 是留痕的保留期默认值:30 天(720h)。
// 依据(同为 720h 的两张表):① `llm_usage`(0017)是**同类逐条样本表**,
// 同为 append-only + 按 created_at 清扫,保留期 720h;② `channel_events`
// (0021)的口径是「事后定案证据」,也取 720h。反例是 `flow_graphs`(168h)——
// 那是「回看最近若干轮」的可视化窗口;而留痕要答的问题是
// 「上周那条记忆**当初为什么**被合掉/放行」,一周往往不够(事故复盘常在事后
// 一周以上)。体量上 30 天 × 1000 行/天 ≈ 3 万行(每行几十字节,无正文),可忽略。
DefaultIngestReviewRetention = 720 * time.Hour
// DefaultIngestReviewGCInterval 是定时清扫间隔默认值:每小时(与 events /
// flowgraph 的 DefaultGCInterval 同值)。清扫只是删几行,频率再高也无收益。
DefaultIngestReviewGCInterval = time.Hour
// DefaultIngestReviewMaxRows 是留痕的条数上限默认值:10 万行。
// 按现网节奏(700–1000 行/天)约合 100–140 天,是保留期(30 天)的 3 倍以上
// ⇒ **正常节奏下永不触发**,只在异常爆发(一次性回填、写路径 bug 循环落痕)
// 时兜底,保证「无论如何库里的留痕行数都有上界」。数量级参考:10 万行
// × 约 150 字节 ≈ 15 MB。
DefaultIngestReviewMaxRows = 100_000
// ingestReviewSweepBatchSize 是单批删除的行数上限;maxIngestReviewSweepBatches
// 是一轮清扫的批数上限。两者一起把「一轮清扫」的时长、单次内存占用与
// **连接池占用**(本项目的 SQLite 池是单连接)都变得有界。
ingestReviewSweepBatchSize = 500
maxIngestReviewSweepBatches = 100
)
// IngestReviewGCPolicy 是一轮清扫的口径。两个字段各自独立生效:
// `Retention <= 0` 只关掉按时间的一档、`MaxRows <= 0` 只关掉条数上限那一档
// (不是总体关闭 —— 本表没有「关掉清扫」的开关是**刻意**的:它的无界增长是
// 结构性的,留一个能关掉 GC 的开关就等于留一个能让库无限长大的开关。
// 真要不留痕,该关掉的是审查本身 `MEMORY_REVIEW_ENABLED=false` —— 那样也就
// 不再产生新行,且组合根根本不会构造审查服务)。
//
// 保留期与上限的默认值见本文件顶部的常量:组合根不传任何配置键,`.env` 已冻结
// (design/13 §2.1),将来若要面板可调,落点是 `$DATA_DIR/config/**`,
// 由 internal/config 侧读出后传进本结构 —— 本包不读任何配置源。
type IngestReviewGCPolicy struct {
// Retention 是留痕保留期;<=0 表示不做按时间的清扫。
Retention time.Duration
// MaxRows 是留痕条数上限;<=0 表示不做条数裁剪。
MaxRows int
}
// IngestReviewSweepStats 是一轮清扫的汇总(成功件只记总数,绝不记任何行内容)。
type IngestReviewSweepStats struct {
// Batches 是实际执行的删除批数(过期档 + 上限档之和)。
Batches int
// Deleted 是按保留期删掉的过期行数。
Deleted int64
// Truncated 是按条数上限裁掉的(**尚未过期**的)行数。它非零即说明发生了
// 异常爆发 —— 是运维信号,不该被当成日常数字。
Truncated int64
}
// sweepIngestReviewsExpiredSQL 按时间戳删一批过期行。子查询带
// `ORDER BY created_at LIMIT ?`,因此它吃得上 idx_memory_ingest_reviews_created_at,
// 且每批的扫描面有界。时间戳用定宽 UTC 串比较:字典序等价于时间序
// (见 timefmt.go 的口径)。边界取**严格小于**:正好落在 cutoff 上的行保留,
// 于是「保留期 = 恰好 30 天」是可断言的(不随执行时刻抖动)。
const sweepIngestReviewsExpiredSQL = `DELETE FROM memory_ingest_reviews WHERE id IN (
SELECT id FROM memory_ingest_reviews WHERE created_at < ? ORDER BY created_at LIMIT ?)`
// selectIngestReviewCapKeySQL 取「要保留的最后一行」的键:按 `(created_at, rowid)`
// 倒序数的第 MaxRows 行。`rowid` 是本表的隐式主键行号(id 是 TEXT 主键,因此
// 本表是普通 rowid 表;读侧 ListIngestReviews 的 `ORDER BY created_at DESC, rowid DESC`
// 已经在用同一口径)。
const selectIngestReviewCapKeySQL = `SELECT created_at, rowid FROM memory_ingest_reviews
ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET ?`
// sweepIngestReviewsCapSQL 按上一步取到的键裁一批:`(created_at, rowid)` 严格小于
// 该键的行即「最旧的尾巴」。用**元组比较**而不是只比 created_at,是为了在
// 同一时间戳的批量写入(注入固定时钟的回填)下也能精确裁到 MaxRows —— 只比
// 时间戳时那批同值行谁也删不掉,上限恰好会在最需要它的时候失效。
const sweepIngestReviewsCapSQL = `DELETE FROM memory_ingest_reviews WHERE rowid IN (
SELECT rowid FROM memory_ingest_reviews
WHERE created_at < ? OR (created_at = ? AND rowid < ?)
ORDER BY created_at, rowid LIMIT ?)`
// SweepIngestReviews 按 policy 清扫留痕表,返回一轮汇总。now 由调用方给出
// (便于测试注入固定时钟;本包不在 Store 里藏墙钟)。
//
// 失败语义:查询/删除失败返回错误并**带上已完成的统计**(调用方据此知道这一轮
// 走到了哪一步,记 WARN 后等下一轮)。ctx 取消时立刻停下:**已删的保持删除、
// 未删的绝不因此被清空**。
func (s *Store) SweepIngestReviews(ctx context.Context, now time.Time, policy IngestReviewGCPolicy) (IngestReviewSweepStats, error) {
var stats IngestReviewSweepStats
if policy.Retention > 0 {
cutoff := formatTime(now.UTC().Add(-policy.Retention))
deleted, batches, err := s.deleteIngestReviews(ctx, func(ctx context.Context) (int64, error) {
return s.execIngestReviewSweep(ctx, sweepIngestReviewsExpiredSQL, cutoff, ingestReviewSweepBatchSize)
})
stats.Deleted += deleted
stats.Batches += batches
if err != nil {
return stats, err
}
}
if policy.MaxRows > 0 {
key, ok, err := s.readIngestReviewCapKey(ctx, policy.MaxRows)
if err != nil {
return stats, err
}
if ok {
deleted, batches, err := s.deleteIngestReviews(ctx, func(ctx context.Context) (int64, error) {
return s.execIngestReviewSweep(ctx, sweepIngestReviewsCapSQL,
key.createdAt, key.createdAt, key.rowid, ingestReviewSweepBatchSize)
})
stats.Truncated += deleted
stats.Batches += batches
if err != nil {
return stats, err
}
}
}
if stats.Deleted > 0 || stats.Truncated > 0 {
s.log.Info("memory ingest review sweep",
"deleted", stats.Deleted, "truncated", stats.Truncated, "batches", stats.Batches,
"retention", policy.Retention.String(), "max_rows", policy.MaxRows)
}
return stats, nil
}
// ingestReviewCapKey 是「要保留的最后一行」的键。
type ingestReviewCapKey struct {
createdAt string
rowid int64
}
// readIngestReviewCapKey 取第 maxRows 新的一行的键(maxRows <= 1 时即最新那一行)。
// 表里行数不足 maxRows 时返回 ok=false(`sql.ErrNoRows`)—— 那是「无需裁剪」,
// 不是错误。
func (s *Store) readIngestReviewCapKey(ctx context.Context, maxRows int) (ingestReviewCapKey, bool, error) {
var key ingestReviewCapKey
err := s.db.QueryRowContext(ctx, selectIngestReviewCapKeySQL, maxRows-1).
Scan(&key.createdAt, &key.rowid)
if errors.Is(err, sql.ErrNoRows) {
return ingestReviewCapKey{}, false, nil
}
if err != nil {
return ingestReviewCapKey{}, false, fmt.Errorf("memory: read ingest review cap key: %w", err)
}
return key, true, nil
}
// deleteIngestReviews 跑有界的批删循环:sweep 每批返回删掉的行数,某批不满一批
// 即收手(说明已经删干净)。批数上限 maxIngestReviewSweepBatches 保证一轮清扫的
// 时长与连接占用有界;ctx 取消时立刻返回(已完成的部分保持删除)。
func (s *Store) deleteIngestReviews(ctx context.Context, sweep func(context.Context) (int64, error)) (int64, int, error) {
var (
total int64
batches int
)
for i := 0; i < maxIngestReviewSweepBatches; i++ {
if err := ctx.Err(); err != nil {
return total, batches, fmt.Errorf("memory: sweep ingest reviews: %w", err)
}
deleted, err := sweep(ctx)
if err != nil {
return total, batches, err
}
batches++
total += deleted
if deleted < int64(ingestReviewSweepBatchSize) {
break
}
}
return total, batches, nil
}
// execIngestReviewSweep 执行一条批删语句并返回本批删掉的行数。
func (s *Store) execIngestReviewSweep(ctx context.Context, query string, args ...any) (int64, error) {
res, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return 0, fmt.Errorf("memory: sweep ingest reviews 删留痕行: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("memory: sweep ingest reviews 读取删除行数: %w", err)
}
return n, nil
}
// sweepLoop 是留痕清扫的常驻循环:启动期先扫一次(清掉上次退出后累积的过期行),
// 此后每 DefaultIngestReviewGCInterval 扫一次。它由 PreInsertReviewer.Start 拉起、
// 随 ctx 取消退出(Stop 会 join 它)。
//
// 它**不**碰审查队列,也不读记忆正文:清扫只按 created_at 与 rowid 删行。
func (r *PreInsertReviewer) sweepLoop(ctx context.Context) {
defer r.wg.Done()
r.sweepOnce(ctx)
ticker := time.NewTicker(DefaultIngestReviewGCInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.sweepOnce(ctx)
}
}
}
// sweepOnce 跑一轮留痕清扫。失败只记 WARN:清扫是旁路维护,绝不让审查服务退出、
// 也绝不影响任何一条记忆(与 design/23 §15 D3-5 的 fail-safe 同口径)。
func (r *PreInsertReviewer) sweepOnce(ctx context.Context) {
stats, err := r.deps.Store.SweepIngestReviews(ctx, r.now(), IngestReviewGCPolicy{
Retention: DefaultIngestReviewRetention,
MaxRows: DefaultIngestReviewMaxRows,
})
if err != nil {
r.log.Warn("memory ingest review sweep failed, expired rows left for the next round",
"error", err, "deleted", stats.Deleted, "truncated", stats.Truncated)
}
}