GitNexus/gitnexus/test/integration/copy-parallel-invariant.test.ts
Gergő Magyar b895a20415
perf(lbug): overlap node COPY with relationship emit (#2203) (#2226)
* test(lbug): lock PARALLEL=false as a tested correctness invariant (#2203)

The parallel CSV reader (Kuzu-derived, default PARALLEL=true) cannot parse
quoted fields with embedded newlines (kuzudb/kuzu#5778); our content/text
columns hold source code, so PARALLEL=false is mandatory for correctness.
Add a live-DB multiline-quoted round-trip that fails if it is ever flipped,
plus a static guard on the generated COPY queries. Export COPY_CSV_OPTS /
getCopyQuery for the static assertion; document the invariant at the source.

* feat(lbug): expose node/rel phase boundary via onNodePhaseComplete hook (#2203)

streamAllCSVsToDisk now fires an optional onNodePhaseComplete(nodeFiles)
callback right after node CSVs are flushed and before the relationship pass
writes any rel_*.csv — the boundary the COPY-overlap leg needs. The node-file
manifest construction is hoisted above the rel pass and reused in the return,
so output is byte-for-byte identical when no callback is supplied (verified by
the emit bench fingerprint and the splitRelCsvByLabelPair differential oracle).
The callback is not awaited, so the rel pass runs concurrently with the
caller's node COPY.

* perf(lbug): overlap node COPY with relationship emit (#2203)

The deferred parallelism leg of #2203. LadybugDB is single-writer and its
parallel CSV reader is unsafe for our multiline content (kuzudb/kuzu#5778), so
the only safe parallelism is pipeline-overlap: node COPY (uses conn, never the
rel files) runs concurrently with the relationship emit pass (writes rel_*.csv,
never conn). Node COPY now starts at streamAllCSVsToDisk's onNodePhaseComplete
boundary while the rel pass keeps writing; the relationship COPY still waits for
node COPY (FK precondition), so DB load order and content are unchanged.

- Extract copyNodeCSVs; start it in the hook (overlap) or after emit (serial).
- GITNEXUS_SERIAL_LBUG_LOAD=1 forces the legacy strictly-sequential path
  (operator escape hatch + differential-test oracle).
- Settle the in-flight node-COPY promise on emit failure (no unhandled
  rejection); rethrow node-COPY errors at the FK barrier.
- Preserve the PDG manifest merge + collision guards (node merge at the hook,
  rel merge before rel COPY) and all retry/fallback/cleanup behavior.
- PROF_LBUG_LOAD gains mode=overlap|serial; copy-nodes becomes the residual
  node-COPY time after emit (trends to 0 as overlap hides it).

* test(lbug): differential gate — overlap load === serial load (#2203)

Loads one fixture (multiple node tables, multiple edge pairs, multiline File
content + BasicBlock text) into two fresh DBs — once via the default node-COPY
‖ rel-emit overlap, once via GITNEXUS_SERIAL_LBUG_LOAD=1 — and asserts the two
databases are content-equivalent: identical per-table node counts, per-type
edge counts, byte-for-byte multiline content/text, and identical
insertedRels/skippedRels/warnings. This is the issue's byte-identical-content
acceptance gate for the parallelism leg.

* fix(review): apply autofix feedback

- csv-generator: onNodePhaseComplete doc-contract now matches reality (a sync
  throw is allowed and is how loadGraphToLbug surfaces the manifest collision
  guard) — drops the inaccurate 'must not throw synchronously' line.
- lbug-adapter: copyNodeCSVs totalSteps is the node-table count (drop the +1
  rel-step holdover; the rel COPY has its own progress line).
- lbug-adapter: on emit+node-COPY double-failure, log the swallowed node-COPY
  error before rethrowing the emit error (diagnosability).
- lbug-load-prof test: assert mode=overlap on the default path.

* fix(test): use mkdtemp for secure temp dirs (CodeQL js/insecure-temporary-file)

CodeQL flagged lbug-load-overlap.test.ts writing a file into a predictable
os.tmpdir() path. Create the base temp dir with fs.mkdtemp (atomic, random
suffix) in both new live-DB tests, and switch to the gitnexus-lbug- prefix that
TEST_FIXTURE_PREFIXES recognizes so the Windows stale-sidecar sweep covers
these fixtures.

* fix(lbug): check PDG manifest rel-pair collision before node COPY (#2203)

Found by Codex in tri-review. The manifest rel-pair collision guard ran after
the FK barrier (after node COPY committed), so on that should-never-happen
error branch the overlap path left orphan node rows AND the
GITNEXUS_SERIAL_LBUG_LOAD escape hatch diverged from the legacy 'validate
manifest before any COPY' behavior. Move the rel merge + collision check ahead
of beginNodeCopy/the barrier: the serial path now detects a collision before
committing any node rows (legacy parity restored — the escape hatch is a
faithful oracle again), and the overlap path detects it as early as csvResult
is available. The node-collision guard already ran before node COPY (in the
hook).

* test(lbug): cover rel-emit failure with node COPY in flight (#2203)

Resolves a P1 review gap on PR #2226: the overlap's catch(emitErr) branch
(settle the in-flight node-COPY promise, then rethrow the emit error) was
untested. Fault-injects via a vi.mock of streamAllCSVsToDisk that fires
onNodePhaseComplete (starting a real node COPY on a live DB) then throws,
asserting loadGraphToLbug rejects with the emit error and no unhandled
rejection leaks. Also covers the both-fail case (node COPY error is logged,
emit error still wins). Listener removed in finally; macrotask queue flushed
before the assertion so it can't pass vacuously.

* test(lbug): cover node-COPY hard-failure rethrow at the FK barrier (#2203)

Resolves the second P1 review gap on PR #2226. Mocks emit to fire
onNodePhaseComplete with a nodeFiles entry pointing at a missing CSV (a
bind-time COPY error that IGNORE_ERRORS does not suppress) and otherwise
succeed, so copyNodeCSVs throws, the error is captured in nodeCopyError, and
loadGraphToLbug rethrows it at the FK barrier — asserted via rejects /COPY
failed for File/.

* test(lbug): cover PDG manifest rel-pair collision in overlap + serial (#2203)

Resolves the P2 gap behind the Codex tri-review finding: the manifest rel-pair
collision guard (moved ahead of node COPY in ad195582) had no test. A leaky
graph with a structural BasicBlock->BasicBlock edge (routed by id-prefix, no
BasicBlock nodes — isolating the rel-pair clash from the node-CSV one) plus a
PdgEmitSink manifest declaring the same pair makes loadGraphToLbug reject with
the rel-pair collision error, asserted on both the overlap (default) and serial
(GITNEXUS_SERIAL_LBUG_LOAD=1) paths.

* perf(lbug): yield the event loop periodically during relationship emit (#2203)

Resolves a P2 review finding on PR #2226: the relationship-emit loop ran long
synchronous stretches between write-stream drain awaits, which could starve the
overlapped node-COPY callbacks on fast I/O and erode the node-COPY-||-rel-emit
overlap. Yield via setImmediate every REL_YIELD_EVERY (5000) edges so the node
COPY and drains get scheduling time. Scheduling-only — emit bench fingerprint
unchanged (byte-identical), csv-pipeline determinism + overlap differential
green.

* refactor(lbug): extract shared copyCsvWithRetry helper (#2203)

Resolves a P2 maintainability finding on PR #2226: the COPY + IGNORE_ERRORS
retry block was duplicated in copyNodeCSVs and the inline relationship-COPY
loop. Extract copyCsvWithRetry(conn, query, onError); the callback receives the
RAW retry error so each site keeps its own message shape + slice length (node
throws, slices 200; relationship warns + records the failed pair, slices 80).
Behavior-preserving — guarded by the live-DB round-trips plus the new
node-COPY-failure and overlap error-path tests.

* docs(lbug): document loadGraphToLbug non-transactionality (#2203)

Resolves the advisory review finding on PR #2226: loadGraphToLbug runs
independent COPYs with no surrounding transaction, so a mid-load failure leaves
a partial DB and recovery is a --force re-analyze. Make that contract explicit
on the function so callers don't assume atomicity.
2026-06-16 10:57:26 +01:00

130 lines
5.2 KiB
TypeScript

/**
* Integration test: PARALLEL=false is a load-bearing correctness invariant for
* the bulk-COPY persistence path (#2203 — the "parallelized emit" follow-up).
*
* Issue #2203 asked us to investigate parallelizing emit. The most obvious
* lever — LadybugDB's intra-COPY parallel CSV reader (`PARALLEL=true`, the
* Kuzu default) — is UNSAFE for our data: that reader splits the file into byte
* ranges parsed concurrently and cannot find line boundaries when a quoted
* field contains an embedded newline (upstream kuzudb/kuzu#5778, still open;
* error text "Quoted newlines are not supported in parallel CSV reader. Please
* specify PARALLEL=FALSE"). Our `content`/`text` columns hold source code, so
* quoted multiline fields are guaranteed. PARALLEL=false is therefore MANDATORY,
* not conservative — this test locks that in two ways:
*
* 1. Live-DB proof — a node whose `text` carries embedded newlines AND quotes
* round-trips byte-exact through the real csv-emit → COPY → query path.
* If anyone flips PARALLEL=true, the parallel reader mis-parses this row and
* the assertion fails loudly. (An edge also round-trips, exercising the rel
* COPY path, which uses the same PARALLEL=false option.)
* 2. Static guard — the generated COPY query strings still carry PARALLEL=false,
* giving a crisp failure independent of a live DB.
*
* Needs a real LadybugDB connection (initLbug), so it lives under integration.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { NODE_TABLES } from 'gitnexus-shared';
import { buildTestGraph } from '../helpers/test-graph.js';
let tmpBase: string;
let storagePath: string;
let dbPath: string;
// A BasicBlock `text` with the exact hazard the parallel reader cannot handle:
// embedded newlines INSIDE a field that also contains double-quotes. After
// escapeCSVField this becomes a quoted multiline CSV field.
const HAZARD_TEXT = 'const msg = "line one";\nconst other = "she said \\"hi\\"";\nreturn msg;';
const BB1 = 'BasicBlock:src/hazard.ts:0';
const BB2 = 'BasicBlock:src/hazard.ts:1';
beforeAll(async () => {
// mkdtemp (not a predictable os.tmpdir join) + the `gitnexus-lbug-` prefix
// that TEST_FIXTURE_PREFIXES recognizes for the stale-sidecar sweep.
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-lbug-copy-parallel-'));
storagePath = path.join(tmpBase, '.gitnexus');
dbPath = path.join(storagePath, 'lbug');
await fs.mkdir(dbPath, { recursive: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const graph = buildTestGraph(
[
{
id: BB1,
label: 'BasicBlock',
name: '',
filePath: 'src/hazard.ts',
startLine: 1,
endLine: 3,
extra: { text: HAZARD_TEXT },
},
{
id: BB2,
label: 'BasicBlock',
name: '',
filePath: 'src/hazard.ts',
startLine: 4,
endLine: 4,
extra: { text: 'sink(msg);' },
},
],
[{ sourceId: BB1, targetId: BB2, type: 'CFG', reason: 'cfg-edge' }],
);
await adapter.loadGraphToLbug(graph, tmpBase, storagePath);
});
afterAll(async () => {
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.closeLbug();
} catch {
/* may not have opened */
}
if (tmpBase) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
await fs.rm(tmpBase, { recursive: true, force: true });
return;
} catch {
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
}
}
}
});
describe('PARALLEL=false correctness invariant (#2203 / kuzudb/kuzu#5778)', () => {
it('a multiline-quoted content field round-trips byte-exact through COPY', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
`MATCH (n:BasicBlock {id: '${BB1}'}) RETURN n.text AS text`,
);
expect(rows).toHaveLength(1);
// Byte-exact: the embedded newlines and the doubled quotes survived the
// quoted-field round-trip. PARALLEL=true would have mis-split this row.
expect(rows[0].text).toBe(HAZARD_TEXT);
});
it('an edge round-trips through the relationship COPY path (same PARALLEL=false)', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
"MATCH (:BasicBlock)-[r:CodeRelation {type: 'CFG'}]->(:BasicBlock) RETURN count(r) AS c",
);
expect(Number(rows[0].c)).toBe(1);
});
it('every generated node COPY query statically carries PARALLEL=false', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
expect(adapter.COPY_CSV_OPTS).toContain('PARALLEL=false');
expect(adapter.COPY_CSV_OPTS).not.toContain('PARALLEL=true');
for (const table of NODE_TABLES) {
const q = adapter.getCopyQuery(table, '/tmp/x.csv');
expect(q, `${table} COPY must keep PARALLEL=false`).toContain('PARALLEL=false');
expect(q, `${table} COPY must not enable PARALLEL`).not.toContain('PARALLEL=true');
}
});
});