From f5fb6fc384f6b2363b8a0dfb3b11171e6dd5097b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 21 Jun 2026 06:05:07 +0000 Subject: [PATCH] fix(lbug): lock deleteNodesForFile count query on the singleton path (#2264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01JBJomjoTdBV2eveDVq4JMm --- gitnexus/src/core/lbug/lbug-adapter.ts | 13 ++++++++----- .../integration/lbug-conn-serialization.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 39c91b7a0..abe2ed896 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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) { diff --git a/gitnexus/test/integration/lbug-conn-serialization.test.ts b/gitnexus/test/integration/lbug-conn-serialization.test.ts index 89fbf1a01..726dee653 100644 --- a/gitnexus/test/integration/lbug-conn-serialization.test.ts +++ b/gitnexus/test/integration/lbug-conn-serialization.test.ts @@ -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 }); + }); }); });