feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增 retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤 一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。 结构(迁移 0026 + 自举前滚): - memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型, 历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过 (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。 - NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。 判定(retention.go,全仓唯一入口): - RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。 - resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源 不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。 - tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔 INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。 合并(defer ⇔ temp): - judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此 全仓唯一(源码级断言钉住)。 - 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。 - 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。 - IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。 读侧: - 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。 - 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位 过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。 原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。 - store.go 的读路径拆到 store_list.go,守 500 行上限。 回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
This commit is contained in:
1 parent
240b59708d
commit
cc6904b1b4
23 files changed
+1592
-181
No files matched your search
@@ -116,6 +116,10 @@ type ingestGatePlan struct {
|
||||
// 它是**时刻**而不是「TTL 秒数」:判定的那一刻就把 TTL 换算掉,落库侧因此
|
||||
// 不需要认识任何配置,也不会出现「写的时候又算一次 TTL」的双口径。
|
||||
ExpiresAt time.Time
|
||||
// Retention 是这一行落库时写进 `retention_tier` 的档位(迁移 0026)。
|
||||
// 它与 ExpiresAt 由 planIngest 的**同一处换算**同时填(temp ⇔ 有到期时刻),
|
||||
// 因此两列不可能分叉 —— 闸的任何分支都不需要自己管这一位。
|
||||
Retention RetentionTier
|
||||
}
|
||||
|
||||
// expiresAt 返回新行落库时的 `expires_at` 文本,与 `insertStatus()` /
|
||||
@@ -129,6 +133,13 @@ func (p ingestGatePlan) expiresAt() string {
|
||||
return expiryText(p.ExpiresAt)
|
||||
}
|
||||
|
||||
// retention 返回新行落库时的 `retention_tier` 文本(迁移 0026 的「留多久」轴)。
|
||||
// 与 expiresAt() 是同一组的两个落库值:`Retention == temp` ⇔ `ExpiresAt` 非零,
|
||||
// 由 planIngest 的同一处换算保证(见 retention.go 的 tempDeadline)。
|
||||
func (p ingestGatePlan) retention() string {
|
||||
return string(p.Retention)
|
||||
}
|
||||
|
||||
// absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
|
||||
func (p ingestGatePlan) absorbedID() string {
|
||||
if p.Action == ingestPlanAbsorbNew {
|
||||
@@ -148,12 +159,21 @@ func (p ingestGatePlan) insertStatus() string {
|
||||
// planIngest 在写入路径上跑同步入库闸。未接线审查(store 上没有 reviewer)、
|
||||
// 总开关关闭、或闸被显式关掉时,它返回「只插不审」的 passthrough 计划 ——
|
||||
// 写入路径因此逐字回到本次改动之前。
|
||||
//
|
||||
// 🔴 **档位与有效期在这里统一落到计划上**(唯一换算点,见 retention.go 的
|
||||
// tempDeadline):无论走闸还是走 passthrough,落库值都从这一处出,因此
|
||||
// 「闸关闭时档位就丢失」这种分叉在结构上不可能发生 —— M27 体检里
|
||||
// 「`expires_at` 全表为空」的那条缺口,一半原因就是这条换算曾经只挂在闸的分支里。
|
||||
func (s *Store) planIngest(ctx context.Context, item MemoryItem) ingestGatePlan {
|
||||
r := s.ingestReviewer()
|
||||
if !r.gateEnabled() {
|
||||
return ingestGatePlan{Action: ingestPlanInsert, Decision: IngestCreate, Async: true}
|
||||
var plan ingestGatePlan
|
||||
if r := s.ingestReviewer(); !r.gateEnabled() {
|
||||
plan = ingestGatePlan{Action: ingestPlanInsert, Decision: IngestCreate, Async: true}
|
||||
} else {
|
||||
plan = r.gate(ctx, item)
|
||||
}
|
||||
return r.gate(ctx, item)
|
||||
plan.Retention = item.RetentionTier
|
||||
plan.ExpiresAt = s.tempDeadline(item)
|
||||
return plan
|
||||
}
|
||||
|
||||
// gate 是闸本体:在**有界**预算内召回同分区候选、用确定性规则判定,并给出
|
||||
@@ -215,8 +235,10 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
|
||||
// 第五态(design/23 §18.1):行照常 active 落库(一个字不丢、可 revert),
|
||||
// 只把 expires_at 写成「现在 + 临时 TTL」;**不投异步队列**(defer 已是
|
||||
// 终局判断,再送语义审查只会把临时性抹掉)。
|
||||
//
|
||||
// 到期时刻**不在这里算**:planIngest 用 tempDeadline 统一换算(唯一口径),
|
||||
// 因此本分支只负责「动作」与「留痕原因码」。
|
||||
plan.Action = ingestPlanDefer
|
||||
plan.ExpiresAt = r.deferDeadline(r.now().UTC())
|
||||
plan.Trace = gateTraceDeferred
|
||||
default:
|
||||
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
|
||||
@@ -350,29 +372,14 @@ func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGa
|
||||
return
|
||||
}
|
||||
if plan.Action == ingestPlanDefer {
|
||||
// 第五态(design/23 §18.1):新行已经在 Add 里照常插好了(active、正文
|
||||
// 一字不差),这里只把 `expires_at` 补成计划给出的时刻 —— 与
|
||||
// absorb_new / absorb_old 是**同一范式**:INSERT 落行、finishIngest 落
|
||||
// 「那一行的派生列」。
|
||||
// 第五态(design/23 §18.1):新行已经在 Add 里**一笔**落好 —— 档位与
|
||||
// `expires_at` 都随 INSERT 写下(plan.expiresAt() / plan.retention()),
|
||||
// 这里**不再有第二笔 UPDATE**。
|
||||
//
|
||||
// 为什么不是「INSERT 直接带上 expires_at」:store.go 的 INSERT 读的是
|
||||
// `expiryText(item.ExpiresAt)`(由**调用方**置位),而 defer 的判断发生在
|
||||
// 闸里(`planIngest` 拿的是 item 的**副本**),计划的值够不到那条 INSERT。
|
||||
//
|
||||
// 🔴 **代价与短暂窗口(如实登记,不静默)**:这一态因此多出一笔按主键的
|
||||
// UPDATE(微秒级),且 INSERT 与这笔补写之间有一个**极短的窗口** ——
|
||||
// 若进程恰在此刻中断,该行会以 `expires_at=''`(**永不过期**)留在库里。
|
||||
// 方向是刻意的:它正是 fail-safe 的「**宁可多留,不可误弃**」
|
||||
// (design/23 §18.3)—— 停在长期层只是多占一条记忆,而反向失败
|
||||
// (把该留的判成临时)会丢信息。写失败同样只记 WARN、**不让 Add 失败**。
|
||||
//
|
||||
// 非 defer 的三态**一笔都不多写**:absorb_new / absorb_old 本来就有它们的
|
||||
// 第二笔(合并登记 / 置为被合并),insert 在函数开头就返回了。
|
||||
if err := s.setIngestExpiry(ctx, item.ID, plan.expiresAt()); err != nil {
|
||||
s.log.Warn("memory ingest gate defer failed, item left long-lived",
|
||||
"id", item.ID, "scope", string(item.Scope), "error", err)
|
||||
return
|
||||
}
|
||||
// 这是本切片相对 M2 的一处结构性改进:那时 defer 的 `expires_at` 靠
|
||||
// INSERT 之后的补写,注释里如实登记过「进程恰在此刻中断 ⇒ 该行以
|
||||
// expires_at="" 永不过期留在库里」的短窗口。现在两列同笔写入,那个窗口
|
||||
// 不存在了;这里剩下的事情只有计数。
|
||||
if r := s.ingestReviewer(); r != nil {
|
||||
r.bump(func(st *ReviewStats) { st.GateDeferred++ })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user