fix(lbug): lock deleteNodesForFile count query on the singleton path (#2264)

The per-table count read used a raw targetConn.query while the sibling DETACH
DELETE already routed through the locked queryAndDrain — an asymmetry that left
the count racing the WAL-checkpoint driver during incremental --pdg writeback.
Gate the count through withConnLock when targetConn === conn (the singleton),
matching queryAndDrain; per-query/temp connections stay lock-free.

Test asserts the count loop takes the lock once per filePath-bearing node table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm
This commit is contained in:
Gergo Magyar 2026-06-21 06:05:07 +00:00
parent 071b120169
commit f5fb6fc384
2 changed files with 20 additions and 5 deletions

View file

@ -1947,12 +1947,15 @@ export const deleteNodesForFile = async (
if (tableName === 'Community' || tableName === 'Process') continue;
try {
// First count how many we'll delete
// First count how many we'll delete. On the singleton connection this
// count runs inside withConnLock (incremental --pdg writeback executes
// while the WAL driver is live); per-query/temp connections skip the
// lock, matching queryAndDrain's `targetConn === conn` gate — the sibling
// DETACH DELETE below already routes through it. (#2264)
const tn = escapeTableName(tableName);
const countResult = await targetConn!.query(
`MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`,
);
const rows = await readQueryRows(countResult);
const countCypher = `MATCH (n:${tn}) WHERE n.filePath = '${escapedPath}' RETURN count(n) AS cnt`;
const runCount = async () => readQueryRows(await targetConn!.query(countCypher));
const rows = targetConn === conn ? await withConnLock(runCount) : await runCount();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {

View file

@ -13,6 +13,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { NODE_TABLES } from '../../src/core/lbug/schema.js';
// Spy `withConnLock` while preserving its real behavior (call-through). The
// adapter imports this module, so the spy observes every lock acquisition.
@ -48,5 +49,16 @@ withTestLbugDB('conn-serialization', () => {
expect(lockSpy).toHaveBeenCalled();
expect(importers).toEqual([]);
});
it('U3: deleteNodesForFile (singleton) locks every per-table count query', async () => {
const { deleteNodesForFile } = await import('../../src/core/lbug/lbug-adapter.js');
const result = await deleteNodesForFile('any/path.ts');
// One locked count per filePath-bearing node table (Community/Process are
// skipped), proving the count read — not just the already-locked DELETE —
// now serializes. Baseline (count unlocked) would show ~1 lock call.
const filePathTables = NODE_TABLES.filter((t) => t !== 'Community' && t !== 'Process');
expect(lockSpy.mock.calls.length).toBeGreaterThanOrEqual(filePathTables.length);
expect(result).toMatchObject({ deletedNodes: 0 });
});
});
});