/** * Cross-replica write-behind aggregating counter — the shared mechanism behind the cost quota (P0-1) * and the rate limiter (P0-2), on BOTH dialects. Both are additive per-key counters over an aligned fixed * window, so the shared store uses an ATOMIC INCREMENT (`val = val + delta`), never last-writer-wins. * * [ref] A12 C1b: this file used to be MySQL-hardwired and the PG lane carried TWO inline copies of the * same 150-line class (pg-cost-quota.ts's private `PgWriteBehindCounter` + pg-rate-limiter.ts's exported one). * They are now ONE dialect-parameterized implementation; `WriteBehindCounter` (mysql2) and * `PgWriteBehindCounter` (node-pg) remain as ctor subclasses so every call site and test keeps its shape. * * Hot path stays sync: `addLocal` accumulates a local pending delta, `used` reads (cached fleet total * from the last refresh) + (this replica's unflushed delta). A periodic `flush` pushes deltas atomically * and refreshes the current window's fleet totals into the cache. * * Consistency is **eventual** → this is **SOFT limiting**: a replica may briefly exceed the ceiling by * up to (replicas × per-flush-interval spend/requests) at the boundary before peers' totals propagate. * Right for cost/fairness ceilings (and backstopped, for cost, by the hard per-task `maxCostUsd` gate). * A HARD compliance ceiling would instead need a linearizable CAS/atomic count, not write-behind — same * trade as the cross-replica circuit breaker. Windows are aligned fixed buckets (`floor(now/windowMs)`), * the standard distributed choice (a rolling per-key window is the hard part to coordinate cross-replica). * * ── Dialect deltas, kept EXPLICIT (the four the PG twins' file headers used to list) ────────────────── * - placeholders `?` vs `$1..$n` * - atomic increment: `ON DUPLICATE KEY UPDATE val = val + VALUES(val)` vs * `ON CONFLICT (, window_bucket) DO UPDATE SET val = .val + EXCLUDED.val` * - affected rows: mysql2 `ResultSetHeader.affectedRows` vs node-pg `result.rowCount` * - BIGINT/INT read back: node-pg returns them as STRINGS → always `Number(...)` on read (the read path * already wraps every counter cell, so this is a no-op difference at the JS boundary) * - per-query timeout ARG SHAPE: mysql2 takes `{sql, timeout}` as the first query arg; node-pg takes a * `QueryConfig` with `query_timeout`. That is why this file keeps its own exec instead of using * sql-driver.ts (which is on the plain `(sql, params)` form). */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; export interface CounterTable { /** Table name; must have columns (, window_bucket BIGINT, , updated_at). */ table: string; keyCol: string; valCol: string; } /** S9 (SILENT-FALLBACK P0-e): a flush/refresh failure notification. `streak` = consecutive failures of that * kind (0 = recovered); `prevStreak` lets the consumer detect the recovery edge. Shared by both dialects. */ export interface CounterDegradeInfo { table: string; kind: "flush" | "refresh" | "stalled" | "reap"; streak: number; prevStreak: number; error?: string; } export type CounterDegradeHook = (info: CounterDegradeInfo) => void; /** S9 deeper fix: default per-query timeout for the counter family's statements. Always ON (unlike the * opt-in pool-wide DB_QUERY_TIMEOUT_MS): every statement here is a tiny single-row/single-bucket op, so * 30s is ~1000× headroom — a query that slow IS the S9 pathology (a DB-side hang would otherwise pin the * flush round forever; the stalled watchdog can then only report, not recover). The rejection lands in * the existing flush/refresh catch → the S9 streak telemetry counts it; the round ENDS and retries. */ export declare const DEFAULT_COUNTER_QUERY_TIMEOUT_MS = 30000; type CounterDialect = "tidb" | "pg"; /** Dual-dialect write-behind aggregating counter. See the file header for the dialect-delta ledger. */ export declare class SqlWriteBehindCounter { private readonly dialect; private readonly pool; private readonly t; /** 🔴 不再 `readonly`([ref]):`costQuotaWindowSec` 热更新经 {@link setWindowMs} 换代,语义见那里。 */ private windowMs; private readonly now; private readonly onDegraded?; private readonly queryTimeoutMs; private pendingDelta; private fleetTotal; private timer?; private flushing; private flushStartedAt; private failStreak; constructor(dialect: CounterDialect, pool: MySqlPool | PgPool, t: CounterTable, /** 🔴 不再 `readonly`([ref]):`costQuotaWindowSec` 热更新经 {@link setWindowMs} 换代,语义见那里。 */ windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook | undefined, // S9: observability tap — never affects counting queryTimeoutMs?: number); /** The ONLY place the two drivers' query shapes diverge. Returns {rows, affected}. */ private exec; /** S9: track the consecutive-failure streak per kind and notify on every failure + on the recovery edge * (streak>0 → 0). Steady healthy state stays silent. The hook must never break the counter. */ private bump; bucket(): number; private rk; /** Cached fleet total (peers, from the last refresh) + this replica's unflushed delta, current window. */ used(key: string): number; addLocal(key: string, n: number): void; /** Seconds until the current aligned window rolls over. */ retryAfterSec(): number; /** Atomically push pending deltas, then refresh the current window's fleet totals + reap old buckets. * Reentrancy-guarded: two overlapping flushes would each apply `+delta` from the same snapshot and * double-count into the shared table (inflating fleet totals → spurious rate-limit/quota rejection). */ flush(): Promise; private flushOnce; /** Load the current window's fleet totals into the cache (pendingDelta is separate → never clobbered). */ refresh(): Promise; /** * Delete expired window buckets to bound the table. * * 🔴 失败**必须留痕**([ref] codex 复审 round2 修):这里原先是裸 `catch { return 0 }` —— 一个只影响 * DELETE 的故障(权限被收回、引擎单独拒 DELETE)会被吞成"删了 0 行",而 flush/refresh 两条腿照常成功 * ⇒ 健康遥测全绿,同时 rate_limit / cost_quota 的历史桶无界增长。那正是本仓禁止的静默 fail-open。 * 处置是**留痕不改失败方向**:清理是 best-effort,失败仍返回 0(把一次运维层面的清理失败升级成业务 * 失败是错的方向),但走与 flush/refresh 同一套 `bump()` ⇒ 有连败计数、有降级钩子、有恢复边沿。 */ reap(): Promise; startRefresh(intervalMs?: number): this; stop(): void; /** [ref]:刷新环是否在跑(关断哨兵的判据:off 态必须**零 SQL**,含这条后台环)。 */ refreshRunning(): boolean; /** * [ref] 批1:窗长换代(`costQuotaWindowSec` 热更新)。 * * 🔴 如实语义:窗长是**桶键的一部分**(`floor(now/windowMs)`),不是行里的一个可改字段。换窗长 ⇒ 当代 * 桶重算 ⇒ 本副本对「当前窗已用多少」的读数从新桶起算(旧桶的行还在库里,按它自己的窗长归属)。 * 未 flush 的本地增量仍带着**它自己的**桶键(rowKey 里已含),所以它们仍落回原来那个桶,不会被搬家。 * 这与限额值的换代不同(那个是纯比较参数,窗内计数一个不动),因此单独成注:运营改窗长要知道自己在 * 换的是**记账周期**,不是一个阈值。 */ setWindowMs(windowMs: number): void; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class WriteBehindCounter extends SqlWriteBehindCounter { constructor(pool: MySqlPool, t: CounterTable, windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook, queryTimeoutMs?: number); } /** PostgreSQL binding — the class pg-rate-limiter.ts used to define inline (and pg-cost-quota.ts duplicated * privately, hardwired to the cost_quota columns). Same ctor shape as the mysql2 twin. */ export declare class PgWriteBehindCounter extends SqlWriteBehindCounter { constructor(pool: PgPool, t: CounterTable, windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook, queryTimeoutMs?: number); } export {}; //# sourceMappingURL=write-behind-counter.d.ts.map