import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool, PoolClient } from "pg"; import { type SqlDriver } from "./sql-driver.js"; import { type BakeStatus, type BakeState, type BakeRecord, type BakeEvent, type CreateBakeInput, type BakeTerminal } from "./store-contracts.js"; import { type IndexSpec } from "./ensure-index.js"; /** PG translation of the image_bake / image_bake_event / image_bake_lease DDL in tidb-pool.ts SCHEMA_STATEMENTS * (JSON→JSONB, TINYINT→SMALLINT, DATETIME(3)→TIMESTAMPTZ(3), inline UNIQUE/KEY→named CONSTRAINT + CREATE INDEX). * TINYINT(1) 归一(clay 批 2026-07-26):布尔列的 PG 映射统一 SMALLINT 0/1(此前本家族用 BOOLEAN)—— * 两方言驱动读回同为 number ⇒ 不再存在「忘了配的适配器」这个病族;写入参与 SQL 字面量一律 0/1, * 读侧 `!!`/`Boolean()` 保持,store 出口 JS 类型逐字节不变(真双库套件的严格值断言为证)。 * The line UNIQUE is given the explicit name uq_image_bake_event_line so a 23505 can be attributed to it via err.constraint. */ export declare const PG_IMAGE_BAKE_SCHEMA: string[]; /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgImageBakeSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */ export declare const IMAGE_BAKE_INDEXES: readonly IndexSpec[]; /** Idempotent self-contained PG schema apply (for the integration test; central aggregation is done separately). */ export declare function ensurePgImageBakeSchema(pool: PgPool | PoolClient, poolName?: string): Promise; /** Dual-dialect BAKE store. See the file header for the dialect-delta ledger. */ export declare class SqlImageBake { protected readonly db: SqlDriver; /** The builder pool a lease serializes on (one docker daemon ⇒ one pool today). */ protected readonly poolName: string; constructor(db: SqlDriver, /** The builder pool a lease serializes on (one docker daemon ⇒ one pool today). */ poolName?: string); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; /** The 12 bound values shared by both admission INSERTs (column order === INSERT_BAKE_COLS). */ private insertParams; /** * Create a bake, queued. Durable submit-idempotency mirrors createRun's CAS: `idem_key` is UNIQUE, so a * re-submit with the same key races to INSERT and the loser catches the dup-key, then re-reads + returns the * EXISTING row (the live bake) instead of a second job. A null `idemKey` always inserts (no dedupe requested). * Returns `{ created, bake }` so the handler can answer 202 either way (idempotent dup ⇒ created:false). */ createBake(input: CreateBakeInput): Promise<{ created: boolean; bake: BakeRecord; }>; /** * M4 (§P2.1/§P2.7 — the 409-busy MUST): create a queued bake ATOMICALLY only when no other non-dryRun bake is * in flight, so two concurrent keyless POSTs can NEVER both queue (the check-then-act in the submit handler — * findActiveAny() then createBake() as two awaited round-trips — interleaves: both see null-busy, both insert). * `uq_idem` only dedupes non-null idem_keys (a keyless submit is NULL = distinct), and the build lease is taken * at CLAIM not submit, so neither backstops this — the admission itself must be serialized. * * A bare conditional INSERT (`INSERT … SELECT … WHERE NOT EXISTS(...)`) is NOT sufficient ON ITS OWN: under * TiDB's default REPEATABLE READ (and PG's READ COMMITTED) two concurrent autocommit conditional inserts each * evaluate NOT EXISTS against a snapshot that excludes the other's still-uncommitted row (PG takes no * predicate/gap lock), so BOTH can pass and double-admit. The only backstop today is the build lease taken at * CLAIM, so a double-admit degrades the 409-busy/202-attach contract rather than double-building — but the * contract is the point. * * Fix: serialize the busy-check+insert on the per-pool admission sentinel (`image_bake_admit`) with * `SELECT … FOR UPDATE` inside one transaction. The second admitter blocks on the sentinel row lock until the * first commits, then sees the committed in-flight bake and inserts 0 → `created:false`, and the caller * re-reads the active bake (findActiveByArgv for an idempotent attach, else findActiveAny) for the * 202-attach / 409 body. A dryRun bypasses this path entirely (never takes the lease — parallel-safe) and uses * plain createBake. * * 🔴 DIALECT DIVERGENCE (deliberate, both preserved verbatim — the real-DB suites are the oracle): * - TiDB: `BEGIN PESSIMISTIC` + a separate `SELECT 1 … FOR UPDATE` busy-check, then a plain INSERT. The * pessimistic txn is REQUIRED: TiDB's snapshot (REPEATABLE READ) txn would evaluate a NOT EXISTS against * the txn-start snapshot and miss a rival's just-committed row EVEN under the sentinel lock. * - PG: plain `BEGIN` + one `INSERT … SELECT … WHERE NOT EXISTS(...)`. READ COMMITTED takes a FRESH * statement snapshot, so the NOT EXISTS run after the sentinel lock is granted already sees the committed * rival — the conditional insert is atomic enough once serialized. */ createBakeIfIdle(input: CreateBakeInput): Promise<{ created: boolean; bake: BakeRecord | null; }>; getBake(bakeId: string): Promise; getBakeByIdem(idemKey: string): Promise; /** * Find an in-flight (queued/running) bake with the identical normalized argv — so a re-submit WITHOUT an * idempotency key still returns the live bake rather than starting a duplicate build (P2.1 "an in-flight * identical-input bake returns the EXISTING row"). argv is the canonical normalized vector, so a JSON * equality is the dedupe key. Newest first (the live one). */ findActiveByArgv(argv: string[]): Promise; /** * The OLDEST in-flight (queued/running) NON-dry-run bake — the single-flight at-submit guard (P2.7). Concurrency * is 1 (one docker daemon), so a new submit while ANY non-dry-run bake is in flight is `409 busy` UNLESS it is * the identical-argv one (then the caller attaches to it, idempotent). dryRun bakes never take the lease, so they * are excluded here. Oldest first = the one that holds/will-hold the lease (the build the UI should attach to). */ findActiveAny(): Promise; /** * The OLDEST queued bake id — the runner's long-poll claim target (P2.12). dryRun bakes never take the lease, so * they are still discoverable here (the runner resolves them with no docker), but ordering oldest-first gives a * stable FIFO. Returns null when the queue is empty. The actual claim is a CAS (`claimBake`), so two runners * racing this both see the id but only one wins the conditional UPDATE — no double-build. */ findNextQueuedId(): Promise; /** * Single-flight CLAIM (P2.7) — the runner leases the next queued bake. Two CASes in one transaction: * 1. flip the bake row `queued → running` (conditional UPDATE `WHERE status='queued'`, 1 row affected wins); * 2. take the single-row `image_bake_lease` for the pool (INSERT, or steal an EXPIRED lease via CAS). * Only the runner that wins BOTH gets the bake; a loser (someone else already claimed, or the lease is held by * a live build) gets null. Mints + stores the per-bake `ingest_secret` (so a stale/rogue runner can't inject * frames into another bake) and the initial `lease_until`. Returns the claimed row (with the secret) or null. */ claimBake(bakeId: string, runnerId: string, leaseMs: number): Promise; /** * Append one event. image-api is the SOLE ++seq writer: `seq = maxSeq(bakeId)+1`. The `(bakeId,line_ord)` * UNIQUE is the at-least-once ingest dedupe (P2.10) — a runner re-sending a line it already acked races to * INSERT and the loser is a NO-OP (the frame is already stored at its seq). Returns the seq written, or null * when the line was a duplicate (already stored). A null `lineOrd` (image-api-synthesized frame: * register/done/heartbeat-derived) never collides on the line-unique (NULLs are distinct in a UNIQUE index). * * seq allocation + the INSERT are NOT one atomic statement, so two concurrent appends could pick the same * seq+1; the (bakeId,seq) PK then rejects the loser with a dup-key. M2: that PK collision is DISTINGUISHED from * a benign line re-send (by the dup-key attribution channel) and RETRIED in a bounded loop with a fresh * maxSeq+1 — never silently swallowed (which would drop a frame, incl. the terminal `done`, at a clean HTTP * 200). A line dup is the at-least-once re-send no-op (returns null). The runner also serializes its per-bake * pump (runner.ts), so the same-seq race is the rare burst case, not the norm. */ appendEvent(bakeId: string, kind: string, data: unknown, opts?: { lineOrd?: number | null; level?: string | null; }): Promise; private lineOrdExists; /** Highest event seq for a bake (0 if none) — the ++seq allocator + the resume cursor head. */ maxSeq(bakeId: string): Promise; /** Earliest retained event seq (0 if none) → the SSE 416 boundary (a resume below this was evicted, P2.9). */ retainedFrom(bakeId: string): Promise; /** Events with seq strictly greater than `afterSeq` (0 for the whole stream), ascending. */ getEvents(bakeId: string, afterSeq: number): Promise; /** * Coarse 4-value lifecycle transition (the column the SSE reader polls for terminality). * * @deprecated [ref] 车7(staleness-sweep P2-12):**test-only**——生产零调用。生产唯一写入路径是 * `ingestBakeLine`(routes/images.ts),它只经 `setState`(denormalize)与 `setTerminal` 驱动状态; * 本方法是绕开「status 只由 state/done 驱动」不变量的唯一直接写口,**禁止新增生产调用**。留而不删 * 的唯一理由=三个集成测试用它铸中间态,整删随该套件下次翻新一并做。 */ setStatus(bakeId: string, status: BakeStatus): Promise; /** Denormalize build.sh's 8-value `state` for the UI progress bar (NEVER drives terminality, P2.3). */ setState(bakeId: string, state: BakeState): Promise; /** * Terminal write (P2.10 step 4): flip the coarse `status` + persist all the terminal facts (digest/repo/ref/ * indexId/exitCode/error/errorCode/state/manifestSha/tag) the `done` frame and the poll row carry, AND release * the pool lease in one transaction (a separate-statement release that failed would wedge the build host). * Idempotent terminal write: only flips a NON-terminal row (`WHERE status IN ('queued','running')`) so a * re-delivered terminal can't clobber the first outcome. Returns the UPDATE's affected count — 1 = THIS call won * the queued/running→terminal transition; 0 = a terminal already exists (M3: the caller then SKIPS appending a * contradictory second terminal `done` event, so at most one terminal `done` per bake matches the coarse row). * 🔴 H3: null `ingest_secret` on the terminal flip so a terminal row can NEVER re-authenticate an ingest * (a lagging/duplicate runner that still holds the secret can't inject frames into a settled bake). */ setTerminal(bakeId: string, t: BakeTerminal): Promise; /** TiDB stores the terminal facts VERBATIM (no protocol-byte class to defend against). */ private terminalParamsTidb; /** PG-ONLY protocol-byte handling. R20/R21:身份位(digest/manifestSha/repo/ref)带不可存字节=帧无效—— * **单一谓词**统管状态折叠与全部身份绑位(R21:只查两位会放 dirty repo/ref 的假 COMPLETE 过——终态单赢, * 假成功不可逆)。事务必须提交(lease 释放/secret 清理=单飞池活性底线)。codex R13/R17-H2:NUL 错误文案 * 或脏 tag 若让终态事务回滚,lease 保持 = bake 永占单飞池,故文本位走 sanitize 而非拒绝。 */ private terminalParamsPg; /** Set the index_id once auto-register succeeds (P2.10 — kept distinct from the terminal flip so the `done` * frame can carry indexId even when the terminal write retries). codex R24:non-terminal 门——并发 done * 竞态下(本请求悬在 registerBuilding 时另一 done 已赢 setTerminal)绝不把 indexId 挂回终态行(R23 折叠 * 的 FAILED/null 身份行会被复写成「null 身份却带 index 绑定」的矛盾形)。返回是否真绑定。 */ setIndexId(bakeId: string, indexId: string): Promise; /** Heartbeat the lease forward (P2.7): the runner pushes `lease_until` (+ the lease row's `expires_at`) while * its build child runs, so the reaper doesn't steal a LIVE build's lease. CAS on the runner owning the row + * the bake still running. Returns whether it refreshed (false ⇒ lease lost/stolen — the runner should abort). */ heartbeatLease(bakeId: string, runnerId: string, leaseMs: number): Promise; /** Request cooperative cancel (P2.11) — durable + cross-replica; the runner's heartbeat tick polls it and kills * the build.sh process group. Only flags a non-terminal row (terminal → 0 affected = no-op). Returns whether a * live bake was flagged (false ⇒ already terminal → the caller answers a no-op 202). */ requestCancel(bakeId: string): Promise; /** Whether a cancel was requested (polled by the runner). */ isCancelRequested(bakeId: string): Promise; /** * Reaper twin of TiDBRunStore.reapStale (P2.7/§P2.6): fail any `running` bake whose lease is STALE (a crashed * runner stopped heartbeating), append a synthetic terminal `done{failed}` so SSE readers SETTLE (never hang), * and force-release the pool lease. `olderThanMs` must exceed >2 lease-heartbeat intervals (≈3× the heartbeat) * so a healthy slow build is not wrongly reaped. Returns the count reaped. * * M3: flip the row terminal FIRST (the CAS on `status='running'`), and append the synthetic `done{failed}` ONLY * when the flip WON (1 row affected). A victim that recovered + COMPLETEd between the SELECT and the flip * no-ops the CAS, so we leak NO contradictory `done{failed}` into its settled SSE log. A reader that polled the * still-running row in the tiny pre-flip gap simply re-polls (streamSseLog re-fetches) and settles on the * eventual terminal — the winner's `done` — rather than a spurious one. */ reapStaleBakes(olderThanMs: number): Promise; /** Run statements atomically; rolls back on failure (mirrors TiDBRunStore.tx). */ private tx; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class TiDBImageBake extends SqlImageBake { constructor(pool: MySqlPool, poolName?: string); } /** PostgreSQL binding — historical class name + ctor shape preserved. */ export declare class PgImageBake extends SqlImageBake { constructor(pool: PgPool, poolName?: string); } //# sourceMappingURL=image-bake-store-sql.d.ts.map