GitNexus/gitnexus/test/integration/pdg-emit-streaming-roundtrip.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

245 lines
9.7 KiB
TypeScript

/**
* Integration test: streamed PDG-emit manifest round-trips the bulk-COPY load
* path (issue #2202 U5).
*
* Simulates the streaming case end-to-end at the persistence boundary: the
* BasicBlock + intra-file PDG-edge layer is flushed to CSV by a real
* `PdgEmitSink` (so the in-memory graph holds ZERO BasicBlocks, exactly as in a
* streamed run), and `loadGraphToLbug` is handed the resulting manifest. Asserts
* the BasicBlock nodes + every PDG edge type land in the DB via the manifest,
* alongside the structural graph — and that there is no double-COPY.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { PdgEmitSink } from '../../src/core/lbug/pdg-emit-sink.js';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
let tmpBase: string;
let storagePath: string;
const FILE_ID = 'File:src/a.ts';
const BB = (i: number) => `BasicBlock:src/a.ts:1:0:${i}`;
const PDG_TYPES = ['CFG', 'REACHING_DEF', 'CDG', 'POST_DOMINATE', 'TAINTED', 'SANITIZES'] as const;
beforeAll(async () => {
// mkdtemp (unpredictable, unique) — not a predictable os-temp path.
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-pdg-stream-rt-'));
storagePath = path.join(tmpBase, '.gitnexus');
await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(path.join(storagePath, 'lbug'));
// Structural graph — NO BasicBlock nodes (they were "streamed out").
const graph = createKnowledgeGraph();
graph.addNode({
id: FILE_ID,
label: 'File',
properties: { name: 'a.ts', filePath: 'src/a.ts' },
});
// Real sink → real manifest: route 3 BasicBlocks + one edge of each PDG type.
const sink = new PdgEmitSink(graph, path.join(storagePath, 'pdg-csv'));
for (let i = 0; i < 3; i++) {
const node: GraphNode = {
id: BB(i),
label: 'BasicBlock',
properties: {
name: '',
filePath: 'src/a.ts',
startLine: i + 1,
endLine: i + 2,
text: `b${i}`,
},
};
sink.addNode(node);
}
for (const type of PDG_TYPES) {
const rel: GraphRelationship = {
id: `${type}:0->1`,
sourceId: BB(0),
targetId: BB(1),
type,
confidence: 1,
reason: type === 'REACHING_DEF' ? 'x' : type === 'CDG' ? 'T' : `${type}-edge`,
};
sink.addRelationship(rel);
}
const manifest = sink.finalize();
// The sink offloaded the whole PDG layer — the graph has only the File node.
expect(graph.nodeCount).toBe(1);
await adapter.loadGraphToLbug(graph, tmpBase, storagePath, undefined, manifest);
});
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('streamed PDG manifest → bulk COPY (#2202 U5)', () => {
it('BasicBlock nodes from the manifest land in the DB with span + text', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
'MATCH (n:BasicBlock) RETURN n.id AS id, n.text AS text, n.startLine AS startLine ORDER BY n.id',
);
expect(rows).toHaveLength(3);
expect(rows[0].id).toBe(BB(0));
expect(rows[0].text).toBe('b0');
expect(Number(rows[0].startLine)).toBe(1);
});
it('the structural graph (File node) loaded alongside the manifest', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
`MATCH (f:File {id: '${FILE_ID}'}) RETURN count(f) AS c`,
);
expect(Number(rows[0].c)).toBe(1);
});
it('every PDG edge type round-trips via the manifest (no double-COPY)', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
for (const type of PDG_TYPES) {
const rows = await adapter.executeQuery(
`MATCH (:BasicBlock)-[r:CodeRelation {type: '${type}'}]->(:BasicBlock) RETURN count(r) AS c`,
);
// Exactly one — not two (double-COPY would double these).
expect(Number(rows[0].c), `${type} should round-trip exactly once`).toBe(1);
}
});
it('REACHING_DEF carries its variable in reason (manifest path)', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const rows = await adapter.executeQuery(
"MATCH (a:BasicBlock)-[r:CodeRelation {type: 'REACHING_DEF', reason: 'x'}]->(b:BasicBlock) RETURN a.id AS from, b.id AS to",
);
expect(rows).toHaveLength(1);
expect(rows[0].from).toBe(BB(0));
expect(rows[0].to).toBe(BB(1));
});
});
describe('streamed PDG manifest → disjoint-key merge guard (#2202 review #3)', () => {
// The merge in loadGraphToLbug assumes the streamed manifest and the
// structural csvResult are disjoint: when streaming is on the in-memory graph
// holds ZERO BasicBlocks, so streamAllCSVsToDisk emits no basicblock.csv and
// the manifest is the sole source. A future BasicBlock-leak-into-graph would
// make both sides carry a "BasicBlock" entry; silently overwriting one CSV
// with the other would drop its rows. The guard fails loudly instead.
it('throws when the manifest collides with a structural node CSV', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// A graph that DOES contain a BasicBlock → streamAllCSVsToDisk emits a
// structural basicblock.csv (the invariant-violation scenario).
const leakyGraph = createKnowledgeGraph();
leakyGraph.addNode({
id: BB(0),
label: 'BasicBlock',
properties: { name: '', filePath: 'src/a.ts', startLine: 1, endLine: 2, text: 'leak' },
});
// A manifest that ALSO declares a BasicBlock node CSV → disjoint-key clash.
const collideBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-pdg-collide-'));
const collideStorage = path.join(collideBase, '.gitnexus');
await fs.mkdir(collideStorage, { recursive: true });
const sink = new PdgEmitSink(createKnowledgeGraph(), path.join(collideStorage, 'pdg-csv'));
sink.addNode({
id: BB(1),
label: 'BasicBlock',
properties: { name: '', filePath: 'src/a.ts', startLine: 3, endLine: 4, text: 'm' },
});
const manifest = sink.finalize();
try {
await expect(
adapter.loadGraphToLbug(leakyGraph, collideBase, collideStorage, undefined, manifest),
).rejects.toThrow(/collides with a structural node CSV for "BasicBlock"/);
} finally {
await fs.rm(collideBase, { recursive: true, force: true });
}
});
});
describe('streamed PDG manifest → rel-pair collision guard (#2226 F3)', () => {
// The rel-pair analogue of the node-CSV guard above, for the case Codex
// flagged on PR #2226. The collision check was moved ahead of node COPY so the
// serial escape hatch detects it before committing node rows; this asserts the
// guard fires in BOTH the overlap (default) and serial paths.
// A leaky graph carrying a structural BasicBlock→BasicBlock EDGE but NO
// BasicBlock nodes: RelPairRouter derives the label from the `BasicBlock:` id
// prefix, so the structural relsByPair gets a `BasicBlock|BasicBlock` pair
// while nodeFiles stays empty — isolating the REL-pair collision from the
// node-CSV one. The manifest declares the same pair via PdgEmitSink.
const buildRelCollision = async (label: string) => {
const leakyGraph = createKnowledgeGraph();
leakyGraph.addRelationship({
id: 'CFG:0->1-structural',
sourceId: BB(0),
targetId: BB(1),
type: 'CFG',
confidence: 1,
reason: 'leak',
});
const base = await fs.mkdtemp(path.join(os.tmpdir(), `gitnexus-lbug-relcollide-${label}-`));
const storage = path.join(base, '.gitnexus');
await fs.mkdir(storage, { recursive: true });
const sink = new PdgEmitSink(createKnowledgeGraph(), path.join(storage, 'pdg-csv'));
sink.addRelationship({
id: 'CFG:0->1-manifest',
sourceId: BB(0),
targetId: BB(1),
type: 'CFG',
confidence: 1,
reason: 'manifest',
});
const manifest = sink.finalize();
return { leakyGraph, base, storage, manifest };
};
it('throws on a rel-pair collision in the overlap (default) path', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { leakyGraph, base, storage, manifest } = await buildRelCollision('overlap');
try {
await expect(
adapter.loadGraphToLbug(leakyGraph, base, storage, undefined, manifest),
).rejects.toThrow(/collides with a structural relationship CSV for pair/);
} finally {
await fs.rm(base, { recursive: true, force: true });
}
});
it('throws on a rel-pair collision in the serial (GITNEXUS_SERIAL_LBUG_LOAD=1) path', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { leakyGraph, base, storage, manifest } = await buildRelCollision('serial');
process.env.GITNEXUS_SERIAL_LBUG_LOAD = '1';
try {
await expect(
adapter.loadGraphToLbug(leakyGraph, base, storage, undefined, manifest),
).rejects.toThrow(/collides with a structural relationship CSV for pair/);
} finally {
delete process.env.GITNEXUS_SERIAL_LBUG_LOAD;
await fs.rm(base, { recursive: true, force: true });
}
});
});