@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
|
@@ -1,13 +1,64 @@
|
|
|
1
|
-
/** R5(车A [3191] 欠账,批γ
|
|
2
|
-
*
|
|
3
|
-
* 静默截断(
|
|
4
|
-
*
|
|
1
|
+
/** R5(车A [3191] 欠账,批γ 落地;#271 件1 补齐写口):memory 面的键宽写前守卫。
|
|
2
|
+
* 列宽收窄后,模型/调用方可控的键(条目名 slug、租户盘 scope)超宽此前落裸 SQL 错(PG
|
|
3
|
+
* `value too long`)或非严格 MySQL 静默截断(截断=两个不同的键折叠成同一行=**条目互串 / 两个租户
|
|
4
|
+
* 的盘塌成一个**,最危险形)。写前响亮拒,错误可分类。
|
|
5
|
+
* 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表);写口在场门=同文件
|
|
6
|
+
* 「#271 件1」组(超宽键一条写语句都不许发出 seam)。
|
|
7
|
+
*
|
|
8
|
+
* **本文件是这两个宽度的唯一属主**(#271 件1):scope 守卫此前住在 `src/memory-scope.ts`,只被
|
|
9
|
+
* `memoryScopeFor` 的产出处消费,而 store 写口全无——同一个列宽有两个居所、两半消费面,正是
|
|
10
|
+
* 「守卫在位≠写口接上」那类缺口的温床。`memory-scope.ts` 现在只**再导出**本文件的这两个符号
|
|
11
|
+
* (旧 import 路径逐字不变)。 */
|
|
5
12
|
/** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
|
|
6
13
|
export const MEMORY_SLUG_COLUMN_CHARS = 512;
|
|
14
|
+
/** memory 面 `scope` 列宽(两方言 VARCHAR(190) 同宽:engine 的 entry/cursor 两表 + memory-sync 的
|
|
15
|
+
* sync_cursor/push_queue/history)。`formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
|
|
16
|
+
* **膨胀**——非 ASCII principal 编码后可超列宽,故产出处与写口两道都要判。 */
|
|
17
|
+
export const MEMORY_SCOPE_COLUMN_CHARS = 190;
|
|
18
|
+
/**
|
|
19
|
+
* 键宽的**计数口径**(codex 对抗复审 M1,#271 件1 验真后修):两方言的 `VARCHAR(n)` 数的都是
|
|
20
|
+
* **字符 = Unicode 码点**(PG varchar(n) / MySQL-protocol utf8mb4 VARCHAR(n) 同口径,后者每字符
|
|
21
|
+
* 最多 4 字节、astral 字符仍算 1)。而 JS 的 `string.length` 数的是 **UTF-16 code unit** —— 一个
|
|
22
|
+
* emoji 算 2。照 `.length` 判会把「库里只占 96 字符」的 scope 按 192 拒掉:守卫从「防截断」变成
|
|
23
|
+
* 「误伤合法窄键」,方向反了。
|
|
24
|
+
*
|
|
25
|
+
* (孤立代理项这种**不可存**字节不归本函数管——那是 `pgHasUnstorable` 的拒绝式,两条判据各司其职。)
|
|
26
|
+
*/
|
|
27
|
+
function widthChars(v) {
|
|
28
|
+
return [...v].length;
|
|
29
|
+
}
|
|
7
30
|
export function assertSlugWidth(slug) {
|
|
8
|
-
|
|
31
|
+
const n = widthChars(slug);
|
|
32
|
+
if (n > MEMORY_SLUG_COLUMN_CHARS) {
|
|
9
33
|
throw new Error(`memory entry slug exceeds ${MEMORY_SLUG_COLUMN_CHARS} characters (the slug column width both SQL dialects pin); ` +
|
|
10
|
-
`got ${
|
|
34
|
+
`got ${n} — shorten the entry name`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function assertMemoryScopeWidth(scope) {
|
|
38
|
+
const n = widthChars(scope);
|
|
39
|
+
if (n > MEMORY_SCOPE_COLUMN_CHARS) {
|
|
40
|
+
throw new Error(`memory scope exceeds ${MEMORY_SCOPE_COLUMN_CHARS} characters after segment encoding (the scope column width ` +
|
|
41
|
+
`both SQL dialects pin); got ${n} — use a shorter principal/projectId (non-ASCII characters expand ~9x when encoded)`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 一个 entry 写(add / update)的两个键宽一起判 —— 两方言的 `applyOne` 在**任何 I/O 之前**调用它。
|
|
46
|
+
*
|
|
47
|
+
* 为什么两条腿共用一个入口而不是各自展开:add 与 update 写的是**同两列**(update 的
|
|
48
|
+
* `SET scope = …, slug = …` 会改盘也会改名),R5 那批只护住了 add ⇒ 一次改名就能把超宽 slug 送进
|
|
49
|
+
* DB。一个入口=两条腿不会再各自漂。
|
|
50
|
+
*
|
|
51
|
+
* 返回**字符串**而不是抛:两方言的 `applyPatches` 契约是「冲突进 report,不抛」(与
|
|
52
|
+
* `unstorable_bytes` 的拒绝式同族——后端能力差异诚实暴露,重试恒同答=幂等成立)。`undefined` = 过。
|
|
53
|
+
*/
|
|
54
|
+
export function memoryEntryKeyWidthRefusal(scope, slug) {
|
|
55
|
+
try {
|
|
56
|
+
assertMemoryScopeWidth(scope);
|
|
57
|
+
assertSlugWidth(slug);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return err instanceof Error ? err.message : String(err);
|
|
11
61
|
}
|
|
62
|
+
return undefined;
|
|
12
63
|
}
|
|
13
64
|
//# sourceMappingURL=memory-key-guards.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pgSafeJsonStringify, pgProtocolJsonStringify } from "./pg-safe-json.js";
|
|
2
|
+
import { assertMemoryScopeWidth } from "./memory-key-guards.js"; // #271 件1:scope 列宽写前守卫(engine 两表同守)
|
|
2
3
|
/** Table names (single source). SAME names as the TiDB twin (the two dialects never share one DB). */
|
|
3
4
|
export const PG_MEMORY_SYNC_TABLES = {
|
|
4
5
|
syncCursor: "agent_memory_engine_sync_cursor",
|
|
@@ -63,6 +64,9 @@ export class PgMemorySyncStore {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
async putCursor(cursor) {
|
|
67
|
+
// #271 件1:scope 是 PK 的一半(varchar(190))——超宽此前落不可分类的裸 22001,而 MySQL-protocol
|
|
68
|
+
// twin 上同一份数据会**静默截断**成另一个盘的基线。两方言同点、同文案拒(engine 两表同守)。
|
|
69
|
+
assertMemoryScopeWidth(cursor.scope);
|
|
66
70
|
// PK (scope,peer) is the table's ONLY unique key ⇒ ON CONFLICT has exactly one target, no
|
|
67
71
|
// hijack face (纪律:the entries-table ban on blind upserts does NOT apply here).
|
|
68
72
|
await this.query(`INSERT INTO ${PG_MEMORY_SYNC_TABLES.syncCursor} (scope, peer, base_revs, updated_at_ms)
|
|
@@ -74,6 +78,7 @@ export class PgMemorySyncStore {
|
|
|
74
78
|
}
|
|
75
79
|
// ── push queue ─────────────────────────────────────────────────────────────
|
|
76
80
|
async enqueue(item) {
|
|
81
|
+
assertMemoryScopeWidth(item.scope); // #271 件1:出站队列行的 scope 截断 = 推给对端时说错了盘
|
|
77
82
|
// Plain INSERT, fail-loud on a duplicate id: the caller mints uuidv7 per enqueue, so a dup is a
|
|
78
83
|
// caller bug (or a replay the caller must decide about) — never silently absorbed here.
|
|
79
84
|
await this.query(`INSERT INTO ${PG_MEMORY_SYNC_TABLES.pushQueue} (id, scope, entry_id, payload, attempts, next_attempt_at_ms, last_error)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { historyRowFrom } from "./memory-sync-store-pg.js";
|
|
2
|
+
import { assertMemoryScopeWidth } from "./memory-key-guards.js"; // #271 件1:scope 列宽写前守卫(engine 两表同守)
|
|
2
3
|
/** Table names (single source) — SAME names as PG_MEMORY_SYNC_TABLES (the two dialects never share
|
|
3
4
|
* one database). */
|
|
4
5
|
export const TIDB_MEMORY_SYNC_TABLES = {
|
|
@@ -64,6 +65,9 @@ export class TiDBMemorySyncStore {
|
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
async putCursor(cursor) {
|
|
68
|
+
// #271 件1:scope 是 PK 的一半(VARCHAR(190))——非严格 MySQL 的静默截断会把两个盘的同步基线
|
|
69
|
+
// 折叠成一行(对账基线互串),写前响亮拒(engine 两表同守;Pg twin 同注)。
|
|
70
|
+
assertMemoryScopeWidth(cursor.scope);
|
|
67
71
|
// The table's ONLY unique key is PK(scope,peer) ⇒ ON DUPLICATE KEY UPDATE has exactly one
|
|
68
72
|
// possible trigger — no hijack face (the entries-table ban does NOT apply; see header).
|
|
69
73
|
await this.pool.query(`INSERT INTO ${TIDB_MEMORY_SYNC_TABLES.syncCursor} (scope, peer, base_revs, updated_at_ms)
|
|
@@ -72,6 +76,7 @@ export class TiDBMemorySyncStore {
|
|
|
72
76
|
}
|
|
73
77
|
// ── push queue ─────────────────────────────────────────────────────────────
|
|
74
78
|
async enqueue(item) {
|
|
79
|
+
assertMemoryScopeWidth(item.scope); // #271 件1:出站队列行的 scope 截断 = 推给对端时说错了盘
|
|
75
80
|
// Plain INSERT, fail-loud on a duplicate id (ER_DUP_ENTRY 1062) — caller mints uuidv7 per
|
|
76
81
|
// enqueue, a dup is a caller bug, never silently absorbed. Same posture as the PG twin.
|
|
77
82
|
await this.pool.query(`INSERT INTO ${TIDB_MEMORY_SYNC_TABLES.pushQueue} (id, scope, entry_id, payload, attempts, next_attempt_at_ms, last_error)
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -39,6 +39,7 @@ import { ensurePgApprovalAskSchema } from "./approval-ask-store-sql.js";
|
|
|
39
39
|
import { ensurePgAdoptionLogSchema } from "./adoption-log-sql.js";
|
|
40
40
|
import { ensurePgPermissionRuleSchema } from "./permission-rule-store-sql.js";
|
|
41
41
|
import { ensurePgLeaderRunSchema } from "./leader-run-store-sql.js";
|
|
42
|
+
import { ensurePgRetentionSchema } from "./retention-store-sql.js";
|
|
42
43
|
export const PG_SCHEMA_STATEMENTS = [
|
|
43
44
|
`CREATE TABLE IF NOT EXISTS task_run (
|
|
44
45
|
task_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
@@ -397,6 +398,9 @@ export async function ensurePgSchema(pool) {
|
|
|
397
398
|
await ensurePgPermissionRuleSchema((text, params) => client.query(text, params));
|
|
398
399
|
// #193 车4 件1:durable leader-run 登记表(MySQL twin 走 tidb-pool 的 SCHEMA_STATEMENTS 展开)。同上绑法。
|
|
399
400
|
await ensurePgLeaderRunSchema((text, params) => client.query(text, params));
|
|
401
|
+
// #270 车1:托管留存执行面四表(审计/hold/sweep 租约/墓碑;MySQL twin 走 tidb-pool 的 SCHEMA_STATEMENTS
|
|
402
|
+
// 展开)。同上绑法 —— DDL 跑在**同一条** client 上,advisory lock 的 session 语义不受影响。
|
|
403
|
+
await ensurePgRetentionSchema((text, params) => client.query(text, params));
|
|
400
404
|
}
|
|
401
405
|
finally {
|
|
402
406
|
if (locked) {
|
|
@@ -48,6 +48,8 @@ export declare class PgSessionStorage extends BaseSessionStorage {
|
|
|
48
48
|
*/
|
|
49
49
|
export declare class PgSessionStore implements SessionStore {
|
|
50
50
|
private readonly pool;
|
|
51
|
+
/** #270 车1:托管留存声明(TiDB 孪生同格)。读法见 `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
|
|
52
|
+
readonly retention: import("@sema-agent/core").RetentionDeclaration;
|
|
51
53
|
/** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
|
|
52
54
|
private readonly pending;
|
|
53
55
|
constructor(pool: Pool);
|
|
@@ -27,6 +27,7 @@ import { contentForkRelation, fastForwardSharedContentDiverged } from "../sessio
|
|
|
27
27
|
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, // 两孪生店同源(理由见 kernel 处顶注)
|
|
28
28
|
} from "../session-sync-kernel.js";
|
|
29
29
|
import { isPgUniqueViolation } from "./sql-errors.js";
|
|
30
|
+
import { MANAGED_RETENTION } from "./retention-store-sql.js";
|
|
30
31
|
/** 判据属主 = `sql-errors.ts`(A-032 P1-①)。 */
|
|
31
32
|
const isDupKey = isPgUniqueViolation;
|
|
32
33
|
/** node-pg parses jsonb → object already; only strings need JSON.parse, everything else passes through. */
|
|
@@ -207,6 +208,8 @@ export class PgSessionStorage extends BaseSessionStorage {
|
|
|
207
208
|
*/
|
|
208
209
|
export class PgSessionStore {
|
|
209
210
|
pool;
|
|
211
|
+
/** #270 车1:托管留存声明(TiDB 孪生同格)。读法见 `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
|
|
212
|
+
retention = MANAGED_RETENTION;
|
|
210
213
|
/** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
|
|
211
214
|
pending = new Map();
|
|
212
215
|
constructor(pool) {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 托管留存执行面的**非破坏性 SQL 半场** —— SINGLE-FILE DUAL-DIALECT(design/158 A12;先例 =
|
|
3
|
+
* `retention-store-sql.ts` 本身)。#270 车2,设计稿 `docs/DESIGN-270-retention-lane.md` v1.3。
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么与车1 的 `retention-store-sql.ts` 分家(而不是往那只店上再挂三组方法)────────────────────
|
|
6
|
+
* 车1 那只店是 core `ManagedRetentionCapability` 的**实现体**:它的每一个公开方法都是一次**破坏性**
|
|
7
|
+
* 事务(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback)。本文件三组口全部是**非破坏**的:
|
|
8
|
+
* · sweep 租约(§3):互斥机制,一行 CAS,与任何业务表无关;
|
|
9
|
+
* · legal-hold 的**放置/解除**(§5):operator 路由的写点,只改 `retention_hold.held` 一列;
|
|
10
|
+
* · 审计表的**读**(§6 查询面)与**非破坏行的写**(`audit_only`/`skipped_legal_hold`/`failed`/`hold_*`
|
|
11
|
+
* —— 属主是 lane 与路由,它们没有「删了没记」的窗口)。
|
|
12
|
+
* 三者的消费者也不同(lane / operator 路由),而车1 那只店的每个方法都**必须**由 lane 带着审计上下文调。
|
|
13
|
+
* 混进同一只对象会让「拿到这只店 = 能删数据」这条读法失真 —— 审计读面与 hold 路由拿到的应当是一把
|
|
14
|
+
* **删不了任何东西**的钥匙。
|
|
15
|
+
*
|
|
16
|
+
* ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
|
|
17
|
+
* - `?` 占位符 vs `$n`;
|
|
18
|
+
* - 幂等插入:`INSERT IGNORE` vs `ON CONFLICT (…) DO NOTHING`;
|
|
19
|
+
* - upsert:`ON DUPLICATE KEY UPDATE col = VALUES(col)` vs `ON CONFLICT (…) DO UPDATE SET col = EXCLUDED.col`;
|
|
20
|
+
* - 条件自增:`IF(cond, 0, 1)` vs `CASE WHEN cond THEN 0 ELSE 1 END`(本文件不用 —— 见 `acquire` 顶注:
|
|
21
|
+
* 自增判据在**应用层**算,因为它同时要产出返回给调用方的 token);
|
|
22
|
+
* - 事务动词:平凡 `begin()`(理由逐字见车1 `SqlRetentionStore.tx` 的头注:`BEGIN PESSIMISTIC` 是 TiDB
|
|
23
|
+
* 专有语法,发到普通 MySQL 上是语法错,而发车门的 canonical `TIDB_TEST_URL` 指向的正是普通 MySQL)。
|
|
24
|
+
* - schema 属主:**四张表全部由车1 的 `retention-store-sql.ts` 建**(`TIDB_RETENTION_STATEMENTS` /
|
|
25
|
+
* `PG_RETENTION_SCHEMA`)。本文件一条 DDL 都不发 —— 「一张表恰好一个 DDL 属主」(db-gate.ts 顶注
|
|
26
|
+
* 的实测结论:第二个属主的 `IF NOT EXISTS` 会静默吞掉列/索引差异)。
|
|
27
|
+
*/
|
|
28
|
+
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
29
|
+
import type { Pool as PgPool } from "pg";
|
|
30
|
+
import { type SqlDriver, type SqlExec } from "./sql-driver.js";
|
|
31
|
+
import { type RetentionAuditAction } from "./retention-store-sql.js";
|
|
32
|
+
/** 审计读面的单页上界(与 roster 名册窗同姿势:读面要有界)。 */
|
|
33
|
+
export declare const RETENTION_AUDIT_MAX_LIMIT = 200;
|
|
34
|
+
/** 审计读面的缺省页大小。 */
|
|
35
|
+
export declare const RETENTION_AUDIT_DEFAULT_LIMIT = 50;
|
|
36
|
+
/** 一次抢/续租的结果。`held:false` = 本 tick 由别人持租(零写,静默跳过 —— 不是错误)。 */
|
|
37
|
+
export type RetentionLeaseClaim = {
|
|
38
|
+
held: true;
|
|
39
|
+
fencingToken: number;
|
|
40
|
+
holder: string;
|
|
41
|
+
} | {
|
|
42
|
+
held: false;
|
|
43
|
+
holder: string;
|
|
44
|
+
expiresAtMs: number;
|
|
45
|
+
};
|
|
46
|
+
/** 一行审计(读面投影;列名→驼峰,BIGINT 统一 `Number`)。 */
|
|
47
|
+
export interface RetentionAuditRow {
|
|
48
|
+
id: number;
|
|
49
|
+
domain: string;
|
|
50
|
+
action: RetentionAuditAction;
|
|
51
|
+
mode: string;
|
|
52
|
+
policyDays: number;
|
|
53
|
+
fencingToken: number | null;
|
|
54
|
+
deleted: number;
|
|
55
|
+
skipped: number;
|
|
56
|
+
tombstones: number;
|
|
57
|
+
executedAtMs: number;
|
|
58
|
+
error: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** 非破坏性审计行的写入形(破坏性三词由车1 的店在删除同事务内写 —— 见文件头)。 */
|
|
61
|
+
export interface RetentionAuditAppend {
|
|
62
|
+
domain: string;
|
|
63
|
+
action: Extract<RetentionAuditAction, "audit_only" | "skipped_legal_hold" | "failed" | "hold_placed" | "hold_released">;
|
|
64
|
+
mode: string;
|
|
65
|
+
policyDays: number;
|
|
66
|
+
fencingToken: number | null;
|
|
67
|
+
deleted: number;
|
|
68
|
+
skipped: number;
|
|
69
|
+
tombstones: number;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 执行面非破坏性三组口的**公开面**(消费方 —— boot 的 lane、operator 路由、`StoreBackend` 座席 —— 一律
|
|
74
|
+
* 依赖本接口,不依赖下面那只类)。
|
|
75
|
+
*
|
|
76
|
+
* 🔴 为什么值得单列一个接口:类身上带 `protected db`,于是它在 TS 里是**名义**类型 —— 任何结构等价的
|
|
77
|
+
* 实现(测试假店、将来的第二种后端)都满足不了它,只能靠 `as unknown as` 走私进去,而那种双重断言把
|
|
78
|
+
* 字段检查整段关灯(`type-hygiene-gate` 数的正是它)。接口一列,假店就是**真的**满足同一份契约:
|
|
79
|
+
* 接口加一个方法,假店当场编译红。
|
|
80
|
+
*/
|
|
81
|
+
export interface RetentionLaneStore {
|
|
82
|
+
acquire(holder: string, ttlMs: number, nowMs: number): Promise<RetentionLeaseClaim>;
|
|
83
|
+
renew(holder: string, fencingToken: number, ttlMs: number, nowMs: number): Promise<boolean>;
|
|
84
|
+
release(holder: string): Promise<void>;
|
|
85
|
+
setHold(input: {
|
|
86
|
+
domain: string;
|
|
87
|
+
held: boolean;
|
|
88
|
+
placedBy?: string;
|
|
89
|
+
note?: string;
|
|
90
|
+
nowMs: number;
|
|
91
|
+
}): Promise<void>;
|
|
92
|
+
/** 状态变更 + 治理审计行,**同一个事务**(理由见实现顶注)。operator 路由**只许**走这一只。 */
|
|
93
|
+
setHoldAudited(input: {
|
|
94
|
+
domain: string;
|
|
95
|
+
held: boolean;
|
|
96
|
+
placedBy?: string;
|
|
97
|
+
note?: string;
|
|
98
|
+
nowMs: number;
|
|
99
|
+
audit: RetentionAuditAppend;
|
|
100
|
+
}): Promise<void>;
|
|
101
|
+
holdInForce(domain: string): Promise<boolean>;
|
|
102
|
+
appendAudit(row: RetentionAuditAppend, nowMs: number): Promise<void>;
|
|
103
|
+
listAudit(input: {
|
|
104
|
+
domain?: string;
|
|
105
|
+
limit: number;
|
|
106
|
+
beforeId?: number;
|
|
107
|
+
}): Promise<RetentionAuditRow[]>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 执行面的非破坏性 SQL 三组口(双方言;绑定类在文件底部)。
|
|
111
|
+
*
|
|
112
|
+
* 🔴 本类**删不掉任何业务数据** —— 它写的只有 `retention_lease` 的一行、`retention_hold` 的一列、
|
|
113
|
+
* 与 `retention_audit` 的追加行。破坏性的三条腿在车1 的 `SqlRetentionStore` 上,拿本对象够不着。
|
|
114
|
+
*/
|
|
115
|
+
export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
116
|
+
protected readonly db: SqlDriver;
|
|
117
|
+
constructor(db: SqlDriver);
|
|
118
|
+
/** 方言取文(两条语句都写在调用点;A12 判据)。 */
|
|
119
|
+
protected q(tidb: string, pg: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* 抢/续租一次(单行 CAS)。抢到 ⇒ `{held:true, fencingToken}`;别人正持着 ⇒ `{held:false}`,调用方
|
|
122
|
+
* **本 tick 零写静默跳过**。
|
|
123
|
+
*
|
|
124
|
+
* 事务三步,一步不能省:
|
|
125
|
+
* ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `expires_at_ms = 0` 的**哨兵行** —— 它永远
|
|
126
|
+
* 不会改写一条既存租约(两方言的这两个动词都只在缺行时写);
|
|
127
|
+
* ② `SELECT … FOR UPDATE` —— 行现在必定存在,锁真的拿得到;
|
|
128
|
+
* ③ 判 + `UPDATE`,同事务提交。
|
|
129
|
+
*
|
|
130
|
+
* 🔴 为什么必须先造行、而不是「一条 `UPDATE … WHERE expires_at_ms < now OR holder = self`」了事
|
|
131
|
+
* (设计稿 §3 写的正是那条裸 UPDATE,本实现是它的**收口**,与车1 hold 哨兵是同一条教训):裸 UPDATE
|
|
132
|
+
* 在**空表**上影响 0 行 —— 于是首启的两个副本都读到「没抢到」,lane 在一个从没跑过的部署上**永远不
|
|
133
|
+
* 起**(静默,读数是"每 tick 跳过",看上去像"别人在跑")。补一条无条件 INSERT 又会在两副本同拍首启时
|
|
134
|
+
* 撞 PK。哨兵 + 行锁把两件事一次解决,并且顺带让 fencing token 的自增判据可以在应用层算。
|
|
135
|
+
*
|
|
136
|
+
* 🔴 fencing token 只在**抢租**(holder 变了)时自增,续租不动它:token 的语义是「第几轮**执行权**」,
|
|
137
|
+
* 一个持续持租的副本跑的是同一段连续执行,给它每 tick 换号会让审计上「同一轮的行」看起来跨了很多轮。
|
|
138
|
+
*
|
|
139
|
+
* @param holder 本副本的身份(instanceId);同一 holder 再来 = 续租。
|
|
140
|
+
* @param ttlMs 租约时长 = 2× sweep 间隔(§3)。
|
|
141
|
+
*/
|
|
142
|
+
acquire(holder: string, ttlMs: number, nowMs: number): Promise<RetentionLeaseClaim>;
|
|
143
|
+
/**
|
|
144
|
+
* 「这一轮的执行权还在我手上吗」**并同时续租** —— 每 domain 处理前调一次(§3 丢租即停)。
|
|
145
|
+
* 返回 `false` = 不再属于我 ⇒ 调用方当场中止本轮。
|
|
146
|
+
*
|
|
147
|
+
* 判据三合取写进**一条 CAS 的 WHERE**:holder 是我 ∧ fencing token 还是本轮那个 ∧ **尚未过期**。
|
|
148
|
+
* · 第三项是承重的:租约过期而 holder 还写着我,必须读成**不再属于我**(fail-closed)——另一个副本
|
|
149
|
+
* 随时可能抢走,两个副本同时跑三方法是这条腿最不能出的事。
|
|
150
|
+
* · 「复核」与「续租」合成一条语句而不是先读后写(codex 对抗复审 R1-[high],验真后改):
|
|
151
|
+
* ① 读+写两条语句之间有窗口,而 CAS 的谓词与更新在引擎里是原子的;
|
|
152
|
+
* ② 更要紧的是**续租本身**:`startRetentionLane` 的重入守卫会跳过下一 tick,于是一轮的续租机会
|
|
153
|
+
* 只有轮首那一次 —— 一轮跑满 2×interval 之后租约自己到期,而本副本毫不知情、继续删下去。
|
|
154
|
+
* 轮子转多久租约就跟着延多久,那个窗口才真正关上。
|
|
155
|
+
*
|
|
156
|
+
* ⚠️ **如实登记的残余**(设计稿 §3 已成文接受,本实现不假装消灭):本调用返回 true 之后、本 domain 的
|
|
157
|
+
* 破坏性事务提交之前,仍有一个「租约在这中间被别人接管」的理论窗口 —— 关它需要把 fencing token 的
|
|
158
|
+
* 校验下推进**每一个破坏性事务的 WHERE**(车1 的三只方法),那是跨车的契约改动。补偿按设计稿:
|
|
159
|
+
* 每条破坏性审计行都带 fencing token,旧轮的写因此**可判别**(§6 的那一列正是为此存在)。
|
|
160
|
+
* 续租之后,这个窗口的宽度从「一整轮」缩到「一个 domain 的耗时」,且要求租约恰在这段内到期。
|
|
161
|
+
*/
|
|
162
|
+
renew(holder: string, fencingToken: number, ttlMs: number, nowMs: number): Promise<boolean>;
|
|
163
|
+
/**
|
|
164
|
+
* 主动让租(优雅停机)——把到期时间归零,**保留 holder 与 token**(它们是审计线索,不是锁本身)。
|
|
165
|
+
* 谓词带 `holder = ?`:一个已经丢了租的副本在退出时不许把**别人**的租约推倒。
|
|
166
|
+
*/
|
|
167
|
+
release(holder: string): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* 放置或解除一个域的 legal hold —— **同一 PK 的 upsert 改 `held` 列,绝不 INSERT/DELETE 行**
|
|
170
|
+
* (车1 交接件②,逐字)。
|
|
171
|
+
*
|
|
172
|
+
* 🔴 为什么删行是**拆锁**而不是"解除":`retention_hold` 的行同时是**域级互斥哨兵** —— 车1 的三条破坏性
|
|
173
|
+
* 事务首步就是锁读这一行(`INSERT IGNORE` 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE`
|
|
174
|
+
* **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。删掉行 = 下一个 PUT 与一条
|
|
175
|
+
* 在飞的删除事务又可以同时读到"没有 hold",于是 operator 拿到成功回执之后数据仍被删 —— 那正是 §5 F2
|
|
176
|
+
* 要消灭的那件事。upsert 让两者抢**同一把行锁**,「PUT 提交完成 ⇒ 其后每个破坏性事务的首读必见
|
|
177
|
+
* held=1」这句承诺才成立。
|
|
178
|
+
*
|
|
179
|
+
* 解除时把 `placed_by/placed_at_ms/note` 一并清空:这张表存的是**当前状态**不是历史,历史在
|
|
180
|
+
* `retention_audit` 的 `hold_placed`/`hold_released` 行上(追加不更新)。留一个「谁放的」在一条
|
|
181
|
+
* held=0 的行上,读它的人分不清那是"现在冻结着"还是"上次谁冻过"。
|
|
182
|
+
*/
|
|
183
|
+
setHold(input: {
|
|
184
|
+
domain: string;
|
|
185
|
+
held: boolean;
|
|
186
|
+
placedBy?: string;
|
|
187
|
+
note?: string;
|
|
188
|
+
nowMs: number;
|
|
189
|
+
}): Promise<void>;
|
|
190
|
+
/** upsert 的**唯一** SQL 文本(自动提交口与事务口共用;见 {@link setHoldAudited} 的头注)。 */
|
|
191
|
+
protected setHoldOn(exec: SqlExec, input: {
|
|
192
|
+
domain: string;
|
|
193
|
+
held: boolean;
|
|
194
|
+
placedBy?: string;
|
|
195
|
+
note?: string;
|
|
196
|
+
nowMs: number;
|
|
197
|
+
}): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* 放置/解除 **+ 治理审计行,同一个事务**(codex 对抗复审 R1-[high],验真后加)。
|
|
200
|
+
*
|
|
201
|
+
* 🔴 为什么必须原子(与 §6 F5 的「删除与审计同事务」是同一条判据,只是换了一张表):两次独立提交下,
|
|
202
|
+
* 第二步失败会留下一次**无审计的状态变更**。PUT 那一支是「冻结了但账上没有」;**DELETE 那一支更重**
|
|
203
|
+
* —— 冻结已经解除、sweep 从下一拍起就能删这个域的数据,而审计里没有任何 `hold_released` 行说明是谁
|
|
204
|
+
* 在什么时候解的;客户端只看到一个 500,重试之前发生的删除再也无法从账本上追溯回那次释放。
|
|
205
|
+
* 一张表两条语句、同库同连接 ⇒ 原子性零分布式代价,没有不做的理由。
|
|
206
|
+
*
|
|
207
|
+
* `protected` 的两条私有腿(`setHoldOn` / `appendAuditOn`)让本方法与 {@link setHold} /
|
|
208
|
+
* {@link appendAudit} 共用**同一份 SQL 文本** —— 两处手抄一份 upsert,迟早只改一处。
|
|
209
|
+
*/
|
|
210
|
+
setHoldAudited(input: {
|
|
211
|
+
domain: string;
|
|
212
|
+
held: boolean;
|
|
213
|
+
placedBy?: string;
|
|
214
|
+
note?: string;
|
|
215
|
+
nowMs: number;
|
|
216
|
+
audit: RetentionAuditAppend;
|
|
217
|
+
}): Promise<void>;
|
|
218
|
+
/** 「这个域现在冻结着吗」—— lane 的**省调**预检(§5 v1.3:降级为优化,**不承重**;承重判在车1 的
|
|
219
|
+
* 店事务内)。零写。 */
|
|
220
|
+
holdInForce(domain: string): Promise<boolean>;
|
|
221
|
+
/** 非破坏性审计行的追加(属主 = lane / 路由)。破坏性三词由车1 的店在删除同事务内写。 */
|
|
222
|
+
appendAudit(row: RetentionAuditAppend, nowMs: number): Promise<void>;
|
|
223
|
+
/** 追加的**唯一** SQL 文本(同上)。 */
|
|
224
|
+
protected appendAuditOn(exec: SqlExec, row: RetentionAuditAppend, nowMs: number): Promise<void>;
|
|
225
|
+
/**
|
|
226
|
+
* 审计读面(keyset 分页,`id` 降序)。
|
|
227
|
+
*
|
|
228
|
+
* 排序键 = **自增 `id` 单列**,而不是 roster 那样的 `(时间戳, 次键)` 二元组:`id` 本身就是全序且唯一,
|
|
229
|
+
* 二元组存在的全部理由(同毫秒兄弟行无 tiebreak ⇒ 漏页/重页)在这里结构上不存在。索引
|
|
230
|
+
* `idx_retention_audit_domain (domain, id)` 正是为这条查询建的。
|
|
231
|
+
*
|
|
232
|
+
* `before` = 上一页最后一行的 `id`(严格小于)。
|
|
233
|
+
*
|
|
234
|
+
* 🔴 读上限是 `MAX_LIMIT + 1` 而不是 `MAX_LIMIT`(codex 对抗复审 R1-[medium],验真属实):调用方要判
|
|
235
|
+
* 「还有没有下一页」就得多取一行,而**页**的上限是 `MAX_LIMIT`。旧形把 limit 无条件夹到 `MAX_LIMIT`
|
|
236
|
+
* ⇒ 请求 `limit=200` 时 scanned 恒 ≤ 200 ⇒ `hasMore` 恒 false ⇒ **第 201 行之后的审计对这条分页链
|
|
237
|
+
* 永久不可达**。这一格是「窗的上限」与「探路的那一行」两个不同的数被写成同一个数造成的,
|
|
238
|
+
* 修法是让它们各是各的:页 ≤ MAX_LIMIT(路由侧保证),读 ≤ MAX_LIMIT+1(本方法)。
|
|
239
|
+
*/
|
|
240
|
+
listAudit(input: {
|
|
241
|
+
domain?: string;
|
|
242
|
+
limit: number;
|
|
243
|
+
beforeId?: number;
|
|
244
|
+
}): Promise<RetentionAuditRow[]>;
|
|
245
|
+
}
|
|
246
|
+
/** MySQL-protocol (TiDB) binding。 */
|
|
247
|
+
export declare class TiDBRetentionLaneStore extends SqlRetentionLaneStore {
|
|
248
|
+
constructor(pool: MySqlPool);
|
|
249
|
+
}
|
|
250
|
+
/** PostgreSQL binding。 */
|
|
251
|
+
export declare class PgRetentionLaneStore extends SqlRetentionLaneStore {
|
|
252
|
+
constructor(pool: PgPool);
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=retention-lane-store-sql.d.ts.map
|