perf: reuse caller stat in fileBelongsToTeam, drop duplicate fs.stat
On the live resolution path collectRootJsonlSessionIds already stat()s each root jsonl for its mtime-window filter, then fileBelongsToTeam stat()ed the very same file again for its cache validation -- two fs.stat syscalls (plus two Stats allocations) per file, every poll. fileBelongsToTeam now takes an optional precomputed stat and the mtime-filter caller passes the stat it already has, so the file is statted once. Measured 20 files -> 20 stat calls on the mtime path (was ~40). Using a single stat snapshot is also slightly more consistent than two reads that could straddle a concurrent write. The other call site (subagent scan) passes no stat and is unchanged (fileBelongsToTeam stats it itself). Adds a regression test that a caller-supplied stat is the one recorded in the affinity cache.
This commit is contained in:
parent
635321dd9a
commit
776298b0e3
2 changed files with 54 additions and 7 deletions
|
|
@ -966,17 +966,19 @@ export class TeamTranscriptProjectResolver {
|
|||
while (nextIndex < rootJsonlEntries.length) {
|
||||
const entry = rootJsonlEntries[nextIndex++];
|
||||
const filePath = path.join(projectDir, entry.name);
|
||||
let precomputedStat: { mtimeMs: number; size: number; isFile: () => boolean } | undefined;
|
||||
if (mtimeSinceMs != null) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile() || stat.mtimeMs < mtimeSinceMs) {
|
||||
continue;
|
||||
}
|
||||
precomputedStat = stat;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!(await this.fileBelongsToTeam(filePath, teamName))) {
|
||||
if (!(await this.fileBelongsToTeam(filePath, teamName, precomputedStat))) {
|
||||
continue;
|
||||
}
|
||||
discovered.add(entry.name.slice(0, -'.jsonl'.length));
|
||||
|
|
@ -1008,17 +1010,29 @@ export class TeamTranscriptProjectResolver {
|
|||
return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName, mtimeSinceMs);
|
||||
}
|
||||
|
||||
private async fileBelongsToTeam(filePath: string, teamName: string): Promise<boolean> {
|
||||
private async fileBelongsToTeam(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean }
|
||||
): Promise<boolean> {
|
||||
const normalizedTeam = teamName.trim().toLowerCase();
|
||||
if (!normalizedTeam) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reuse the caller's stat when it already statted this exact file (the mtime-window
|
||||
// filter in collectRootJsonlSessionIds does). On the live resolution path this drops
|
||||
// a second fs.stat of the same file per entry, every poll — and using a single stat
|
||||
// snapshot is also more consistent than two reads that could straddle a write.
|
||||
let fileStat: { mtimeMs: number; size: number; isFile: () => boolean };
|
||||
try {
|
||||
fileStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
if (precomputedStat) {
|
||||
fileStat = precomputedStat;
|
||||
} else {
|
||||
try {
|
||||
fileStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
|
|
|
|||
|
|
@ -637,7 +637,11 @@ describe('TeamTranscriptProjectResolver', () => {
|
|||
headWindowFull: boolean;
|
||||
};
|
||||
type ResolverProbe = {
|
||||
fileBelongsToTeam: (filePath: string, teamName: string) => Promise<boolean>;
|
||||
fileBelongsToTeam: (
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean }
|
||||
) => Promise<boolean>;
|
||||
buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string;
|
||||
teamAffinityFileCache: Map<string, AffinityCacheEntry>;
|
||||
};
|
||||
|
|
@ -716,4 +720,33 @@ describe('TeamTranscriptProjectResolver', () => {
|
|||
expect(second?.belongsToTeam).toBe(true);
|
||||
expect(second!.size).toBeGreaterThan(sizeAfterFirst); // re-scanned + re-cached
|
||||
});
|
||||
|
||||
// Regression: when the caller already statted the file (the mtime-window filter in
|
||||
// collectRootJsonlSessionIds), fileBelongsToTeam must reuse that stat rather than
|
||||
// issuing a second fs.stat of the same file. Proven without mocking fs: a precomputed
|
||||
// stat with a deliberately distinct size/mtime must be the one recorded in the cache.
|
||||
it('reuses a caller-supplied stat instead of re-statting the file', async () => {
|
||||
await setupClaudeRoot();
|
||||
const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe;
|
||||
const team = 'absent-team';
|
||||
const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/precomp'));
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const jsonlPath = path.join(projectDir, 'f.jsonl');
|
||||
await fs.writeFile(
|
||||
jsonlPath,
|
||||
`${Array.from({ length: 45 }, (_, i) =>
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: `x ${i}` } })
|
||||
).join('\n')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Distinct sentinel values the real file does not have.
|
||||
const precomputedStat = { mtimeMs: 123_456, size: 999_999, isFile: () => true };
|
||||
expect(await resolver.fileBelongsToTeam(jsonlPath, team, precomputedStat)).toBe(false);
|
||||
|
||||
const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team);
|
||||
const entry = resolver.teamAffinityFileCache.get(key);
|
||||
expect(entry?.size).toBe(999_999); // cache recorded the precomputed stat -> no re-stat
|
||||
expect(entry?.mtimeMs).toBe(123_456);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue