[blob-store 3/5] Pipeline state as a snapshot blob; DB becomes a downstream consumer - #9
Closed
chondl wants to merge 5 commits into
Closed
[blob-store 3/5] Pipeline state as a snapshot blob; DB becomes a downstream consumer#9chondl wants to merge 5 commits into
chondl wants to merge 5 commits into
Conversation
This was referenced Jul 10, 2026
… rows F1: the event-blob gate needs the pre-cycle objs to tell which events actually changed, so deepcopy them and pass as orig instead of None. team-page lag: build each team's current-year row from the in-memory objs (fresh this cycle) instead of the persisted read (previous cycle), matching the team_years list blob.
A5: deserialize now checks the embedded schema version and read_snapshot falls back to the DB path (logging why) on mismatch or a corrupt/short payload, instead of silently None-ing missing fields. Bump SNAPSHOT_SCHEMA on any ORM column change. A6: the staging tmp blob key carries pid+uuid so two concurrent publishers cannot race on a shared key.
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.
Summary
The website already serves from bucket blobs (content-addressed blobs behind a manifest, from the stacked
bucket-first-servingandblob-gcPRs). This PR takes the next step: it makes the update/publish pipeline itself resilient to database unavailability, and demotes the relational DB from a hard dependency of the pipeline to a downstream consumer that serves only the public/v3API.Today the DB is used as the pipeline's serialization format, not a query engine:
read_objs()loads the whole current year from the DB at the start of every cycle, andwrite_objs()diff-upserts it back at the end. If the DB is unavailable — the failure mode behind the June production outage — the entire fetch/compute/publish cycle fails and the site goes stale even though it serves from blobs.This PR replaces that seam:
objstuple —year,team_years,events,team_events,matches,etags— plusteams) is persisted as one compressed object atstate/snapshot.<year>, written before the blob publish./v3API still serves from the DB — but only after the snapshot and blob publish, and it is now non-fatal: a DB outage is logged and the cycle continues.Per-change rationale
src/google/snapshot.py(new) — deterministic state serializationSerializes the
objstuple +teamstojson+zlib, consistent with the existing blob conventions (compress()instorage.py). Chosen because it is human-inspectable, dependency-free, and already the project's blob format."schema"field (currently1).deserialize()checks it andread_snapshot()falls back to the DB path (logging why) on a mismatch, missingobjs, or any corrupt/short payload — it does not silentlyNone-fill missing columns viafrom_dict. BumpSNAPSHOT_SCHEMAon any incompatible ORM/layout change (e.g. an added column) so a pre-deploy snapshot is rejected rather than read with the new column silently null forever.comp_level,status,winner,type, ...) are detected generically from the SQLAlchemy ORM and coerced back to theirEnummembers on load, so a snapshot-loaded object is byte-for-byte indistinguishable from a DB-loaded one. This matters because the honest-diff gate comparesstr(obj).pid+uuidsuffix so two concurrent publishers (double scheduler fire / overlapping revisions) cannot race on a shared staging blob.src/google/storage.py— publish from memory, tolerate DB losswrite_objs()now renders the core current-year blobs (team_years,events,event/{key},team_to_events, ...) purely from the in-memoryobjs+teamspassed in, so the publish no longer needs the DB. The remaining cross-year enrichment reads (teams/all,events/all, per-team pages, noteworthy/upcoming matches) are wrapped best-effort: if the DB is down they are skipped, and because publishing is content-addressed behind the manifest, the previous good version of each skipped blob is simply carried forward — no stale-overwrite, no cycle failure.Two consistency refinements carried from the bucket-first event-content gate: (1) the gate needs the pre-cycle state to tell which events changed, so
process_yearnowdeepcopys the cycle-startobjsand passes them asorig(rather thanNone); without this, every event blob would re-upload each cycle as the embedded year stats drift (measured 215 → 0 on the rig). The deepcopy adds ~2.8 s to a partial cycle — cheaper than re-uploading 215 blobs and re-warming the edge cache. (2) each team's current-year row on itsteam/{num}page is now taken from the in-memoryobjs(fresh this cycle) rather than the persisted read (previous cycle), so a team page's current EPA no longer lags theteam_yearslist by one cycle.src/data/main.py+src/data/utils.py— wire the new seamupdate_curr_year()loads state from the snapshot and only falls back toget_teams_db()+read_objs()when no snapshot is present.process_year()writes the snapshot, then publishes blobs, then does the DB upsert inside a non-fataltry.read_objs()now returns its dicts in primary-key order to match the snapshot's deterministic ordering, so the DB and snapshot load paths publish byte-identical output (and publishing becomes deterministic regardless of DB row order).Deferred (follow-up)
Cross-year seed reads used for EPA initialization (prior years'
team_years,norm_epaaggregates) still read the DB, wrapped best-effort. They are static reference data and belong inhist/blobs; moving them there is a separate, smaller change and keeps this diff focused.Verification (local rig: CockroachDB + fake-gcs, full 2026 season)
Byte-identical replay. A full cycle loaded from the DB vs. from the snapshot produced identical published content across all 3950 logical blobs (compared via manifest content-hashes): 0 mismatches.
Headline test — DB down. With the CockroachDB container stopped, one update cycle:
The cycle fetched TBA, computed EPA, and published blobs + snapshot successfully; the only failure was the logged, non-fatal DB write.
Healing. Deleted 25
team_yearsrows from the DB; the next DB-up cycle's diff (computed against the live DB) restored the count from 3699 back to 3724.Cold start. With no snapshot present the pipeline falls back to
get_teams_db()+read_objs(), runs a full cycle, and writes the first snapshot; the following cycle loads from it.Shared smoke suite: 10/10.
Timings (this machine, static 2026 data)
Loading state from the snapshot roughly halves the state-load step and removes it from the resilience-critical path. The snapshot write adds ~8.8 s per cycle (serialize + upload of the full ~23 MB state); a delta/streamed snapshot is a reasonable future optimization but is intentionally out of scope here. The cycle-start
deepcopyfor the event-content gate adds ~2.8 s. The best-effort DB read for the heal-diff is the residual DB touch on a healthy cycle and is skipped during an outage.Notes
/v3API or to the frontend.