[blob-store 1/5] Bucket-first serving: versioned, atomic, edge-cacheable blobs - #2
Closed
chondl wants to merge 8 commits into
Closed
[blob-store 1/5] Bucket-first serving: versioned, atomic, edge-cacheable blobs#2chondl wants to merge 8 commits into
chondl wants to merge 8 commits into
Conversation
Pull the /team/{num} and /team/{num}/{year} payload shaping out of the
route bodies into reusable helpers (verified byte-identical responses)
so the blob export and historical backfill can produce payloads
identical to the API. Adds the HIST_EPOCH constant that versions the
immutable historical blob path.
write_objs now renders the current-year blob set, uploads only blobs
whose content hash changed (copy-on-write, to immutable content-addressed
v2/{path}.{hash} keys with long-lived Cache-Control), and writes
manifest.json last so readers always resolve a complete old or new set,
never a torn mix. Adds a team/{num} blob per active team. Legacy
unversioned paths are still written each cycle for compatibility with
the deployed frontend. The lossy str(Event) upload gate is replaced by
the content hash, which also stops event blobs going stale between
match updates.
One-time script exporting team_years/{year}, events/{year}, event/{key}
and team/{num}/{year} for every past season (2021 skipped) to the
immutable hist/{HIST_EPOCH} path, using the shared site _read_* helpers.
Idempotent per object and resumable via a bucket-side progress
checkpoint.
chondl
force-pushed
the
bucket-first-serving
branch
from
July 10, 2026 04:59
7608d11 to
e7f8a83
Compare
This was referenced Jul 10, 2026
Blob URLs now resolve through manifest.json: immutable versioned URLs for the current-year set, epoch-prefixed paths for historical years, with no per-request cache-buster. When no manifest exists (backend not yet deployed) fetches fall back to the legacy path plus ?t= buster, and the backend keeps writing legacy paths, so either deploy order works. getTeam, getYearTeamYears, and historical getTeamYear become bucket-first; the two team-match fetches read the event blob and team-year payload instead of API-only endpoints. Expired IndexedDB entries are retained and served as a last resort when both bucket and API fail, instead of rendering an empty page.
chondl
force-pushed
the
bucket-first-serving
branch
from
July 10, 2026 07:05
e7f8a83 to
032db39
Compare
F1/F2: the event blob embeds the full year object, whose stats churn every in-season cycle, so content-addressing re-uploaded all ~215 event blobs each cycle (defeating the immutable edge cache). Render an event blob only when its own event/match/team_event content changes (NaN-stable comparison); unchanged events carry forward their prior versioned key and stale year snapshot.
…params A7: a single failed manifest.json fetch no longer pins every client to the uncached legacy ?t= path for a full 60s TTL (a null result is dropped from the cache so the next call retries). toLogicalPath now replaces all ? and & so blob keys with 3+ query params resolve instead of missing to the backend.
… wave
- getMatch: derive the match view from the (bucket-first, edge-cached) event
blob — the match key encodes its event, and the event blob already carries the
match + its team_matches + team_events. Falls back to the /match API when the
blob lacks the match. Removes the Cloud-Run round trip (and its cold-start
tail) from the match page's critical path.
- getTeamYear: start the event-blob fetches as soon as team_to_events resolves
instead of gating them on the whole metadata Promise.all, which included the
large team_years/{year} blob the event wave does not need.
The not-found placeholder waited 8000ms before rendering, leaving a blank content area for 8 full seconds on any nonexistent or slow-to-load entity (/team/99999, /event/2026zzzzz, etc.). The debounce exists only to avoid a flash of the not-found message before data arrives; 1.5s is ample for that while no longer looking like a hung page. (The comment already described the intent as 'one second'; the 8000 value grew from 1000 over prior commits.)
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bucket-first serving: versioned, atomic, edge-cacheable blobs
Problem
Team and event pages mix static GCS blob reads with live API calls. When the database
layer went down (~2026-06-12, avgupta456#414: every DB-backed route returns
500 {}), the blob-backedviews kept working and the API-backed views broke. On healthy days, the API-backed views
queue on the two F1 instances during event weekends. Separately, bucket fetches append
?t=${Date.now()/1000/60}— not floored, so the query string is unique per request anddefeats GCS/browser caching entirely — and blob uploads are sequential and non-atomic, so a
reader mid-cycle can get a mix of old and new blobs.
This PR serves every team-page and event-page view from blobs with the API as fallback
only, and makes the blob set atomic, immutable, and edge-cacheable. It follows the
direction the outage validated: the blob layer is the resilient half, so lean on it.
What changed and why
Manifest-last atomic publishing (
src/google/storage.py,src/google/publish.py).Uploading N blobs sequentially can never be atomic by itself: a crash or a concurrent
reader mid-cycle observes a torn set. Instead, each cycle uploads changed blobs to new
keys and then writes
manifest.json(logical path → versioned key) as the final step.Readers resolve every blob URL through the manifest, so they see the complete old set or
the complete new set, never a mix; a crash before the manifest write simply leaves the old
manifest pointing at the old set, and the next successful cycle recovers.
Content addressing + copy-on-write. Versioned keys are
v2/{path}.{sha256[:12]}of thecompressed payload, served with
Cache-Control: public, max-age=31536000, immutable. A blobis uploaded only when its content hash differs from what the previous manifest referenced,
so unchanged blobs keep a stable URL — edge caches keep absorbing event-weekend load — and
per-cycle upload volume is proportional to what actually changed. This replaces the lossy
str(Event)upload gate, which compared hand-picked fields and let event blobs go stale forweeks during registration windows (EPAs frozen at the last registration change). The
manifest itself is the only short-TTL fetch (
max-age=60).Content addressing alone is not enough for the
event/{key}blobs: each embeds a snapshotof the year object, whose stats (percentiles, counts, means) drift every partial cycle
in-season, so a purely content-hashed event blob would get a fresh hash and re-upload all
~215 event blobs every cycle — churning exactly the edge cache this design exists to warm.
An event blob is therefore rendered only when its own event/match/team_event content
changes (compared NaN-stably against the cycle-start state); unchanged events carry their
prior versioned key forward through the manifest. Measured on the rig: perturbing only the
year stats between two cycles uploads 0 event blobs (was 215 without the gate); mutating
one match's score uploads exactly 1.
Per-team
team/{num}blobs. The team page's/team/{num}fetch was API-only, so itdied with the database and queued on F1 during peaks. Each cycle now renders a blob per
active team (team info + all-years history) from the same
_read_teamhelper the routeuses, gated by the content hash so only teams whose payload changed get re-uploaded.
Historical blobs at
hist/{epoch}/{path}(backfill_blobs.py). Historical payloadsnever change after backfill, so content-addressing buys nothing there — and listing ~100K
per-team-year objects in the manifest would make the short-TTL fetch megabytes. Historical
blobs instead use a deterministic epoch-prefixed path; the epoch is a single field in the
manifest, so one manifest fetch resolves both current and historical URLs. The backfill
script is idempotent (skips existing objects), resumable (bucket-side progress checkpoint),
uses the same
_read_*helpers as the live routes, and skips 2021 (no season).Frontend bucket-first + stale-if-error (
frontend/src/api/).storage.tsxfetches themanifest (once per 60 s, deduped in-flight) and resolves immutable URLs with no cache-buster.
getTeam,getYearTeamYears(all years), and historicalgetTeamYearbecome bucket-first;the two team-match figure fetches now read the event blob and the team-year payload (which
already contain the data) instead of API-only endpoints. When both bucket and API fail,
expired IndexedDB entries are served as a last resort instead of being deleted — a transient
outage renders slightly stale data rather than an empty page. The
DISABLE_GCSkill-switchis honored throughout. A single blipped
manifest.jsonfetch no longer pins every client tothe uncached legacy
?t=path for a full 60 s: a null (failed) manifest is dropped from thememo so the next request retries.
toLogicalPathrewrites all?/&(not just thefirst of each), so a blob key with three or more query params resolves through the manifest
instead of missing to the backend.
Shorter not-found debounce, now that the site is uniformly fast. The not-found placeholder waited 8000 ms before rendering (
frontend/src/pagesContent/shared/notFound.tsx), a long debounce that papered over slow API-backed loads; once bucket-first serving makes every page load uniformly fast, the short 1500 ms timeout is safe — enough to suppress the flash without reading as a hung page.Match pages serve from the event blob. A match key encodes its event
(
2026cmptx_f1m1→2026cmptx), and theevent/{key}blob already carries every match plusthe per-match
team_matchesand the event'steam_events— exactly what/v3/site/match/{key}returns.
getMatchnow derives the match view from the (bucket-first, immutable,edge-cacheable) event blob, filtering
team_matchesto the requested match, and falls back tothe
/matchAPI only when the blob does not contain it. This takes the match page — previouslythe one page class still served API-only — off the Cloud Run critical path and onto the same
edge cache as the rest of the site, so a match page shares the warmed event blob instead of
paying a cold-container round trip.
Team page event wave no longer waits on the full metadata fetch. The current-year team
view fanned its per-event blob fetches out of a single
Promise.all([team_to_events, getTeam, getYearTeamYears]), so the event wave could not start until the largest blob in that batch —the league-wide
team_years/{year}— had also finished, even though the event fetches dependonly on
team_to_events. The event wave now starts as soon asteam_to_eventsresolves,in parallel with
getTeamandgetYearTeamYears, removing that avoidable gate from theteam-page waterfall.
Deploy-ordering compatibility. Vercel deploys the frontend on merge; App Engine deploys
manually and may lag in either direction. The new frontend falls back to today's legacy
path +
?t=behavior when no manifest exists (old backend), and the new backend keepswriting the legacy unversioned paths every cycle (gated by the same hash) so the deployed
frontend keeps working unmodified (new backend). Both orders verified below.
Measured (local rig: full 2026 season — 3,724 teams, 215 events, 18,372 matches — CockroachDB + fake-gcs-server)
event/{key})team/{num},teams/all)max-age=60. Write Storage step: 27.5 s firstpublish, 5.8–8.2 s steady state (previously 2–18 s uploading unconditionally).
unchanged, 32/32 sampled referenced blobs still downloadable, readers saw the old
name; next cycle republished and readers flipped to the new set.
the EPA-over-time figure fully rendered in a real browser from blobs — 24 bucket
fetches, 0 API fallbacks, 0
?t=busters, exactly 1 manifest fetch.team/{num},team_years/{year},team/{num}/{year}). Theevent/{key}payload is equal as a set;ordering of teams within a match differs between pipeline- and DB-rendered
team_matches— a pre-existing property at the base commit, unchanged by this PR./team/{num}and/team/{num}/{year}routes verified byte-identicalbefore/after the helper extraction.
Operational notes
v2/{hash}URLs make caching work atevery layer — browser, GCS edge, or any CDN placed in front of the bucket. FRC traffic
is unusually cache-friendly: an event's audience is physically co-located (same venue,
same CDN point of presence) and fetches the same blobs, so the first spectator warms
the cache and origin traffic per event collapses to roughly changed-blobs-per-cycle,
independent of crowd size. Today's
?t=cache-buster defeats all of this — every pageview is a full origin fetch. With this change, event-day load scales with matches
played, not with spectators.
v2/objects: a naive age-based lifecycle rule would beincorrect — an unchanged blob keeps its hash and stays referenced by the manifest
indefinitely, so "old" does not mean "unreferenced". A reference-aware GC job (keep
everything the current manifest references, plus a grace window for in-flight
publishes and cached manifests, delete the rest) is provided as a stacked follow-up
PR. Growth without it is modest (roughly a few hundred MB per active competition day).
python backfill_blobs.pyonce after deploy; re-runs are safe andresume where they left off. Bump
HIST_EPOCHonly to force a full re-export.frontend has been deployed for a while.
Testing
Pytest coverage for the pure publish logic (hashing, manifest round-trip, copy-on-write
planning) is in a separate follow-up branch (
bucket-first-serving-tests) to keep this PRfree of new test infrastructure.
yarn lintclean;yarn buildcompiles (the static-exportstep fails identically on the unmodified base commit — unrelated). End-to-end behavior
verified on a local rig seeded with the full 2026 season via TBA, running CockroachDB and
fake-gcs-server, including a 10-check smoke suite (9/9 static checks pass; the 10th asserts
the old "re-upload
team_yearsevery cycle even if unchanged" behavior, which copy-on-writeintentionally removes — the manifest cycle stamp confirms each publish ran).
Note for reviewers
This branch and the parallel EPA-consistency branch both replace the publish gate in
src/google/storage.py; a rebase conflict there is expected and mechanical (this PR'scontent-hash plan subsumes that branch's blob gate).
Cross-track conflicts (only if the Track 1 bug fixes are also taken)
Taken first, before Track 1, this stack applies to bare
masterwith no conflicts. If Track 1 is already merged, two files conflict when this PR lands:src/google/storage.pyvs Fix EPA consistency across pages #1 (epa-consistency). Both rewrite the publish path. Take this PR's version — its content-addressed manifest uploader carries an equivalentnan_safe_eqevent-content gate that subsumes Fix EPA consistency across pages #1's blob gate. Fix EPA consistency across pages #1's other fixes live in other files and survive untouched: the DB write gate (data/utils.py), breakdown deferral (tba/read_tba.py),CUTOFF = 200(db/write/template.py), and publish-before-DB ordering (data/main.py).write_objs(objs, orig_objs=None)has the same signature in both branches, so Fix EPA consistency across pages #1's reordered call binds to this PR's writer.frontend/src/api/storage.tsxvs Fix various UI performance or minor correctness issues #5 (match-page-fixes, expected to be merged before this stack ships). Both add in-flight fetch dedup. Take this PR's rewrite — itsbucketInFlightdedup already removes the double blob fetch Fix various UI performance or minor correctness issues #5 targeted. To also keep Fix various UI performance or minor correctness issues #5's query-level (API / IndexedDB) dedup, re-wrap this PR'squery()in Fix various UI performance or minor correctness issues #5'sinFlight[storageKey]pattern.