/** * 托管留存执行面的**非破坏性 SQL 半场** —— SINGLE-FILE DUAL-DIALECT([ref] A12;先例 = * `retention-store-sql.ts` 本身)。[ref] 车2,设计稿 `docs/DESIGN-270-retention-lane.md` v1.3。 * * ── 为什么与车1 的 `retention-store-sql.ts` 分家(而不是往那只店上再挂三组方法)──────────────────── * 车1 那只店是 core `ManagedRetentionCapability` 的**实现体**:它的每一个公开方法都是一次**破坏性** * 事务(hold 锁读 → 变更+墓碑 → 审计行,同 commit 同 rollback)。本文件三组口全部是**非破坏**的: * · sweep 租约(§3):互斥机制,一行 CAS,与任何业务表无关; * · legal-hold 的**放置/解除**(§5):operator 路由的写点,只改 `retention_hold.held` 一列; * · 审计表的**读**(§6 查询面)与**非破坏行的写**(`audit_only`/`skipped_legal_hold`/`failed`/`hold_*` * —— 属主是 lane 与路由,它们没有「删了没记」的窗口)。 * 三者的消费者也不同(lane / operator 路由),而车1 那只店的每个方法都**必须**由 lane 带着审计上下文调。 * 混进同一只对象会让「拿到这只店 = 能删数据」这条读法失真 —— 审计读面与 hold 路由拿到的应当是一把 * **删不了任何东西**的钥匙。 * * ── 方言差异台账(显式,永不藏进抽象;A12 判据)─────────────────────────────────────────────────── * - `?` 占位符 vs `$n`; * - 幂等建行(哨兵形 A,S-127):`ON DUPLICATE KEY UPDATE = ` vs `ON CONFLICT (…) DO NOTHING` * —— MySQL 侧刻意**不用** `INSERT IGNORE`:重复键的 IGNORE 取 S 锁,与随后的 `FOR UPDATE` 之间是 * S→X 升级,InnoDB 上两并发事务互等即 ER_LOCK_DEADLOCK(B-013);ON DUPLICATE 的重复键取的就是 X; * - upsert:`ON DUPLICATE KEY UPDATE col = VALUES(col)` vs `ON CONFLICT (…) DO UPDATE SET col = EXCLUDED.col`; * - 条件自增:`IF(cond, 0, 1)` vs `CASE WHEN cond THEN 0 ELSE 1 END`(本文件不用 —— 见 `acquire` 顶注: * 自增判据在**应用层**算,因为它同时要产出返回给调用方的 token); * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} 的 `@contract txn.read-semantics` * (本店唯一的读是 `acquire` 的租约行 `FOR UPDATE` —— 锁读在两引擎都是当前读)。 * - schema 属主:**四张表全部由车1 的 `retention-store-sql.ts` 建**(`TIDB_RETENTION_STATEMENTS` / * `PG_RETENTION_SCHEMA`)。本文件一条 DDL 都不发 —— 「一张表恰好一个 DDL 属主」(db-gate.ts 顶注 * 的实测结论:第二个属主的 `IF NOT EXISTS` 会静默吞掉列/索引差异)。 */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type SqlDriver, type SqlExec } from "./sql-driver.js"; import { type RetentionAuditAction } from "./retention-store-sql.js"; /** 审计读面的单页上界(与 roster 名册窗同姿势:读面要有界)。 */ export declare const RETENTION_AUDIT_MAX_LIMIT = 200; /** 审计读面的缺省页大小。 */ export declare const RETENTION_AUDIT_DEFAULT_LIMIT = 50; /** 一次抢/续租的结果。`held:false` = 本 tick 由别人持租(零写,静默跳过 —— 不是错误)。 */ export type RetentionLeaseClaim = { held: true; fencingToken: number; holder: string; } | { held: false; holder: string; expiresAtMs: number; }; /** 一行审计(读面投影;列名→驼峰,BIGINT 统一 `Number`)。 */ export interface RetentionAuditRow { id: number; domain: string; action: RetentionAuditAction; mode: string; policyDays: number; fencingToken: number | null; deleted: number; skipped: number; tombstones: number; executedAtMs: number; error: string | null; } /** 非破坏性审计行的写入形(破坏性三词由车1 的店在删除同事务内写 —— 见文件头)。 */ export interface RetentionAuditAppend { domain: string; action: Extract; mode: string; policyDays: number; fencingToken: number | null; deleted: number; skipped: number; tombstones: number; error?: string; } /** * 执行面非破坏性三组口的**公开面**(消费方 —— boot 的 lane、operator 路由、`StoreBackend` 座席 —— 一律 * 依赖本接口,不依赖下面那只类)。 * * 🔴 为什么值得单列一个接口:类身上带 `protected db`,于是它在 TS 里是**名义**类型 —— 任何结构等价的 * 实现(测试假店、将来的第二种后端)都满足不了它,只能靠 `as unknown as` 走私进去,而那种双重断言把 * 字段检查整段关灯(`type-hygiene-gate` 数的正是它)。接口一列,假店就是**真的**满足同一份契约: * 接口加一个方法,假店当场编译红。 */ export interface RetentionLaneStore { acquire(holder: string, ttlMs: number, nowMs: number): Promise; renew(holder: string, fencingToken: number, ttlMs: number, nowMs: number): Promise; release(holder: string): Promise; setHold(input: { domain: string; held: boolean; placedBy?: string; note?: string; nowMs: number; }): Promise; /** 状态变更 + 治理审计行,**同一个事务**(理由见实现顶注)。operator 路由**只许**走这一只。 */ setHoldAudited(input: { domain: string; held: boolean; placedBy?: string; note?: string; nowMs: number; audit: RetentionAuditAppend; }): Promise; holdInForce(domain: string): Promise; appendAudit(row: RetentionAuditAppend, nowMs: number): Promise; listAudit(input: { domain?: string; limit: number; beforeId?: number; }): Promise; } /** * 执行面的非破坏性 SQL 三组口(双方言;绑定类在文件底部)。 * * 🔴 本类**删不掉任何业务数据** —— 它写的只有 `retention_lease` 的一行、`retention_hold` 的一列、 * 与 `retention_audit` 的追加行。破坏性的三条腿在车1 的 `SqlRetentionStore` 上,拿本对象够不着。 */ export declare class SqlRetentionLaneStore implements RetentionLaneStore { protected readonly db: SqlDriver; constructor(db: SqlDriver); /** 方言取文(两条语句都写在调用点;A12 判据)。 */ protected q(tidb: string, pg: string): string; /** * 抢/续租一次(单行 CAS)。抢到 ⇒ `{held:true, fencingToken}`;别人正持着 ⇒ `{held:false}`,调用方 * **本 tick 零写静默跳过**。 * * 事务三步,一步不能省: * ① 幂等建一条 `expires_at_ms = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE * singleton = singleton` / PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存租约(两方言的这 * 两个动词都只在缺行时写),且重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式(B-013: * `INSERT IGNORE` 在这里取 S 再升 X,InnoDB 上两副本抢租互等直接死锁); * ② `SELECT … FOR UPDATE` —— 行现在必定存在,锁真的拿得到; * ③ 判 + `UPDATE`,同事务提交。 * * 🔴 为什么必须先造行、而不是「一条 `UPDATE … WHERE expires_at_ms < now OR holder = self`」了事 * (设计稿 §3 写的正是那条裸 UPDATE,本实现是它的**收口**,与车1 hold 哨兵是同一条教训):裸 UPDATE * 在**空表**上影响 0 行 —— 于是首启的两个副本都读到「没抢到」,lane 在一个从没跑过的部署上**永远不 * 起**(静默,读数是"每 tick 跳过",看上去像"别人在跑")。补一条无条件 INSERT 又会在两副本同拍首启时 * 撞 PK。哨兵 + 行锁把两件事一次解决,并且顺带让 fencing token 的自增判据可以在应用层算。 * * 🔴 fencing token 只在**抢租**(holder 变了)时自增,续租不动它:token 的语义是「第几轮**执行权**」, * 一个持续持租的副本跑的是同一段连续执行,给它每 tick 换号会让审计上「同一轮的行」看起来跨了很多轮。 * * @param holder 本副本的身份(instanceId);同一 holder 再来 = 续租。 * @param ttlMs 租约时长 = 2× sweep 间隔(§3)。 */ acquire(holder: string, ttlMs: number, nowMs: number): Promise; /** * 「这一轮的执行权还在我手上吗」**并同时续租** —— 每 domain 处理前调一次(§3 丢租即停)。 * 返回 `false` = 不再属于我 ⇒ 调用方当场中止本轮。 * * 判据三合取写进**一条 CAS 的 WHERE**:holder 是我 ∧ fencing token 还是本轮那个 ∧ **尚未过期**。 * · 第三项是承重的:租约过期而 holder 还写着我,必须读成**不再属于我**(fail-closed)——另一个副本 * 随时可能抢走,两个副本同时跑三方法是这条腿最不能出的事。 * · 「复核」与「续租」合成一条语句而不是先读后写(codex 交叉复审 R1-[high],验真后改): * ① 读+写两条语句之间有窗口,而 CAS 的谓词与更新在引擎里是原子的; * ② 更要紧的是**续租本身**:`startRetentionLane` 的重入守卫会跳过下一 tick,于是一轮的续租机会 * 只有轮首那一次 —— 一轮跑满 2×interval 之后租约自己到期,而本副本毫不知情、继续删下去。 * 轮子转多久租约就跟着延多久,那个窗口才真正关上。 * * ⚠️ **如实登记的残余**(设计稿 §3 已成文接受,本实现不假装消灭):本调用返回 true 之后、本 domain 的 * 破坏性事务提交之前,仍有一个「租约在这中间被别人接管」的理论窗口 —— 关它需要把 fencing token 的 * 校验下推进**每一个破坏性事务的 WHERE**(车1 的三只方法),那是跨车的契约改动。补偿按设计稿: * 每条破坏性审计行都带 fencing token,旧轮的写因此**可判别**(§6 的那一列正是为此存在)。 * 续租之后,这个窗口的宽度从「一整轮」缩到「一个 domain 的耗时」,且要求租约恰在这段内到期。 */ renew(holder: string, fencingToken: number, ttlMs: number, nowMs: number): Promise; /** * 主动让租(优雅停机)——把到期时间归零,**保留 holder 与 token**(它们是审计线索,不是锁本身)。 * 谓词带 `holder = ?`:一个已经丢了租的副本在退出时不许把**别人**的租约推倒。 */ release(holder: string): Promise; /** * 放置或解除一个域的 legal hold —— **同一 PK 的 upsert 改 `held` 列,绝不 INSERT/DELETE 行** * (车1 交接件②,逐字)。 * * 🔴 为什么删行是**拆锁**而不是"解除":`retention_hold` 的行同时是**域级互斥哨兵** —— 车1 的三条破坏性 * 事务首步就是锁读这一行(ON DUPLICATE 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE` * **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。删掉行 = 下一个 PUT 与一条 * 在飞的删除事务又可以同时读到"没有 hold",于是 operator 拿到成功回执之后数据仍被删 —— 那正是 §5 F2 * 要消灭的那件事。upsert 让两者抢**同一把行锁**,「PUT 提交完成 ⇒ 其后每个破坏性事务的首读必见 * held=1」这句承诺才成立。 * * 解除时把 `placed_by/placed_at_ms/note` 一并清空:这张表存的是**当前状态**不是历史,历史在 * `retention_audit` 的 `hold_placed`/`hold_released` 行上(追加不更新)。留一个「谁放的」在一条 * held=0 的行上,读它的人分不清那是"现在冻结着"还是"上次谁冻过"。 */ setHold(input: { domain: string; held: boolean; placedBy?: string; note?: string; nowMs: number; }): Promise; /** upsert 的**唯一** SQL 文本(自动提交口与事务口共用;见 {@link setHoldAudited} 的头注)。 */ protected setHoldOn(exec: SqlExec, input: { domain: string; held: boolean; placedBy?: string; note?: string; nowMs: number; }): Promise; /** * 放置/解除 **+ 治理审计行,同一个事务**(codex 交叉复审 R1-[high],验真后加)。 * * 🔴 为什么必须原子(与 §6 F5 的「删除与审计同事务」是同一条判据,只是换了一张表):两次独立提交下, * 第二步失败会留下一次**无审计的状态变更**。PUT 那一支是「冻结了但账上没有」;**DELETE 那一支更重** * —— 冻结已经解除、sweep 从下一拍起就能删这个域的数据,而审计里没有任何 `hold_released` 行说明是谁 * 在什么时候解的;客户端只看到一个 500,重试之前发生的删除再也无法从账本上追溯回那次释放。 * 一张表两条语句、同库同连接 ⇒ 原子性零分布式代价,没有不做的理由。 * * `protected` 的两条私有腿(`setHoldOn` / `appendAuditOn`)让本方法与 {@link setHold} / * {@link appendAudit} 共用**同一份 SQL 文本** —— 两处手抄一份 upsert,迟早只改一处。 */ setHoldAudited(input: { domain: string; held: boolean; placedBy?: string; note?: string; nowMs: number; audit: RetentionAuditAppend; }): Promise; /** 「这个域现在冻结着吗」—— lane 的**省调**预检(§5 v1.3:降级为优化,**不承重**;承重判在车1 的 * 店事务内)。零写。 */ holdInForce(domain: string): Promise; /** 非破坏性审计行的追加(属主 = lane / 路由)。破坏性三词由车1 的店在删除同事务内写。 */ appendAudit(row: RetentionAuditAppend, nowMs: number): Promise; /** 追加的**唯一** SQL 文本(同上)。 */ protected appendAuditOn(exec: SqlExec, row: RetentionAuditAppend, nowMs: number): Promise; /** * 审计读面(keyset 分页,`id` 降序)。 * * 排序键 = **自增 `id` 单列**,而不是 roster 那样的 `(时间戳, 次键)` 二元组:`id` 本身就是全序且唯一, * 二元组存在的全部理由(同毫秒兄弟行无 tiebreak ⇒ 漏页/重页)在这里结构上不存在。索引 * `idx_retention_audit_domain (domain, id)` 正是为这条查询建的。 * * `before` = 上一页最后一行的 `id`(严格小于)。 * * 🔴 读上限是 `MAX_LIMIT + 1` 而不是 `MAX_LIMIT`(codex 交叉复审 R1-[medium],验真属实):调用方要判 * 「还有没有下一页」就得多取一行,而**页**的上限是 `MAX_LIMIT`。旧形把 limit 无条件夹到 `MAX_LIMIT` * ⇒ 请求 `limit=200` 时 scanned 恒 ≤ 200 ⇒ `hasMore` 恒 false ⇒ **第 201 行之后的审计对这条分页链 * 永久不可达**。这一格是「窗的上限」与「探路的那一行」两个不同的数被写成同一个数造成的, * 修法是让它们各是各的:页 ≤ MAX_LIMIT(路由侧保证),读 ≤ MAX_LIMIT+1(本方法)。 */ listAudit(input: { domain?: string; limit: number; beforeId?: number; }): Promise; } /** MySQL-protocol (TiDB) binding。 */ export declare class TiDBRetentionLaneStore extends SqlRetentionLaneStore { constructor(pool: MySqlPool); } /** PostgreSQL binding。 */ export declare class PgRetentionLaneStore extends SqlRetentionLaneStore { constructor(pool: PgPool); } //# sourceMappingURL=retention-lane-store-sql.d.ts.map