mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
perf(synthesis): line numbers from a newline index, not a split per match
Two quadratic idioms in callback-synthesizer.ts. Twelve passes computed
each match's line as `safe.slice(0, m.index).split('\n').length`, which
copies and splits the file's prefix for every match, and `sliceLines`
split the whole file again for every node a pass looked at (7 callers,
one per node of the same file in a row).
The twelve sites now call `lineOf(src, idx)`, which keeps the newline
index of the last text (built with synth-utils' `makeLineAt`, which
counts the newlines strictly before `idx`, exactly like the old idiom),
and `sliceLines` keeps the last file's lines.
Ours on top: `releaseLineCaches()` at the end of synthesizeCallbackEdges.
The pool workers' copies go with the workers (pool.destroy() after
synthesis), but a sync runs synthesis on the main thread, so a long-lived
MCP server or `watch` kept the last file's text, its lines and its newline
index until the next synthesis. And a celery test with two files of the
same length scanned in a row, which fails if the line index is reused
across texts (checked by keying the cache on length).
Validation, main 81559dcc vs this:
- dump-graph of every table (nodes, edges with metadata, refs, files)
byte-identical on pretix c9f7bf86 (79,452 edges), trezor-suite
505aece5 (493,138 edges), excalidraw d3a9c2a1 (46 react-render),
vben 61c07936 (128 pinia-store), gin and eShopOnWeb (Go and C#
controls with gin-middleware-chain and mediatr-dispatch edges).
- The pass outputs are identical, edge for edge and in the same order,
when each touched pass runs alone over the same index. Their CPU, the
median of 3 runs after a warm-up: pretix 1,018 -> 845 ms (React render
204 -> 32 ms, field channels 235 -> 172 ms), trezor-suite 7,686 vs
7,841 ms (noise; its Pinia and Vuex scans dominate and are untouched).
- Whole-index CPU on pretix, median of 3 interleaved: 99.8 vs 99.4 s.
No CHANGELOG entry: the gain is per pass, not visible per index; the
PR's bullet sat in the released 1.6.1 section and is dropped.
Original PR #1985 by @bompus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Colby Mchenry
co-authored by
Claude Opus 5.5
parent
0b194448dc
commit
4eb8c2a5f2
@@ -103,6 +103,34 @@ def handle_request(req):
|
||||
cg.close?.();
|
||||
});
|
||||
|
||||
it('numbers each dispatch by its own file, when two files of one length are scanned in a row', async () => {
|
||||
// The pass reuses a line index while the text is the same; two files of the
|
||||
// same length but different line breaks must still each get their own lines.
|
||||
fs.writeFileSync(path.join(dir, 'tasks.py'), 'from celery import shared_task\n\n\n@shared_task\ndef send_email(to):\n return to\n');
|
||||
const a = 'from tasks import send_email\n\n\n\ndef first(req):\n send_email.delay(req.addr)\n';
|
||||
const b = 'from tasks import send_email\ndef second(req):\n send_email.delay(req.addr)\n';
|
||||
const padTo = (s: string, n: number): string => `${s}#${'x'.repeat(n - s.length - 2)}\n`;
|
||||
const len = Math.max(a.length, b.length) + 8;
|
||||
fs.writeFileSync(path.join(dir, 'a.py'), padTo(a, len));
|
||||
fs.writeFileSync(path.join(dir, 'b.py'), padTo(b, len));
|
||||
expect(fs.statSync(path.join(dir, 'a.py')).size).toBe(fs.statSync(path.join(dir, 'b.py')).size);
|
||||
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const rows = (cg as any).db.db
|
||||
.prepare(
|
||||
`SELECT s.name source, e.line line, json_extract(e.metadata,'$.registeredAt') at
|
||||
FROM edges e JOIN nodes s ON s.id = e.source
|
||||
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'celery-dispatch' ORDER BY s.name`
|
||||
)
|
||||
.all();
|
||||
expect(rows).toEqual([
|
||||
{ source: 'first', line: 6, at: 'a.py:6' },
|
||||
{ source: 'second', line: 3, at: 'b.py:3' },
|
||||
]);
|
||||
cg.close?.();
|
||||
});
|
||||
|
||||
it('produces no edges in a Celery-free project (clean control)', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'app.py'),
|
||||
|
||||
@@ -132,9 +132,38 @@ function nuxtComponentName(filePath: string): string | null {
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// Passes call sliceLines once per node, so splitting the whole file each
|
||||
// time is quadratic in a file's node count. Keep the last file's lines.
|
||||
let sliceLinesSource: string | null = null;
|
||||
let sliceLinesSplit: string[] = [];
|
||||
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
|
||||
if (!startLine || !endLine) return null;
|
||||
return content.split('\n').slice(startLine - 1, endLine).join('\n');
|
||||
if (content !== sliceLinesSource) {
|
||||
sliceLinesSource = content;
|
||||
sliceLinesSplit = content.split('\n');
|
||||
}
|
||||
return sliceLinesSplit.slice(startLine - 1, endLine).join('\n');
|
||||
}
|
||||
|
||||
// `src.slice(0, idx).split('\n').length` per match is quadratic in a file's
|
||||
// match count. The newline index is built once per text, and the last text's
|
||||
// index is kept.
|
||||
let lineOfSource: string | null = null;
|
||||
let lineOfIndex: (idx: number) => number = () => 1;
|
||||
function lineOf(src: string, idx: number): number {
|
||||
if (src !== lineOfSource) {
|
||||
lineOfSource = src;
|
||||
lineOfIndex = makeLineAt(src, 1);
|
||||
}
|
||||
return lineOfIndex(idx);
|
||||
}
|
||||
|
||||
/** Forget the last file sliceLines and lineOf keep, once a synthesis run is over. */
|
||||
function releaseLineCaches(): void {
|
||||
sliceLinesSource = null;
|
||||
sliceLinesSplit = [];
|
||||
lineOfSource = null;
|
||||
lineOfIndex = () => 1;
|
||||
}
|
||||
|
||||
function registrarField(src: string): string | null {
|
||||
@@ -611,7 +640,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
|
||||
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
|
||||
const verb = m[1]!;
|
||||
const arg = m[2]!.trim();
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const encl = nodes
|
||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
||||
@@ -715,7 +744,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
|
||||
const url = m[1]!;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const encl = nodes
|
||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
||||
@@ -3462,7 +3491,7 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon
|
||||
const parenIdx = m.index + m[0].length - 1;
|
||||
const argStr = goBalancedArgs(safe, parenIdx);
|
||||
if (!argStr) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
for (const arg of goSplitArgs(argStr)) {
|
||||
const name = goHandlerIdent(arg);
|
||||
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
|
||||
@@ -3639,7 +3668,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on
|
||||
const key = `${node.id}>${target.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const line = node.startLine + safe.slice(0, m.index).split('\n').length - 1;
|
||||
const line = node.startLine + lineOf(safe, m.index) - 1;
|
||||
edges.push({
|
||||
source: node.id,
|
||||
target: target.id,
|
||||
@@ -3957,7 +3986,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
|
||||
const storeFile = varStore.get(cm[1]!);
|
||||
if (!storeFile) continue;
|
||||
const method = cm[2]!;
|
||||
const line = safe.slice(0, cm.index).split('\n').length;
|
||||
const line = lineOf(safe, cm.index);
|
||||
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
|
||||
if (!disp) continue;
|
||||
const target = ctx
|
||||
@@ -4049,7 +4078,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P
|
||||
let added = 0;
|
||||
while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) {
|
||||
const key = m[1]!;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line) ?? fallback;
|
||||
if (!disp) continue;
|
||||
const target = resolve(key, file);
|
||||
@@ -4145,7 +4174,7 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
let added = 0;
|
||||
while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
|
||||
const name = m[1]!;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue; // module-level dispatch — no source symbol to attribute
|
||||
const target = resolve(name, file);
|
||||
@@ -4267,7 +4296,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
|
||||
while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) {
|
||||
const targets = listeners.get(m[1]!);
|
||||
if (!targets || !targets.length) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue;
|
||||
for (const target of targets) {
|
||||
@@ -4380,7 +4409,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
|
||||
let added = 0;
|
||||
while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
|
||||
if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …)
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue;
|
||||
const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
|
||||
@@ -4476,7 +4505,7 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
|
||||
let m: RegExpExecArray | null;
|
||||
let added = 0;
|
||||
while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) {
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue;
|
||||
const target = resolve(m[1]!);
|
||||
@@ -4855,7 +4884,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
|
||||
const behaviour = behaviours[0]!;
|
||||
const targets = targetsOf(behaviour, fn, arity);
|
||||
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue;
|
||||
for (const target of targets) {
|
||||
@@ -5002,7 +5031,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
|
||||
while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) {
|
||||
const targets = listeners.get(phpSimpleName(m[1]!));
|
||||
if (!targets) continue;
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const line = lineOf(safe, m.index);
|
||||
const disp = enclosingFn(nodesInFile, line);
|
||||
if (!disp) continue;
|
||||
for (const target of targets.values()) {
|
||||
@@ -5439,6 +5468,10 @@ export async function synthesizeCallbackEdges(
|
||||
await runPassOnMain(i);
|
||||
}
|
||||
}
|
||||
// The passes are done. In a long-lived process (the MCP server, `watch`),
|
||||
// the two line caches would otherwise keep the last file read until the next
|
||||
// synthesis; a pool worker's go with the worker.
|
||||
releaseLineCaches();
|
||||
|
||||
// A Jest or Vitest manual mock answers only for test code (#2455): whatever
|
||||
// pass paired them, production code never links into a `__mocks__/` file.
|
||||
|
||||
Reference in New Issue
Block a user