GitNexus/gitnexus/test/unit/impact-frontier-row-cap.test.ts
Neutron c2d9012b58
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(lbug): guard read queries against runaway native memory (#3696) (#3749)
* fix(lbug): guard read queries against runaway native memory (#3696)

A single impact query was reported to allocate ~184 GB of native heap on a
66k-symbol index. That memory is outside the buffer pool and V8, so neither
bufferManagerSize nor --max-old-space-size bounds it, and the Node binding
cannot interrupt a running query.

- Add a process-wide memory guard around every pooled read query. While a
  query runs it samples RSS every 200 ms and, on macOS, the physical
  footprint for queries running over 1 s (RSS excludes compressed and
  swapped pages there). Past the budget it names the running queries on
  stderr and exits with code 70 instead of letting the host run out of
  memory. GITNEXUS_QUERY_MAX_RSS_MB sets the limit (0 disables); the
  default is max(8 GiB, 25% of RAM).
- Add GITNEXUS_QUERY_TRACE=1: one stderr line per query with duration,
  rows and memory, so a reporter can identify the query that blows up.
- Cap the impact BFS frontier query at 100k rows per depth level. A capped
  level stops the walk, marks the result partial, and cannot report a
  LOW/MEDIUM risk.

* fix(ci): initialize shards after fork approval

GitHub approval increments the attempt number without running any jobs. Allow initial timing snapshot creation on attempt 2 only when the first attempt required approval and had zero jobs. Keep real retries fail-closed on missing frozen input.

* fix(impact): qualify both capped risk scores

Apply the same conservative cap policy to risk and riskSharedAxes, retain proven high warnings, and strengthen frontier-limit regression assertions. Correct the memory-budget test description.

* fix: skip query phases after deadline

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-10-28 14:08:17 +00:00

150 lines
5.6 KiB
TypeScript

/**
* #3696 — the impact BFS reads at most IMPACT_FRONTIER_ROW_CAP relationship
* rows per depth level. A capped level stops the walk, marks the result
* partial, and cannot publish a LOW/MEDIUM risk.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const executeQueryMock = vi.fn();
const executeParameterizedMock = vi.fn();
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/lbug/pool-adapter.js')>();
return {
...actual,
initLbug: vi.fn(),
executeQuery: (...args: any[]) => executeQueryMock(...args),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/mcp/core/lbug-adapter.js')>();
return {
...actual,
initLbug: vi.fn(),
executeQuery: (...args: any[]) => executeQueryMock(...args),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
import { LocalBackend, IMPACT_FRONTIER_ROW_CAP } from '../../src/mcp/local/local-backend.js';
function makeBackend() {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo1',
name: 'repo1',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
lbugPath: '/tmp/repo/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
(backend as any).repos.set(repoHandle.id, repoHandle);
(backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
return { backend, repoHandle };
}
/**
* Every frontier query returns `rowCount` CALLS rows spread over
* `distinctCallers` caller ids — duplicate edges, the shape a row-multiplying
* query produces. Returns the frontier query texts seen.
*/
function setupFrontier(rowCount: number, distinctCallers: number): string[] {
const frontierQueries: string[] = [];
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF')) return [];
if (query.includes('RETURN h.id AS hid')) return [];
if (query.includes('iface')) return [];
if (query.includes('r.type IN')) {
frontierQueries.push(query);
const rows: any[] = new Array(rowCount);
for (let i = 0; i < rowCount; i++) {
const c = i % distinctCallers;
rows[i] = {
sourceId: 'hub1',
id: `caller-${c}`,
name: `caller${c}`,
filePath: `src/caller-${c}.ts`,
relType: 'CALLS',
confidence: null,
};
}
return rows;
}
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
});
executeQueryMock.mockImplementation(async () => []);
return frontierQueries;
}
const impact = (backend: any, repoHandle: any, direction = 'upstream') =>
backend._impactImpl(repoHandle, { target: 'HubSymbol', direction, maxDepth: 3 });
describe('impact: per-level frontier row cap (#3696)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('asks the engine for at most one row past the cap', async () => {
const { backend, repoHandle } = makeBackend();
const queries = setupFrontier(1, 1);
await impact(backend, repoHandle);
expect(queries.length).toBeGreaterThan(0);
for (const q of queries) expect(q).toContain(`LIMIT ${IMPACT_FRONTIER_ROW_CAP + 1}`);
});
it('a level exactly at the cap is complete and keeps its measured risk', async () => {
const { backend, repoHandle } = makeBackend();
setupFrontier(IMPACT_FRONTIER_ROW_CAP, 1);
const res = await impact(backend, repoHandle);
expect(res.impactedCount).toBe(1);
expect(res.risk).toBe('LOW');
expect(res.riskSharedAxes).toBe('LOW');
expect(res.partial).toBeUndefined();
});
it('a capped level stops the walk and cannot read as LOW', async () => {
const { backend, repoHandle } = makeBackend();
const queries = setupFrontier(IMPACT_FRONTIER_ROW_CAP + 1, 1);
const res = await impact(backend, repoHandle);
// No depth-2 query from an arbitrary subset of depth 1.
expect(queries).toHaveLength(1);
expect(res.impactedCount).toBe(1);
expect(res.partial).toBe(true);
expect(res.risk).toBe('UNKNOWN');
expect(res.riskSharedAxes).toBe('UNKNOWN');
expect(res.riskNote).toContain('depth 1');
expect(res.riskNote).toContain(String(IMPACT_FRONTIER_ROW_CAP));
});
it('a capped level keeps a CRITICAL its counts already prove', async () => {
const { backend, repoHandle } = makeBackend();
setupFrontier(IMPACT_FRONTIER_ROW_CAP + 1, 300);
const res = await impact(backend, repoHandle);
expect(res.impactedCount).toBe(300);
expect(res.risk).toBe('CRITICAL');
expect(res.riskSharedAxes).toBe('CRITICAL');
expect(res.partial).toBe(true);
});
it('also bounds downstream traversal and reports its truncated counts', async () => {
const { backend, repoHandle } = makeBackend();
const queries = setupFrontier(IMPACT_FRONTIER_ROW_CAP + 1, 1);
const res = await impact(backend, repoHandle, 'downstream');
expect(queries).toHaveLength(1);
expect(queries[0]).toContain(`LIMIT ${IMPACT_FRONTIER_ROW_CAP + 1}`);
expect(res.impactedCount).toBe(1);
expect(res.partial).toBe(true);
expect(res.risk).toBe('UNKNOWN');
expect(res.riskSharedAxes).toBe('UNKNOWN');
});
});