From f92ea9605d7e029573de4b6b07114af8894f9de2 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 28 Aug 2026 14:24:05 -0300 Subject: [PATCH] perf(decofile): make in-commit blocks.gen.json regen cache-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decofile commit path regenerates a committed .deco/blocks.gen.json inside every save commit. It fetched every unchanged block via client.getBlobText (no disk cache) in an unbounded Promise.all — on a large repo (e.g. ~325 blocks) that is hundreds of uncached GitHub round trips per save, adding several seconds of latency before the preview can load. Align the regen with the read path (resolveSnapshot): - Read unchanged blocks disk-cache-first via a shared getBlockText, with bounded concurrency (BLOB_FETCH_CONCURRENCY). The editor's read just primed those blobs, so a same-replica save is mostly cache hits; a cold replica fetches at most N at a time instead of stampeding GitHub's abuse limiter. - After the commit lands, prime the merged-doc cache (primeMergedSnapshot) with the just-computed document — byte-identical to what resolveSnapshot produces at that sha — so the preview read immediately after a save is a disk hit rather than a fresh tree read + re-merge. Only affects repos that commit blocks.gen.json; gitignored repos (the common case) never enter the regen branch and are unchanged. Adds an e2e test asserting the regen replaces a stale committed artifact with a full re-merge of the block set. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/decofile/commit-coalescer.ts | 32 +++++++++++--- apps/api/src/decofile/read-decofile.ts | 38 ++++++++++++++++- packages/e2e/tests/decofile-api.spec.ts | 52 +++++++++++++++++++++++ 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/apps/api/src/decofile/commit-coalescer.ts b/apps/api/src/decofile/commit-coalescer.ts index dfad203935..de24e587a4 100644 --- a/apps/api/src/decofile/commit-coalescer.ts +++ b/apps/api/src/decofile/commit-coalescer.ts @@ -8,9 +8,13 @@ import type { GitDataClient, TreeWriteEntry } from "./github-git-data"; import { GitHubApiError } from "./github-git-data"; import { aliasPathsForKey, + BLOB_FETCH_CONCURRENCY, blockEntriesInTree, blocksDirPath, + getBlockText, + mapBounded, primeBlobCache, + primeMergedSnapshot, resolveOrCreateHead, } from "./read-decofile"; @@ -171,14 +175,19 @@ async function commitBatch(batch: Batch): Promise { const genPath = packagePath ? `${packagePath}/.deco/blocks.gen.json` : ".deco/blocks.gen.json"; + // Non-null after an in-commit regen, so it can prime the read cache below. + let genContent: string | null = null; if (tree.some((e) => e.type === "blob" && e.path === genPath)) { - const files = await Promise.all( - [...nextBlocks.values()].map(async (b) => ({ + // Disk-cache-first + bounded (see getBlockText), mirroring the read path. + const files = await mapBounded( + [...nextBlocks.values()], + BLOB_FETCH_CONCURRENCY, + async (b) => ({ stem: b.stem, - content: b.content ?? (await client.getBlobText(b.sha as string)), - })), + content: b.content ?? (await getBlockText(client, b.sha as string)), + }), ); - const { decofile: genContent, skipped } = mergeBlocks(files); + const { decofile, skipped } = mergeBlocks(files); if (skipped.length > 0) { console.warn("decofile gen: dropped blocks that were not valid JSON", { repo: `${client.owner}/${client.repo}`, @@ -187,13 +196,14 @@ async function commitBatch(batch: Batch): Promise { blocks: skipped.map((s) => s.key), }); } - const genBlobSha = await client.createBlob(genContent); + const genBlobSha = await client.createBlob(decofile); writes.push({ path: genPath, mode: "100644", type: "blob", sha: genBlobSha, }); + genContent = decofile; } const newTreeSha = await client.createTree(baseTreeSha, writes); @@ -204,6 +214,16 @@ async function commitBatch(batch: Batch): Promise { }); try { await client.updateRef(branch, commitSha); + // The read after this save then hits the merged doc on disk (fail-open). + if (genContent !== null) { + await primeMergedSnapshot( + client.owner, + client.repo, + commitSha, + packagePath, + genContent, + ); + } return commitSha; } catch (err) { // Non-fast-forward: someone else advanced the branch between our head diff --git a/apps/api/src/decofile/read-decofile.ts b/apps/api/src/decofile/read-decofile.ts index e6e99bdad0..4086c592bd 100644 --- a/apps/api/src/decofile/read-decofile.ts +++ b/apps/api/src/decofile/read-decofile.ts @@ -59,7 +59,7 @@ export async function resolveOrCreateHead( * stampedes the connection pool, flakes into timeouts, and can trip GitHub's * secondary (abuse) rate limit. Cold reads are rare — the blob cache makes * every subsequent read fetch only what changed. */ -const BLOB_FETCH_CONCURRENCY = 12; +export const BLOB_FETCH_CONCURRENCY = 12; /** Above this many cache-missing blobs, a single tarball download beats * per-blob API calls (rate-limit- and latency-wise). */ @@ -188,6 +188,23 @@ export function primeBlobCache( return putBlob(owner, repo, blobSha, content); } +/** + * A block blob's text, disk-cache-first (mirrors `resolveSnapshot`'s tail + * fetch). Writers share this so the in-commit `blocks.gen.json` regeneration + * reads the warm blobs the editor's read just primed, instead of re-fetching + * every block from GitHub uncached. A miss fetches once and writes through. + */ +export async function getBlockText( + client: GitDataClient, + sha: string, +): Promise { + const hit = await getBlob(client.owner, client.repo, sha); + if (hit !== null) return hit; + const content = await client.getBlobText(sha); + await putBlob(client.owner, client.repo, sha, content); + return content; +} + export interface DecofileSnapshot { /** Branch head commit sha — the version everywhere (ETag, __draft, response). */ sha: string; @@ -211,6 +228,25 @@ function mergedDocSha(sha: string, packagePath: string | null): string { .digest("hex"); } +/** + * Prime the merged-doc disk cache for `(sha, packagePath)` with a document the + * writer already computed in-commit, so the preview read right after a save is + * a disk hit instead of a tree read + full re-merge. Keyed identically to + * `resolveSnapshot`'s own writes (same `mergedDocSha`/format), and the writer's + * `mergeBlocks` output is byte-for-byte what `resolveSnapshot` would produce at + * that sha, so a later read is served correct. Fail-open (`putMerged` never + * throws): a cache miss just recomputes. + */ +export function primeMergedSnapshot( + owner: string, + repo: string, + sha: string, + packagePath: string | null, + decofile: string, +): Promise { + return putMerged(owner, repo, mergedDocSha(sha, packagePath), decofile); +} + /** Concurrent snapshot reads of the same content share ONE resolution — this * exists for the thundering herd on cold reads (spec §Cold read) but wraps * warm reads too (cheap). Keyed by content, not branch: the branch → sha diff --git a/packages/e2e/tests/decofile-api.spec.ts b/packages/e2e/tests/decofile-api.spec.ts index 67f5b87990..f43c66d252 100644 --- a/packages/e2e/tests/decofile-api.spec.ts +++ b/packages/e2e/tests/decofile-api.spec.ts @@ -561,6 +561,58 @@ test.describe("decofile API", () => { } }); + test("PATCH regenerates a committed blocks.gen.json from the full block set", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + try { + const user = await signUpViaApi(ctx); + const org = user.orgSlug; + const owner = uniqueOwner(); + const repo = "site"; + + const oldHero = { __resolveType: "site/sections/Hero.tsx", title: "old" }; + const footer = { __resolveType: "site/sections/Footer.tsx", year: 2024 }; + await seedStubRepo(ctx, { + owner, + repo, + defaultBranch: "main", + branches: { + main: { + files: { + ".deco/blocks/Hero.json": blockFileContent(oldHero), + ".deco/blocks/Footer.json": blockFileContent(footer), + // Deliberately stale (no Footer, pre-edit Hero): a pass proves a full re-merge. + ".deco/blocks.gen.json": `${JSON.stringify({ Hero: oldHero })}\n`, + }, + }, + draft: null, + }, + }); + const project = await createFastPreviewProject(ctx, org, { owner, repo }); + const url = decofileUrl(project, "draft"); + + const newHero = { __resolveType: "site/sections/Hero.tsx", title: "new" }; + const patchRes = await ctx.patch(url, { + data: { set: { Hero: newHero } }, + }); + expect(patchRes.status()).toBe(200); + + const inspected = await inspectStubRepo(ctx, owner, repo); + const genRaw = + inspected.branches["draft"]?.files[".deco/blocks.gen.json"]; + expect(genRaw).toBeDefined(); + const gen = JSON.parse(genRaw as string) as Record; + // Edited Hero + untouched Footer: the stale seed was replaced, not key-patched. + expect(gen["Hero"]).toEqual(newHero); + expect(gen["Footer"]).toEqual(footer); + // Keys sorted by filename (Footer.json < Hero.json), matching the daemon. + expect(Object.keys(gen)).toEqual(["Footer", "Hero"]); + } finally { + await ctx.dispose(); + } + }); + test("large repo whose recursive tree read truncates still reads and writes blocks", async ({ playwright, }) => {