/** * 托管留存(managed retention)执行面的 **store 半场** —— SINGLE-FILE DUAL-DIALECT([ref] A12 定型半场; * 先例 = `leader-run-store-sql.ts` / `checkpoint-store-sql.ts`)。[ref] 车1,设计稿 * `docs/DESIGN-270-retention-lane.md` v1.2。上游契约真源 = `@sema-agent/core` 的 * `core/retention.d.ts`(`ManagedRetentionCapability` / `RetentionReceipt` / `RetentionDeclaration`)。 * * ── 为什么这四张表 + 一只**聚合**店 ───────────────────────────────────────────────────────────────── * core 把破坏性级联整个下沉成 store 方法(`expireCheckpoints` / `deleteExpiredSessions` / * `deleteOrphanToolResults`),并点名 server 的 session-purge 顺序契约(E21,`boot/session-faces.ts` 的 * `purgeSession` 协调器)为参考实现。那条顺序契约横跨**十余张表**(run 账本 / checkpoint / tool_result / * anchor / exemption / policy / attachment / snapshot / workflow inbox / session_meta+event),而设计稿 * §6 的 F5 又要求「审计行与删除同一个 DB 事务」—— 一个事务 ⇒ 一条连接 ⇒ **一个属主**。 * * ⇒ 布局裁定:本能力**不能**挂在 session / checkpoint / tool-result 任何一只店上(每只店只拥有自己的表, * 把跨表级联塞进其中一只 = 让它去写别人的表,且三只店各持自己的 driver ⇒ 三个事务,F5 当场破)。它是 * `purgeSession` 协调器的**事务化孪生**:协调器在应用层按序调各店,本店在**一个事务里**按同一顺序发同 * 一组 DELETE。同一条顺序契约,两个属主(在线删除 = 协调器;留存删除 = 本店),等价性由 * `retention-store-db-integration.test.ts` 的 E21 同种子行集断言钉住。 * * ── 域(domain)口径 ──────────────────────────────────────────────────────────────────────────────── * 域 = 租户键。三张保留表各有自己的载体列:`session_meta.owner`(**可空**)、`checkpoint.scope`(非空)、 * `tool_result` 无域列(经属主 session 派生)。归一化: * · 域键 = `COALESCE(session_meta.owner, '')` / `checkpoint.scope` 原样; * · **空串 `''` = 无主(anonymous/dev)桶**,谓词写成 `(owner IS NULL OR owner = '')` —— 两者合并成一个 * 桶是**保守**的(合并只发生在无主侧,永远不会伸进一个真租户);拆成两个桶才会漏(NULL 那半永不被 * 枚举 = codex F4 同族的病)。真部署里 `''` 不是可铸的 principal(auth 门拒空 system 名)。 * * ── 方言差异台账(显式,永不藏进抽象;A12 判据)─────────────────────────────────────────────────── * - `?` 占位符 vs `$n`;多值集合 MySQL 用 `IN (?,?,…)`、PG 用 `= ANY($n::text[])`(先例: * workflow-run-store-sql 的 reap) * - 幂等建行:哨兵形(形 A,S-127)`ON DUPLICATE KEY UPDATE = ` vs `ON CONFLICT (…) DO NOTHING` * —— 随后有同行 `FOR UPDATE` 的建行**必须**用 ON DUPLICATE(重复键取 X,同锁模式);墓碑那种 * 「只读 affected 不加锁读」的写点(形 B)才保留 `INSERT IGNORE` * - 级联 DELETE 的 JOIN 形:`DELETE te FROM a te JOIN b …` vs `DELETE FROM a te USING b …` * - null-safe 比较:`<=>` vs `IS NOT DISTINCT FROM`(及其**占位符数量**差:`?` 不可复用) * - JSON 提取(workflow_run 的 originatingSessionId):`JSON_UNQUOTE(JSON_EXTRACT(run,'$.x'))` vs * `run::json->>'x'` * - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(`isolation-key-collation` 门执法) * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} 的 `@contract txn.read-semantics` * - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 `SCHEMA_STATEMENTS`;PG DDL 由本文件的 * `ensurePgRetentionSchema` 导出、由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合(与 leader-run 同姿势)。 * * ── 命名([ref] 纪律,与设计稿的复数写法的**有意**偏离)──────────────────────────────────────────── * 设计稿 §5/§2 写的是 `retention_holds` / `retention_tombstones`;本仓的表名闭集词表是**单数**制且由 * `test/schema-naming-invariants.test.ts` 机器执法(「一张表是一类行的集合,表名说的是『一行是什么』」)。 * ⇒ 落库名一律单数:`retention_audit` / `retention_hold` / `retention_lease` / `retention_tombstone`。 */ import type { ManagedRetentionCapability, RetentionDeclaration, RetentionReceipt } from "@sema-agent/core"; import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SqlDriver, type SqlTxConn } from "./sql-driver.js"; import { type IndexSpec } from "./ensure-index.js"; /** * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。 * * 🔴 为什么是一个共享常量而不是各写各的字面量:这一格是 core `assertRetentionCapability` 的**唯一**读点, * 三只店的答案必须同进同退 —— 哪天本能力从某只店的行上撤了(比如 tool_result 改由别处清),漏改一处 * 就是一句谎,而谎的方向是「locked policy 放行了一只其实不删的店」。 * * 🔴 声明的**读法**(与 core 契约的字面对齐,别读大也别读小):`"managed"` 说的是「**这只店的行**由本 * 部署的托管留存按期删除」,不是「本对象自己实现了三个方法」。三方法的实现体是本文件的 * `SqlRetentionStore` —— 它是 E21 purge 协调器的事务化孪生,横跨十余张表(理由见文件顶注的布局裁定)。 * core 的门读的是「这只店的数据会不会被按期删掉」(拒启文案逐字:「a locked retention policy over stores * that cannot delete would be *policy locked, data immortal*」),SQL 三店的诚实答案就是 `"managed"`。 * file/in-memory 形没有任何东西会按期删它们的行 ⇒ 显式 `"none"`(缺席也读 none,但显式是文档义务)。 */ export declare const MANAGED_RETENTION: RetentionDeclaration; /** file/in-memory 形的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */ export declare const UNMANAGED_RETENTION: RetentionDeclaration; /** 追加式审计表(设计稿 §6)。 */ export declare const RETENTION_AUDIT_TABLE = "retention_audit"; /** per-domain 法务保留标记(设计稿 §5)——**行在 = 该域整体冻结**。 */ export declare const RETENTION_HOLD_TABLE = "retention_hold"; /** sweep 互斥的单行租约表(设计稿 §3)。表建在车1(schema 一次齐),抢/续租的逻辑归车2。 */ export declare const RETENTION_LEASE_TABLE = "retention_lease"; /** 删除墓碑(设计稿 §2)——副本/备份收敛 **+ 域枚举并集的第四条腿**(codex F4)。 */ export declare const RETENTION_TOMBSTONE_TABLE = "retention_tombstone"; /** * 墓碑的 `kind` 闭集。**词表在这里,不在调用点**:一个手写的 kind 字面量会让域枚举与孤儿归属各读各的。 * v1 两个成员 —— 会话树(`deleteExpiredSessions`)与孤儿卸载结果(`deleteOrphanToolResults`)。 */ export type RetentionTombstoneKind = "session" | "tool_result"; /** * 审计行的 `action` 闭集(设计稿 §6 逐字)。**破坏性三词**由本店在删除同事务内写;其余四词 * (`audit_only` / `skipped_legal_hold` / `failed` / `hold_placed` / `hold_released`)属主是 lane/路由(车2) * —— 它们没有「删了没记」的窗口。词表整只放在这里是为了两半场共用一份真源。 */ export type RetentionAuditAction = "expired_checkpoints" | "deleted_sessions" | "deleted_tool_results" | "audit_only" | "skipped_legal_hold" | "failed" | "hold_placed" | "hold_released"; /** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 `SCHEMA_STATEMENTS`)。 */ export declare const TIDB_RETENTION_STATEMENTS: readonly string[]; /** PG 方言的建表语句(MySQL 孪生的逐条翻译:AUTO_INCREMENT→BIGSERIAL,逐列 `COLLATE "C"`,内联 KEY→独立 * CREATE INDEX)。语义逐字见 MySQL 孪生的行内注,此处不复述(两份注释迟早分叉)。 */ export declare const PG_RETENTION_SCHEMA: readonly string[]; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgRetentionSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const RETENTION_INDEXES: readonly IndexSpec[]; /** PG 侧的幂等 schema apply(由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合;裸 `PgQueryFn` 形,与 * approval-ask / leader-run 同姿势 —— DDL 仍跑在中央那条 client 上,advisory lock 的 session 语义不受影响)。 */ export declare function ensurePgRetentionSchema(query: (text: string, params?: unknown[]) => Promise): Promise; /** * 一次破坏性留存调用的**审计上下文**——判定时点的三格,由调用方(车2 的 lane)传入。 * * 🔴 为什么必须由调用方传、而不是店自己去读配置:`mode`/`policyDays` 是**判定时点**的事实(策略是部署 * env、模式是灰度旋钮,两者都会在两次 sweep 之间被改),`fencingToken` 更是本轮租约的身份。店去现读 * 配置 = 审计行记的是「写这行的那一刻的配置」,而不是「按哪档删的」,那正好把审计的意义抹掉。 */ export interface RetentionAuditContext { /** 判定时点的灰度档。词表闭集(与 §4 同一张表);`audit-only` 档下 lane 不该调破坏性方法,但如果调了, * 审计行会如实记下这个矛盾(而不是被店悄悄改写成 enforce)。 */ mode: "audit-only" | "enforce"; /** 判定时点的策略值(天)。 */ policyDays: number; /** 本轮 sweep lease 的 fencing token(§3)。`null` = 调用方没有租约面(单机/测试);破坏性行仍会落 * 审计,只是这一位为空 —— 「没有轮次身份」本身也是审计要记的事实。 */ fencingToken: number | null; } /** 三只契约方法的入参 —— core 的 `{domain, cutoffMs}` **超集**(多一个可选的审计上下文,见下)。 */ export interface RetentionCallInput { domain: string; cutoffMs: number; /** * 🔴 类型上**可选**、运行时**必需**(fail-closed)。可选是为了结构上仍然满足 core 的 * `ManagedRetentionCapability`(拿着契约类型的调用方看得见的形必须是 core 那个形);运行时缺席则当场 * 抛 —— 一次没有审计上下文的破坏性调用 = 无证删除,而 §6 F5 的全部意义就是不许有无证删除。 */ audit?: RetentionAuditContext; } /** `previewRetention` 的三候选计数(**非契约**窄读口:core 无 count 面,这是 server 自己店上的超集)。 */ export interface RetentionPreview { checkpoints: number; sessions: number; toolResults: number; } /** 装配面必须如实告知的**可选表在场性**(见 {@link SqlRetentionStore} 的构造器注)。 */ export interface SqlRetentionStoreOptions { /** * `task_attachment` 表是否在场。**必填,没有默认值**:boot 只在配了对象存储时才 ensure 这张表 * (`boot/stores.ts` 的附件段:无 MinIO ⇒ 整段不接线、表不建),于是一条盲发的 DELETE 在无对象存储的 * 部署上会以 unknown-table 报错、把整轮 sweep 打红。让装配层如实回答,比让店去吞一个错误好 —— 吞错误 * 就是静默 fail-open(附件行从此永不被留存清理,而没有任何人知道)。 */ taskAttachmentTable: boolean; /** * [ref] `task_list_meta` / `task_list_item` 两表是否在场。**必填,没有默认值**(与 * `taskAttachmentTable` 同一条理由:盲发 DELETE 会在没建表的库上把整轮 sweep 打红)。 * 生产上 `boot/task-list-lane.ts` 在**任何 SQL 后端**都无条件 ensure 这两张表 ⇒ 装配层照实传 true; * 纯内存/local 车道压根没有 SQL 留存腿。 */ taskListTables: boolean; /** 诊断日志座(可选)。 */ logger?: { info?(msg: string, meta?: unknown): void; warn?(msg: string, meta?: unknown): void; }; } /** 单次调用的候选上界(**安全界,不是分批语义**)。设计稿 §3 的 v1 口径是「先由 cutoff 天然限量」, * 这里额外扣一个上界,理由是运维面而不是功能面:一个从未清过账的大租户会让「一个事务删十余张表的 * 全部历史」变成一场锁堆积。剩下的行在下一拍自然收敛(三方法皆幂等),读数因此偏保守而不会偏危险。 */ export declare const RETENTION_SESSION_BATCH = 500; /** checkpoint / tool_result 腿的同款上界(单行代价远小于会话树,故取大一档)。 */ export declare const RETENTION_ROW_BATCH = 2000; /** * 托管留存能力的双方言实现 —— core `ManagedRetentionCapability` 的 server 侧真身。 * 方言差异台账见文件顶注;每个破坏性方法的事务形(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback) * 见各方法的头注。 */ export declare class SqlRetentionStore implements ManagedRetentionCapability { protected readonly db: SqlDriver; protected readonly opts: SqlRetentionStoreOptions; /** 本店自己也是一只 `retention: "managed"` 的店(它就是那三个方法的实现体)。 */ readonly retention: RetentionDeclaration; constructor(db: SqlDriver, opts: SqlRetentionStoreOptions); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE (A12 判据)。 */ protected q(tidb: string, pg: string): string; /** * 域谓词。**四种写法逐条写出来**(方言 × 有主/无主): * · 有主:`col = ?` / `col = $n`; * · 无主(域键 `""`):`(col IS NULL OR col = ?)` / `(col IS NULL OR col = $n)` —— 把 SQL NULL 与空串 * 合并进同一个桶。合并只发生在**无主侧**,永远伸不进一个真租户;拆开才会漏(NULL 那半永不被枚举)。 * 🔴 两支的**占位符数量刻意相同**(无主支也绑一个 `""`):arity 一变,PG 的 `$n` 编号就会在同一条 SQL 的 * 后续参数上错位——那是一类只在某个域值上才复现的 bug,不许存在。 */ protected domainWhere(col: string, domain: string, pgIndex: number): { sql: string; params: unknown[]; }; /** * 多值集合谓词 —— **真方言分叉**(不是 SQL 文本差):MySQL 展开 N 个 `?`,PG 用**一枚**数组参数 * `= ANY($n::text[])`。占位符数量因此不同(N vs 1),这是先例(workflow-run-store-sql 的 reap)也是 * 必然:把 500 个 id 展开成 500 个 `$n` 会让语句文本随批量变形,PG 的预编译缓存跟着退化。 */ protected idSet(col: string, ids: readonly string[], pgIndex: number): { sql: string; params: unknown[]; }; /** * 事务壳。本店要的是「`SELECT … FOR UPDATE` 是**当前读**」—— 判据不在这里,在 * {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`(隔离级与 TiDB 悲观模式是**连接的事实**, * 每条池连接初始化时钉一次,设不上就拒启)。 * * `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。 */ protected tx(fn: (conn: SqlTxConn) => Promise): Promise; /** * 破坏性调用的**入门检**:审计上下文缺席 ⇒ 当场抛(fail-closed)。 * core 的契约形只有 `{domain, cutoffMs}`,所以一个拿着契约类型的调用方**在类型上**能省掉它 —— 那正是 * 这道运行时门存在的理由:省掉它的那次调用如果照删不误,库里就多了一批无证删除的行。 */ protected requireAudit(input: RetentionCallInput, action: RetentionAuditAction): RetentionAuditContext; /** * 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。 * * 两步,缺一不可: * ① 幂等建一条 `held = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE domain = domain` / * PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存的 hold(两方言的这两个动词都只在缺行时 * 写),只保证**接下来那把行锁有东西可锁**;重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式 * (`INSERT IGNORE` 在这里取 S 再升 X = InnoDB 上两个并发 sweep 互等的死锁形,B-013); * ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。 * * 🔴 为什么必须先造行(codex 交叉复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap * lock;PG READ COMMITTED 同样)。presence 形(「行在 = 冻结」)下,operator 的 PUT 与本事务可以**同时** * 都读到「没有 hold」,PUT 提交返回成功之后本事务照删不误 —— 设计稿 §5 声称由行锁给出的串行化在那一形 * 上根本不存在。哨兵行把 hold 的写点与删除的读点压到**同一把行锁**上,那句承诺才成立。 * ⚠️ 车2 交接:PUT/DELETE hold 必须走**同一 PK 的 upsert 改 `held` 列**(不是 INSERT/DELETE 行), * 否则这把锁又会退回缺席形。 */ protected holdInForce(conn: SqlTxConn, domain: string): Promise; /** * 审计行的**唯一**写点(§6 F5:破坏性行由店在删除同事务内写)。 * `protected` 是**有意的注入口**:真双库套件用一只覆盖本方法为抛错的子类来证 V10(「注入 audit 写失败 * ⇒ 整事务回滚、数据仍在」)。把注入点做成 mock 层的猴补丁反而证不了这件事 —— 它证的是 mock。 */ protected writeAuditRow(conn: SqlTxConn, row: { domain: string; action: RetentionAuditAction; audit: RetentionAuditContext; deleted: number; skipped: number; tombstones: number; }): Promise; /** * 墓碑写入(幂等)。返回**真正新落地**的墓碑数 —— 重跑时既有墓碑不重复计数,于是 receipt 的 * `tombstones` 与 `deleted` 在幂等重跑上同步归零。 * * 方言差异:MySQL 展开多值 `VALUES (…),(…)` + `INSERT IGNORE`;PG 用 `unnest($n::text[])` 定 arity + * `ON CONFLICT (domain, kind, row_key) DO NOTHING`(数组形让语句文本不随批量变形)。 */ protected writeTombstones(conn: SqlTxConn, domain: string, kind: RetentionTombstoneKind, keys: readonly string[]): Promise; /** * 三保留表 ∪ 墓碑的域并集(v1.2 codex F4 形)。 * * 🔴 为什么 `tool_result` 没有自己的 UNION 臂(如实说明,不是漏写):这张表**没有域列** —— 一行卸载结果的 * 域由它的属主 session 派生,所以「属主还在」的行的域 ⊆ session 臂;而属主 session 一旦没了(上一轮删掉/ * 崩在中途),它的域在 SQL 里**无从得知**。这正是墓碑臂存在的理由:会话树删除时落的墓碑带着 domain,于是 * 「只剩孤儿的域」依然可枚举(V11)。再写一条 join 臂只会得到 session 臂的子集,不会多枚举出任何东西。 * * 文本**两方言逐字相同**(无占位符)⇒ 单条共享语句(先例:file-snapshot-store 的 gcOrphanBlobs)。 * 排序在 JS 侧,判据面因此与引擎的排序规则无关。 */ listRetentionDomains(): Promise; /** * 三候选计数(audit-only 档的读数源)。**零写、零事务**:它是纯读口,开事务只会白占一条连接。 * * 口径 = 与三个破坏性方法**同一套谓词**(候选而非上限): * · checkpoints:本域过视界的 pending 行,**扣掉 live-pinned**(那些行本来就不会被删); * · sessions:本域过视界、且当下无活引用的会话树; * · toolResults:本域可归属的孤儿卸载结果。 * 🔴 preview **不看 hold**:hold 是「这一轮跳不跳」的域级裁决(lane 记 `skipped_legal_hold` 行),而 * preview 回答的是「命中集有多大」。两件事混在一起,operator 就看不出「冻结期间到底积压了多少候选」。 */ previewRetention(input: { domain: string; cutoffMs: number; }): Promise; /** * CAS 栅栏 `pending → expired`,每行恰一胜者(胜负由 `status = 'pending'` 谓词在引擎里裁,跨副本安全)。 * * 事务形(§5 F2 + §6 F5,一步不省): * ① `retention_hold` 本域行的**锁读** —— 行在 ⇒ 本事务零变更、全计 skipped 返回; * ② 候选读 + CAS UPDATE; * ③ 审计行(同事务)→ COMMIT。**没有**提交前的第二次 hold 复核:域的行锁在 ① 就拿住了并持有到 * COMMIT,一个并发的 PUT hold 在此期间提交不了(它抢同一把锁)——一段恒真的"复核"比没有更坏。 * * 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条 * 没有审批 TTL 的 pending 行就永远过不了期([ref] §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。 * * 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [ref] 定谳:retention.ts 契约那句 * 「AND release the associated session pins」是真意,[ref] 引错了对象——它引的是引擎 live-path reap * 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'` * 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用 * 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [ref] 的 * 「释放已释放 pin=no-op」要求。单写者不变式按 [ref] 改述:**每行的 pin 恰好释放一次,由终局该行 * 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是 * 「不交」的执行判据)。 * 边界形([ref] 建议,[ref] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条 * 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。 * * receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里 * 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。 */ expireCheckpoints(input: RetentionCallInput): Promise; /** * 视界谓词的两条臂(`deadline` 在场按 deadline、缺席按 `created_at_ms`)—— 两方言逐字相同,占位符各自编号。 * 抽成一处是因为它同时被「可过期候选」与「被 pin 的计数」两条查询用,手抄两份必分家。 */ private horizonArms; /** 「这条 checkpoint 的会话被一次活跑占着」= [ref] 的 live-pinned 判据(SQL 谓词形,两方言同文本)。 */ private static readonly PINNED_EXISTS; /** * **可过期**的候选 token(保护谓词写在 SQL 里、扣在 `LIMIT` **之前**)。 * * 🔴 为什么保护必须下推(codex 交叉复审 R1-[medium],验真后改):旧形先按时间取 N 行、再在 JS 里剔除 * pinned —— 只要最老的一批持续被占着,每轮取回的都是同一批、每轮删 0,**它们后面**那些同样过期、又 * 没有任何引用的行**永远轮不到**。那不是「下一拍自然收敛」,那是一个域的留存静默停摆(而且读数一直 * 是 deleted=0,看上去像"没有候选")。判据下推之后 LIMIT 扣的是**真能动的行**,头部被保护多少行都不 * 影响后面的收敛。 */ private expiryCandidates; /** * 过期视界之内、**当下被 live-pin 护住**的 pending 行数 = receipt 的 `skipped`。 * * 🔴 账法定谳(两轮交叉复审各推了一次,最终落在这一形): * · 曾经用「候选 + pinned 两读相加当分母、差值当 skipped」——被 codex R2 抓到会把中途翻转 pin 的行数两遍; * · 改成「视界总数当分母、差值当 skipped」——被 codex R3 抓到在 PG 的 READ COMMITTED 下可以**为负** * (分母读完之后又有行被 put/reopen 成 pending,候选与 UPDATE 都看得见它 ⇒ expired > 分母),而一条 * 负数写进**追加式**审计表是比「小幅重复计数」坏得多的事(审计不可改,读它的人无从判断哪一格出了错); * · ⇒ 现形:`deleted` = UPDATE 的**真实命中**(pin 判据写在它自己的 WHERE 里,数据面按构造正确), * `skipped` = 同一事务内测得的**受保护行数**(本方法)。两者是**两个独立测得的事实**,不构成 * `deleted + skipped = 总数` 的恒等式,两者都恒 ≥ 0。中途翻转 pin 状态的行最多被两边各记一次 * (量级=翻转数,通常 0),这是刻意选的失真方向:**宁可轻微重复计数,也不让审计出现负数**。 * * 🔴 **有界**(codex R2-[medium],验真后改):裸 `COUNT(*)` 对一个积压很深的域是无界扫描,而 EXISTS 关联的 * 都是没有为本用途建过索引的列。留存是每小时一拍的后台活,不该把一次精确到最后一行的计数买成数据库尖峰 * ⇒ 计数扣在与批量同阶的上界内(派生表 LIMIT),读数是「至少这么多」。少算的部分下一拍照样看得见 * (判据幂等),而运维要的是「有没有积压、量级多大」。索引面(workflow originating_session_id 物化列 / * background-agent 活锚 / checkpoint 的 scope+status+deadline 复合索引)是独立一车,已进交接清单。 */ private countPinnedExpiryCandidates; /** * 会话树的**四条活引用腿**的 SQL 谓词(`sm` = session_meta 的别名)。写成一处的理由与 {@link horizonArms} * 相同,而且更重:它同时是「可删候选」的否定谓词与「被保护计数」的肯定谓词,两份手抄必然在某次改动后分家, * 而分家的方向是**多删**(候选放宽、计数收紧,两边都不会有人红)。 * * 腿的词表:活跑 claim / 挂起 park / 活着的后台子代(running|parked,三根会话锚)/ 在跑的 workflow * (`originatingSessionId` 住在 run blob 里 ⇒ JSON 提取是真方言分叉)。fork 子代**结构上没有边**, * 理由见 {@link SqlRetentionStore.deleteExpiredSessions} 的头注。 */ private liveReferenceExists; /** * 本域过视界、**当下被活引用护住**的会话数 = receipt 的 `skipped`。 * 账法与理由(为什么不是「总数减已删」的差值、为什么恒 ≥ 0、为什么有界)逐字见 * {@link SqlRetentionStore.countPinnedExpiryCandidates}:两条腿同一条账法,不许在这里另立一套。 */ private countProtectedSessions; /** * **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 —— * 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。 * * `forUpdate = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 交叉复审 R1-[high]): * · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住; * · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries / * replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集 * 删完附属表、最后那条属主门 DELETE 却落空」就成立,而下一句无条件的 `session_event` 删除会把一个 * **刚刚换了主**的会话的历史一并抹掉(跨租户不可逆丢失)。 * · 锁 + 谓词在同一条语句里,于是「候选」与「删除」之间不存在窗口;`purgeSessionTrees` 尾部再断言 * 「meta 命中数 == 预期数」,两道一起把这条路封死。 */ private sessionCandidates; /** 孤儿卸载结果的候选计数(实现随 `deleteOrphanToolResults` 同刀落地)。 */ private countOrphanToolResultCandidates; /** * 本域**可归属**的孤儿卸载结果(属主 session 已亡 + 墓碑归属本域 + 归属**无歧义**)。 * * 🔴 第三个条件是 fail-closed 的归属门(codex 交叉复审 R3-[high],验真后加):`tool_result` 没有域列, * 它的域完全靠「属主 sessionId 的墓碑」推。而会话 id 是**调用方可自选**的 ⇒ 两个租户先后用过同一个 id * 完全合法;两边都删过之后,同一个 row_key 上会有**两个域**的墓碑,这时任何一边的 sweep 拿自己的墓碑 * 去认领这些字节都只是猜。判据因此是:**存在第二个域的同名墓碑 ⇒ 这批行不可归属 ⇒ 一根不删**, * 实体清理退回既有的 TTL 腿(`reapOlderThan`)。少删一次是可接受的;删掉另一个租户的字节、还跳过它的 * legal hold 与审计域,不可接受。 */ protected orphanToolResultCandidates(exec: { query: SqlDriver["query"]; }, input: { domain: string; cutoffMs: number; }): Promise; /** * lineage-aware 的会话树删除。**引用在删除事务内重查**(core 契约逐字:「references are re-checked inside * the deletion transaction, never assumed from a prior scan」),有活引用的树**整棵**跳过计 `skipped`。 * * 事务形(§5 F2 + §6 F5): * ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT; * ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见 * {@link SqlRetentionStore.sessionCandidates}); * ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。 * * 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 交叉复审 R1 的两条 high 合并采纳): * · 两条查询之间有窗口,而下推之后「被查到的」按构造就是「四条腿一条都不命中的」; * · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 —— * 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉; * · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的 * "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。 * ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰 * `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前 * 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样 * (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁 * `session_meta` 那一行(共同锁序)—— 那是在线热路径的改动,归属另一次裁定,已进交接清单。 * * 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边): * · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据); * · `checkpoint.status='pending'` —— 还赎得回的 park; * · `background_agent` 的 `running|parked` 行(session_id / parent_session_id / root_session_id 三根锚); * · `workflow_run` 的 `running` 行(`originatingSessionId` 在 run blob 里,JSON 提取两方言各写各的)。 * · 🔴 **fork 子代没有 durable 边**(亲读 `TiDBSessionStore.fork` / `session_meta` 的 DDL 坐实):E17 的 * fork 是**整段历史拷贝**成一个独立会话,库里不留父子指针。这不是漏查 —— 拷贝形下删父会话不会让子 * 会话悬空(它的字节是它自己的),所以这条边**结构上不需要保护**。设计稿把「fork 子」列进 lineage * 清单是按 core 契约的通用措辞抄的;本仓的 fork 语义使它落空,记在这里免得后人当缺口再"补"一次。 * * receipt 口径:`deleted` = **删掉的会话树棵数**(= `session_meta` 的命中行数),不是级联删掉的总行数 * ——后者跨十余张表、随部署形态漂(附件表可能不在场),做不成稳定判据;而「幂等重跑 ⇒ 0」这条契约在 * 两种口径下同真。`tombstones` = 本次**新落地**的墓碑数(重跑时既有墓碑不重复计)。 */ deleteExpiredSessions(input: RetentionCallInput): Promise; /** * E21 顺序契约的**事务化搬入**:同一组 DELETE、同一个顺序,一次性发在一条事务连接上。 * 顺序的两条承重理由(其余是可读性): * · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在 * `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉); * · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。 * 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等 * 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言 * (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。 * * 属主门的一处**有意改写**(而不是照抄 `<=>`):在线协调器传的是"这个会话的 owner"(可能是 SQL NULL), * 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达 * `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL); * 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。 * 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。 * * 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17 * 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量 * 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta` * 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是 * "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta * 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。 */ protected purgeSessionTrees(conn: SqlTxConn, domain: string, ids: readonly string[]): Promise; /** * 属主 session 已亡的 offloaded 结果(core 契约:「whose owning sessions are gone (or past the horizon), * skipping refs still reachable from live transcripts」)。 * * 三条口径,逐条都是判据(它们决定了这条腿**不会**碰什么): * · **删的那一支**:`owner_session_id` 指向一个**不在 `session_meta` 里**的会话,且该会话在本域留有 * `kind='session'` 墓碑,且行本身过了视界。墓碑是**域归属的唯一证据** —— 没有它,一条孤儿行的租户 * 在 SQL 里根本问不出来(表没有域列),而按 ref 前缀猜属主是 core 明令禁止的(ref 是 opaque handle, * 调用方可自铸;`tool-result-store-sql.deleteBySession` 的顶注逐字记着这条被 codex 收窄过的教训)。 * · **skip 的那一支**:属主会话**还在** = 转录活着 = 「still reachable from live transcripts」⇒ 过视界 * 也只计 `skipped` 不删。会话本体的清理归 {@link SqlRetentionStore.deleteExpiredSessions}(它会连着 * 这行一起删),两条腿因此不重叠也不互相抢行。 * · **一根不碰的那一支**:`owner_session_id IS NULL` 的**无出处**行。没有出处就没有域,按任何域去删它 * 都是猜;实体清理归既有的 TTL 腿(`reapOlderThan`,`boot/reapers.ts` 驱动)。这与 core 的 * 「an UNOWNED entry is never deleted」是同一条纪律的两处执行点。 * * 事务形与另外两只方法逐字同款(hold 哨兵行锁读 → 删除+墓碑 → 审计行,同 commit 同 rollback)。 */ deleteOrphanToolResults(input: RetentionCallInput): Promise; /** 「活转录可达」的行数(属主会话仍在 `session_meta`、域是本域、行已过视界)= receipt 的 `skipped`。 */ protected countLiveReachableToolResults(exec: { query: SqlDriver["query"]; }, input: { domain: string; cutoffMs: number; }): Promise; } /** MySQL-protocol (TiDB) binding。 */ export declare class TiDBRetentionStore extends SqlRetentionStore { constructor(pool: MySqlPool, opts: SqlRetentionStoreOptions); } /** PostgreSQL binding。 */ export declare class PgRetentionStore extends SqlRetentionStore { constructor(pool: PgPool, opts: SqlRetentionStoreOptions); } //# sourceMappingURL=retention-store-sql.d.ts.map