GitNexus/gitnexus/test/integration/spring-inheritance-benchmark.test.ts

314 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Spring interface-inheritance benchmark (#2288 / PR #2290 review).
*
* Measures the cost of the cross-file interface-inheritance path this PR adds:
* parse → `extractSpringTypes` (per file) → `resolveInheritedSpringRoutes`
* (project-wide). The fixture is a *realistic* Spring shape — N route-defining
* interfaces, each implemented by exactly one `@RestController` (1:1), the
* dominant `interface + @RestController impl` pattern. (An every-controller-
* implements-every-interface shape is not a real Spring idiom, so it is not
* modelled.)
*
* Three suites:
* 1. An UNGATED O(n²) tripwire that runs in normal CI — calls the hotpath
* directly on a large fixture and asserts it stays well under a coarse
* budget (the actual regression guard).
* 2. A GATED isolated-resolver scaling suite (GITNEXUS_BENCH=1) that grows N
* and asserts the time ratio stays linear (time_ratio / size_ratio < 1.5).
* 3. A GATED end-to-end suite (GITNEXUS_BENCH=1 + a built worker) that runs the
* full `runPipelineFromRepo` on synthetic Spring repos and asserts the whole
* analyze scales linearly.
*
* Suites 12 are build-free (they import the `.ts` hotpaths directly), so the
* tripwire runs in normal CI. Suite 3 needs the compiled worker because
* `extractSpringTypes` runs inside it; it auto-skips when the worker is unbuilt.
*
* Run the gated suites:
* (cd gitnexus && npm run build) # only needed for suite 3
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-inheritance-benchmark.test.ts
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { extractSpringTypes } from '../../src/core/ingestion/route-extractors/spring.js';
import {
resolveInheritedSpringRoutes,
type SharedSpringType,
} from '../../src/core/ingestion/route-extractors/spring-shared.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
/**
* The compiled worker the pool spawns. Under vitest `import.meta.url` resolves
* to `src/`, where no `.js` exists — so point at the `dist/` build (the same
* fallback parse-impl uses). The full-pipeline suite needs it (extractSpringTypes
* runs inside the worker); it auto-skips when unbuilt.
*/
const DIST_WORKER_URL = new URL(
'../../dist/core/ingestion/workers/parse-worker.js',
import.meta.url,
);
const DIST_WORKER_AVAILABLE = fs.existsSync(fileURLToPath(DIST_WORKER_URL));
/** Methods declared per interface (each becomes one inherited route). */
const METHODS_PER_INTERFACE = 4;
function parse(src: string): Parser.Tree {
const p = new Parser();
p.setLanguage(Java);
return p.parse(src);
}
/**
* Generate `pairCount` interface+controller pairs and return the project-wide
* `SharedSpringType[]` (the shape `resolveInheritedSpringRoutes` consumes),
* built through the real `extractSpringTypes` collector so the benchmark
* exercises both halves of the pass.
*/
function buildSpringTypes(pairCount: number): SharedSpringType[] {
const types: SharedSpringType[] = [];
for (let i = 0; i < pairCount; i++) {
const methods = Array.from(
{ length: METHODS_PER_INTERFACE },
(_, m) => ` @GetMapping("/m${m}") Object op${m}();`,
).join('\n');
const iface = `package com.example.api;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/r${i}")
public interface Api${i} {
${methods}
}
`;
const impls = Array.from(
{ length: METHODS_PER_INTERFACE },
(_, m) => ` public Object op${m}() { return null; }`,
).join('\n');
const controller = `package com.example.web;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/v1")
public class Controller${i} implements Api${i} {
${impls}
}
`;
types.push(...extractSpringTypes(parse(iface), `Api${i}.java`));
types.push(...extractSpringTypes(parse(controller), `Controller${i}.java`));
}
return types;
}
describe('Spring inheritance O(n²) regression tripwire (#2288)', () => {
it('resolves N=400 interface→controller pairs within the coarse budget', () => {
const PAIRS = 400;
const BUDGET_MS = 5_000; // coarse tripwire: a quadratic re-regression blows this
const types = buildSpringTypes(PAIRS);
// warm up the JIT
resolveInheritedSpringRoutes(buildSpringTypes(5));
const start = Date.now();
const routes = resolveInheritedSpringRoutes(types);
const elapsedMs = Date.now() - start;
// Non-vacuous: every pair contributes METHODS_PER_INTERFACE inherited routes.
expect(routes.length).toBe(PAIRS * METHODS_PER_INTERFACE);
expect(elapsedMs).toBeLessThan(BUDGET_MS);
console.log(
` spring-inheritance tripwire: ${PAIRS} pairs → ${routes.length} inherited routes, ${elapsedMs}ms`,
);
}, 30_000);
it('is a no-op (and instant) when there are no Spring types', () => {
// Non-Java / no-interface repos pay ~0 — the Java provider hook is the only
// populator of springTypes, so the pass receives an empty list elsewhere.
expect(resolveInheritedSpringRoutes([])).toEqual([]);
});
});
describe.skipIf(!BENCH_ENABLED)('Spring inheritance scaling benchmark (#2288)', () => {
it('scales linearly with interface×controller pair count', () => {
const scales = [250, 500, 1000];
// The resolve is microseconds-per-pair, far below Date.now()'s 1ms floor, so
// we sum many repetitions with performance.now() (sub-ms) to get a stable,
// noise-free signal — otherwise the ratio is pure timer quantisation.
const REPS = 200;
interface ScaleResult {
pairs: number;
routes: number;
totalMs: number;
}
const results: ScaleResult[] = [];
for (const pairs of scales) {
const types = buildSpringTypes(pairs);
for (let w = 0; w < 5; w++) resolveInheritedSpringRoutes(types); // warm up
let routeCount = 0;
const start = performance.now();
for (let r = 0; r < REPS; r++) {
routeCount = resolveInheritedSpringRoutes(types).length;
}
const totalMs = performance.now() - start;
results.push({ pairs, routes: routeCount, totalMs });
console.log(
` ${pairs} pairs ×${REPS}: ${totalMs.toFixed(1)}ms total (${routeCount} routes/run)`,
);
}
console.log('\nSpring Inheritance — Scaling');
console.log('┌──────────┬───────────────┬───────────┐');
console.log('│ Pairs │ Time ×REPS ms │ Routes │');
console.log('├──────────┼───────────────┼───────────┤');
for (const r of results) {
console.log(
`${String(r.pairs).padStart(8)}${r.totalMs.toFixed(1).padStart(13)}${String(r.routes).padStart(9)}`,
);
}
console.log('└──────────┴───────────────┴───────────┘');
console.log('\nScaling ratios (time_ratio / size_ratio):');
for (let i = 1; i < results.length; i++) {
const sizeRatio = results[i].pairs / results[i - 1].pairs;
const timeRatio = results[i].totalMs / results[i - 1].totalMs;
const scaling = timeRatio / sizeRatio;
console.log(
` ${results[i - 1].pairs}${results[i].pairs}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
expect(scaling).toBeLessThan(1.5);
}
}, 120_000);
});
/**
* Write `pairCount` interface+controller pairs to a fresh temp repo and return
* its path. Mirrors `buildSpringTypes`' shape but on disk, so the full pipeline
* (read → parse → worker extractSpringTypes → cross-file inheritance pass →
* graph) is exercised exactly as a real analyze would be.
*/
function writeSpringRepo(pairCount: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `spring-inherit-bench-${pairCount}-`));
const apiDir = path.join(dir, 'api');
const webDir = path.join(dir, 'web');
fs.mkdirSync(apiDir, { recursive: true });
fs.mkdirSync(webDir, { recursive: true });
for (let i = 0; i < pairCount; i++) {
const methods = Array.from(
{ length: METHODS_PER_INTERFACE },
(_, m) => ` @GetMapping("/m${m}") Object op${m}();`,
).join('\n');
fs.writeFileSync(
path.join(apiDir, `Api${i}.java`),
`package com.example.api;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/r${i}")
public interface Api${i} {
${methods}
}
`,
);
const impls = Array.from(
{ length: METHODS_PER_INTERFACE },
(_, m) => ` public Object op${m}() { return null; }`,
).join('\n');
fs.writeFileSync(
path.join(webDir, `Controller${i}.java`),
`package com.example.web;
import com.example.api.Api${i};
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/v1")
public class Controller${i} implements Api${i} {
${impls}
}
`,
);
}
return dir;
}
/**
* End-to-end pipeline scaling: runs the WHOLE `runPipelineFromRepo` on synthetic
* Spring repos of growing size and asserts the total analyze time stays linear
* in the number of interface→controller pairs. This is the "end-to-end" measure
* (read → parse → worker → cross-file inheritance pass → graph), complementing
* the isolated-resolver tripwire above.
*
* Needs the compiled worker (extractSpringTypes runs inside it):
* (cd gitnexus && npm run build)
* GITNEXUS_BENCH=1 npx vitest run test/integration/spring-inheritance-benchmark.test.ts
*/
describe.skipIf(!BENCH_ENABLED || !DIST_WORKER_AVAILABLE)(
'Spring inheritance pipeline benchmark — end-to-end (#2288)',
() => {
if (BENCH_ENABLED && !DIST_WORKER_AVAILABLE) {
console.warn(
`\n[spring-inherit-bench] Skipping end-to-end suite: compiled worker not found at\n ${fileURLToPath(DIST_WORKER_URL)}\n Build first: (cd gitnexus && npm run build)\n`,
);
}
it('full analyze scales linearly with interface→controller pair count', async () => {
const scales = [100, 250, 500];
interface PipeResult {
pairs: number;
elapsedMs: number;
routeNodes: number;
}
const results: PipeResult[] = [];
for (const pairs of scales) {
const dir = writeSpringRepo(pairs);
try {
const start = Date.now();
const result = await runPipelineFromRepo(dir, () => {}, {
workerUrlForTest: DIST_WORKER_URL,
});
const elapsedMs = Date.now() - start;
let routeNodes = 0;
result.graph.forEachNode((n) => {
if (n.label === 'Route') routeNodes++;
});
results.push({ pairs, elapsedMs, routeNodes });
console.log(` ${pairs} pairs: ${elapsedMs}ms (${routeNodes} Route nodes)`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log('\nSpring Inheritance — End-to-End Pipeline');
console.log('┌──────────┬───────────┬────────────┐');
console.log('│ Pairs │ Time (ms) │ Route nodes│');
console.log('├──────────┼───────────┼────────────┤');
for (const r of results) {
console.log(
`${String(r.pairs).padStart(8)}${String(r.elapsedMs).padStart(9)}${String(r.routeNodes).padStart(10)}`,
);
}
console.log('└──────────┴───────────┴────────────┘');
// Sanity: each pair yields METHODS_PER_INTERFACE inherited Route nodes.
for (const r of results) {
expect(r.routeNodes).toBe(r.pairs * METHODS_PER_INTERFACE);
}
console.log('\nScaling ratios (time_ratio / size_ratio):');
for (let i = 1; i < results.length; i++) {
const sizeRatio = results[i].pairs / results[i - 1].pairs;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
const scaling = timeRatio / sizeRatio;
console.log(
` ${results[i - 1].pairs}${results[i].pairs}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
// Full-pipeline wall-clock carries fixed startup/teardown overhead that
// dominates at small N, so the ratio is biased BELOW 1 at these sizes; a
// quadratic regression in the inheritance pass still pushes it past 1.5.
expect(scaling).toBeLessThan(1.5);
}
}, 300_000);
},
);