Skip to content

Commit fb82f5b

Browse files
authored
feat(build): report collectMs/detectMs as separate phases (#993)
* feat(build): report collectMs/detectMs as separate phases (#977) Split collect and detect-change work out of setupMs in BuildResult.phases. These two phases have very different cost profiles (file-walk + ignore matching vs hash/mtime checks), and collapsing them hid where time was actually being spent during incremental-build perf investigations. - types.ts: add collectMs/detectMs to BuildResult.phases - context.ts: add collectMs/detectMs to ctx.timing - collect-files.ts, detect-changes.ts: wrap exported entries with performance.now() (try/finally so early returns still record timing) - pipeline.ts: formatTimingResult and formatNativeTimingResult surface the two fields separately instead of folding native-side values back into setupMs Rust PipelineTiming already tracks collect_ms and detect_ms, so no new instrumentation on the native side. docs check acknowledged — observability-only change, no README/CLAUDE/ ROADMAP impact. Impact: 6 functions changed, 7 affected * fix(types): align PipelineContext.timing interface with class definition (#993) Impact: 1 functions changed, 0 affected * refactor(build): separate collectMs from detectMs on scoped path (#993) Addresses Greptile P2 feedback on #993: - collectMs no longer includes change-detection work on the scoped-rebuild path. Only filesystem-walk work (existence checks + file list) is timed under collectMs; parseChanges/removed/isFullBuild assignments are timed under detectMs for semantic consistency with the non-scoped path. - detectChanges now accumulates detectMs additively to respect the partial contribution from collectFiles on the scoped path. - Pipeline test asserts collectMs and detectMs are >= 0, not just 'number', so a silent regression where timing is never recorded would be caught. Impact: 2 functions changed, 5 affected
1 parent 2014151 commit fb82f5b

6 files changed

Lines changed: 129 additions & 74 deletions

File tree

src/domain/graph/builder/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export class PipelineContext {
8787
// ── Phase timing ───────────────────────────────────────────────────
8888
timing: {
8989
setupMs?: number;
90+
collectMs?: number;
91+
detectMs?: number;
9092
parseMs?: number;
9193
insertMs?: number;
9294
resolveMs?: number;

src/domain/graph/builder/pipeline.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ function formatTimingResult(ctx: PipelineContext): BuildResult {
184184
return {
185185
phases: {
186186
setupMs: +(t.setupMs ?? 0).toFixed(1),
187+
collectMs: +(t.collectMs ?? 0).toFixed(1),
188+
detectMs: +(t.detectMs ?? 0).toFixed(1),
187189
parseMs: +(t.parseMs ?? 0).toFixed(1),
188190
insertMs: +(t.insertMs ?? 0).toFixed(1),
189191
resolveMs: +(t.resolveMs ?? 0).toFixed(1),
@@ -558,7 +560,9 @@ function formatNativeTimingResult(
558560
): BuildResult {
559561
return {
560562
phases: {
561-
setupMs: +((p.setupMs ?? 0) + (p.collectMs ?? 0) + (p.detectMs ?? 0)).toFixed(1),
563+
setupMs: +(p.setupMs ?? 0).toFixed(1),
564+
collectMs: +(p.collectMs ?? 0).toFixed(1),
565+
detectMs: +(p.detectMs ?? 0).toFixed(1),
562566
parseMs: +(p.parseMs ?? 0).toFixed(1),
563567
insertMs: +(p.insertMs ?? 0).toFixed(1),
564568
resolveMs: +(p.resolveMs ?? 0).toFixed(1),

src/domain/graph/builder/stages/collect-files.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import fs from 'node:fs';
99
import path from 'node:path';
10+
import { performance } from 'node:perf_hooks';
1011
import { debug, info } from '../../../../infrastructure/logger.js';
1112
import { normalizePath } from '../../../../shared/constants.js';
1213
import { readJournal } from '../../journal.js';
@@ -89,42 +90,60 @@ export async function collectFiles(ctx: PipelineContext): Promise<void> {
8990
const { rootDir, config, opts } = ctx;
9091

9192
if (opts.scope) {
92-
// Scoped rebuild: rebuild only specified files
93+
// Scoped rebuild: rebuild only specified files.
94+
//
95+
// Timer only wraps the filesystem-walk portion (existence checks + file
96+
// list construction). Change-detection outputs (parseChanges, removed,
97+
// isFullBuild) are attributed to detectMs for semantic consistency with
98+
// the non-scoped path, even though this stage computes them.
99+
const start = performance.now();
93100
const scopedFiles = opts.scope.map((f: string) => normalizePath(f));
94101
const existing: Array<{ file: string; relPath: string }> = [];
95102
const missing: string[] = [];
96-
for (const rel of scopedFiles) {
97-
const abs = path.join(rootDir, rel);
98-
if (fs.existsSync(abs)) {
99-
existing.push({ file: abs, relPath: rel });
100-
} else {
101-
missing.push(rel);
103+
try {
104+
for (const rel of scopedFiles) {
105+
const abs = path.join(rootDir, rel);
106+
if (fs.existsSync(abs)) {
107+
existing.push({ file: abs, relPath: rel });
108+
} else {
109+
missing.push(rel);
110+
}
102111
}
112+
ctx.allFiles = existing.map((e) => e.file);
113+
ctx.discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
114+
} finally {
115+
ctx.timing.collectMs = performance.now() - start;
103116
}
104-
ctx.allFiles = existing.map((e) => e.file);
105-
ctx.discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
117+
// Change-detection outputs — timed under detectMs for semantic parity.
118+
const detectStart = performance.now();
106119
ctx.parseChanges = existing;
107120
ctx.metadataUpdates = [];
108121
ctx.removed = missing;
109122
ctx.isFullBuild = false;
123+
ctx.timing.detectMs = (ctx.timing.detectMs ?? 0) + (performance.now() - detectStart);
110124
info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
111125
return;
112126
}
113127

114-
// Incremental fast path: reconstruct file list from DB + journal deltas
115-
// instead of full recursive filesystem scan (~8ms savings on 473 files).
116-
if (ctx.incremental && !ctx.forceFullRebuild) {
117-
const fast = tryFastCollect(ctx);
118-
if (fast) {
119-
ctx.allFiles = fast.files;
120-
ctx.discoveredDirs = fast.directories;
121-
info(`Found ${ctx.allFiles.length} files (cached)`);
122-
return;
128+
const start = performance.now();
129+
try {
130+
// Incremental fast path: reconstruct file list from DB + journal deltas
131+
// instead of full recursive filesystem scan (~8ms savings on 473 files).
132+
if (ctx.incremental && !ctx.forceFullRebuild) {
133+
const fast = tryFastCollect(ctx);
134+
if (fast) {
135+
ctx.allFiles = fast.files;
136+
ctx.discoveredDirs = fast.directories;
137+
info(`Found ${ctx.allFiles.length} files (cached)`);
138+
return;
139+
}
123140
}
124-
}
125141

126-
const collected = collectFilesUtil(rootDir, [], config, new Set<string>());
127-
ctx.allFiles = collected.files;
128-
ctx.discoveredDirs = collected.directories;
129-
info(`Found ${ctx.allFiles.length} files to parse`);
142+
const collected = collectFilesUtil(rootDir, [], config, new Set<string>());
143+
ctx.allFiles = collected.files;
144+
ctx.discoveredDirs = collected.directories;
145+
info(`Found ${ctx.allFiles.length} files to parse`);
146+
} finally {
147+
ctx.timing.collectMs = performance.now() - start;
148+
}
130149
}

src/domain/graph/builder/stages/detect-changes.ts

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import fs from 'node:fs';
99
import path from 'node:path';
10+
import { performance } from 'node:perf_hooks';
1011
import { closeDb } from '../../../../db/index.js';
1112
import { debug, info } from '../../../../infrastructure/logger.js';
1213
import { normalizePath } from '../../../../shared/constants.js';
@@ -512,59 +513,66 @@ function handleIncrementalBuild(ctx: PipelineContext): void {
512513
}
513514

514515
export async function detectChanges(ctx: PipelineContext): Promise<void> {
515-
const { db, allFiles, rootDir, incremental, forceFullRebuild, opts } = ctx;
516-
if ((opts as Record<string, unknown>).scope) {
517-
handleScopedBuild(ctx);
518-
return;
519-
}
520-
const increResult =
521-
incremental && !forceFullRebuild
522-
? getChangedFiles(db, allFiles, rootDir)
523-
: {
524-
changed: allFiles.map((f): ChangedFile => ({ file: f })),
525-
removed: [] as string[],
526-
isFullBuild: true,
527-
};
528-
ctx.removed = increResult.removed;
529-
ctx.isFullBuild = increResult.isFullBuild;
530-
ctx.parseChanges = increResult.changed
531-
.filter((c) => !c.metadataOnly)
532-
.map((c) => ({
533-
file: c.file,
534-
relPath: c.relPath,
535-
content: c.content,
536-
hash: c.hash,
537-
stat: c.stat ? { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size } : undefined,
538-
_reverseDepOnly: c._reverseDepOnly,
539-
}));
540-
ctx.metadataUpdates = increResult.changed
541-
.filter(
542-
(c): c is ChangedFile & { relPath: string; hash: string; stat: FileStat } =>
543-
!!c.metadataOnly && !!c.relPath && !!c.hash && !!c.stat,
544-
)
545-
.map((c) => ({
546-
relPath: c.relPath,
547-
hash: c.hash,
548-
stat: { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size },
549-
}));
550-
if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
551-
const ranAnalysis = await runPendingAnalysis(ctx);
552-
if (ranAnalysis) {
516+
const start = performance.now();
517+
try {
518+
const { db, allFiles, rootDir, incremental, forceFullRebuild, opts } = ctx;
519+
if ((opts as Record<string, unknown>).scope) {
520+
handleScopedBuild(ctx);
521+
return;
522+
}
523+
const increResult =
524+
incremental && !forceFullRebuild
525+
? getChangedFiles(db, allFiles, rootDir)
526+
: {
527+
changed: allFiles.map((f): ChangedFile => ({ file: f })),
528+
removed: [] as string[],
529+
isFullBuild: true,
530+
};
531+
ctx.removed = increResult.removed;
532+
ctx.isFullBuild = increResult.isFullBuild;
533+
ctx.parseChanges = increResult.changed
534+
.filter((c) => !c.metadataOnly)
535+
.map((c) => ({
536+
file: c.file,
537+
relPath: c.relPath,
538+
content: c.content,
539+
hash: c.hash,
540+
stat: c.stat ? { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size } : undefined,
541+
_reverseDepOnly: c._reverseDepOnly,
542+
}));
543+
ctx.metadataUpdates = increResult.changed
544+
.filter(
545+
(c): c is ChangedFile & { relPath: string; hash: string; stat: FileStat } =>
546+
!!c.metadataOnly && !!c.relPath && !!c.hash && !!c.stat,
547+
)
548+
.map((c) => ({
549+
relPath: c.relPath,
550+
hash: c.hash,
551+
stat: { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size },
552+
}));
553+
if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
554+
const ranAnalysis = await runPendingAnalysis(ctx);
555+
if (ranAnalysis) {
556+
closeDb(db);
557+
writeJournalHeader(rootDir, Date.now());
558+
ctx.earlyExit = true;
559+
return;
560+
}
561+
healMetadata(ctx);
562+
info('No changes detected. Graph is up to date.');
553563
closeDb(db);
554564
writeJournalHeader(rootDir, Date.now());
555565
ctx.earlyExit = true;
556566
return;
557567
}
558-
healMetadata(ctx);
559-
info('No changes detected. Graph is up to date.');
560-
closeDb(db);
561-
writeJournalHeader(rootDir, Date.now());
562-
ctx.earlyExit = true;
563-
return;
564-
}
565-
if (ctx.isFullBuild) {
566-
handleFullBuild(ctx);
567-
} else {
568-
handleIncrementalBuild(ctx);
568+
if (ctx.isFullBuild) {
569+
handleFullBuild(ctx);
570+
} else {
571+
handleIncrementalBuild(ctx);
572+
}
573+
} finally {
574+
// Additive to respect any partial detectMs contribution from collectFiles
575+
// (scoped-rebuild path splits change-detection outputs across both stages).
576+
ctx.timing.detectMs = (ctx.timing.detectMs ?? 0) + (performance.now() - start);
569577
}
570578
}

src/types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1033,7 +1033,23 @@ export interface PipelineContext {
10331033
lineCountMap: Map<string, number>;
10341034

10351035
// Phase timing
1036-
timing: Record<string, number>;
1036+
timing: {
1037+
setupMs?: number;
1038+
collectMs?: number;
1039+
detectMs?: number;
1040+
parseMs?: number;
1041+
insertMs?: number;
1042+
resolveMs?: number;
1043+
edgesMs?: number;
1044+
structureMs?: number;
1045+
rolesMs?: number;
1046+
astMs?: number;
1047+
complexityMs?: number;
1048+
cfgMs?: number;
1049+
dataflowMs?: number;
1050+
finalizeMs?: number;
1051+
[key: string]: number | undefined;
1052+
};
10371053
buildStart: number;
10381054
}
10391055

@@ -1053,6 +1069,8 @@ export interface BuildGraphOpts {
10531069
export interface BuildResult {
10541070
phases: {
10551071
setupMs: number;
1072+
collectMs: number;
1073+
detectMs: number;
10561074
parseMs: number;
10571075
insertMs: number;
10581076
resolveMs: number;

tests/builder/pipeline.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ describe('buildGraph pipeline', () => {
3131
expect(result).toBeDefined();
3232
expect(result.phases).toBeDefined();
3333
expect(typeof result.phases.setupMs).toBe('number');
34+
expect(typeof result.phases.collectMs).toBe('number');
35+
expect(result.phases.collectMs).toBeGreaterThanOrEqual(0);
36+
expect(typeof result.phases.detectMs).toBe('number');
37+
expect(result.phases.detectMs).toBeGreaterThanOrEqual(0);
3438
expect(typeof result.phases.parseMs).toBe('number');
3539
expect(typeof result.phases.insertMs).toBe('number');
3640
expect(typeof result.phases.resolveMs).toBe('number');

0 commit comments

Comments
 (0)