@sema-agent/server 7.10.0 → 7.12.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/README.md +1 -1
- package/USAGE.md +56 -0
- package/dist/adoption/plan.d.ts +152 -0
- package/dist/adoption/plan.js +513 -0
- package/dist/adoption/runner.d.ts +54 -0
- package/dist/adoption/runner.js +505 -0
- package/dist/adoption/sql.d.ts +76 -0
- package/dist/adoption/sql.js +106 -0
- package/dist/adoption/wire.d.ts +250 -0
- package/dist/adoption/wire.js +153 -0
- package/dist/approval-card.d.ts +24 -0
- package/dist/approval-card.js +32 -0
- package/dist/auth-keys.d.ts +28 -4
- package/dist/auth-keys.js +60 -15
- package/dist/boot/adoption.d.ts +30 -0
- package/dist/boot/adoption.js +57 -0
- package/dist/boot/coordinators.d.ts +4 -0
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/parked-revive-gate.d.ts +56 -7
- package/dist/boot/parked-revive-gate.js +177 -8
- package/dist/boot/permission-rules-audit.d.ts +49 -0
- package/dist/boot/permission-rules-audit.js +57 -0
- package/dist/boot/resolve-spec.js +43 -12
- package/dist/boot/runner-deps.d.ts +10 -2
- package/dist/boot/runner-deps.js +12 -1
- package/dist/budget.js +22 -0
- package/dist/config-types.d.ts +24 -1
- package/dist/config.d.ts +28 -2
- package/dist/config.js +348 -75
- package/dist/governance-ask-marks.js +8 -2
- package/dist/http/route-ctx.d.ts +6 -3
- package/dist/http/routes/adoption.d.ts +26 -0
- package/dist/http/routes/adoption.js +120 -0
- package/dist/http/routes/approvals-assistant.js +2 -1
- package/dist/http/routes/capabilities.js +49 -2
- package/dist/http/routes/rules.d.ts +35 -0
- package/dist/http/routes/rules.js +293 -0
- package/dist/http/routes/shared-memory.d.ts +31 -0
- package/dist/http/routes/shared-memory.js +181 -0
- package/dist/http/routes/trace-usage.js +139 -3
- package/dist/http/server.d.ts +23 -3
- package/dist/http/server.js +123 -3
- package/dist/http/wire-types.d.ts +48 -0
- package/dist/main.js +70 -3
- package/dist/observability/fail-open.d.ts +12 -0
- package/dist/observability/fail-open.js +12 -0
- package/dist/observability/metrics.js +2 -1
- package/dist/observability/tool-trace.d.ts +5 -1
- package/dist/observability/tool-trace.js +33 -6
- package/dist/parked-decide.d.ts +13 -3
- package/dist/parked-decide.js +10 -1
- package/dist/plugins/adoption-log-sql.d.ts +191 -0
- package/dist/plugins/adoption-log-sql.js +273 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -0
- package/dist/plugins/checkpoint-store-sql.js +11 -0
- package/dist/plugins/local-checkpoint-store.d.ts +10 -0
- package/dist/plugins/local-checkpoint-store.js +8 -0
- package/dist/plugins/permission-rule-store-file.d.ts +83 -0
- package/dist/plugins/permission-rule-store-file.js +371 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +249 -0
- package/dist/plugins/permission-rule-store-sql.js +828 -0
- package/dist/plugins/pg-pool.js +37 -0
- package/dist/plugins/session-policy-store-sql.d.ts +6 -0
- package/dist/plugins/session-policy-store-sql.js +7 -1
- package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
- package/dist/plugins/shared-memory-store-sql.js +516 -0
- package/dist/plugins/store-backend.d.ts +38 -2
- package/dist/plugins/store-backend.js +69 -6
- package/dist/plugins/tidb-pool.js +47 -0
- package/dist/rules-consent.d.ts +194 -0
- package/dist/rules-consent.js +240 -0
- package/dist/run-local.js +120 -13
- package/dist/runtime-governance.js +9 -3
- package/dist/shared-memory-scope-authorizer.d.ts +29 -0
- package/dist/shared-memory-scope-authorizer.js +17 -0
- package/dist/task-settings.d.ts +44 -0
- package/dist/task-settings.js +57 -1
- package/dist/tool-approval.d.ts +60 -0
- package/dist/tool-approval.js +223 -25
- package/dist/trace/core-keyset-guard.d.ts +15 -4
- package/dist/trace/project.d.ts +20 -2
- package/dist/trace/project.js +25 -4
- package/package.json +3 -3
package/dist/parked-decide.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 判别必须 parked-first:legacy 腿的 `resumeStream` 会绕开 bg registry(无 consumeParkedFlip、
|
|
9
9
|
* 行永 parked、生命周期分叉)。
|
|
10
10
|
*/
|
|
11
|
-
import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
|
|
11
|
+
import type { ApprovalSettledBy, BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
|
|
12
12
|
export interface ParkedAgentMatch {
|
|
13
13
|
handle: string;
|
|
14
14
|
row: BackgroundAgentRecord;
|
|
@@ -46,8 +46,13 @@ export interface ParkedDecideDeps {
|
|
|
46
46
|
* 解析槽一条,单层链;exempt 探针锚= row.rootSessionId,与 remember grant 锚同键)。core 1.396 起
|
|
47
47
|
* `parkedResume.inheritedGate` 席位把它透传进 resume 的官方重供通道——不供 = 重启后带
|
|
48
48
|
* requiresParentConstraint 的 checkpoint 恒被 pre-CAS 门拒(retryable 假话,永不可赎回)。链长形状
|
|
49
|
-
* 校验在 core(fail-closed,供错链长照拒)。
|
|
50
|
-
|
|
49
|
+
* 校验在 core(fail-closed,供错链长照拒)。
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ **async**(A-010.1):重建条目的第三位决议链元数据 `autoModeArmed` 与 `durableMandate` 的
|
|
52
|
+
* `forceDurableGate` 项都要按**本行的 principal** 现解 entitlement(`RunnerDeps.runtimeCapsResolver`
|
|
53
|
+
* 本身就是 sync-or-async 的座),理由全在 `boot/parked-revive-gate.ts`。本腿一直就在 async 里,
|
|
54
|
+
* 「席位必须同步」是那个文件此前自设的约束、不是结构事实。 */
|
|
55
|
+
rebuildInheritedGate?: (row: BackgroundAgentRecord) => Promise<unknown>;
|
|
51
56
|
warn?: (event: string, fields: Record<string, unknown>) => void;
|
|
52
57
|
}
|
|
53
58
|
export interface ParkedDecideRequest {
|
|
@@ -57,6 +62,11 @@ export interface ParkedDecideRequest {
|
|
|
57
62
|
/** cp.pendingAction 原样(outcome 铸造的 persisted 回落源,与 legacy 腿 D-1 姿势逐字同形)。 */
|
|
58
63
|
pendingAction: unknown;
|
|
59
64
|
decision: "approve" | "deny";
|
|
65
|
+
/** #204 件6③ core `ApprovalSettledBy`:**这次结算的出处**,由调用方命名(必填,理由与 legacy 腿
|
|
66
|
+
* `resumeCheckpoint` 的同名参数逐字同源 —— 本函数分辨不出谁在叫它,漏报要是编译错而不是假出处)。
|
|
67
|
+
* 今天唯一的调用方是 `server.ts` 的人为 `/decide`(那道门写着 `req !== undefined`,而内部 D-D SLA
|
|
68
|
+
* deny-sweep 恰是 req 缺席的那条腿,它对 parked 行另有静默跳过臂)⇒ 恒 `"human"`。 */
|
|
69
|
+
settledBy: ApprovalSettledBy;
|
|
60
70
|
reason?: string;
|
|
61
71
|
/** D-1 decision-binding echo(操作员在 GET 看到的 boundCallId/boundInputHash 原样透传;server
|
|
62
72
|
* 绝不重算,core resume 侧做 fail-closed 等值校验——与 legacy 腿同姿势)。 */
|
package/dist/parked-decide.js
CHANGED
|
@@ -97,6 +97,13 @@ export async function decideParkedAgent(deps, req) {
|
|
|
97
97
|
// ([1588] Q1:name 必传承重——省略会把 revive 降级成匿名形)。
|
|
98
98
|
return { status: 409, body: { error: "parked row carries no agent name — the revive path requires a named agent (row damaged?)", errorCode: "decide.row_unrevivable", taskId: match.handle } };
|
|
99
99
|
}
|
|
100
|
+
// [1597]/A-010.1:席位重建**在 claim 之前**解开(codex 对抗复审 R1-中 的真 finding)。
|
|
101
|
+
// 🔴 位置即正确性:claim 之后到下面那道 try/catch(它才挂 `rollbackParkedClaim`)之间是一段
|
|
102
|
+
// **无回滚保护区**。工厂现在是 async(要现解 entitlement),把 await 放进那一段 = 任何 rejection
|
|
103
|
+
// 都带着一个已认领的 claim 逃逸,行上的 `parkClaimId` 要等 stale-claim 清算窗(默认可达一小时)
|
|
104
|
+
// 才放开,这期间该审批**再决不动**。搬到 claim 前:此刻还没有任何东西需要回滚,抛错就是干净地抛错。
|
|
105
|
+
// (工厂本身只读 row 的 scope/handle/sessionId/rootSessionId —— claim 不改这四个字段,搬位零语义差。)
|
|
106
|
+
const inheritedGate = deps.rebuildInheritedGate ? await deps.rebuildInheritedGate(row) : undefined;
|
|
100
107
|
const stores = { agentStore: deps.agentStore, checkpointStore: deps.checkpointStore };
|
|
101
108
|
const claim = await deps.registry.claimParkedAgent(stores, match.handle, { scope: row.scope, owner: row.owner });
|
|
102
109
|
if (!claim.ok) {
|
|
@@ -147,6 +154,8 @@ export async function decideParkedAgent(deps, req) {
|
|
|
147
154
|
// 是两件不同的事(姊妹 approval-hmac.ts `env.reason ?? null` 同判据:`??` 只在 null/undefined 时落
|
|
148
155
|
// null,空串照样入签名载荷)。真值判定会把显式 "" 与缺席折成同一个结果,审计/签名面丢了这个区分。
|
|
149
156
|
...(req.reason !== undefined ? { reason: req.reason } : {}),
|
|
157
|
+
// #204 件6③:结算出处逐字上 outcome(见 `ParkedDecideRequest.settledBy` 顶注)。
|
|
158
|
+
settledBy: req.settledBy,
|
|
150
159
|
};
|
|
151
160
|
const ctx = {
|
|
152
161
|
toolCallId: `drv-${ticket.claimId}`,
|
|
@@ -158,7 +167,7 @@ export async function decideParkedAgent(deps, req) {
|
|
|
158
167
|
outcome,
|
|
159
168
|
// [1597] 席位:重建父约束链(同进程时 core 的内存 registry 也能自动重供,席位供了也无害
|
|
160
169
|
// ——core 侧 count 校验对得上即用;跨进程/重启形全靠这里)。
|
|
161
|
-
...(deps.rebuildInheritedGate ? { inheritedGate
|
|
170
|
+
...(deps.rebuildInheritedGate ? { inheritedGate } : {}),
|
|
162
171
|
},
|
|
163
172
|
},
|
|
164
173
|
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/183 I2(form b 位)—— **SQL 收编日志**:phase 真源与被迁的行**同库**。
|
|
3
|
+
*
|
|
4
|
+
* 🔴 为什么 phase 必须住 SQL 而不是一个 sidecar 文件(183 r3 按 codex r2-F2):发起收编的那个 pod 在两条
|
|
5
|
+
* 腿之间被摧毁时,进程本地的标记会与已经半迁移的 SQL 状态**分家** —— 接棒副本读不到「迁到哪了」。
|
|
6
|
+
* 标记与行同事务 ⇒ 崩溃后接棒副本按日志续跑,分家形不存在。
|
|
7
|
+
*
|
|
8
|
+
* ── 幂等身份 = DB 约束,不是「查了再插」(小票 v2/F2)────────────────────────────────────────────
|
|
9
|
+
* `from_principal` 上有 **UNIQUE**(183 D7「拒二次收编」的 DB 强形)。发起走 **insert-or-fetch 原子形**:
|
|
10
|
+
* 一条 `INSERT … ON CONFLICT DO NOTHING` / `INSERT IGNORE`,再按 `from_principal` 读回行。
|
|
11
|
+
* **禁 SELECT-then-INSERT** —— 那两句之间的窗正是两个并发同参 POST 各插一行(或各读到空)的地方,
|
|
12
|
+
* 而 UNIQUE 让「谁插进去了」由数据库仲裁,`affected` 就是判别式。
|
|
13
|
+
* 同 from 异 to ⇒ 读回的行 `to_principal` 不等 ⇒ typed conflict(调用方看到的是 409,不是「成功」)。
|
|
14
|
+
*
|
|
15
|
+
* ── 列的口径 ────────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* · `legs` / `configs` / `report` 是 **TEXT 不是 JSON 列**:`report` 是 `immutableReport` 的落库形,
|
|
17
|
+
* 契约是「逐字节恒同」;JSON 列会按引擎自己的规范形重排键序,而 TEXT 是「存什么读什么」。
|
|
18
|
+
* (workflow_run.run / workflow_journal.result 同款先例,理由同源:String()→JSON.parse 的字节面。)
|
|
19
|
+
* · 时间列一律 `_ms BIGINT`(schema-naming 门 ② 咬 `_at BIGINT`)。
|
|
20
|
+
* · `phase` 是**单调**推进的阶段号,不是乐观锁计数 —— 但它的推进走的就是 CAS(`WHERE phase = :expect`),
|
|
21
|
+
* 所以它同时是那把锁。命名不叫 `rev`,因为它不守卫「行内容有没有变」,而守卫「弧走到哪了」。
|
|
22
|
+
*/
|
|
23
|
+
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
24
|
+
import type { Pool as PgPool } from "pg";
|
|
25
|
+
import { type SqlDriver, type SqlExec, type SqlTxConn } from "./sql-driver.js";
|
|
26
|
+
import type { AdoptionRejectCode } from "../adoption/wire.js";
|
|
27
|
+
export declare const ADOPTION_LOG_TABLE = "adoption_log";
|
|
28
|
+
/** 弧的阶段(183 §3.2 根级状态机的 form b 投影)。**单调**,只增不减。 */
|
|
29
|
+
export declare const ADOPTION_PHASE: {
|
|
30
|
+
/** ② 意图已落库(行在,尚未动任何数据行)。 */
|
|
31
|
+
readonly INTENT: 2;
|
|
32
|
+
/** ③ 身份轴重绑腿全部完成(与腿的 UPDATE **同一个事务**)。 */
|
|
33
|
+
readonly REBOUND: 3;
|
|
34
|
+
/** ④ 解析切换 / 配置清单产出。form b 的 server 半场:配置账已物化进 `configs`。 */
|
|
35
|
+
readonly CONFIGS: 4;
|
|
36
|
+
/** ⑤ 承运腿。**form b 恒零承运**(数据本来就在 SQL)—— 阶段位仍显式推进,好让 form a 上车时挂得上。 */
|
|
37
|
+
readonly CARRIED: 5;
|
|
38
|
+
/** ⑥ 永久终态。 */
|
|
39
|
+
readonly TERMINAL: 6;
|
|
40
|
+
};
|
|
41
|
+
export type AdoptionState = "in_flight" | "adopted" | "rejected";
|
|
42
|
+
export interface AdoptionRow {
|
|
43
|
+
adoptionId: string;
|
|
44
|
+
fromPrincipal: string;
|
|
45
|
+
toPrincipal: string;
|
|
46
|
+
phase: number;
|
|
47
|
+
state: AdoptionState;
|
|
48
|
+
/** `AdoptionLeg[]` 的 JSON 文本(phase 3 与腿同事务落库,此后不改)。 */
|
|
49
|
+
legsJson: string;
|
|
50
|
+
/** `AdoptionConfigEntry[]` 的 JSON 文本(phase 4 落库;见证位后续可更新)。 */
|
|
51
|
+
configsJson: string;
|
|
52
|
+
/** `AdoptionReport` 的 JSON 文本;`null` = 尚未终态。**落库后逐字节不变**(183 I4)。 */
|
|
53
|
+
reportJson: string | null;
|
|
54
|
+
rejectCode: string | null;
|
|
55
|
+
rejectDetail: string | null;
|
|
56
|
+
createdAtMs: number;
|
|
57
|
+
updatedAtMs: number;
|
|
58
|
+
}
|
|
59
|
+
export interface NewAdoptionRow {
|
|
60
|
+
adoptionId: string;
|
|
61
|
+
fromPrincipal: string;
|
|
62
|
+
toPrincipal: string;
|
|
63
|
+
nowMs: number;
|
|
64
|
+
}
|
|
65
|
+
/** `INSERT … ON CONFLICT DO NOTHING` 之后按 `from_principal` 读回的结果。`inserted` 只有真插进去的那一
|
|
66
|
+
* 路为 true —— 并发同参 POST 里恰有一条为 true,其余全 false 且拿到**同一行**。 */
|
|
67
|
+
export interface EnsureAdoptionResult {
|
|
68
|
+
row: AdoptionRow;
|
|
69
|
+
inserted: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 收编日志的**消费面接口**。真实现只有 {@link SqlAdoptionLogStore} 一个(双方言);抽出接口是为了让
|
|
73
|
+
* 协议层(`adoption/runner.ts`)只依赖行为而不依赖那个带 `protected db` 的类 —— 后者是**名义类型**,
|
|
74
|
+
* 测试想给一个结构等价的假店都做不到,于是「弧的状态机」与「SQL 文本」这两件事只能一起测,
|
|
75
|
+
* 两个变量同时动的实验没有判别力。
|
|
76
|
+
*/
|
|
77
|
+
export interface AdoptionLogStore {
|
|
78
|
+
/** 池级只读执行面(**不在弧锁下**的读用它:锁外的现势查询、boot 扫描的名单)。 */
|
|
79
|
+
readonly reader: SqlExec;
|
|
80
|
+
ensureAdoption(row: NewAdoptionRow): Promise<EnsureAdoptionResult>;
|
|
81
|
+
/**
|
|
82
|
+
* 🔴 每个弧内方法都收一个 `exec`(codex R3-F2):弧锁**占着一条池连接**,如果弧内的读/事务再去池里
|
|
83
|
+
* 要第二条,`connectionLimit=1` 的部署会当场死锁(而 boot 的在飞扫描跑在 listen 之前 ⇒ 副本起不来)。
|
|
84
|
+
* 收编弧因此**全程单连接**:锁、preflight 读、腿的事务、phase CAS,全在 `withLock` 交出来的那一条上。
|
|
85
|
+
*/
|
|
86
|
+
getById(adoptionId: string, exec?: SqlExec): Promise<AdoptionRow | null>;
|
|
87
|
+
getByFrom(fromPrincipal: string, exec?: SqlExec): Promise<AdoptionRow | null>;
|
|
88
|
+
listInFlight(exec?: SqlExec): Promise<AdoptionRow[]>;
|
|
89
|
+
/** 在**给定连接**上跑腿 + phase CAS(不自己 connect —— 见 getById 的注)。 */
|
|
90
|
+
rebindOn<L>(conn: SqlTxConn, adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, work: (exec: SqlExec) => Promise<L[]>, encodeLegs: (legs: L[]) => string): Promise<{
|
|
91
|
+
ok: true;
|
|
92
|
+
legs: L[];
|
|
93
|
+
} | {
|
|
94
|
+
ok: false;
|
|
95
|
+
reason: "phase_moved";
|
|
96
|
+
}>;
|
|
97
|
+
/** 终态后的迟到行清扫,同样在给定连接上。 */
|
|
98
|
+
sweepOn<T>(conn: SqlTxConn, work: (exec: SqlExec) => Promise<T>): Promise<T>;
|
|
99
|
+
advancePhase(adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
100
|
+
putConfigs(adoptionId: string, expectPhase: number, nextPhase: number, configsJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
101
|
+
finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
102
|
+
finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
103
|
+
/** 取弧锁并把**那一条连接**交给回调(整条弧都跑在它上面)。`undefined` = 没取到锁。 */
|
|
104
|
+
withLock<T>(name: string, fn: (conn: SqlTxConn) => Promise<T>, attempts?: number, sleepMs?: number): Promise<T | undefined>;
|
|
105
|
+
}
|
|
106
|
+
/** MySQL 协议方言的建表语句(展开进 tidb-pool 的中央 `SCHEMA_STATEMENTS`,与 approval-ask 同姿势:
|
|
107
|
+
* 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化)。 */
|
|
108
|
+
export declare const TIDB_ADOPTION_LOG_STATEMENTS: readonly string[];
|
|
109
|
+
/** PostgreSQL 孪生(由 `ensurePgSchema` 在 advisory lock 的那条 client 上调用)。 */
|
|
110
|
+
export declare function ensurePgAdoptionLogSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* 弧锁的名字。**定长**(前缀 14 + 32 位十六进制 = 46 字符)。
|
|
113
|
+
*
|
|
114
|
+
* 🔴 为什么必须 hash 而不是把 principal 直接拼进去(codex R3-F3,亲核属实):TiDB/MySQL 的 `GET_LOCK`
|
|
115
|
+
* 锁名上限是 **64 字符**,而请求面收的 principal 到 190 —— 超过 50 字符的源身份会让取锁**在弧动起来
|
|
116
|
+
* 之前**就失败,而那时意图行已经落库,于是每一次重发、每一次副本 boot 续跑都确定性地再撞一次同一堵墙。
|
|
117
|
+
* (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
|
|
118
|
+
*/
|
|
119
|
+
export declare function adoptionLockName(fromPrincipal: string): string;
|
|
120
|
+
/**
|
|
121
|
+
* 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
|
|
122
|
+
*
|
|
123
|
+
* 每条语句的两方言文本**并排写在调用点**(A12 判据:方言差异必须显式,不许用占位符编号循环拼)。
|
|
124
|
+
*/
|
|
125
|
+
export declare class SqlAdoptionLogStore implements AdoptionLogStore {
|
|
126
|
+
protected readonly db: SqlDriver;
|
|
127
|
+
constructor(db: SqlDriver);
|
|
128
|
+
private q;
|
|
129
|
+
/** 池级只读执行面(phase 0 的 preflight 扫描用——它**零写**,不需要也不该占一条事务连接)。
|
|
130
|
+
* 写路径一律走 {@link rebindTransaction};把驱动整个暴露出去会让调用方能绕开 phase CAS。 */
|
|
131
|
+
get reader(): SqlExec;
|
|
132
|
+
/**
|
|
133
|
+
* 发起腿的 **insert-or-fetch 原子形**。返回读回的行(可能是别人插的)+ 是否本次插入。
|
|
134
|
+
* 调用方据 `row.toPrincipal !== toPrincipal` 判「同 from 异 to」⇒ typed 409。
|
|
135
|
+
*/
|
|
136
|
+
ensureAdoption(row: NewAdoptionRow): Promise<EnsureAdoptionResult>;
|
|
137
|
+
getById(adoptionId: string, exec?: SqlExec): Promise<AdoptionRow | null>;
|
|
138
|
+
getByFrom(fromPrincipal: string, exec?: SqlExec): Promise<AdoptionRow | null>;
|
|
139
|
+
/** I6 server 同族:每副本 boot 必查的在飞名单(**响亮**,不静默跳过)。 */
|
|
140
|
+
listInFlight(exec?: SqlExec): Promise<AdoptionRow[]>;
|
|
141
|
+
/**
|
|
142
|
+
* 迁移事务:腿的全部 UPDATE **与** phase CAS 落在**同一个事务**里。
|
|
143
|
+
*
|
|
144
|
+
* 🔴 为什么必须同事务(而不是「先迁完再推 phase」):两句分开就留下一个窗 —— 腿提交了、phase 没推,
|
|
145
|
+
* 崩溃后接棒副本重跑腿(幂等,零行变化)却把**首次的行数读数**丢了,回执里的 `legs` 会变成一串 0。
|
|
146
|
+
* 同事务之后这个窗根本不存在:要么「行迁了且 phase=3 且 legs 读数在」,要么什么都没发生。
|
|
147
|
+
* 任何异常(含唯一键违例 = 跨轴组合冲突)⇒ ROLLBACK ⇒ **源/目的地两侧字节零变更**。
|
|
148
|
+
*/
|
|
149
|
+
rebindOn<L>(conn: SqlTxConn, adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, work: (exec: SqlExec) => Promise<L[]>, encodeLegs: (legs: L[]) => string): Promise<{
|
|
150
|
+
ok: true;
|
|
151
|
+
legs: L[];
|
|
152
|
+
} | {
|
|
153
|
+
ok: false;
|
|
154
|
+
reason: "phase_moved";
|
|
155
|
+
}>;
|
|
156
|
+
/**
|
|
157
|
+
* 终态**之后**的迟到行清扫事务(无 phase 变更 —— phase 已经是终态,而终态是永久的)。
|
|
158
|
+
*
|
|
159
|
+
* 🔴 它不是「再收编一次」:腿本身幂等,清扫只是把收编**结束后**又落到旧身份下的行(配置未随迁的
|
|
160
|
+
* 部署会持续制造它们)一并搬过去,并让 `current.residualSourceRows` 有个能归零的动作。
|
|
161
|
+
* `immutableReport` 一个字节都不动(史实与现势分家,183 §6)。
|
|
162
|
+
*/
|
|
163
|
+
sweepOn<T>(conn: SqlTxConn, work: (exec: SqlExec) => Promise<T>): Promise<T>;
|
|
164
|
+
/** 单调 CAS 推进(无数据写的阶段;`false` = 别人已经推过了/相位不符 ⇒ 调用方重读行)。 */
|
|
165
|
+
advancePhase(adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
166
|
+
/** phase 4:配置账物化(与 phase 推进同一条语句,免得账落了相位没推)。 */
|
|
167
|
+
putConfigs(adoptionId: string, expectPhase: number, nextPhase: number, configsJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
168
|
+
/**
|
|
169
|
+
* ⑥ 终态:`report` 一次写入,`state='adopted'`。
|
|
170
|
+
* 🔴 `AND report IS NULL` 是 I4(永久终态 + 不可变史实)的**数据库强形**:即使调用序漂了,快照也
|
|
171
|
+
* 不可能被第二次写盖掉 —— 「逐字节恒同」于是不依赖调用方自觉。
|
|
172
|
+
*/
|
|
173
|
+
finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
174
|
+
/** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
|
|
175
|
+
finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
|
|
176
|
+
/**
|
|
177
|
+
* 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
|
|
178
|
+
*
|
|
179
|
+
* **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
|
|
180
|
+
* 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
|
|
181
|
+
* 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
|
|
182
|
+
*/
|
|
183
|
+
withLock<T>(name: string, fn: (conn: SqlTxConn) => Promise<T>, attempts?: number, sleepMs?: number): Promise<T | undefined>;
|
|
184
|
+
}
|
|
185
|
+
export declare class TiDBAdoptionLogStore extends SqlAdoptionLogStore {
|
|
186
|
+
constructor(pool: MySqlPool);
|
|
187
|
+
}
|
|
188
|
+
export declare class PgAdoptionLogStore extends SqlAdoptionLogStore {
|
|
189
|
+
constructor(pool: PgPool);
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=adoption-log-sql.d.ts.map
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
3
|
+
export const ADOPTION_LOG_TABLE = "adoption_log";
|
|
4
|
+
/** 弧的阶段(183 §3.2 根级状态机的 form b 投影)。**单调**,只增不减。 */
|
|
5
|
+
export const ADOPTION_PHASE = {
|
|
6
|
+
/** ② 意图已落库(行在,尚未动任何数据行)。 */
|
|
7
|
+
INTENT: 2,
|
|
8
|
+
/** ③ 身份轴重绑腿全部完成(与腿的 UPDATE **同一个事务**)。 */
|
|
9
|
+
REBOUND: 3,
|
|
10
|
+
/** ④ 解析切换 / 配置清单产出。form b 的 server 半场:配置账已物化进 `configs`。 */
|
|
11
|
+
CONFIGS: 4,
|
|
12
|
+
/** ⑤ 承运腿。**form b 恒零承运**(数据本来就在 SQL)—— 阶段位仍显式推进,好让 form a 上车时挂得上。 */
|
|
13
|
+
CARRIED: 5,
|
|
14
|
+
/** ⑥ 永久终态。 */
|
|
15
|
+
TERMINAL: 6,
|
|
16
|
+
};
|
|
17
|
+
/** MySQL 协议方言的建表语句(展开进 tidb-pool 的中央 `SCHEMA_STATEMENTS`,与 approval-ask 同姿势:
|
|
18
|
+
* 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化)。 */
|
|
19
|
+
export const TIDB_ADOPTION_LOG_STATEMENTS = [
|
|
20
|
+
`CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
|
|
21
|
+
adoption_id VARCHAR(64) NOT NULL,
|
|
22
|
+
-- from_principal 上的 UNIQUE = 183 D7「同一个源不许被收编两次」的**数据库强形**。
|
|
23
|
+
-- 它同时是发起腿的幂等身份:并发同参 POST 由这条约束仲裁,不靠应用层先查后插。
|
|
24
|
+
from_principal VARCHAR(190) NOT NULL,
|
|
25
|
+
to_principal VARCHAR(190) NOT NULL,
|
|
26
|
+
phase INT NOT NULL,
|
|
27
|
+
state VARCHAR(16) NOT NULL,
|
|
28
|
+
-- legs/configs/report 是 TEXT 不是 JSON:report 的契约是「逐字节恒同」,JSON 列会重排键序(顶注)。
|
|
29
|
+
legs MEDIUMTEXT NOT NULL,
|
|
30
|
+
configs MEDIUMTEXT NOT NULL,
|
|
31
|
+
report MEDIUMTEXT NULL,
|
|
32
|
+
reject_code VARCHAR(64) NULL,
|
|
33
|
+
reject_detail TEXT NULL,
|
|
34
|
+
created_at_ms BIGINT NOT NULL,
|
|
35
|
+
updated_at_ms BIGINT NOT NULL,
|
|
36
|
+
PRIMARY KEY (adoption_id),
|
|
37
|
+
UNIQUE KEY uq_adoption_log_from (from_principal),
|
|
38
|
+
KEY idx_adoption_log_state (state)
|
|
39
|
+
) COLLATE utf8mb4_bin`,
|
|
40
|
+
];
|
|
41
|
+
/** PostgreSQL 孪生(由 `ensurePgSchema` 在 advisory lock 的那条 client 上调用)。 */
|
|
42
|
+
export async function ensurePgAdoptionLogSchema(q) {
|
|
43
|
+
await q(`CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
|
|
44
|
+
adoption_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
45
|
+
-- MySQL 孪生的行内注解释了这条 UNIQUE 为什么是幂等身份的真源(183 D7 的 DB 强形)。
|
|
46
|
+
from_principal VARCHAR(190) COLLATE "C" NOT NULL,
|
|
47
|
+
to_principal VARCHAR(190) COLLATE "C" NOT NULL,
|
|
48
|
+
phase INT NOT NULL,
|
|
49
|
+
state VARCHAR(16) COLLATE "C" NOT NULL,
|
|
50
|
+
legs TEXT COLLATE "C" NOT NULL,
|
|
51
|
+
configs TEXT COLLATE "C" NOT NULL,
|
|
52
|
+
report TEXT COLLATE "C",
|
|
53
|
+
reject_code VARCHAR(64) COLLATE "C",
|
|
54
|
+
reject_detail TEXT COLLATE "C",
|
|
55
|
+
created_at_ms BIGINT NOT NULL,
|
|
56
|
+
updated_at_ms BIGINT NOT NULL,
|
|
57
|
+
PRIMARY KEY (adoption_id),
|
|
58
|
+
CONSTRAINT uq_adoption_log_from UNIQUE (from_principal)
|
|
59
|
+
)`);
|
|
60
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_adoption_log_state ON ${ADOPTION_LOG_TABLE} (state)`);
|
|
61
|
+
}
|
|
62
|
+
const COLS = "adoption_id, from_principal, to_principal, phase, state, legs, configs, report, reject_code, reject_detail, created_at_ms, updated_at_ms";
|
|
63
|
+
function toRow(r) {
|
|
64
|
+
return {
|
|
65
|
+
adoptionId: String(r.adoption_id),
|
|
66
|
+
fromPrincipal: String(r.from_principal),
|
|
67
|
+
toPrincipal: String(r.to_principal),
|
|
68
|
+
phase: Number(r.phase),
|
|
69
|
+
state: String(r.state),
|
|
70
|
+
legsJson: String(r.legs),
|
|
71
|
+
configsJson: String(r.configs),
|
|
72
|
+
reportJson: r.report == null ? null : String(r.report),
|
|
73
|
+
rejectCode: r.reject_code == null ? null : String(r.reject_code),
|
|
74
|
+
rejectDetail: r.reject_detail == null ? null : String(r.reject_detail),
|
|
75
|
+
createdAtMs: Number(r.created_at_ms),
|
|
76
|
+
updatedAtMs: Number(r.updated_at_ms),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** PG advisory lock 的 32 位对象键(类键固定,对象键 = 名字的 sha256 前 4 字节转有符号 int4)。 */
|
|
80
|
+
const PG_ADOPTION_LOCK_CLASS = 4907;
|
|
81
|
+
function pgLockObjectKey(name) {
|
|
82
|
+
return createHash("sha256").update(name).digest().readInt32BE(0);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 弧锁的名字。**定长**(前缀 14 + 32 位十六进制 = 46 字符)。
|
|
86
|
+
*
|
|
87
|
+
* 🔴 为什么必须 hash 而不是把 principal 直接拼进去(codex R3-F3,亲核属实):TiDB/MySQL 的 `GET_LOCK`
|
|
88
|
+
* 锁名上限是 **64 字符**,而请求面收的 principal 到 190 —— 超过 50 字符的源身份会让取锁**在弧动起来
|
|
89
|
+
* 之前**就失败,而那时意图行已经落库,于是每一次重发、每一次副本 boot 续跑都确定性地再撞一次同一堵墙。
|
|
90
|
+
* (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
|
|
91
|
+
*/
|
|
92
|
+
export function adoptionLockName(fromPrincipal) {
|
|
93
|
+
return `sema_adoption:${createHash("sha256").update(fromPrincipal).digest("hex").slice(0, 32)}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
|
|
97
|
+
*
|
|
98
|
+
* 每条语句的两方言文本**并排写在调用点**(A12 判据:方言差异必须显式,不许用占位符编号循环拼)。
|
|
99
|
+
*/
|
|
100
|
+
export class SqlAdoptionLogStore {
|
|
101
|
+
db;
|
|
102
|
+
constructor(db) {
|
|
103
|
+
this.db = db;
|
|
104
|
+
}
|
|
105
|
+
q(tidb, pg) {
|
|
106
|
+
return this.db.dialect === "tidb" ? tidb : pg;
|
|
107
|
+
}
|
|
108
|
+
/** 池级只读执行面(phase 0 的 preflight 扫描用——它**零写**,不需要也不该占一条事务连接)。
|
|
109
|
+
* 写路径一律走 {@link rebindTransaction};把驱动整个暴露出去会让调用方能绕开 phase CAS。 */
|
|
110
|
+
get reader() {
|
|
111
|
+
return this.db;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 发起腿的 **insert-or-fetch 原子形**。返回读回的行(可能是别人插的)+ 是否本次插入。
|
|
115
|
+
* 调用方据 `row.toPrincipal !== toPrincipal` 判「同 from 异 to」⇒ typed 409。
|
|
116
|
+
*/
|
|
117
|
+
async ensureAdoption(row) {
|
|
118
|
+
const emptyLegs = "[]";
|
|
119
|
+
const res = await this.db.query(this.q(`INSERT IGNORE INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES (?, ?, ?, ?, 'in_flight', ?, ?, NULL, NULL, NULL, ?, ?)`, `INSERT INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES ($1, $2, $3, $4, 'in_flight', $5, $6, NULL, NULL, NULL, $7, $8) ON CONFLICT (from_principal) DO NOTHING`), [row.adoptionId, row.fromPrincipal, row.toPrincipal, ADOPTION_PHASE.INTENT, emptyLegs, emptyLegs, row.nowMs, row.nowMs]);
|
|
120
|
+
const inserted = res.affected > 0;
|
|
121
|
+
const stored = await this.getByFrom(row.fromPrincipal);
|
|
122
|
+
if (!stored)
|
|
123
|
+
throw new Error(`ensureAdoption: row vanished right after insert (from=${row.fromPrincipal})`);
|
|
124
|
+
return { row: stored, inserted };
|
|
125
|
+
}
|
|
126
|
+
async getById(adoptionId, exec = this.db) {
|
|
127
|
+
const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = $1`), [adoptionId]);
|
|
128
|
+
return res.rows[0] ? toRow(res.rows[0]) : null;
|
|
129
|
+
}
|
|
130
|
+
async getByFrom(fromPrincipal, exec = this.db) {
|
|
131
|
+
const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = $1`), [fromPrincipal]);
|
|
132
|
+
return res.rows[0] ? toRow(res.rows[0]) : null;
|
|
133
|
+
}
|
|
134
|
+
/** I6 server 同族:每副本 boot 必查的在飞名单(**响亮**,不静默跳过)。 */
|
|
135
|
+
async listInFlight(exec = this.db) {
|
|
136
|
+
const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`), []);
|
|
137
|
+
return res.rows.map(toRow);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 迁移事务:腿的全部 UPDATE **与** phase CAS 落在**同一个事务**里。
|
|
141
|
+
*
|
|
142
|
+
* 🔴 为什么必须同事务(而不是「先迁完再推 phase」):两句分开就留下一个窗 —— 腿提交了、phase 没推,
|
|
143
|
+
* 崩溃后接棒副本重跑腿(幂等,零行变化)却把**首次的行数读数**丢了,回执里的 `legs` 会变成一串 0。
|
|
144
|
+
* 同事务之后这个窗根本不存在:要么「行迁了且 phase=3 且 legs 读数在」,要么什么都没发生。
|
|
145
|
+
* 任何异常(含唯一键违例 = 跨轴组合冲突)⇒ ROLLBACK ⇒ **源/目的地两侧字节零变更**。
|
|
146
|
+
*/
|
|
147
|
+
async rebindOn(conn, adoptionId, expectPhase, nextPhase, nowMs, work, encodeLegs) {
|
|
148
|
+
try {
|
|
149
|
+
await conn.begin();
|
|
150
|
+
const legs = await work(conn);
|
|
151
|
+
const cas = await conn.query(
|
|
152
|
+
// 🔴 谓词里必须带 `state = 'in_flight'`(codex R2-F1..F3 的 F3):被拒的行**故意**停在 phase INTENT
|
|
153
|
+
// (「两侧字节零变更」要求它一步都别走),所以只判 phase 的 CAS 对一条已 rejected 的行是**满足**的
|
|
154
|
+
// —— 一个在锁上排队的过期调用方于是能把一条永久拒了的弧推去迁数据。终态永久性是库级不变量,
|
|
155
|
+
// 这道守卫就该在库里,不在调用方的 if 里。
|
|
156
|
+
this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, legs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, legs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND state = 'in_flight'`), [nextPhase, encodeLegs(legs), nowMs, adoptionId, expectPhase]);
|
|
157
|
+
if (cas.affected !== 1) {
|
|
158
|
+
await conn.rollback();
|
|
159
|
+
return { ok: false, reason: "phase_moved" };
|
|
160
|
+
}
|
|
161
|
+
await conn.commit();
|
|
162
|
+
return { ok: true, legs };
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
try {
|
|
166
|
+
await conn.rollback();
|
|
167
|
+
}
|
|
168
|
+
catch (rollbackErr) {
|
|
169
|
+
// 🔴 不空吞(#191 静默降级门):回滚本身失败通常意味着连接已经废了,原始异常才是要上抛的那个,
|
|
170
|
+
// 但「回滚没成功」是运维必须看见的事 —— 它决定了库里可能留着一段未提交/已废弃的事务状态。
|
|
171
|
+
console.warn("[adoption-log] rollback after a failed rebind transaction also failed:", rollbackErr);
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 终态**之后**的迟到行清扫事务(无 phase 变更 —— phase 已经是终态,而终态是永久的)。
|
|
178
|
+
*
|
|
179
|
+
* 🔴 它不是「再收编一次」:腿本身幂等,清扫只是把收编**结束后**又落到旧身份下的行(配置未随迁的
|
|
180
|
+
* 部署会持续制造它们)一并搬过去,并让 `current.residualSourceRows` 有个能归零的动作。
|
|
181
|
+
* `immutableReport` 一个字节都不动(史实与现势分家,183 §6)。
|
|
182
|
+
*/
|
|
183
|
+
async sweepOn(conn, work) {
|
|
184
|
+
try {
|
|
185
|
+
await conn.begin();
|
|
186
|
+
const out = await work(conn);
|
|
187
|
+
await conn.commit();
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
try {
|
|
192
|
+
await conn.rollback();
|
|
193
|
+
}
|
|
194
|
+
catch (rollbackErr) {
|
|
195
|
+
console.warn("[adoption-log] rollback after a failed sweep transaction also failed:", rollbackErr); // 同上:不空吞
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** 单调 CAS 推进(无数据写的阶段;`false` = 别人已经推过了/相位不符 ⇒ 调用方重读行)。 */
|
|
201
|
+
async advancePhase(adoptionId, expectPhase, nextPhase, nowMs, exec = this.db) {
|
|
202
|
+
const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, updated_at_ms = $2 WHERE adoption_id = $3 AND phase = $4`), [nextPhase, nowMs, adoptionId, expectPhase]);
|
|
203
|
+
return res.affected === 1;
|
|
204
|
+
}
|
|
205
|
+
/** phase 4:配置账物化(与 phase 推进同一条语句,免得账落了相位没推)。 */
|
|
206
|
+
async putConfigs(adoptionId, expectPhase, nextPhase, configsJson, nowMs, exec = this.db) {
|
|
207
|
+
const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, configs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, configs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5`), [nextPhase, configsJson, nowMs, adoptionId, expectPhase]);
|
|
208
|
+
return res.affected === 1;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* ⑥ 终态:`report` 一次写入,`state='adopted'`。
|
|
212
|
+
* 🔴 `AND report IS NULL` 是 I4(永久终态 + 不可变史实)的**数据库强形**:即使调用序漂了,快照也
|
|
213
|
+
* 不可能被第二次写盖掉 —— 「逐字节恒同」于是不依赖调用方自觉。
|
|
214
|
+
*/
|
|
215
|
+
async finalizeAdopted(adoptionId, expectPhase, reportJson, nowMs, exec = this.db) {
|
|
216
|
+
const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, state = 'adopted', report = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND report IS NULL`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, state = 'adopted', report = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND report IS NULL`), [ADOPTION_PHASE.TERMINAL, reportJson, nowMs, adoptionId, expectPhase]);
|
|
217
|
+
return res.affected === 1;
|
|
218
|
+
}
|
|
219
|
+
/** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
|
|
220
|
+
async finalizeRejected(adoptionId, code, detail, nowMs, exec = this.db) {
|
|
221
|
+
const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = ?, reject_detail = ?, updated_at_ms = ? WHERE adoption_id = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = $1, reject_detail = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND state = 'in_flight'`), [code, detail, nowMs, adoptionId]);
|
|
222
|
+
return res.affected === 1;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
|
|
226
|
+
*
|
|
227
|
+
* **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
|
|
228
|
+
* 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
|
|
229
|
+
* 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
|
|
230
|
+
*/
|
|
231
|
+
async withLock(name, fn, attempts = 20, sleepMs = 100) {
|
|
232
|
+
const conn = await this.db.connect();
|
|
233
|
+
let held = false;
|
|
234
|
+
try {
|
|
235
|
+
for (let i = 0; i < attempts && !held; i += 1) {
|
|
236
|
+
const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
|
|
237
|
+
const got = res.rows[0]?.got;
|
|
238
|
+
held = got === 1 || got === true || got === "1";
|
|
239
|
+
if (!held)
|
|
240
|
+
await new Promise((r) => setTimeout(r, sleepMs));
|
|
241
|
+
}
|
|
242
|
+
if (!held)
|
|
243
|
+
return undefined;
|
|
244
|
+
// 🔴 把**这一条**连接交出去:整条弧(读 / 事务 / CAS)都跑在它上面,弧内绝不再向池要第二条
|
|
245
|
+
// ——`connectionLimit=1` 的部署上那第二条永远等不到(codex R3-F2)。
|
|
246
|
+
return await fn(conn);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (held) {
|
|
250
|
+
try {
|
|
251
|
+
await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
|
|
252
|
+
}
|
|
253
|
+
catch (releaseErr) {
|
|
254
|
+
// 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
|
|
255
|
+
// 释放失败意味着这把弧锁会一直持到连接回收,下一个副本要多等一会儿,那是要能看见的。
|
|
256
|
+
console.warn("[adoption-log] releasing the adoption arc lock failed (it auto-frees when the connection closes):", releaseErr);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
conn.release();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export class TiDBAdoptionLogStore extends SqlAdoptionLogStore {
|
|
264
|
+
constructor(pool) {
|
|
265
|
+
super(mysqlDriver(pool));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export class PgAdoptionLogStore extends SqlAdoptionLogStore {
|
|
269
|
+
constructor(pool) {
|
|
270
|
+
super(pgDriver(pool));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=adoption-log-sql.js.map
|
|
@@ -152,6 +152,19 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
152
152
|
* `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
|
|
153
153
|
*/
|
|
154
154
|
readonly fidelity: StoreFidelity;
|
|
155
|
+
/**
|
|
156
|
+
* `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
|
|
157
|
+
* 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
|
|
158
|
+
* 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
|
|
159
|
+
* 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
|
|
160
|
+
* 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
|
|
161
|
+
* 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
|
|
162
|
+
* `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
|
|
163
|
+
* 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
|
|
164
|
+
*/
|
|
165
|
+
readonly redecision: {
|
|
166
|
+
readonly reopen: true;
|
|
167
|
+
};
|
|
155
168
|
constructor(db: SqlDriver, logger?: {
|
|
156
169
|
info?(msg: string, meta?: unknown): void;
|
|
157
170
|
} | undefined);
|
|
@@ -269,6 +269,17 @@ export class SqlCheckpointStore {
|
|
|
269
269
|
* `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
|
|
270
270
|
*/
|
|
271
271
|
fidelity = "json";
|
|
272
|
+
/**
|
|
273
|
+
* `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
|
|
274
|
+
* 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
|
|
275
|
+
* 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
|
|
276
|
+
* 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
|
|
277
|
+
* 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
|
|
278
|
+
* 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
|
|
279
|
+
* `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
|
|
280
|
+
* 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
|
|
281
|
+
*/
|
|
282
|
+
redecision = { reopen: true };
|
|
272
283
|
constructor(db, logger) {
|
|
273
284
|
this.db = db;
|
|
274
285
|
this.logger = logger;
|
|
@@ -27,6 +27,16 @@ export declare class LocalCheckpointStore {
|
|
|
27
27
|
* 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
|
|
28
28
|
*/
|
|
29
29
|
readonly fidelity: StoreFidelity;
|
|
30
|
+
/**
|
|
31
|
+
* `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
|
|
32
|
+
* 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
|
|
33
|
+
* 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
|
|
34
|
+
* 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
|
|
35
|
+
* 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
|
|
36
|
+
*/
|
|
37
|
+
readonly redecision: {
|
|
38
|
+
readonly reopen: true;
|
|
39
|
+
};
|
|
30
40
|
private readonly inner;
|
|
31
41
|
private readonly scopesPath;
|
|
32
42
|
private readonly sessionTokensPath;
|
|
@@ -54,6 +54,14 @@ export class LocalCheckpointStore {
|
|
|
54
54
|
* 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
|
|
55
55
|
*/
|
|
56
56
|
fidelity = "json";
|
|
57
|
+
/**
|
|
58
|
+
* `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
|
|
59
|
+
* 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
|
|
60
|
+
* 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
|
|
61
|
+
* 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
|
|
62
|
+
* 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
|
|
63
|
+
*/
|
|
64
|
+
redecision = { reopen: true };
|
|
57
65
|
inner;
|
|
58
66
|
scopesPath;
|
|
59
67
|
sessionTokensPath;
|