| 78 | } |
| 79 | |
| 80 | export class WalCheckpointValve { |
| 81 | private timer: ReturnType<typeof setInterval> | null = null; |
| 82 | private inflight: Promise<void> | null = null; |
| 83 | /** Writer pause in progress (hard cap breached): passes loop until a full backfill. */ |
| 84 | private pause: Promise<void> | null = null; |
| 85 | /** |
| 86 | * WAL file size observed when a checkpoint last reported the ENTIRE WAL |
| 87 | * backfilled. Growth is measured against this baseline — see the header |
| 88 | * comment for why absolute size cannot be used. |
| 89 | */ |
| 90 | private sizeAtLastFullBackfill = 0; |
| 91 | private readonly softBytes: number; |
| 92 | private readonly hardBytes: number; |
| 93 | private readonly fileCapBytes: number; |
| 94 | |
| 95 | /** |
| 96 | * Futility latch: consecutive backfill give-ups (a reader pinning the WAL) |
| 97 | * disable further writer pauses for a cooldown, so a pinned phase degrades |
| 98 | * to the pre-valve behavior (unbounded WAL, folded when the pinner exits) |
| 99 | * instead of burning a 20-pass checkpoint attempt — each pass a worker |
| 100 | * thread + fresh connection — at EVERY over-cap boundary. That churn is |
| 101 | * what turned a pinned kernel-scale resolution from slow into OOM-killed |
| 102 | * (§7a.1 run 1: 22GB WAL, exit 137 at an envelope the pre-fix build |
| 103 | * survived). |
| 104 | */ |
| 105 | private consecutiveGiveUps = 0; |
| 106 | private futileUntil = 0; |
| 107 | |
| 108 | constructor( |
| 109 | private readonly db: DatabaseConnection, |
| 110 | softMb: number = resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB), |
| 111 | private readonly intervalMs: number = CHECK_INTERVAL_MS, |
| 112 | log: (msg: string) => void = () => {} |
| 113 | ) { |
| 114 | this.softBytes = softMb * 1024 * 1024; |
| 115 | this.hardBytes = this.softBytes * HARD_CAP_MULTIPLIER; |
| 116 | this.fileCapBytes = this.softBytes * FILE_CAP_MULTIPLIER; |
| 117 | // CODEGRAPH_WAL_VALVE_DEBUG=1 surfaces valve decisions to stderr without |
| 118 | // needing the caller's verbose plumbing — the observability gap that let |
| 119 | // §7a.1 run 1 fail silently (give-ups were verbose-gated and invisible). |
| 120 | this.log = process.env.CODEGRAPH_WAL_VALVE_DEBUG |
| 121 | ? (m) => console.error(`[wal-valve] ${m}`) |
| 122 | : log; |
| 123 | } |
| 124 | |
| 125 | private readonly log: (msg: string) => void; |
| 126 | |
| 127 | private mb(n: number): string { |
| 128 | return `${Math.round(n / 1024 / 1024)}MB`; |
| 129 | } |
| 130 | |
| 131 | /** Un-backfilled growth estimate: bytes the WAL has grown past the last full backfill. */ |
| 132 | private growthBytes(): number { |
| 133 | return this.db.getWalSizeBytes() - this.sizeAtLastFullBackfill; |
| 134 | } |
| 135 | |
| 136 | /** Begin watching the WAL. Idempotent; the timer never holds the loop open. */ |
| 137 | start(): void { |
nothing calls this directly
no outgoing calls
no test coverage detected