/** * [ref] I2(form b 位)—— **SQL 收编日志**:phase 真源与被迁的行**同库**。 * * 🔴 为什么 phase 必须住 SQL 而不是一个 sidecar 文件(183 r3 按 codex r2-F2):发起收编的那个 pod 在两条 * 腿之间被摧毁时,进程本地的标记会与已经半迁移的 SQL 状态**分家** —— 接棒副本读不到「迁到哪了」。 * 标记与行同事务 ⇒ 崩溃后接棒副本按日志续跑,分家形不存在。 * * ── 幂等身份 = DB 约束,不是「查了再插」(小票 v2/F2)──────────────────────────────────────────── * `from_principal` 上有 **UNIQUE**(183 D7「拒二次收编」的 DB 强形)。发起走 **insert-or-fetch 原子形**: * 一条 `INSERT … ON CONFLICT DO NOTHING` / `INSERT IGNORE`,再按 `from_principal` 读回行。 * **禁 SELECT-then-INSERT** —— 那两句之间的窗正是两个并发同参 POST 各插一行(或各读到空)的地方, * 而 UNIQUE 让「谁插进去了」由数据库仲裁,`affected` 就是判别式。 * 同 from 异 to ⇒ 读回的行 `to_principal` 不等 ⇒ typed conflict(调用方看到的是 409,不是「成功」)。 * * ── 列的口径 ──────────────────────────────────────────────────────────────────────────────────── * · `legs` / `configs` / `report` 是 **TEXT 不是 JSON 列**:`report` 是 `immutableReport` 的落库形, * 契约是「逐字节恒同」;JSON 列会按引擎自己的规范形重排键序,而 TEXT 是「存什么读什么」。 * (workflow_run.run / workflow_journal.result 同款先例,理由同源:String()→JSON.parse 的字节面。) * · 时间列一律 `_ms BIGINT`(schema-naming 门 ② 咬 `_at BIGINT`)。 * · `phase` 是**单调**推进的阶段号,不是乐观锁计数 —— 但它的推进走的就是 CAS(`WHERE phase = :expect`), * 所以它同时是那把锁。命名不叫 `rev`,因为它不守卫「行内容有没有变」,而守卫「弧走到哪了」。 */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SqlDriver, type SqlExec, type SqlTxConn } from "./sql-driver.js"; import type { AdoptionRejectCode } from "../adoption/wire.js"; import { type IndexSpec } from "./ensure-index.js"; export declare const ADOPTION_LOG_TABLE = "adoption_log"; /** 弧的阶段(183 §3.2 根级状态机的 form b 投影)。**单调**,只增不减。 */ export declare const ADOPTION_PHASE: { /** ② 意图已落库(行在,尚未动任何数据行)。 */ readonly INTENT: 2; /** ③ 身份轴重绑腿全部完成(与腿的 UPDATE **同一个事务**)。 */ readonly REBOUND: 3; /** ④ 解析切换 / 配置清单产出。form b 的 server 半场:配置账已物化进 `configs`。 */ readonly CONFIGS: 4; /** ⑤ 承运腿。**form b 恒零承运**(数据本来就在 SQL)—— 阶段位仍显式推进,好让 form a 上车时挂得上。 */ readonly CARRIED: 5; /** ⑥ 永久终态。 */ readonly TERMINAL: 6; }; export type AdoptionState = "in_flight" | "adopted" | "rejected"; export interface AdoptionRow { adoptionId: string; fromPrincipal: string; toPrincipal: string; phase: number; state: AdoptionState; /** `AdoptionLeg[]` 的 JSON 文本(phase 3 与腿同事务落库,此后不改)。 */ legsJson: string; /** `AdoptionConfigEntry[]` 的 JSON 文本(phase 4 落库;见证位后续可更新)。 */ configsJson: string; /** `AdoptionReport` 的 JSON 文本;`null` = 尚未终态。**落库后逐字节不变**(183 I4)。 */ reportJson: string | null; rejectCode: string | null; rejectDetail: string | null; createdAtMs: number; updatedAtMs: number; } export interface NewAdoptionRow { adoptionId: string; fromPrincipal: string; toPrincipal: string; nowMs: number; } /** `INSERT … ON CONFLICT DO NOTHING` 之后按 `from_principal` 读回的结果。`inserted` 只有真插进去的那一 * 路为 true —— 并发同参 POST 里恰有一条为 true,其余全 false 且拿到**同一行**。 */ export interface EnsureAdoptionResult { row: AdoptionRow; inserted: boolean; } /** * 收编日志的**消费面接口**。真实现只有 {@link SqlAdoptionLogStore} 一个(双方言);抽出接口是为了让 * 协议层(`adoption/runner.ts`)只依赖行为而不依赖那个带 `protected db` 的类 —— 后者是**名义类型**, * 测试想给一个结构等价的假店都做不到,于是「弧的状态机」与「SQL 文本」这两件事只能一起测, * 两个变量同时动的实验没有判别力。 */ export interface AdoptionLogStore { /** 池级只读执行面(**不在弧锁下**的读用它:锁外的现势查询、boot 扫描的名单)。 */ readonly reader: SqlExec; ensureAdoption(row: NewAdoptionRow): Promise; /** * 🔴 每个弧内方法都收一个 `exec`(codex R3-F2):弧锁**占着一条池连接**,如果弧内的读/事务再去池里 * 要第二条,`connectionLimit=1` 的部署会当场死锁(而 boot 的在飞扫描跑在 listen 之前 ⇒ 副本起不来)。 * 收编弧因此**全程单连接**:锁、preflight 读、腿的事务、phase CAS,全在 `withLock` 交出来的那一条上。 */ getById(adoptionId: string, exec?: SqlExec): Promise; getByFrom(fromPrincipal: string, exec?: SqlExec): Promise; listInFlight(exec?: SqlExec): Promise; /** 在**给定连接**上跑腿 + phase CAS(不自己 connect —— 见 getById 的注)。 */ rebindOn(conn: SqlTxConn, adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, work: (exec: SqlExec) => Promise, encodeLegs: (legs: L[]) => string): Promise<{ ok: true; legs: L[]; } | { ok: false; reason: "phase_moved"; }>; /** 终态后的迟到行清扫,同样在给定连接上。 */ sweepOn(conn: SqlTxConn, work: (exec: SqlExec) => Promise): Promise; advancePhase(adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, exec?: SqlExec): Promise; putConfigs(adoptionId: string, expectPhase: number, nextPhase: number, configsJson: string, nowMs: number, exec?: SqlExec): Promise; finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise; finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise; /** 取弧锁并把**那一条连接**交给回调(整条弧都跑在它上面)。`undefined` = 没取到锁。 */ withLock(name: string, fn: (conn: SqlTxConn) => Promise, attempts?: number, sleepMs?: number): Promise; } /** MySQL 协议方言的建表语句(展开进 tidb-pool 的中央 `SCHEMA_STATEMENTS`,与 approval-ask 同姿势: * 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化)。 */ export declare const TIDB_ADOPTION_LOG_STATEMENTS: readonly string[]; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgAdoptionLogSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const ADOPTION_LOG_INDEXES: readonly IndexSpec[]; /** PostgreSQL 孪生(由 `ensurePgSchema` 在 advisory lock 的那条 client 上调用)。 */ export declare function ensurePgAdoptionLogSchema(q: (text: string, params?: unknown[]) => Promise): Promise; /** * 弧锁的名字。**定长**(前缀 14 + 32 位十六进制 = 46 字符)。 * * 🔴 为什么必须 hash 而不是把 principal 直接拼进去(codex R3-F3,亲核属实):TiDB/MySQL 的 `GET_LOCK` * 锁名上限是 **64 字符**,而请求面收的 principal 到 190 —— 超过 50 字符的源身份会让取锁**在弧动起来 * 之前**就失败,而那时意图行已经落库,于是每一次重发、每一次副本 boot 续跑都确定性地再撞一次同一堵墙。 * (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。) */ export declare function adoptionLockName(fromPrincipal: string): string; /** * 把弧锁名限定到**一个库**的键空间([ref],验真后修)。 * * 🔴 病:两条腿的锁键空间**不等价**。MySQL/TiDB 的 `GET_LOCK` 名字是**服务器实例全局**的 * (`performance_schema.metadata_locks` 里就是一个进程级命名空间,与你连的是哪个 schema 无关); * PG 的 advisory lock 是**每数据库**的(`pg_locks` 的 database 列参与身份)。而锁名此前只 hash 了 * `fromPrincipal`、前缀写死 `sema_adoption:` —— 于是同一台 MySQL 上并存的两套部署 * (`aiagent_prod` / `aiagent_staging`,同一个源身份)会**互相抢同一把锁**:一边正在跑弧,另一边 * 取不到锁、按「在飞」回一份 `stalled` 回执 —— 一个与它自己的库状态完全对不上的答复。切到 PG 同样 * 两套部署却各跑各的。同一份代码、同一份配置,两个后端上语义不同 = 方言不等价。 * * 🔴 姿势:限定符 = **当前数据库名**(MySQL `DATABASE()` / PG `current_database()`),与 principal * 一起进 hash 原像。于是 MySQL 腿被收窄到与 PG 腿同一个键空间语义(每库一把),而不是把 PG 放宽到 * 实例全局(放宽的那个方向会让**同库**的两副本以为各自持锁,那是真丢互斥,方向错得多)。 * * 🔴 为什么仍然 hash 而不是 `${db}/${name}` 直接拼:`GET_LOCK` 的名字上限是 **64 字符**,而库名没有 * 本仓能保证的上限。拼接形会让一个长库名的部署在**取锁那一步**才失败,而那时意图行已经落库 —— * 每一次重发、每一次 boot 续跑都确定性地再撞一次同一堵墙(与 `adoptionLockName` 自己头注里那条 * codex R3-F3 是同一个病)。hash 后定长 46 字符,与库名长度无关。 * 分隔符取 **NUL**(源码里写成转义 `\u0000`,不落裸控制字节 —— 那会让 grep 把整个文件当二进制): * 库名可以含 `/`、空格这类字符(MySQL 反引号标识符 / PG 双引号标识符都允许),拿它们当分隔符时 * `(db="a", name="b/c")` 与 `(db="a/b", name="c")` 会铸出**同一个原像** —— 弱分隔符拼接的经典撞形。 * NUL 在两种引擎的标识符里都不合法,所以它是真的不可能出现在任何一段里。 */ export declare function qualifyAdoptionLockName(database: string, name: string): string; /** * 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。 * * 每条语句的两方言文本**并排写在调用点**(A12 判据:方言差异必须显式,不许用占位符编号循环拼)。 */ export declare class SqlAdoptionLogStore implements AdoptionLogStore { protected readonly db: SqlDriver; constructor(db: SqlDriver); private q; /** 池级只读执行面(phase 0 的 preflight 扫描用——它**零写**,不需要也不该占一条事务连接)。 * 写路径一律走 {@link rebindTransaction};把驱动整个暴露出去会让调用方能绕开 phase CAS。 */ get reader(): SqlExec; /** * 发起腿的 **insert-or-fetch 原子形**。返回读回的行(可能是别人插的)+ 是否本次插入。 * 调用方据 `row.toPrincipal !== toPrincipal` 判「同 from 异 to」⇒ typed 409。 */ ensureAdoption(row: NewAdoptionRow): Promise; getById(adoptionId: string, exec?: SqlExec): Promise; getByFrom(fromPrincipal: string, exec?: SqlExec): Promise; /** I6 server 同族:每副本 boot 必查的在飞名单(**响亮**,不静默跳过)。 */ listInFlight(exec?: SqlExec): Promise; /** * 迁移事务:腿的全部 UPDATE **与** phase CAS 落在**同一个事务**里。 * * 🔴 为什么必须同事务(而不是「先迁完再推 phase」):两句分开就留下一个窗 —— 腿提交了、phase 没推, * 崩溃后接棒副本重跑腿(幂等,零行变化)却把**首次的行数读数**丢了,回执里的 `legs` 会变成一串 0。 * 同事务之后这个窗根本不存在:要么「行迁了且 phase=3 且 legs 读数在」,要么什么都没发生。 * 任何异常(含唯一键违例 = 跨轴组合冲突)⇒ ROLLBACK ⇒ **源/目的地两侧字节零变更**。 */ rebindOn(conn: SqlTxConn, adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, work: (exec: SqlExec) => Promise, encodeLegs: (legs: L[]) => string): Promise<{ ok: true; legs: L[]; } | { ok: false; reason: "phase_moved"; }>; /** * 终态**之后**的迟到行清扫事务(无 phase 变更 —— phase 已经是终态,而终态是永久的)。 * * 🔴 它不是「再收编一次」:腿本身幂等,清扫只是把收编**结束后**又落到旧身份下的行(配置未随迁的 * 部署会持续制造它们)一并搬过去,并让 `current.residualSourceRows` 有个能归零的动作。 * `immutableReport` 一个字节都不动(史实与现势分家,183 §6)。 */ sweepOn(conn: SqlTxConn, work: (exec: SqlExec) => Promise): Promise; /** 单调 CAS 推进(无数据写的阶段;`false` = 别人已经推过了/相位不符 ⇒ 调用方重读行)。 */ advancePhase(adoptionId: string, expectPhase: number, nextPhase: number, nowMs: number, exec?: SqlExec): Promise; /** phase 4:配置账物化(与 phase 推进同一条语句,免得账落了相位没推)。 */ putConfigs(adoptionId: string, expectPhase: number, nextPhase: number, configsJson: string, nowMs: number, exec?: SqlExec): Promise; /** * ⑥ 终态:`report` 一次写入,`state='adopted'`。 * 🔴 `AND report IS NULL` 是 I4(永久终态 + 不可变史实)的**数据库强形**:即使调用序漂了,快照也 * 不可能被第二次写盖掉 —— 「逐字节恒同」于是不依赖调用方自觉。 */ finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise; /** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */ finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise; /** * 本连接所在**数据库**的名字 = 弧锁的键空间限定符([ref],理由见 {@link qualifyAdoptionLockName})。 * * 在**交出来的那条连接**上问(不向池要第二条 —— 弧全程单连接,见 `AdoptionLogStore.getById` 的注), * 结果缓存在店上:库名在一条驱动的生命周期里不会变,而每次取锁多打一个往返是白付的。 * * 读不出来 ⇒ **响亮抛**,不回落到未限定的旧名字:那正是本条要消灭的形,静默回落等于「修了个寂寞」 * 且只在多部署共库的那台机器上才发作(安全轴禁静默 fail-open 的同族判据)。 */ private lockKeyspace?; private resolveLockKeyspace; /** * 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。 * * **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发 * 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒ * 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。 * * 🔴 入参 `name` 是**逻辑**锁名(`adoptionLockName(fromPrincipal)`);真正发给引擎的是它被 * {@link qualifyAdoptionLockName} 限定到本库之后的形([ref]:两方言键空间不等价)。限定发生在 * **这里**而不是调用方,因为「键空间是每库还是每实例」是**存储层**的知识,协议层(runner)不该知道。 */ withLock(name: string, fn: (conn: SqlTxConn) => Promise, attempts?: number, sleepMs?: number): Promise; } export declare class TiDBAdoptionLogStore extends SqlAdoptionLogStore { constructor(pool: MySqlPool); } export declare class PgAdoptionLogStore extends SqlAdoptionLogStore { constructor(pool: PgPool); } //# sourceMappingURL=adoption-log-sql.d.ts.map