@sema-agent/server 7.29.0 → 7.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +20 -5
- package/dist/boot/governance-seams.d.ts +13 -0
- package/dist/boot/governance-seams.js +21 -1
- package/dist/boot/retention-lane.d.ts +206 -0
- package/dist/boot/retention-lane.js +279 -0
- package/dist/boot/shutdown.d.ts +8 -0
- package/dist/boot/shutdown.js +21 -2
- package/dist/config-types.d.ts +22 -1
- package/dist/config-types.js +6 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +43 -2
- package/dist/http/routes/capabilities.js +30 -0
- package/dist/http/routes/memory-policy.d.ts +13 -0
- package/dist/http/routes/memory-policy.js +79 -2
- package/dist/http/routes/retention-ops.d.ts +36 -0
- package/dist/http/routes/retention-ops.js +190 -0
- package/dist/http/server.js +14 -0
- package/dist/main.js +74 -0
- package/dist/memory-scope.d.ts +5 -3
- package/dist/memory-scope.js +6 -8
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/caching-session-store.d.ts +11 -1
- package/dist/plugins/caching-session-store.js +13 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +3 -0
- package/dist/plugins/checkpoint-store-sql.js +4 -0
- package/dist/plugins/local-checkpoint-store.d.ts +3 -0
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/local-session-store.d.ts +5 -0
- package/dist/plugins/local-session-store.js +6 -0
- package/dist/plugins/memory-engine-pg.js +22 -10
- package/dist/plugins/memory-engine-tidb.js +22 -11
- package/dist/plugins/memory-key-guards.d.ts +27 -4
- package/dist/plugins/memory-key-guards.js +57 -6
- package/dist/plugins/memory-sync-store-pg.js +5 -0
- package/dist/plugins/memory-sync-store-tidb.js +5 -0
- package/dist/plugins/pg-pool.js +4 -0
- package/dist/plugins/pg-session-storage.d.ts +2 -0
- package/dist/plugins/pg-session-storage.js +3 -0
- package/dist/plugins/retention-lane-store-sql.d.ts +254 -0
- package/dist/plugins/retention-lane-store-sql.js +236 -0
- package/dist/plugins/retention-store-sql.d.ts +468 -0
- package/dist/plugins/retention-store-sql.js +793 -0
- package/dist/plugins/store-backend.d.ts +20 -0
- package/dist/plugins/store-backend.js +7 -0
- package/dist/plugins/tidb-pool.js +5 -0
- package/dist/plugins/tidb-session-store.d.ts +3 -0
- package/dist/plugins/tidb-session-store.js +4 -0
- package/dist/plugins/tool-result-store-sql.d.ts +3 -0
- package/dist/plugins/tool-result-store-sql.js +4 -0
- package/dist/run-local.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
2
|
+
/**
|
|
3
|
+
* 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 为什么是一个共享常量而不是各写各的字面量:这一格是 core `assertRetentionCapability` 的**唯一**读点,
|
|
6
|
+
* 三只店的答案必须同进同退 —— 哪天本能力从某只店的行上撤了(比如 tool_result 改由别处清),漏改一处
|
|
7
|
+
* 就是一句谎,而谎的方向是「locked policy 放行了一只其实不删的店」。
|
|
8
|
+
*
|
|
9
|
+
* 🔴 声明的**读法**(与 core 契约的字面对齐,别读大也别读小):`"managed"` 说的是「**这只店的行**由本
|
|
10
|
+
* 部署的托管留存按期删除」,不是「本对象自己实现了三个方法」。三方法的实现体是本文件的
|
|
11
|
+
* `SqlRetentionStore` —— 它是 E21 purge 协调器的事务化孪生,横跨十余张表(理由见文件顶注的布局裁定)。
|
|
12
|
+
* core 的门读的是「这只店的数据会不会被按期删掉」(拒启文案逐字:「a locked retention policy over stores
|
|
13
|
+
* that cannot delete would be *policy locked, data immortal*」),SQL 三店的诚实答案就是 `"managed"`。
|
|
14
|
+
* file/in-memory 形没有任何东西会按期删它们的行 ⇒ 显式 `"none"`(缺席也读 none,但显式是文档义务)。
|
|
15
|
+
*/
|
|
16
|
+
export const MANAGED_RETENTION = "managed";
|
|
17
|
+
/** file/in-memory 形的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */
|
|
18
|
+
export const UNMANAGED_RETENTION = "none";
|
|
19
|
+
/** 追加式审计表(设计稿 §6)。 */
|
|
20
|
+
export const RETENTION_AUDIT_TABLE = "retention_audit";
|
|
21
|
+
/** per-domain 法务保留标记(设计稿 §5)——**行在 = 该域整体冻结**。 */
|
|
22
|
+
export const RETENTION_HOLD_TABLE = "retention_hold";
|
|
23
|
+
/** sweep 互斥的单行租约表(设计稿 §3)。表建在车1(schema 一次齐),抢/续租的逻辑归车2。 */
|
|
24
|
+
export const RETENTION_LEASE_TABLE = "retention_lease";
|
|
25
|
+
/** 删除墓碑(设计稿 §2)——副本/备份收敛 **+ 域枚举并集的第四条腿**(codex F4)。 */
|
|
26
|
+
export const RETENTION_TOMBSTONE_TABLE = "retention_tombstone";
|
|
27
|
+
/** MySQL 协议方言的建表语句(真源;由 `tidb-pool.ts` 展开进中央 `SCHEMA_STATEMENTS`)。 */
|
|
28
|
+
export const TIDB_RETENTION_STATEMENTS = [
|
|
29
|
+
// ── §6 审计表:**追加不更新**。破坏性行(前三 action)由 store 在删除同一个事务内写(F5:先删后补记
|
|
30
|
+
// = crash-after-delete-before-audit 会留下无证删除,而幂等重试 deleted=0 再也重构不出原始计数)。
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_AUDIT_TABLE} (
|
|
32
|
+
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
33
|
+
domain VARCHAR(190) NOT NULL,
|
|
34
|
+
-- action:闭集词表见 RetentionAuditAction。前三词 = 破坏性行(本店写),其余 = lane/路由写。
|
|
35
|
+
action VARCHAR(32) NOT NULL,
|
|
36
|
+
-- mode:判定时点的灰度档(audit-only | enforce)——「按哪档跑的」必须与计数同行,否则事后无法解释
|
|
37
|
+
-- 一行 deleted=0 是"没有候选"还是"当时是 audit-only"。
|
|
38
|
+
mode VARCHAR(16) NOT NULL,
|
|
39
|
+
-- policy_days:判定时点的策略值(按哪版策略删的)。策略是部署 env,会被改;计数离开它就没有意义。
|
|
40
|
+
policy_days INT NOT NULL,
|
|
41
|
+
-- fencing_token:本轮 sweep lease 的 fencing token(§3;**破坏性行必填**,非破坏行可空)。丢租后
|
|
42
|
+
-- 在飞的旧轮仍可能提交一条审计行,靠这一位判别新旧轮。
|
|
43
|
+
fencing_token BIGINT NULL,
|
|
44
|
+
-- 三数字 = RetentionReceipt(audit_only 行 = previewRetention 的三候选数)。
|
|
45
|
+
deleted INT NOT NULL DEFAULT 0,
|
|
46
|
+
skipped INT NOT NULL DEFAULT 0,
|
|
47
|
+
tombstones INT NOT NULL DEFAULT 0,
|
|
48
|
+
executed_at_ms BIGINT NOT NULL,
|
|
49
|
+
error TEXT NULL,
|
|
50
|
+
PRIMARY KEY (id),
|
|
51
|
+
KEY idx_retention_audit_domain (domain, id),
|
|
52
|
+
KEY idx_retention_audit_executed (executed_at_ms)
|
|
53
|
+
) COLLATE utf8mb4_bin`,
|
|
54
|
+
// ── §5 法务保留 + **域级互斥哨兵**(一张表两个职责,理由在下面 held 列的注里)。
|
|
55
|
+
// 承重判在**店事务内**(设计稿 F2:lane 预检后、三方法之间 hold 才 commit ⇒ 数据在 operator 成功
|
|
56
|
+
// 放置 hold **之后**仍被不可逆删除)。放置/解除的路由与非破坏性审计行归车2。
|
|
57
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_HOLD_TABLE} (
|
|
58
|
+
domain VARCHAR(190) NOT NULL,
|
|
59
|
+
-- held:**1 = 冻结中,0 = 只是一把锁**。
|
|
60
|
+
-- 🔴 为什么冻结不能用「行在/行不在」表达(codex 对抗复审 R1-[high],验真后改的**承重**形):
|
|
61
|
+
-- 设计稿 §5 声称「PUT hold 的 commit 一旦完成,其后任何删除事务都必然看见它,串行化由行锁给出」——
|
|
62
|
+
-- 而 SELECT … FOR UPDATE **锁不住一条不存在的行**(TiDB 无 gap lock;PG 的 READ COMMITTED 同样
|
|
63
|
+
-- 不锁缺席行)。presence 形下两边都读到「没有 hold」是完全可能的,于是 operator 拿到成功回执之后
|
|
64
|
+
-- 数据仍被删 —— 那正是 F2 要消灭的那件事,只是换了个位置复发。
|
|
65
|
+
-- ⇒ 行**恒存在**(破坏性事务首步 INSERT IGNORE 一条 held=0 的哨兵),冻结与否写在这一列上。
|
|
66
|
+
-- 于是 hold 的放置(同一 PK 的 upsert)与三只破坏性事务抢的是**同一把行锁**,串行化由引擎给出:
|
|
67
|
+
-- PUT 提交完成 ⇒ 其后每一个破坏性事务的首读必见 held=1。放置/解除都必须走这一列(车2 交接件)。
|
|
68
|
+
held TINYINT(1) NOT NULL DEFAULT 0,
|
|
69
|
+
placed_by VARCHAR(190) NULL,
|
|
70
|
+
placed_at_ms BIGINT NULL,
|
|
71
|
+
note TEXT NULL,
|
|
72
|
+
PRIMARY KEY (domain)
|
|
73
|
+
) COLLATE utf8mb4_bin`,
|
|
74
|
+
// ── §3 sweep 租约(**单行**):互斥机制 = durable lease,不是 LEADER_ENABLED(codex F1:仓内
|
|
75
|
+
// `leaderEnabled` 是纯布尔配置门,零选举/零租约/零 fencing ⇒ 每台配 true 的副本都自认 leader)。
|
|
76
|
+
// 抢/续租(CAS)与 fencing token 自增归车2;本车只保证表在。
|
|
77
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_LEASE_TABLE} (
|
|
78
|
+
-- singleton:恒 'x' 的单行键(全局唯一一只 sweep 租约)。用定值列而不是 id=1,是为了让"这张表只能有
|
|
79
|
+
-- 一行"写在**主键**上而不是写在注释里。
|
|
80
|
+
singleton CHAR(1) NOT NULL DEFAULT 'x',
|
|
81
|
+
holder VARCHAR(190) NOT NULL,
|
|
82
|
+
-- fencing_token:每次**抢租**(而非续租)自增;随本轮全部审计行走,旧轮的写因此可判别。
|
|
83
|
+
fencing_token BIGINT NOT NULL DEFAULT 0,
|
|
84
|
+
expires_at_ms BIGINT NOT NULL,
|
|
85
|
+
PRIMARY KEY (singleton)
|
|
86
|
+
) COLLATE utf8mb4_bin`,
|
|
87
|
+
// ── §2 墓碑:删除收敛(副本/备份重放不复活)+ **域枚举并集的第四条腿**(codex F4)。
|
|
88
|
+
// 为什么枚举离不开它:`tool_result` 没有域列,它的域从属主 session 派生;一旦属主 session 行没了
|
|
89
|
+
// (上一轮删掉/崩在中途),那些孤儿结果的域在 SQL 里**无从得知** ⇒ 该域从枚举里消失、孤儿永不清。
|
|
90
|
+
// 墓碑行带 domain,于是"只剩孤儿的域"依然可枚举(V11)。
|
|
91
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_TOMBSTONE_TABLE} (
|
|
92
|
+
domain VARCHAR(190) NOT NULL,
|
|
93
|
+
-- kind:闭集(session | tool_result),见 RetentionTombstoneKind。
|
|
94
|
+
kind VARCHAR(32) NOT NULL,
|
|
95
|
+
-- row_key:被删行的身份键(session_id / tool_result.ref)。宽度取 tool_result.ref 的上界 518
|
|
96
|
+
-- (core 的 MAX_MINTED_TOOL_RESULT_REF_CHARS)—— 截断的墓碑键 = 两枚不同的 ref 折成一条墓碑。
|
|
97
|
+
row_key VARCHAR(518) NOT NULL,
|
|
98
|
+
deleted_at_ms BIGINT NOT NULL,
|
|
99
|
+
-- 🔴 主键**带 domain**(codex 对抗复审 R3-[high],验真后改):行键是 sessionId / tool_result.ref,而
|
|
100
|
+
-- 会话 id 是**调用方可自选**的(server.ts 逐字:「a client MAY supply an ARBITRARY id … LENGTH-ONLY」)
|
|
101
|
+
-- ⇒ 两个租户先后用同一个 id 完全合法。旧形 PK(kind,row_key) 全局唯一,后果有两条,都不轻:
|
|
102
|
+
-- ① 租户 B 删同名会话时墓碑 INSERT IGNORE 空转 ⇒ 回执 deleted=1 而 tombstones=0(账对不上),
|
|
103
|
+
-- 且 B 的域从此**枚举不到**它的孤儿(F4 的病灶原样复发);
|
|
104
|
+
-- ② A 的墓碑会被当成「这条孤儿 tool_result 属于 A」的唯一证据 ⇒ A 的 sweep 删 B 的字节,绕过 B 的
|
|
105
|
+
-- legal hold 与 B 的审计域。
|
|
106
|
+
-- 带 domain 之后每个域记自己的删除;**跨域同名**造成的归属歧义由孤儿查询那道 fail-closed 门兜底
|
|
107
|
+
-- (见 orphanToolResultCandidates:两个域都留过同名墓碑 ⇒ 不可归属 ⇒ 一根不删,交给 TTL)。
|
|
108
|
+
PRIMARY KEY (domain, kind, row_key),
|
|
109
|
+
KEY idx_retention_tombstone_key (kind, row_key)
|
|
110
|
+
) COLLATE utf8mb4_bin`,
|
|
111
|
+
];
|
|
112
|
+
/** PG 方言的建表语句(MySQL 孪生的逐条翻译:AUTO_INCREMENT→BIGSERIAL,逐列 `COLLATE "C"`,内联 KEY→独立
|
|
113
|
+
* CREATE INDEX)。语义逐字见 MySQL 孪生的行内注,此处不复述(两份注释迟早分叉)。 */
|
|
114
|
+
export const PG_RETENTION_SCHEMA = [
|
|
115
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_AUDIT_TABLE} (
|
|
116
|
+
id BIGSERIAL,
|
|
117
|
+
domain VARCHAR(190) COLLATE "C" NOT NULL,
|
|
118
|
+
action VARCHAR(32) COLLATE "C" NOT NULL,
|
|
119
|
+
mode VARCHAR(16) COLLATE "C" NOT NULL,
|
|
120
|
+
policy_days INT NOT NULL,
|
|
121
|
+
fencing_token BIGINT NULL,
|
|
122
|
+
deleted INT NOT NULL DEFAULT 0,
|
|
123
|
+
skipped INT NOT NULL DEFAULT 0,
|
|
124
|
+
tombstones INT NOT NULL DEFAULT 0,
|
|
125
|
+
executed_at_ms BIGINT NOT NULL,
|
|
126
|
+
error TEXT COLLATE "C" NULL,
|
|
127
|
+
PRIMARY KEY (id)
|
|
128
|
+
)`,
|
|
129
|
+
`CREATE INDEX IF NOT EXISTS idx_retention_audit_domain ON ${RETENTION_AUDIT_TABLE} (domain, id)`,
|
|
130
|
+
`CREATE INDEX IF NOT EXISTS idx_retention_audit_executed ON ${RETENTION_AUDIT_TABLE} (executed_at_ms)`,
|
|
131
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_HOLD_TABLE} (
|
|
132
|
+
domain VARCHAR(190) COLLATE "C" NOT NULL,
|
|
133
|
+
-- held:1 = 冻结中,0 = 只是那把域级互斥行锁的哨兵。完整判据(为什么 presence 形不成立)见 MySQL 孪生。
|
|
134
|
+
held SMALLINT NOT NULL DEFAULT 0,
|
|
135
|
+
placed_by VARCHAR(190) COLLATE "C" NULL,
|
|
136
|
+
placed_at_ms BIGINT NULL,
|
|
137
|
+
note TEXT COLLATE "C" NULL,
|
|
138
|
+
PRIMARY KEY (domain)
|
|
139
|
+
)`,
|
|
140
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_LEASE_TABLE} (
|
|
141
|
+
singleton CHAR(1) COLLATE "C" NOT NULL DEFAULT 'x',
|
|
142
|
+
holder VARCHAR(190) COLLATE "C" NOT NULL,
|
|
143
|
+
fencing_token BIGINT NOT NULL DEFAULT 0,
|
|
144
|
+
expires_at_ms BIGINT NOT NULL,
|
|
145
|
+
PRIMARY KEY (singleton)
|
|
146
|
+
)`,
|
|
147
|
+
`CREATE TABLE IF NOT EXISTS ${RETENTION_TOMBSTONE_TABLE} (
|
|
148
|
+
domain VARCHAR(190) COLLATE "C" NOT NULL,
|
|
149
|
+
kind VARCHAR(32) COLLATE "C" NOT NULL,
|
|
150
|
+
row_key VARCHAR(518) COLLATE "C" NOT NULL,
|
|
151
|
+
deleted_at_ms BIGINT NOT NULL,
|
|
152
|
+
-- 主键带 domain(理由逐字见 MySQL 孪生:会话 id 是调用方可自选的,跨租户同名合法)。
|
|
153
|
+
PRIMARY KEY (domain, kind, row_key)
|
|
154
|
+
)`,
|
|
155
|
+
`CREATE INDEX IF NOT EXISTS idx_retention_tombstone_key ON ${RETENTION_TOMBSTONE_TABLE} (kind, row_key)`,
|
|
156
|
+
];
|
|
157
|
+
/** PG 侧的幂等 schema apply(由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合;裸 `PgQueryFn` 形,与
|
|
158
|
+
* approval-ask / leader-run 同姿势 —— DDL 仍跑在中央那条 client 上,advisory lock 的 session 语义不受影响)。 */
|
|
159
|
+
export async function ensurePgRetentionSchema(query) {
|
|
160
|
+
for (const stmt of PG_RETENTION_SCHEMA)
|
|
161
|
+
await query(stmt);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 一次事务因为**中途出现的 hold**(或中途出现的活引用)而整体放弃 —— 私有哨兵,不外泄。
|
|
165
|
+
* 为什么用异常而不是返回值:放弃必须连带 ROLLBACK,而 rollback 的属主是 {@link SqlRetentionStore.tx};
|
|
166
|
+
* 让回调用返回值表达「请回滚」会让每个调用点都要记得回滚一次,漏一处就是一次真删除。
|
|
167
|
+
*/
|
|
168
|
+
class RetentionAborted extends Error {
|
|
169
|
+
why;
|
|
170
|
+
constructor(why) {
|
|
171
|
+
super(`retention transaction aborted: ${why}`);
|
|
172
|
+
this.why = why;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** 单次调用的候选上界(**安全界,不是分批语义**)。设计稿 §3 的 v1 口径是「先由 cutoff 天然限量」,
|
|
176
|
+
* 这里额外扣一个上界,理由是运维面而不是功能面:一个从未清过账的大租户会让「一个事务删十余张表的
|
|
177
|
+
* 全部历史」变成一场锁堆积。剩下的行在下一拍自然收敛(三方法皆幂等),读数因此偏保守而不会偏危险。 */
|
|
178
|
+
export const RETENTION_SESSION_BATCH = 500;
|
|
179
|
+
/** checkpoint / tool_result 腿的同款上界(单行代价远小于会话树,故取大一档)。 */
|
|
180
|
+
export const RETENTION_ROW_BATCH = 2000;
|
|
181
|
+
/**
|
|
182
|
+
* 托管留存能力的双方言实现 —— core `ManagedRetentionCapability` 的 server 侧真身。
|
|
183
|
+
* 方言差异台账见文件顶注;每个破坏性方法的事务形(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback)
|
|
184
|
+
* 见各方法的头注。
|
|
185
|
+
*/
|
|
186
|
+
export class SqlRetentionStore {
|
|
187
|
+
db;
|
|
188
|
+
opts;
|
|
189
|
+
/** 本店自己也是一只 `retention: "managed"` 的店(它就是那三个方法的实现体)。 */
|
|
190
|
+
retention = MANAGED_RETENTION;
|
|
191
|
+
constructor(db, opts) {
|
|
192
|
+
this.db = db;
|
|
193
|
+
this.opts = opts;
|
|
194
|
+
}
|
|
195
|
+
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE (A12 判据)。 */
|
|
196
|
+
q(tidb, pg) {
|
|
197
|
+
return this.db.dialect === "tidb" ? tidb : pg;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 域谓词。**四种写法逐条写出来**(方言 × 有主/无主):
|
|
201
|
+
* · 有主:`col = ?` / `col = $n`;
|
|
202
|
+
* · 无主(域键 `""`):`(col IS NULL OR col = ?)` / `(col IS NULL OR col = $n)` —— 把 SQL NULL 与空串
|
|
203
|
+
* 合并进同一个桶。合并只发生在**无主侧**,永远伸不进一个真租户;拆开才会漏(NULL 那半永不被枚举)。
|
|
204
|
+
* 🔴 两支的**占位符数量刻意相同**(无主支也绑一个 `""`):arity 一变,PG 的 `$n` 编号就会在同一条 SQL 的
|
|
205
|
+
* 后续参数上错位——那是一类只在某个域值上才复现的 bug,不许存在。
|
|
206
|
+
*/
|
|
207
|
+
domainWhere(col, domain, pgIndex) {
|
|
208
|
+
return domain === ""
|
|
209
|
+
? { sql: this.q(`(${col} IS NULL OR ${col} = ?)`, `(${col} IS NULL OR ${col} = $${pgIndex})`), params: [""] }
|
|
210
|
+
: { sql: this.q(`${col} = ?`, `${col} = $${pgIndex}`), params: [domain] };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 多值集合谓词 —— **真方言分叉**(不是 SQL 文本差):MySQL 展开 N 个 `?`,PG 用**一枚**数组参数
|
|
214
|
+
* `= ANY($n::text[])`。占位符数量因此不同(N vs 1),这是先例(workflow-run-store-sql 的 reap)也是
|
|
215
|
+
* 必然:把 500 个 id 展开成 500 个 `$n` 会让语句文本随批量变形,PG 的预编译缓存跟着退化。
|
|
216
|
+
*/
|
|
217
|
+
idSet(col, ids, pgIndex) {
|
|
218
|
+
return this.db.dialect === "tidb"
|
|
219
|
+
? { sql: `${col} IN (${ids.map(() => "?").join(",")})`, params: [...ids] }
|
|
220
|
+
: { sql: `${col} = ANY($${pgIndex}::text[])`, params: [ids] };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 事务壳 —— **平凡 `begin()`,不是 `beginPessimistic()`**(真库实测定谳,不是口味):
|
|
224
|
+
*
|
|
225
|
+
* · MySQL-protocol 腿:`BEGIN PESSIMISTIC` 是 **TiDB 专有语法**,发到普通 MySQL 上是语法错
|
|
226
|
+
* (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
|
|
227
|
+
* `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
|
|
228
|
+
* · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
|
|
229
|
+
* 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
|
|
230
|
+
* TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
|
|
231
|
+
* (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
|
|
232
|
+
* · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
|
|
233
|
+
*
|
|
234
|
+
* `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
|
|
235
|
+
*/
|
|
236
|
+
async tx(fn) {
|
|
237
|
+
const conn = await this.db.connect();
|
|
238
|
+
try {
|
|
239
|
+
await conn.begin();
|
|
240
|
+
const out = await fn(conn);
|
|
241
|
+
await conn.commit();
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
try {
|
|
246
|
+
await conn.rollback();
|
|
247
|
+
}
|
|
248
|
+
catch (rollbackErr) {
|
|
249
|
+
// 🔴 **回滚失败不许无声**(#191 门② 的形:兄弟店在这里普遍是空 catch,本店不跟)。原始错误仍然是
|
|
250
|
+
// 这次调用失败的原因、照旧上抛;但「一条破坏性事务连回滚都没回成」是运维**必须**看得见的事——
|
|
251
|
+
// 连接释放时事务会随之夭折,可事后要查「那一刻库里发生了什么」,得先知道它发生过。
|
|
252
|
+
this.opts.logger?.warn?.("retention_transaction_rollback_failed", { err: String(rollbackErr) });
|
|
253
|
+
}
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
conn.release();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 破坏性调用的**入门检**:审计上下文缺席 ⇒ 当场抛(fail-closed)。
|
|
262
|
+
* core 的契约形只有 `{domain, cutoffMs}`,所以一个拿着契约类型的调用方**在类型上**能省掉它 —— 那正是
|
|
263
|
+
* 这道运行时门存在的理由:省掉它的那次调用如果照删不误,库里就多了一批无证删除的行。
|
|
264
|
+
*/
|
|
265
|
+
requireAudit(input, action) {
|
|
266
|
+
if (!input.audit) {
|
|
267
|
+
throw new Error(`retention: refusing a destructive call (${action}) with no audit context — every destructive retention ` +
|
|
268
|
+
`transaction must persist its {mode, policyDays, fencingToken} alongside the deletion (DESIGN-270 §6 F5). ` +
|
|
269
|
+
`Pass \`audit\` from the sweep lane's current round.`);
|
|
270
|
+
}
|
|
271
|
+
return input.audit;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。
|
|
275
|
+
*
|
|
276
|
+
* 两步,缺一不可:
|
|
277
|
+
* ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `held = 0` 的**哨兵行** —— 它永远不会改写
|
|
278
|
+
* 一条既存的 hold(两方言的这两个动词都只在缺行时写),只保证**接下来那把行锁有东西可锁**;
|
|
279
|
+
* ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
|
|
280
|
+
*
|
|
281
|
+
* 🔴 为什么必须先造行(codex 对抗复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
|
|
282
|
+
* lock;PG READ COMMITTED 同样)。presence 形(「行在 = 冻结」)下,operator 的 PUT 与本事务可以**同时**
|
|
283
|
+
* 都读到「没有 hold」,PUT 提交返回成功之后本事务照删不误 —— 设计稿 §5 声称由行锁给出的串行化在那一形
|
|
284
|
+
* 上根本不存在。哨兵行把 hold 的写点与删除的读点压到**同一把行锁**上,那句承诺才成立。
|
|
285
|
+
* ⚠️ 车2 交接:PUT/DELETE hold 必须走**同一 PK 的 upsert 改 `held` 列**(不是 INSERT/DELETE 行),
|
|
286
|
+
* 否则这把锁又会退回缺席形。
|
|
287
|
+
*/
|
|
288
|
+
async holdInForce(conn, domain) {
|
|
289
|
+
// 域键 `""` 的无主桶在这张表上落成一条 `domain = ''` 的真行(它是键,不是谓词——两条腿都用同一个
|
|
290
|
+
// 字面量,锁的是同一行)。
|
|
291
|
+
const key = domain;
|
|
292
|
+
await conn.query(this.q(`INSERT IGNORE INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,0,NULL,NULL,NULL)`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,0,NULL,NULL,NULL) ON CONFLICT (domain) DO NOTHING`), [key]);
|
|
293
|
+
const { rows } = await conn.query(this.q(`SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = ? FOR UPDATE`, `SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = $1 FOR UPDATE`), [key]);
|
|
294
|
+
return Number(rows[0]?.held ?? 0) === 1;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 审计行的**唯一**写点(§6 F5:破坏性行由店在删除同事务内写)。
|
|
298
|
+
* `protected` 是**有意的注入口**:真双库套件用一只覆盖本方法为抛错的子类来证 V10(「注入 audit 写失败
|
|
299
|
+
* ⇒ 整事务回滚、数据仍在」)。把注入点做成 mock 层的猴补丁反而证不了这件事 —— 它证的是 mock。
|
|
300
|
+
*/
|
|
301
|
+
async writeAuditRow(conn, row) {
|
|
302
|
+
await conn.query(this.q(`INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms) VALUES (?,?,?,?,?,?,?,?,?)`, `INSERT INTO ${RETENTION_AUDIT_TABLE} (domain, action, mode, policy_days, fencing_token, deleted, skipped, tombstones, executed_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`), [row.domain, row.action, row.audit.mode, row.audit.policyDays, row.audit.fencingToken, row.deleted, row.skipped, row.tombstones, Date.now()]);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 墓碑写入(幂等)。返回**真正新落地**的墓碑数 —— 重跑时既有墓碑不重复计数,于是 receipt 的
|
|
306
|
+
* `tombstones` 与 `deleted` 在幂等重跑上同步归零。
|
|
307
|
+
*
|
|
308
|
+
* 方言差异:MySQL 展开多值 `VALUES (…),(…)` + `INSERT IGNORE`;PG 用 `unnest($n::text[])` 定 arity +
|
|
309
|
+
* `ON CONFLICT (domain, kind, row_key) DO NOTHING`(数组形让语句文本不随批量变形)。
|
|
310
|
+
*/
|
|
311
|
+
async writeTombstones(conn, domain, kind, keys) {
|
|
312
|
+
if (keys.length === 0)
|
|
313
|
+
return 0;
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
if (this.db.dialect === "tidb") {
|
|
316
|
+
const params = [];
|
|
317
|
+
for (const k of keys)
|
|
318
|
+
params.push(domain, kind, k, now);
|
|
319
|
+
const { affected } = await conn.query(`INSERT IGNORE INTO ${RETENTION_TOMBSTONE_TABLE} (domain, kind, row_key, deleted_at_ms) VALUES ${keys.map(() => "(?,?,?,?)").join(",")}`, params);
|
|
320
|
+
return affected;
|
|
321
|
+
}
|
|
322
|
+
const { affected } = await conn.query(`INSERT INTO ${RETENTION_TOMBSTONE_TABLE} (domain, kind, row_key, deleted_at_ms) ` +
|
|
323
|
+
`SELECT $1, $2, k, $3 FROM unnest($4::text[]) AS k ON CONFLICT (domain, kind, row_key) DO NOTHING`, [domain, kind, now, [...keys]]);
|
|
324
|
+
return affected;
|
|
325
|
+
}
|
|
326
|
+
// ───────────────────────────── 契约面 ① 域枚举 ─────────────────────────────
|
|
327
|
+
/**
|
|
328
|
+
* 三保留表 ∪ 墓碑的域并集(v1.2 codex F4 形)。
|
|
329
|
+
*
|
|
330
|
+
* 🔴 为什么 `tool_result` 没有自己的 UNION 臂(如实说明,不是漏写):这张表**没有域列** —— 一行卸载结果的
|
|
331
|
+
* 域由它的属主 session 派生,所以「属主还在」的行的域 ⊆ session 臂;而属主 session 一旦没了(上一轮删掉/
|
|
332
|
+
* 崩在中途),它的域在 SQL 里**无从得知**。这正是墓碑臂存在的理由:会话树删除时落的墓碑带着 domain,于是
|
|
333
|
+
* 「只剩孤儿的域」依然可枚举(V11)。再写一条 join 臂只会得到 session 臂的子集,不会多枚举出任何东西。
|
|
334
|
+
*
|
|
335
|
+
* 文本**两方言逐字相同**(无占位符)⇒ 单条共享语句(先例:file-snapshot-store 的 gcOrphanBlobs)。
|
|
336
|
+
* 排序在 JS 侧,判据面因此与引擎的排序规则无关。
|
|
337
|
+
*/
|
|
338
|
+
async listRetentionDomains() {
|
|
339
|
+
const { rows } = await this.db.query(`SELECT DISTINCT COALESCE(owner, '') AS d FROM session_meta ` +
|
|
340
|
+
`UNION SELECT DISTINCT scope AS d FROM checkpoint ` +
|
|
341
|
+
`UNION SELECT DISTINCT domain AS d FROM ${RETENTION_TOMBSTONE_TABLE}`);
|
|
342
|
+
return [...new Set(rows.map((r) => String(r.d ?? "")))].sort();
|
|
343
|
+
}
|
|
344
|
+
// ───────────────────────────── 非契约窄读口 preview ─────────────────────────────
|
|
345
|
+
/**
|
|
346
|
+
* 三候选计数(audit-only 档的读数源)。**零写、零事务**:它是纯读口,开事务只会白占一条连接。
|
|
347
|
+
*
|
|
348
|
+
* 口径 = 与三个破坏性方法**同一套谓词**(候选而非上限):
|
|
349
|
+
* · checkpoints:本域过视界的 pending 行,**扣掉 live-pinned**(那些行本来就不会被删);
|
|
350
|
+
* · sessions:本域过视界、且当下无活引用的会话树;
|
|
351
|
+
* · toolResults:本域可归属的孤儿卸载结果。
|
|
352
|
+
* 🔴 preview **不看 hold**:hold 是「这一轮跳不跳」的域级裁决(lane 记 `skipped_legal_hold` 行),而
|
|
353
|
+
* preview 回答的是「命中集有多大」。两件事混在一起,operator 就看不出「冻结期间到底积压了多少候选」。
|
|
354
|
+
*/
|
|
355
|
+
async previewRetention(input) {
|
|
356
|
+
const [checkpoints, sessions, toolResults] = await Promise.all([
|
|
357
|
+
this.expiryCandidates(this.db, input).then((tokens) => tokens.length),
|
|
358
|
+
this.sessionCandidates(this.db, input).then((ids) => ids.length),
|
|
359
|
+
this.countOrphanToolResultCandidates(this.db, input),
|
|
360
|
+
]);
|
|
361
|
+
return { checkpoints, sessions, toolResults };
|
|
362
|
+
}
|
|
363
|
+
// ───────────────────────────── 契约面 ② checkpoint 过期 ─────────────────────────────
|
|
364
|
+
/**
|
|
365
|
+
* CAS 栅栏 `pending → expired`,每行恰一胜者(胜负由 `status = 'pending'` 谓词在引擎里裁,跨副本安全)。
|
|
366
|
+
*
|
|
367
|
+
* 事务形(§5 F2 + §6 F5,一步不省):
|
|
368
|
+
* ① `retention_hold` 本域行的**锁读** —— 行在 ⇒ 本事务零变更、全计 skipped 返回;
|
|
369
|
+
* ② 候选读 + CAS UPDATE;
|
|
370
|
+
* ③ 审计行(同事务)→ COMMIT。**没有**提交前的第二次 hold 复核:域的行锁在 ① 就拿住了并持有到
|
|
371
|
+
* COMMIT,一个并发的 PUT hold 在此期间提交不了(它抢同一把锁)——一段恒真的"复核"比没有更坏。
|
|
372
|
+
*
|
|
373
|
+
* 视界判据**两条臂都写出来**:`deadline` 在场按 deadline,缺席按 `created_at_ms`。少了后一条臂,一条
|
|
374
|
+
* 没有审批 TTL 的 pending 行就永远过不了期(design/80 §3 inv#3 的 terminal_at_ms 要挡的正是这种永久泄漏)。
|
|
375
|
+
*
|
|
376
|
+
* 🔴 **pin 释放 = 本方法的 `pending → expired` 翻转本身**(core [4180] 定谳:retention.ts 契约那句
|
|
377
|
+
* 「AND release the associated session pins」是真意,[4175] 引错了对象——它引的是引擎 live-path reap
|
|
378
|
+
* 的 JSDoc)。本仓 SQL 后端**没有独立的 pin 存储**:钉住 session 的正是 `checkpoint.status='pending'`
|
|
379
|
+
* 这一状态(它是 deleteExpiredSessions 活引用四腿之一,见下方 lineage 注 :79x),所以状态翻转 = 引用
|
|
380
|
+
* 解除 = pin 释放——不需要第二个写操作,而幂等(重跑对已 expired 行零命中)恰好满足 [4180] 的
|
|
381
|
+
* 「释放已释放 pin=no-op」要求。单写者不变式按 [4180] 改述:**每行的 pin 恰好释放一次,由终局该行
|
|
382
|
+
* 的车道执行**——live 行归 reaper,retention 行归本方法,两行集不交(下一段的 live-pin skip 正是
|
|
383
|
+
* 「不交」的执行判据)。
|
|
384
|
+
* 边界形([4175] 建议,[4180] 确认仍成立):**live-pinned 且已过视界**的行(`task_active` 还占着这条
|
|
385
|
+
* 会话)⇒ skip 计数、不动那行 —— 不在一条活会话脚下抽掉它的 resume 坐标,与 legal-hold 的 skip 同形。
|
|
386
|
+
*
|
|
387
|
+
* receipt 口径:`deleted` = **本次 CAS 赢下的行数**(过期销毁的是那道 pending 门,行本身按设计留在库里
|
|
388
|
+
* 供审计/对账);`tombstones` = 0(没有行被移除,写墓碑会让「墓碑 ⇒ 这行没了」这条读法失真)。
|
|
389
|
+
*/
|
|
390
|
+
async expireCheckpoints(input) {
|
|
391
|
+
const audit = this.requireAudit(input, "expired_checkpoints");
|
|
392
|
+
let skippedOnAbort = 0;
|
|
393
|
+
try {
|
|
394
|
+
return await this.tx(async (conn) => {
|
|
395
|
+
if (await this.holdInForce(conn, input.domain)) {
|
|
396
|
+
// 冻结时的 `skipped` = 本轮**本该动**的全部行(域级冻结跳过的是整批:可过期的 + 被 pin 的)。
|
|
397
|
+
// 这一支不写审计行,也就没有「负数进不可变账」的风险,两读相加在这里是安全的。
|
|
398
|
+
skippedOnAbort = (await this.expiryCandidates(conn, input)).length + (await this.countPinnedExpiryCandidates(conn, input));
|
|
399
|
+
throw new RetentionAborted("hold_in_force");
|
|
400
|
+
}
|
|
401
|
+
const doomed = await this.expiryCandidates(conn, input);
|
|
402
|
+
const pinned = await this.countPinnedExpiryCandidates(conn, input); // 独立测得的事实,恒 ≥ 0(账法见该方法顶注)
|
|
403
|
+
skippedOnAbort = doomed.length + pinned;
|
|
404
|
+
let expired = 0;
|
|
405
|
+
if (doomed.length > 0) {
|
|
406
|
+
const w = this.domainWhere("scope", input.domain, 2);
|
|
407
|
+
const set = this.idSet("token", doomed, 3);
|
|
408
|
+
// 🔴 **pin 判据必须写进 UPDATE 本身**(codex 对抗复审 R2-[high],验真后修):候选读、pinned 计数、
|
|
409
|
+
// CAS 更新是三条语句;若一次活跑 claim 落在前两条读之间,同一行会**既进 doomed 又被计进 pinned**,
|
|
410
|
+
// 最后照样被置成 expired —— 一条活任务的 resume 坐标被撤销,而回执自称"跳过了它"。把
|
|
411
|
+
// `NOT EXISTS(task_active)` 放进 UPDATE 的 WHERE(加锁读 = 当前读)之后,这条路径按构造不存在:
|
|
412
|
+
// 更新前一刻仍被占着的行,一定不会被这条语句动到。
|
|
413
|
+
const res = await conn.query(this.q(`UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE ${w.sql} AND status = 'pending' AND ${set.sql} ` +
|
|
414
|
+
`AND NOT EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = checkpoint.session_id)`, `UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE ${w.sql} AND status = 'pending' AND ${set.sql} ` +
|
|
415
|
+
`AND NOT EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = checkpoint.session_id)`), [Date.now(), ...w.params, ...set.params]);
|
|
416
|
+
expired = res.affected;
|
|
417
|
+
}
|
|
418
|
+
await this.writeAuditRow(conn, { domain: input.domain, action: "expired_checkpoints", audit, deleted: expired, skipped: pinned, tombstones: 0 });
|
|
419
|
+
// 🔴 **没有**提交前的第二次 hold 复核(旧形有,已删):域的 hold 行锁在事务首步就拿住了并持有到
|
|
420
|
+
// COMMIT —— 一个并发的 PUT hold 在此期间根本提交不了(它抢的是同一把行锁)。再读一次不会有新信息,
|
|
421
|
+
// 而一段"看起来在防什么、实际上恒真"的代码比没有更坏(它会让下一个读者以为窗口已经被这一步堵住)。
|
|
422
|
+
return { domain: input.domain, deleted: expired, skipped: pinned, tombstones: 0 };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
if (err instanceof RetentionAborted)
|
|
427
|
+
return { domain: input.domain, deleted: 0, skipped: skippedOnAbort, tombstones: 0 };
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* 视界谓词的两条臂(`deadline` 在场按 deadline、缺席按 `created_at_ms`)—— 两方言逐字相同,占位符各自编号。
|
|
433
|
+
* 抽成一处是因为它同时被「可过期候选」与「被 pin 的计数」两条查询用,手抄两份必分家。
|
|
434
|
+
*/
|
|
435
|
+
horizonArms(pgFirst) {
|
|
436
|
+
return this.db.dialect === "tidb"
|
|
437
|
+
? `((c.deadline IS NOT NULL AND c.deadline <= ?) OR (c.deadline IS NULL AND c.created_at_ms <= ?))`
|
|
438
|
+
: `((c.deadline IS NOT NULL AND c.deadline <= $${pgFirst}) OR (c.deadline IS NULL AND c.created_at_ms <= $${pgFirst + 1}))`;
|
|
439
|
+
}
|
|
440
|
+
/** 「这条 checkpoint 的会话被一次活跑占着」= [4175] 的 live-pinned 判据(SQL 谓词形,两方言同文本)。 */
|
|
441
|
+
static PINNED_EXISTS = `EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = c.session_id)`;
|
|
442
|
+
/**
|
|
443
|
+
* **可过期**的候选 token(保护谓词写在 SQL 里、扣在 `LIMIT` **之前**)。
|
|
444
|
+
*
|
|
445
|
+
* 🔴 为什么保护必须下推(codex 对抗复审 R1-[medium],验真后改):旧形先按时间取 N 行、再在 JS 里剔除
|
|
446
|
+
* pinned —— 只要最老的一批持续被占着,每轮取回的都是同一批、每轮删 0,**它们后面**那些同样过期、又
|
|
447
|
+
* 没有任何引用的行**永远轮不到**。那不是「下一拍自然收敛」,那是一个域的留存静默停摆(而且读数一直
|
|
448
|
+
* 是 deleted=0,看上去像"没有候选")。判据下推之后 LIMIT 扣的是**真能动的行**,头部被保护多少行都不
|
|
449
|
+
* 影响后面的收敛。
|
|
450
|
+
*/
|
|
451
|
+
async expiryCandidates(exec, input) {
|
|
452
|
+
const w = this.domainWhere("c.scope", input.domain, 1);
|
|
453
|
+
const { rows } = await exec.query(this.q(`SELECT c.token FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
454
|
+
`AND NOT ${SqlRetentionStore.PINNED_EXISTS} ORDER BY c.created_at_ms ASC LIMIT ?`, `SELECT c.token FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
455
|
+
`AND NOT ${SqlRetentionStore.PINNED_EXISTS} ORDER BY c.created_at_ms ASC LIMIT $4`), [...w.params, input.cutoffMs, input.cutoffMs, RETENTION_ROW_BATCH]);
|
|
456
|
+
return rows.map((r) => String(r.token));
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* 过期视界之内、**当下被 live-pin 护住**的 pending 行数 = receipt 的 `skipped`。
|
|
460
|
+
*
|
|
461
|
+
* 🔴 账法定谳(两轮对抗复审各推了一次,最终落在这一形):
|
|
462
|
+
* · 曾经用「候选 + pinned 两读相加当分母、差值当 skipped」——被 codex R2 抓到会把中途翻转 pin 的行数两遍;
|
|
463
|
+
* · 改成「视界总数当分母、差值当 skipped」——被 codex R3 抓到在 PG 的 READ COMMITTED 下可以**为负**
|
|
464
|
+
* (分母读完之后又有行被 put/reopen 成 pending,候选与 UPDATE 都看得见它 ⇒ expired > 分母),而一条
|
|
465
|
+
* 负数写进**追加式**审计表是比「小幅重复计数」坏得多的事(审计不可改,读它的人无从判断哪一格出了错);
|
|
466
|
+
* · ⇒ 现形:`deleted` = UPDATE 的**真实命中**(pin 判据写在它自己的 WHERE 里,数据面按构造正确),
|
|
467
|
+
* `skipped` = 同一事务内测得的**受保护行数**(本方法)。两者是**两个独立测得的事实**,不构成
|
|
468
|
+
* `deleted + skipped = 总数` 的恒等式,两者都恒 ≥ 0。中途翻转 pin 状态的行最多被两边各记一次
|
|
469
|
+
* (量级=翻转数,通常 0),这是刻意选的失真方向:**宁可轻微重复计数,也不让审计出现负数**。
|
|
470
|
+
*
|
|
471
|
+
* 🔴 **有界**(codex R2-[medium],验真后改):裸 `COUNT(*)` 对一个积压很深的域是无界扫描,而 EXISTS 关联的
|
|
472
|
+
* 都是没有为本用途建过索引的列。留存是每小时一拍的后台活,不该把一次精确到最后一行的计数买成数据库尖峰
|
|
473
|
+
* ⇒ 计数扣在与批量同阶的上界内(派生表 LIMIT),读数是「至少这么多」。少算的部分下一拍照样看得见
|
|
474
|
+
* (判据幂等),而运维要的是「有没有积压、量级多大」。索引面(workflow originating_session_id 物化列 /
|
|
475
|
+
* background-agent 活锚 / checkpoint 的 scope+status+deadline 复合索引)是独立一车,已进交接清单。
|
|
476
|
+
*/
|
|
477
|
+
async countPinnedExpiryCandidates(exec, input) {
|
|
478
|
+
const w = this.domainWhere("c.scope", input.domain, 1);
|
|
479
|
+
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
480
|
+
`AND ${SqlRetentionStore.PINNED_EXISTS} LIMIT ?) x`, `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM checkpoint c WHERE ${w.sql} AND c.status = 'pending' AND ${this.horizonArms(2)} ` +
|
|
481
|
+
`AND ${SqlRetentionStore.PINNED_EXISTS} LIMIT $4) x`), [...w.params, input.cutoffMs, input.cutoffMs, RETENTION_ROW_BATCH]);
|
|
482
|
+
return Number(rows[0]?.n ?? 0);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 会话树的**四条活引用腿**的 SQL 谓词(`sm` = session_meta 的别名)。写成一处的理由与 {@link horizonArms}
|
|
486
|
+
* 相同,而且更重:它同时是「可删候选」的否定谓词与「被保护计数」的肯定谓词,两份手抄必然在某次改动后分家,
|
|
487
|
+
* 而分家的方向是**多删**(候选放宽、计数收紧,两边都不会有人红)。
|
|
488
|
+
*
|
|
489
|
+
* 腿的词表:活跑 claim / 挂起 park / 活着的后台子代(running|parked,三根会话锚)/ 在跑的 workflow
|
|
490
|
+
* (`originatingSessionId` 住在 run blob 里 ⇒ JSON 提取是真方言分叉)。fork 子代**结构上没有边**,
|
|
491
|
+
* 理由见 {@link SqlRetentionStore.deleteExpiredSessions} 的头注。
|
|
492
|
+
*/
|
|
493
|
+
liveReferenceExists() {
|
|
494
|
+
const workflow = this.db.dialect === "tidb"
|
|
495
|
+
? `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND JSON_UNQUOTE(JSON_EXTRACT(wr.run, '$.originatingSessionId')) = sm.session_id)`
|
|
496
|
+
: `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND wr.run::json->>'originatingSessionId' = sm.session_id)`;
|
|
497
|
+
return (`(EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = sm.session_id)` +
|
|
498
|
+
` OR EXISTS (SELECT 1 FROM checkpoint c2 WHERE c2.session_id = sm.session_id AND c2.status = 'pending')` +
|
|
499
|
+
` OR EXISTS (SELECT 1 FROM background_agent ba WHERE ba.status IN ('running','parked')` +
|
|
500
|
+
` AND (ba.session_id = sm.session_id OR ba.parent_session_id = sm.session_id OR ba.root_session_id = sm.session_id))` +
|
|
501
|
+
` OR ${workflow})`);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* 本域过视界、**当下被活引用护住**的会话数 = receipt 的 `skipped`。
|
|
505
|
+
* 账法与理由(为什么不是「总数减已删」的差值、为什么恒 ≥ 0、为什么有界)逐字见
|
|
506
|
+
* {@link SqlRetentionStore.countPinnedExpiryCandidates}:两条腿同一条账法,不许在这里另立一套。
|
|
507
|
+
*/
|
|
508
|
+
async countProtectedSessions(exec, input) {
|
|
509
|
+
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
510
|
+
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? ` +
|
|
511
|
+
`AND ${this.liveReferenceExists()} LIMIT ?) x`, `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < $2 ` +
|
|
512
|
+
`AND ${this.liveReferenceExists()} LIMIT $3) x`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
513
|
+
return Number(rows[0]?.n ?? 0);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 ——
|
|
517
|
+
* 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。
|
|
518
|
+
*
|
|
519
|
+
* `lock = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 对抗复审 R1-[high]):
|
|
520
|
+
* · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住;
|
|
521
|
+
* · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries /
|
|
522
|
+
* replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集
|
|
523
|
+
* 删完附属表、最后那条属主门 DELETE 却落空」就成立,而下一句无条件的 `session_event` 删除会把一个
|
|
524
|
+
* **刚刚换了主**的会话的历史一并抹掉(跨租户不可逆丢失)。
|
|
525
|
+
* · 锁 + 谓词在同一条语句里,于是「候选」与「删除」之间不存在窗口;`purgeSessionTrees` 尾部再断言
|
|
526
|
+
* 「meta 命中数 == 预期数」,两道一起把这条路封死。
|
|
527
|
+
*/
|
|
528
|
+
async sessionCandidates(exec, input, lock = false) {
|
|
529
|
+
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
530
|
+
const { rows } = await exec.query(this.q(`SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? AND NOT ${this.liveReferenceExists()} ` +
|
|
531
|
+
`ORDER BY sm.updated_at ASC LIMIT ?${lock ? " FOR UPDATE" : ""}`, `SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < $2 AND NOT ${this.liveReferenceExists()} ` +
|
|
532
|
+
`ORDER BY sm.updated_at ASC LIMIT $3${lock ? " FOR UPDATE" : ""}`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
|
|
533
|
+
return rows.map((r) => String(r.session_id));
|
|
534
|
+
}
|
|
535
|
+
/** 孤儿卸载结果的候选计数(实现随 `deleteOrphanToolResults` 同刀落地)。 */
|
|
536
|
+
async countOrphanToolResultCandidates(exec, input) {
|
|
537
|
+
return (await this.orphanToolResultCandidates(exec, input)).length;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* 本域**可归属**的孤儿卸载结果(属主 session 已亡 + 墓碑归属本域 + 归属**无歧义**)。
|
|
541
|
+
*
|
|
542
|
+
* 🔴 第三个条件是 fail-closed 的归属门(codex 对抗复审 R3-[high],验真后加):`tool_result` 没有域列,
|
|
543
|
+
* 它的域完全靠「属主 sessionId 的墓碑」推。而会话 id 是**调用方可自选**的 ⇒ 两个租户先后用过同一个 id
|
|
544
|
+
* 完全合法;两边都删过之后,同一个 row_key 上会有**两个域**的墓碑,这时任何一边的 sweep 拿自己的墓碑
|
|
545
|
+
* 去认领这些字节都只是猜。判据因此是:**存在第二个域的同名墓碑 ⇒ 这批行不可归属 ⇒ 一根不删**,
|
|
546
|
+
* 实体清理退回既有的 TTL 腿(`reapOlderThan`)。少删一次是可接受的;删掉另一个租户的字节、还绕过它的
|
|
547
|
+
* legal hold 与审计域,不可接受。
|
|
548
|
+
*/
|
|
549
|
+
async orphanToolResultCandidates(exec, input) {
|
|
550
|
+
const w = this.domainWhere("ts.domain", input.domain, 1);
|
|
551
|
+
const ambiguous = this.q(`NOT EXISTS (SELECT 1 FROM ${RETENTION_TOMBSTONE_TABLE} other WHERE other.kind = 'session' AND other.row_key = tr.owner_session_id AND other.domain <> ts.domain)`, `NOT EXISTS (SELECT 1 FROM ${RETENTION_TOMBSTONE_TABLE} other WHERE other.kind = 'session' AND other.row_key = tr.owner_session_id AND other.domain <> ts.domain)`);
|
|
552
|
+
const { rows } = await exec.query(this.q(`SELECT tr.ref FROM tool_result tr JOIN ${RETENTION_TOMBSTONE_TABLE} ts ON ts.kind = 'session' AND ts.row_key = tr.owner_session_id ` +
|
|
553
|
+
`WHERE ${w.sql} AND tr.created_at < ? AND NOT EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = tr.owner_session_id) ` +
|
|
554
|
+
`AND ${ambiguous} ORDER BY tr.created_at ASC LIMIT ?`, `SELECT tr.ref FROM tool_result tr JOIN ${RETENTION_TOMBSTONE_TABLE} ts ON ts.kind = 'session' AND ts.row_key = tr.owner_session_id ` +
|
|
555
|
+
`WHERE ${w.sql} AND tr.created_at < $2 AND NOT EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = tr.owner_session_id) ` +
|
|
556
|
+
`AND ${ambiguous} ORDER BY tr.created_at ASC LIMIT $3`), [...w.params, new Date(input.cutoffMs), RETENTION_ROW_BATCH]);
|
|
557
|
+
return rows.map((r) => String(r.ref));
|
|
558
|
+
}
|
|
559
|
+
// ───────────────────────────── 契约面 ③ 会话树删除 ─────────────────────────────
|
|
560
|
+
/**
|
|
561
|
+
* lineage-aware 的会话树删除。**引用在删除事务内重查**(core 契约逐字:「references are re-checked inside
|
|
562
|
+
* the deletion transaction, never assumed from a prior scan」),有活引用的树**整棵**跳过计 `skipped`。
|
|
563
|
+
*
|
|
564
|
+
* 事务形(§5 F2 + §6 F5):
|
|
565
|
+
* ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT;
|
|
566
|
+
* ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见
|
|
567
|
+
* {@link SqlRetentionStore.sessionCandidates});
|
|
568
|
+
* ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
|
|
569
|
+
*
|
|
570
|
+
* 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 对抗复审 R1 的两条 high 合并采纳):
|
|
571
|
+
* · 两条查询之间有窗口,而下推之后「被查到的」按构造就是「四条腿一条都不命中的」;
|
|
572
|
+
* · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
|
|
573
|
+
* 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
|
|
574
|
+
* · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的
|
|
575
|
+
* "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。
|
|
576
|
+
* ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰
|
|
577
|
+
* `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前
|
|
578
|
+
* 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样
|
|
579
|
+
* (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁
|
|
580
|
+
* `session_meta` 那一行(共同锁序)—— 那是在线热路径的改动,归属另一次裁定,已进交接清单。
|
|
581
|
+
*
|
|
582
|
+
* 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边):
|
|
583
|
+
* · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据);
|
|
584
|
+
* · `checkpoint.status='pending'` —— 还赎得回的 park;
|
|
585
|
+
* · `background_agent` 的 `running|parked` 行(session_id / parent_session_id / root_session_id 三根锚);
|
|
586
|
+
* · `workflow_run` 的 `running` 行(`originatingSessionId` 在 run blob 里,JSON 提取两方言各写各的)。
|
|
587
|
+
* · 🔴 **fork 子代没有 durable 边**(亲读 `TiDBSessionStore.fork` / `session_meta` 的 DDL 坐实):E17 的
|
|
588
|
+
* fork 是**整段历史拷贝**成一个独立会话,库里不留父子指针。这不是漏查 —— 拷贝形下删父会话不会让子
|
|
589
|
+
* 会话悬空(它的字节是它自己的),所以这条边**结构上不需要保护**。设计稿把「fork 子」列进 lineage
|
|
590
|
+
* 清单是按 core 契约的通用措辞抄的;本仓的 fork 语义使它落空,记在这里免得后人当缺口再"补"一次。
|
|
591
|
+
*
|
|
592
|
+
* receipt 口径:`deleted` = **删掉的会话树棵数**(= `session_meta` 的命中行数),不是级联删掉的总行数
|
|
593
|
+
* ——后者跨十余张表、随部署形态漂(附件表可能不在场),做不成稳定判据;而「幂等重跑 ⇒ 0」这条契约在
|
|
594
|
+
* 两种口径下同真。`tombstones` = 本次**新落地**的墓碑数(重跑时既有墓碑不重复计)。
|
|
595
|
+
*/
|
|
596
|
+
async deleteExpiredSessions(input) {
|
|
597
|
+
const audit = this.requireAudit(input, "deleted_sessions");
|
|
598
|
+
let skippedOnAbort = 0;
|
|
599
|
+
try {
|
|
600
|
+
return await this.tx(async (conn) => {
|
|
601
|
+
if (await this.holdInForce(conn, input.domain)) {
|
|
602
|
+
// 冻结时的 `skipped` = 本轮本该动的全部树(可删的 + 被护住的);该支不写审计行,故两读相加安全。
|
|
603
|
+
skippedOnAbort = (await this.sessionCandidates(conn, input)).length + (await this.countProtectedSessions(conn, input));
|
|
604
|
+
throw new RetentionAborted("hold_in_force");
|
|
605
|
+
}
|
|
606
|
+
const doomed = await this.sessionCandidates(conn, input, true); // 加锁读 = 候选 ∧ 活引用重查(一条语句)
|
|
607
|
+
const skipped = await this.countProtectedSessions(conn, input); // 两个独立测得的事实,恒 ≥ 0(账法见该方法顶注)
|
|
608
|
+
skippedOnAbort = doomed.length + skipped;
|
|
609
|
+
let deleted = 0;
|
|
610
|
+
let tombstones = 0;
|
|
611
|
+
if (doomed.length > 0) {
|
|
612
|
+
deleted = await this.purgeSessionTrees(conn, input.domain, doomed);
|
|
613
|
+
tombstones = await this.writeTombstones(conn, input.domain, "session", doomed);
|
|
614
|
+
}
|
|
615
|
+
await this.writeAuditRow(conn, { domain: input.domain, action: "deleted_sessions", audit, deleted, skipped, tombstones });
|
|
616
|
+
return { domain: input.domain, deleted, skipped, tombstones };
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
if (err instanceof RetentionAborted)
|
|
621
|
+
return { domain: input.domain, deleted: 0, skipped: skippedOnAbort, tombstones: 0 };
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* E21 顺序契约的**事务化搬入**:同一组 DELETE、同一个顺序,一次性发在一条事务连接上。
|
|
627
|
+
* 顺序的两条承重理由(其余是可读性):
|
|
628
|
+
* · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
|
|
629
|
+
* `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
|
|
630
|
+
* · `task_event` / `task_active` 经 `task_run` 的属主门连删 ⇒ 必须排在 `task_run` 删除**之前**。
|
|
631
|
+
* 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
|
|
632
|
+
* 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
|
|
633
|
+
* (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
|
|
634
|
+
*
|
|
635
|
+
* 属主门的一处**有意改写**(而不是照抄 `<=>`):在线协调器传的是"这个会话的 owner"(可能是 SQL NULL),
|
|
636
|
+
* 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
|
|
637
|
+
* `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
|
|
638
|
+
* 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
|
|
639
|
+
*/
|
|
640
|
+
async purgeSessionTrees(conn, domain, ids) {
|
|
641
|
+
/** 每条腿都要一份新的谓词(PG 的 `$n` 编号随参数位置变)。 */
|
|
642
|
+
const set = (col, pgIndex) => this.idSet(col, ids, pgIndex);
|
|
643
|
+
// ① 运行账本:task_event → task_active → task_run(经 task_run 的属主门;与协调器逐字同序)
|
|
644
|
+
{
|
|
645
|
+
const s = set("tr.session_id", 1);
|
|
646
|
+
const w = this.domainWhere("tr.owner", domain, 2);
|
|
647
|
+
await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
|
|
648
|
+
}
|
|
649
|
+
{
|
|
650
|
+
const s = set("tr.session_id", 1);
|
|
651
|
+
const w = this.domainWhere("tr.owner", domain, 2);
|
|
652
|
+
await conn.query(this.q(`DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
|
|
653
|
+
}
|
|
654
|
+
{
|
|
655
|
+
const s = set("session_id", 1);
|
|
656
|
+
const w = this.domainWhere("owner", domain, 2);
|
|
657
|
+
await conn.query(`DELETE FROM task_run WHERE ${s.sql} AND ${w.sql}`, [...s.params, ...w.params]);
|
|
658
|
+
}
|
|
659
|
+
// ② checkpoint + checkpoint_ctx(属主门 = session_meta.owner 的 EXISTS;**必须**在 meta 删除之前)
|
|
660
|
+
{
|
|
661
|
+
const s = set("checkpoint.session_id", 1);
|
|
662
|
+
const w = this.domainWhere("sm.owner", domain, 2);
|
|
663
|
+
await conn.query(`DELETE FROM checkpoint WHERE ${s.sql} AND EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = checkpoint.session_id AND ${w.sql})`, [...s.params, ...w.params]);
|
|
664
|
+
}
|
|
665
|
+
{
|
|
666
|
+
const s = set("checkpoint_ctx.session_id", 1);
|
|
667
|
+
const w = this.domainWhere("sm.owner", domain, 2);
|
|
668
|
+
await conn.query(`DELETE FROM checkpoint_ctx WHERE ${s.sql} AND EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = checkpoint_ctx.session_id AND ${w.sql})`, [...s.params, ...w.params]);
|
|
669
|
+
}
|
|
670
|
+
// ③ 卸载的工具结果 —— 只按**记录下来的出处**选行(core 契约:ref 是 opaque handle,禁解析)
|
|
671
|
+
{
|
|
672
|
+
const s = set("owner_session_id", 1);
|
|
673
|
+
await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
|
|
674
|
+
}
|
|
675
|
+
// ④ 会话级附属表(协调器对它们一律 session_id 单键 scoping —— 路由已证会话所有权;留存腿的证明是
|
|
676
|
+
// 候选集本身来自本域的 session_meta,同一事务内成立)
|
|
677
|
+
for (const [table, col] of [
|
|
678
|
+
["resume_anchor", "session_id"],
|
|
679
|
+
["approval_exemption", "session_id"],
|
|
680
|
+
["session_policy", "session_id"],
|
|
681
|
+
...(this.opts.taskAttachmentTable ? [["task_attachment", "session_id"]] : []),
|
|
682
|
+
// E19 快照:本表的 `scope` 列存的就是 sessionId(见 file-snapshot-store 的 deleteBySession)
|
|
683
|
+
["snapshot_manifest", "scope"],
|
|
684
|
+
]) {
|
|
685
|
+
const s = set(col, 1);
|
|
686
|
+
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
687
|
+
}
|
|
688
|
+
// ④b workflow 完成信箱:**只删行,不写 purge 围栏**(三轮对抗复审打完的定谳;这一格看着像"少做了
|
|
689
|
+
// 一步",实际是在两种残余里选了伤害小的那一种,理由必须留全)。
|
|
690
|
+
// · 在线协调器 `SqlWorkflowCompletionInbox.purge` 先写围栏再删行,挡住与删除赛跑的 enqueue;
|
|
691
|
+
// · 留存腿照抄过一版**事务内写**——变异实测证伪:事务内的围栏对别的连接不可见(PG READ COMMITTED /
|
|
692
|
+
// 无 gap lock 的 TiDB 都一样),挡不住任何东西,而那次 upsert 占住围栏行的排他锁,反把并发 enqueue
|
|
693
|
+
// 的 `sweepFences` 顶成 `Lock wait timeout exceeded`;
|
|
694
|
+
// · 再改成**事务前无锁预读 + 独立提交**——也不成立:预读无锁,会给最终**没被删**的会话(预读后被
|
|
695
|
+
// touch / 刚获得活引用 / 域中途进 hold)也写上围栏;而 SQL 版 enqueue 命中 purge 围栏是**成功返回
|
|
696
|
+
// 不落行**,上层随即把 notify journal 记成已投递 ⇒ 一个**活过来的**会话**永久**丢掉完成通知。
|
|
697
|
+
// 那是用户可见的损失,比下面那条泄漏重一个量级。
|
|
698
|
+
// ⇒ **保留的残余**(如实登记,交接件):workflow 已终态、完成通知仍在重试窗口、其会话又恰好过了留存
|
|
699
|
+
// 视界 ⇒ 迟到的 enqueue 会在删除之后落一行指向已删会话的 inbox 行。它**不会被投递**(drain 按
|
|
700
|
+
// session 取且带属主门),代价是一行残留;窗口窄(要"刚终态"与"闲置 ≥ maxAgeDays"同时成立)。
|
|
701
|
+
// ⇒ **真正的收口**有两条候选,都超出 store 半场:①把 `workflow_notify_journal.acked = 0` 作为第五条
|
|
702
|
+
// 活引用腿(需先确认 journal 行是"enqueue 之前写、投递之后 ack"——它的写点在 core 侧,本车没能亲验,
|
|
703
|
+
// 故不擅自加腿);②给会话删除一个 durable 的"预删除"态,使误围栏可回滚、通知可恢复。两者都跨 inbox
|
|
704
|
+
// 契约,归设计属主裁定。
|
|
705
|
+
{
|
|
706
|
+
const s = set("session_id", 1);
|
|
707
|
+
await conn.query(`DELETE FROM workflow_completion_inbox WHERE ${s.sql}`, s.params);
|
|
708
|
+
}
|
|
709
|
+
// ⑤ 会话本体:meta(域门)LAST,然后它的事件日志。返回值 = 删掉的树棵数。
|
|
710
|
+
const s = set("session_id", 1);
|
|
711
|
+
const w = this.domainWhere("owner", domain, 2);
|
|
712
|
+
const metaRes = await conn.query(`DELETE FROM session_meta WHERE ${s.sql} AND ${w.sql}`, [...s.params, ...w.params]);
|
|
713
|
+
// 🔴 **不变量断言,不是装饰**(codex 对抗复审 R1-[high] 的第二半):`session_event` 没有属主列,它的
|
|
714
|
+
// 删除只能靠上面那条属主门背书。候选是加锁读出来的(见 sessionCandidates 的 `FOR UPDATE`),所以命中数
|
|
715
|
+
// **必须**逐个对上;对不上只意味着一件事——有人绕过锁改了 `session_meta`(带外 SQL / 未来某条新写路径),
|
|
716
|
+
// 而那种局面下继续删事件日志就是在删一个**已经不属于本域**的会话的历史。fail-closed:整事务回滚。
|
|
717
|
+
if (metaRes.affected !== ids.length) {
|
|
718
|
+
throw new Error(`retention: session_meta owner-gated delete matched ${metaRes.affected} of ${ids.length} locked candidates in domain ` +
|
|
719
|
+
`${JSON.stringify(domain)} — refusing to delete session_event (which carries no owner column and is gated on this ` +
|
|
720
|
+
`match). A row changed owner/left the domain despite the FOR UPDATE lock taken at candidate selection; rolling back.`);
|
|
721
|
+
}
|
|
722
|
+
const ev = set("session_id", 1);
|
|
723
|
+
await conn.query(`DELETE FROM session_event WHERE ${ev.sql}`, ev.params);
|
|
724
|
+
return metaRes.affected;
|
|
725
|
+
}
|
|
726
|
+
// ───────────────────────────── 契约面 ④ 孤儿卸载结果 ─────────────────────────────
|
|
727
|
+
/**
|
|
728
|
+
* 属主 session 已亡的 offloaded 结果(core 契约:「whose owning sessions are gone (or past the horizon),
|
|
729
|
+
* skipping refs still reachable from live transcripts」)。
|
|
730
|
+
*
|
|
731
|
+
* 三条口径,逐条都是判据(它们决定了这条腿**不会**碰什么):
|
|
732
|
+
* · **删的那一支**:`owner_session_id` 指向一个**不在 `session_meta` 里**的会话,且该会话在本域留有
|
|
733
|
+
* `kind='session'` 墓碑,且行本身过了视界。墓碑是**域归属的唯一证据** —— 没有它,一条孤儿行的租户
|
|
734
|
+
* 在 SQL 里根本问不出来(表没有域列),而按 ref 前缀猜属主是 core 明令禁止的(ref 是 opaque handle,
|
|
735
|
+
* 调用方可自铸;`tool-result-store-sql.deleteBySession` 的顶注逐字记着这条被 codex 收窄过的教训)。
|
|
736
|
+
* · **skip 的那一支**:属主会话**还在** = 转录活着 = 「still reachable from live transcripts」⇒ 过视界
|
|
737
|
+
* 也只计 `skipped` 不删。会话本体的清理归 {@link SqlRetentionStore.deleteExpiredSessions}(它会连着
|
|
738
|
+
* 这行一起删),两条腿因此不重叠也不互相抢行。
|
|
739
|
+
* · **一根不碰的那一支**:`owner_session_id IS NULL` 的**无出处**行。没有出处就没有域,按任何域去删它
|
|
740
|
+
* 都是猜;实体清理归既有的 TTL 腿(`reapOlderThan`,`boot/reapers.ts` 驱动)。这与 core 的
|
|
741
|
+
* 「an UNOWNED entry is never deleted」是同一条纪律的两处执行点。
|
|
742
|
+
*
|
|
743
|
+
* 事务形与另外两只方法逐字同款(hold 哨兵行锁读 → 删除+墓碑 → 审计行,同 commit 同 rollback)。
|
|
744
|
+
*/
|
|
745
|
+
async deleteOrphanToolResults(input) {
|
|
746
|
+
const audit = this.requireAudit(input, "deleted_tool_results");
|
|
747
|
+
let skippedOnAbort = 0;
|
|
748
|
+
try {
|
|
749
|
+
return await this.tx(async (conn) => {
|
|
750
|
+
if (await this.holdInForce(conn, input.domain)) {
|
|
751
|
+
skippedOnAbort = (await this.orphanToolResultCandidates(conn, input)).length + (await this.countLiveReachableToolResults(conn, input));
|
|
752
|
+
throw new RetentionAborted("hold_in_force");
|
|
753
|
+
}
|
|
754
|
+
const refs = await this.orphanToolResultCandidates(conn, input);
|
|
755
|
+
const reachable = await this.countLiveReachableToolResults(conn, input);
|
|
756
|
+
let deleted = 0;
|
|
757
|
+
let tombstones = 0;
|
|
758
|
+
if (refs.length > 0) {
|
|
759
|
+
const s = this.idSet("ref", refs, 1);
|
|
760
|
+
const res = await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
|
|
761
|
+
deleted = res.affected;
|
|
762
|
+
tombstones = await this.writeTombstones(conn, input.domain, "tool_result", refs);
|
|
763
|
+
}
|
|
764
|
+
await this.writeAuditRow(conn, { domain: input.domain, action: "deleted_tool_results", audit, deleted, skipped: reachable, tombstones });
|
|
765
|
+
return { domain: input.domain, deleted, skipped: reachable, tombstones };
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
if (err instanceof RetentionAborted)
|
|
770
|
+
return { domain: input.domain, deleted: 0, skipped: skippedOnAbort, tombstones: 0 };
|
|
771
|
+
throw err;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/** 「活转录可达」的行数(属主会话仍在 `session_meta`、域是本域、行已过视界)= receipt 的 `skipped`。 */
|
|
775
|
+
async countLiveReachableToolResults(exec, input) {
|
|
776
|
+
const w = this.domainWhere("sm.owner", input.domain, 1);
|
|
777
|
+
const { rows } = await exec.query(this.q(`SELECT COUNT(*) AS n FROM tool_result tr JOIN session_meta sm ON sm.session_id = tr.owner_session_id WHERE ${w.sql} AND tr.created_at < ?`, `SELECT COUNT(*) AS n FROM tool_result tr JOIN session_meta sm ON sm.session_id = tr.owner_session_id WHERE ${w.sql} AND tr.created_at < $2`), [...w.params, new Date(input.cutoffMs)]);
|
|
778
|
+
return Number(rows[0]?.n ?? 0);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** MySQL-protocol (TiDB) binding。 */
|
|
782
|
+
export class TiDBRetentionStore extends SqlRetentionStore {
|
|
783
|
+
constructor(pool, opts) {
|
|
784
|
+
super(mysqlDriver(pool), opts);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** PostgreSQL binding。 */
|
|
788
|
+
export class PgRetentionStore extends SqlRetentionStore {
|
|
789
|
+
constructor(pool, opts) {
|
|
790
|
+
super(pgDriver(pool), opts);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
//# sourceMappingURL=retention-store-sql.js.map
|