fix(db): fail closed when WAL valve cannot checkpoint past caps (#1539) (#1751)

sync() armed the WAL valve but never called backpressure(), so daemon
catch-up could grow the WAL without bound while query-pool readers pinned
frames. Wire the writer pause into sync store + batched resolution, and
abort with WalValveAbortError after parked backfills fail past the
documented hard/file caps instead of disabling parking for 60s.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 01:06:56 -05:00
committed by GitHub
co-authored by Colby McHenry
parent bb204f8855
commit 9b8bb4aba0
5 changed files with 199 additions and 38 deletions
+104 -15
View File
@@ -9,12 +9,12 @@
* the valve's trigger/dedupe/backpressure logic, and the end-to-end indexAll
* behavior (identical graph with and without deferral; interval restored).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';
import { WalCheckpointValve, resolveWalValveMb } from '../src/db/wal-valve';
import { WalCheckpointValve, WalValveAbortError, resolveWalValveMb } from '../src/db/wal-valve';
import CodeGraph from '../src/index';
let tmpDir: string;
@@ -193,6 +193,20 @@ function writeFixtureProject(): void {
}
}
async function seedPendingRefs(cg: CodeGraph): Promise<void> {
const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
| { id: string; file_path: string }
| undefined;
expect(node).toBeDefined();
const ins = raw.prepare(
"INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
);
ins.run(node!.id, 'helper0', node!.file_path);
ins.run(node!.id, 'helper1', node!.file_path);
}
describe('indexAll WAL deferral end-to-end', () => {
it('produces the same graph with and without deferral, and restores the interval', async () => {
@@ -298,6 +312,35 @@ describe('sync WAL deferral end-to-end (#1248)', () => {
delete process.env.CODEGRAPH_NO_WAL_DEFER;
}
});
it('applies WAL backpressure during changed-file storage and orphan resolution (#1539)', async () => {
writeFixtureProject();
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const backpressure = vi
.spyOn(WalCheckpointValve.prototype, 'backpressure')
.mockReturnValue(null);
try {
fs.writeFileSync(
path.join(tmpDir, 'src', 'mod0.ts'),
`export function fn0(x: number): number { return helper0(x) + 100; }\n` +
`function helper0(x: number): number { return x * 100; }\n`
);
const changed = await cg.sync();
expect(changed.filesModified).toBe(1);
expect(backpressure).toHaveBeenCalled();
backpressure.mockClear();
await seedPendingRefs(cg);
const recovered = await cg.sync();
expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
expect(backpressure).toHaveBeenCalled();
} finally {
backpressure.mockRestore();
await cg.close();
}
});
});
describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
@@ -308,19 +351,6 @@ describe('resolution-phase WAL backpressure plumbing (§7a.1)', () => {
// 22GB WAL on a 4.6GB DB. These pin that the batch loop (a) calls the hook
// at the pool-idle boundary and (b) actually parks on a returned promise.
async function seedPendingRefs(cg: CodeGraph): Promise<void> {
const raw = (cg as unknown as { db: DatabaseConnection }).db.getDb();
const node = raw.prepare("SELECT id, file_path FROM nodes WHERE kind = 'function' LIMIT 1").get() as
| { id: string; file_path: string }
| undefined;
expect(node).toBeDefined();
const ins = raw.prepare(
"INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, status) VALUES (?, ?, 'calls', 1, 0, ?, 'typescript', 'pending')"
);
ins.run(node!.id, 'helper0', node!.file_path);
ins.run(node!.id, 'helper1', node!.file_path);
}
it('calls the backpressure hook once per settled batch', async () => {
writeFixtureProject();
const cg = CodeGraph.initSync(tmpDir);
@@ -417,6 +447,65 @@ describe('valve file-size trigger (§7a.1: backfilled WAL still grows the file)'
});
});
describe('WAL valve fail-closed (#1539)', () => {
it('aborts with WalValveAbortError when parked backfills cannot progress past the file cap', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 800); // well past a 0.5MB soft / 2MB file cap
expect(db.getWalSizeBytes()).toBeGreaterThan(2 * 1024 * 1024);
const valve = new WalCheckpointValve(db, 0.5);
// Simulate a reader pinning every PASSIVE/TRUNCATE attempt.
db.checkpointWalPassive = async () => ({ busy: 1, log: 100, checkpointed: 0 });
db.checkpointWalTruncate = async () => ({ busy: 1, log: 100, checkpointed: 0 });
const bp = valve.backpressure();
expect(bp).not.toBeNull();
await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
try {
await bp!;
} catch (err) {
expect(err).toMatchObject({
name: 'WalValveAbortError',
code: 'WAL_VALVE_ABORT',
});
expect((err as WalValveAbortError).message).toMatch(/Aborting to avoid unbounded disk growth/);
expect((err as WalValveAbortError).walBytes).toBeGreaterThan((err as WalValveAbortError).fileCapBytes);
}
// Caps remain enforceable: a subsequent backpressure call still parks (no
// futility latch that returns null and lets the writer race past the cap).
const again = valve.backpressure();
expect(again).not.toBeNull();
await expect(again!).rejects.toBeInstanceOf(WalValveAbortError);
db.close();
});
it('aborts when checkpoint machinery is unavailable while over the file cap', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 800);
const valve = new WalCheckpointValve(db, 0.5);
db.checkpointWalPassive = async () => null;
const bp = valve.backpressure();
expect(bp).not.toBeNull();
await expect(bp!).rejects.toBeInstanceOf(WalValveAbortError);
db.close();
});
it('does not abort a soft foldNow give-up that stays under both caps', async () => {
const db = openDb();
db.setWalAutocheckpoint(0);
writeRows(db, 50); // small WAL
const valve = new WalCheckpointValve(db, 1024); // 1GB soft — hard 2GB, fileCap 4GB
db.checkpointWalPassive = async () => ({ busy: 1, log: 10, checkpointed: 0 });
// foldNow calls backfillFully even with modest growth; under caps this is soft.
await expect(valve.foldNow()).resolves.toBeUndefined();
expect(valve.backpressure()).toBeNull();
db.close();
});
});
describe('resolveWalValveMb DB-size scaling (§7a.2 fold-tax reduction)', () => {
it('scales soft cap ~dbSize/4 within [256, 2048]MB; env always wins', () => {
const GB = 1024 * 1024 * 1024;