perf(session): gate blob-ref resolution behind synchronous precheck

resolveBlobRefsInEntries handed every non-session entry to the recursive
async resolvePersistedBlobRefs walk, allocating and awaiting child promises
even for plain-text entries with no blob:sha256: refs. On large text-heavy
histories this dominated the blob_resolve phase of session open.

Add a cheap synchronous containsBlobRef precheck that early-exits on the
first ref and allocates nothing. Interleave the precheck with per-entry
initiation so positive entries still start resolution at the same relative
point as the old filter+map schedule (a later entry that gains a ref during
an earlier BlobStore.get is still scanned after that mutation).

Blob-free N=5000 fixture: blob_resolve median 19.5ms -> 1.1ms, zero
BlobStore.get calls.

Fixes #5922
This commit is contained in:
roboomp
2026-07-17 23:25:16 +00:00
parent 48241afcc4
commit 1e209caeee
3 changed files with 77 additions and 3 deletions
@@ -271,10 +271,38 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
);
}
/**
* Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string?
* Early-exits on the first hit and allocates no promises, so blob-free entries skip the
* async {@link resolvePersistedBlobRefs} descent entirely. Conservative — a blob ref in a
* non-resolved position still returns true, which only costs an extra (no-op) walk.
*/
function containsBlobRef(value: unknown): boolean {
if (typeof value === "string") return isBlobRef(value);
if (Array.isArray(value)) {
for (const item of value) {
if (containsBlobRef(item)) return true;
}
return false;
}
if (typeof value !== "object" || value === null) return false;
for (const key in value) {
if (containsBlobRef((value as Record<string, unknown>)[key])) return true;
}
return false;
}
export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
await Promise.all(
entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)),
);
const pending: Promise<void>[] = [];
// Interleave precheck + initiation per entry so a positive entry begins resolution at the same
// relative point as the old filter+map schedule (no scan-all-first pass that could observe a
// later entry before an earlier resolution mutates it).
for (const entry of entries) {
if (entry.type === "session") continue;
if (!containsBlobRef(entry)) continue;
pending.push(resolvePersistedBlobRefs(entry, blobStore));
}
await Promise.all(pending);
}
/**