Skip to content

fix(cdn): serialize builds per project to stop concurrent-build corruption#154

Merged
ABB65 merged 2 commits into
mainfrom
fix/cdn-build-serialization
Jul 16, 2026
Merged

fix(cdn): serialize builds per project to stop concurrent-build corruption#154
ABB65 merged 2 commits into
mainfrom
fix/cdn-build-serialization

Conversation

@ABB65

@ABB65 ABB65 commented Jul 16, 2026

Copy link
Copy Markdown
Member

Stacked on #153 — this branch is based on fix/cdn-skip-content-less-builds, so the diff currently includes #153's commit too. Merge #153 first, then this (the catch-up relies on #153's content-less-push no-op). Rebase onto main after #153 lands.

Problem

Both CDN build triggers — the GitHub push webhook (server/api/webhooks/github.post.ts) and the manual "Rebuild now" endpoint (.../cdn/builds/trigger.post.ts) — ran executeCDNBuild fire-and-forget with no coordination. Two builds for one project could run concurrently and race on the shared R2 namespace. In particular the manual full rebuild's "delete every object not in this build" cleanup can wipe a concurrent webhook build's fresh uploads, stranding content until the next rebuild. Observed on staging: a manual build (19:36–19:37) overlapping a webhook build (19:37–19:38).

Fix — one atomic claim, cross-instance + crash safe

Production runs on managed+postgres and can scale to multiple Nitro instances, so an in-process lock is insufficient. Both triggers now go through a single atomic gate:

  • supabase/migrations/019_cdn_build_single_inflight.sqlclaim_cdn_build(...): a SECURITY DEFINER function that takes a per-project transaction-scoped advisory lock (released when the claim commits — never held for the build), reclaims slots whose build died mid-flight (>15 min), and inserts a new building row only if none is in flight. Returns the id, or NULL when blocked. Mirrors increment_mcp_cloud_usage_if_allowed; shared migration lineage (both provider pairs).
  • DatabaseProvider.createCDNBuildPromise<DatabaseRow | null> — both impls (postgres-db/cdn.ts via sql, supabase-db/cdn.ts via rpc) call the function. null = a build is already running.
  • Webhook skips on null (the running build's catch-up chases this commit). Manual returns 409 (cdn.build_in_progress).
  • server/utils/cdn-build-runner.ts — shared completion path: persists the result, then does a bounded catch-up — if the branch head advanced while the build ran (a mid-build push turned away by the claim), it claims and runs one more full rebuild at the new head. Serialization makes those full rebuilds safe.

Validation

  • Against a throwaway postgres:16 (full lineage applied): claim returns an id → second concurrent claim returns null → a >15-min-stale slot is reclaimed (old row failed) and re-claimed → exactly one in-flight row remains. pnpm db:verify:pg green.
  • Unit (cdn-build-runner.test.ts): catch-up chases an advanced head, stops when stable, stops when the follow-up claim is blocked, and never runs after a failed build.
  • Integration: webhook skips when the claim is blocked; manual returns 409 when blocked.
  • Full suite 1068 green, typecheck + lint clean.

Staging verification (post-merge)

  • Two rapid content saves → one build at a time; the second is picked up by catch-up (no content loss).
  • "Rebuild now" while a webhook build runs → 409, no overlap. cdn_builds shows no overlapping building rows.

Contentrain added 2 commits July 16, 2026 23:11
…bundle

A webhook build for a push that changes no content models (a pure code
push) still ran the full pipeline: it re-uploaded `_manifest.json` with
the new commit SHA but skipped the locale-bundle block (which only runs
when a model is affected). That left `_manifest.json.commitSha` ahead of
every `_bundle/*.json`, and CDN consumers that key content freshness off
the manifest rendered stale/empty content until a manual full rebuild
re-aligned the two — reproduced on staging (Lanista/collabers), where
every 22-file code-only webhook build stranded content and forced an
88-file manual rebuild, while 28-file content builds were always fine.

The manifest tracks the CONTENT version, so a content-less push must not
bump it. Early-return a 0-file no-op from executeCDNBuild when a
selective build resolves zero affected models. Manual rebuilds
(fullRebuild) and config/model-def changes never reach this branch, so
they are unaffected; the same push also stops wasting a build cycle.
…ption

Two CDN build triggers (the GitHub push webhook and the manual "Rebuild
now" endpoint) ran fire-and-forget with no coordination, so two builds
for one project could run at once and race on the shared R2 namespace —
a manual full rebuild's "delete every object not in this build" cleanup
can wipe a concurrent webhook build's fresh uploads, stranding content.

Gate both triggers through a single atomic claim: `createCDNBuild` now
calls the `claim_cdn_build` SQL function (per-project transaction-scoped
advisory lock + stale-slot reclaim + conditional insert) and returns the
new row, or null when a build is already in flight. The webhook skips on
null; the manual trigger returns 409. Cross-instance safe (single
Postgres), crash-safe (a dead build's slot is reclaimed after 15 min),
and provider-agnostic (both DatabaseProvider impls call the same
function, mirroring increment_mcp_cloud_usage_if_allowed).

A shared runner (server/utils/cdn-build-runner.ts) persists each build's
result and performs a bounded catch-up: if the branch head advanced
while the build ran (a mid-build push whose own webhook was turned away
by the claim), it claims and runs one more build at the new head, so no
push is stranded. Serialization makes catch-up's full rebuilds safe.

Validated against a real postgres:16: the claim returns an id, blocks
the second concurrent claim (null), reclaims a >15-min-stale slot, and
keeps exactly one in-flight row. db:verify:pg + full suite green.
@ABB65
ABB65 merged commit 199f61e into main Jul 16, 2026
2 checks passed
@ABB65
ABB65 deleted the fix/cdn-build-serialization branch July 16, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant