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:
co-authored by
Colby McHenry
parent
bb204f8855
commit
9b8bb4aba0
+104
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user