mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 00:01:39 +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?.();
|
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 () => {
|
it('produces no edges in a Celery-free project (clean control)', async () => {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'app.py'),
|
path.join(dir, 'app.py'),
|
||||||
|
|||||||
@@ -132,9 +132,38 @@ function nuxtComponentName(filePath: string): string | null {
|
|||||||
return out.join('');
|
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 {
|
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
|
||||||
if (!startLine || !endLine) return 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 {
|
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))) {
|
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
|
||||||
const verb = m[1]!;
|
const verb = m[1]!;
|
||||||
const arg = m[2]!.trim();
|
const arg = m[2]!.trim();
|
||||||
const line = safe.slice(0, m.index).split('\n').length;
|
const line = lineOf(safe, m.index);
|
||||||
const encl = nodes
|
const encl = nodes
|
||||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
.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;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
|
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
|
||||||
const url = m[1]!;
|
const url = m[1]!;
|
||||||
const line = safe.slice(0, m.index).split('\n').length;
|
const line = lineOf(safe, m.index);
|
||||||
const encl = nodes
|
const encl = nodes
|
||||||
.filter((n) => n.startLine <= line && n.endLine >= line)
|
.filter((n) => n.startLine <= line && n.endLine >= line)
|
||||||
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
.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 parenIdx = m.index + m[0].length - 1;
|
||||||
const argStr = goBalancedArgs(safe, parenIdx);
|
const argStr = goBalancedArgs(safe, parenIdx);
|
||||||
if (!argStr) continue;
|
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)) {
|
for (const arg of goSplitArgs(argStr)) {
|
||||||
const name = goHandlerIdent(arg);
|
const name = goHandlerIdent(arg);
|
||||||
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
|
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}`;
|
const key = `${node.id}>${target.id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
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({
|
edges.push({
|
||||||
source: node.id,
|
source: node.id,
|
||||||
target: target.id,
|
target: target.id,
|
||||||
@@ -3957,7 +3986,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
|
|||||||
const storeFile = varStore.get(cm[1]!);
|
const storeFile = varStore.get(cm[1]!);
|
||||||
if (!storeFile) continue;
|
if (!storeFile) continue;
|
||||||
const method = cm[2]!;
|
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;
|
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
const target = ctx
|
const target = ctx
|
||||||
@@ -4049,7 +4078,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P
|
|||||||
let added = 0;
|
let added = 0;
|
||||||
while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) {
|
while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) {
|
||||||
const key = m[1]!;
|
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;
|
const disp = enclosingFn(nodesInFile, line) ?? fallback;
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
const target = resolve(key, file);
|
const target = resolve(key, file);
|
||||||
@@ -4145,7 +4174,7 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
|||||||
let added = 0;
|
let added = 0;
|
||||||
while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
|
while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
|
||||||
const name = m[1]!;
|
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);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue; // module-level dispatch — no source symbol to attribute
|
if (!disp) continue; // module-level dispatch — no source symbol to attribute
|
||||||
const target = resolve(name, file);
|
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) {
|
while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) {
|
||||||
const targets = listeners.get(m[1]!);
|
const targets = listeners.get(m[1]!);
|
||||||
if (!targets || !targets.length) continue;
|
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);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
@@ -4380,7 +4409,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
|
|||||||
let added = 0;
|
let added = 0;
|
||||||
while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
|
while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
|
||||||
if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …)
|
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);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
|
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 m: RegExpExecArray | null;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) {
|
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);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
const target = resolve(m[1]!);
|
const target = resolve(m[1]!);
|
||||||
@@ -4855,7 +4884,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
|
|||||||
const behaviour = behaviours[0]!;
|
const behaviour = behaviours[0]!;
|
||||||
const targets = targetsOf(behaviour, fn, arity);
|
const targets = targetsOf(behaviour, fn, arity);
|
||||||
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
|
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);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
for (const target of targets) {
|
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) {
|
while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) {
|
||||||
const targets = listeners.get(phpSimpleName(m[1]!));
|
const targets = listeners.get(phpSimpleName(m[1]!));
|
||||||
if (!targets) continue;
|
if (!targets) continue;
|
||||||
const line = safe.slice(0, m.index).split('\n').length;
|
const line = lineOf(safe, m.index);
|
||||||
const disp = enclosingFn(nodesInFile, line);
|
const disp = enclosingFn(nodesInFile, line);
|
||||||
if (!disp) continue;
|
if (!disp) continue;
|
||||||
for (const target of targets.values()) {
|
for (const target of targets.values()) {
|
||||||
@@ -5439,6 +5468,10 @@ export async function synthesizeCallbackEdges(
|
|||||||
await runPassOnMain(i);
|
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
|
// A Jest or Vitest manual mock answers only for test code (#2455): whatever
|
||||||
// pass paired them, production code never links into a `__mocks__/` file.
|
// pass paired them, production code never links into a `__mocks__/` file.
|
||||||
|
|||||||
Reference in New Issue
Block a user