[blob-store 4/5] DuckDB read layer + Parquet publish for /v3 (demonstrative) - #10
Closed
chondl wants to merge 7 commits into
Closed
[blob-store 4/5] DuckDB read layer + Parquet publish for /v3 (demonstrative)#10chondl wants to merge 7 commits into
chondl wants to merge 7 commits into
Conversation
Content-address each table object under the v2/ prefix the reference-aware
GC already scans (v2/parquet/{year}/{table}.parquet.{digest}, immutable) and
record it in manifest.blobs under a parquet/ logical key. The manifest is
written last, so a reader resolving the set through it never joins tables from
different runs. Content-gate against the manifest digest keeps steady-state
uploads at zero.
The DuckDB read layer resolves the whole set from one manifest fetch per sync,
materializes the referenced objects into a per-generation cache dir (reusing
unchanged objects by hardlink), and swaps the active dir atomically; each query
captures the base dir once so it never spans two generations.
…ad failure A3: the current-year cycle now writes the manifest exactly once, last, carrying both site-blob and parquet entries, so DuckDB and the frontend never disagree by a cycle and a crash between two writes cannot strand the API. A1: parquet no longer does read-modify-write with 'read_manifest() or Manifest()' on the hot path; the historical standalone writer aborts (logs + skips) when the manifest is unreadable instead of fabricating an empty one that erases every site-blob ref.
A2: _teams_source falls back to a glob (which _query turns into an empty result) instead of crashing on max([]), so /v3/team and /v3/teams return empty rather than 500 on a first db-less deploy before the first pipeline cycle.
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.
Stacked on #9 (
state-snapshot). Basestate-snapshot, headduckdb-api, fork-only. Draft: the default backend is unchanged, so this is zero-risk to merge and purely demonstrative.This shows the two moves that let us drop CockroachDB entirely: (1) publish the season as Parquet alongside the existing blobs every cycle, and (2) serve the public
/v3API from an in-process DuckDB reading those Parquet files instead of the relational DB. Selection is behindAPI_BACKEND=duckdb; unset, everything runs exactly as before.Why each change
Add Parquet/DuckDB table schema introspection (
src/db_duckdb/schema.py). The seven entity tables already describe themselves through SQLAlchemy. Rather than hand-maintain column lists, this derives per-columnkind(int/float/bool/str/enum/json) from the ORM, so the Parquet writer and the DuckDB reader share one source of truth and stay in lockstep with the models. Enum columns (allstr, Enum) are stored as their value; the two JSON columns (team_year.matches,match.pre_epas/epas) as JSON text, coerced back on read so DuckDB-loaded objects are byte-identical to DB-loaded ones. Every SQLAlchemyInteger-family column maps to Parquet int64 — deliberately wider than the ORM's declaredInteger(int32) onmatch.time/predicted_time,event.time,team_event.time, because TBA's ~1900-era placeholder timestamps (-2208988800) underflow int32; a historical load on a strict-int32 backend crashes on exactly this (CockroachDB's 64-bit INT masked it in prod). Verified by round-tripping a synthetic pre-1970 placeholder row: writer -> Parquet (physical int64) -> DuckDB -> model, value preserved exactly. And sinceBigIntegersubclassesInteger, a future ORMInteger->BigIntegerfix for those columns changes nothing in the Parquet schema.Publish the Parquet tables as a manifest-referenced atomic set (
src/google/parquet.py, wired insrc/data/main.py). Built from the same in-memoryobjstuple the snapshot and blobs already use — no extra query. Each ofteam_years, events, team_events, matches, teams, yearis serialized with a columnar pyarrow build and written content-addressed under the samev2/prefix the versioned site blobs use (v2/parquet/{year}/{table}.parquet.{sha}, immutable long Cache-Control), then recorded inmanifest.jsonunder aparquet/{year}/{table}.parquetlogical key. For the current-year cycle, the parquet entries are folded into the same, singlemanifest.jsonwrite the site blobs already do (storage.write_objstakes the serialized parquet tables and adds them to its oneplan/manifest, written last). So the whole cycle writes the manifest exactly once — verified on the rig: onewrite_manifestcall carrying both the site blobs and theparquet/*entries. This closes the earlier two-write window (site manifest, then a second parquet manifest) where a DuckDB sync landing between the writes served the previous cycle's Parquet while the frontend served the current one, and a crash between them stranded the API a cycle behind. Historical years (reset_all_yearsbackfill) have no co-occurring site publish, so they still write parquet on their own manifest (also last). (The even earlier scheme wrote fixed paths and refreshed each file independently by GCS generation, so a reader syncing mid-publish could pairmatchesfrom one run witheventsfrom another.) Content-gating is against the manifest digest, so an unchanged table uploads nothing (steady-state = 0 uploads).A related safety fix: the parquet manifest update no longer does
read_manifest() or Manifest(). A transient GCS read failure previously fabricated an empty manifest and wrote back a manifest containing onlyparquet/*keys — erasing every site-blob reference until the next full re-render. The current-year cycle now can't hit this (parquet rides the site manifest, which is rebuilt from all rendered blobs each cycle, so a failed read just rebuilds fully); the historical writer aborts and logs if the manifest is unreadable rather than clobbering it. Folding intomanifest.blobskeeps the reference-aware GC (#7) and the frontend unchanged (both key offblobs).Add DuckDB read layer over the Parquet set (
src/db_duckdb/). Same twelveget_*interfaces the/v3routers call fromsrc/db/read. An in-process DuckDB queries a local cache resolved from a singlemanifest.jsonfetch per sync: the referenced Parquet objects are materialized into a cache dir versioned by manifest generation (unchanged objects reused by hardlink), then the active dir is swapped atomically, so an in-flight query never sees a half-updated set and each query captures its base dir once (a 30s throttle avoids per-request manifest reads). Year-scoped queries read a single file; cross-year queries globparquet/*/{table}.parquet(the design doc's Query Layer). Sortmetricis validated against the column whitelist; every filter is parameterized. Cold start / bootstrap: when no Parquet exists yet (first-ever deploy, before the first cycle), every entity endpoint degrades to an empty result — including team endpoints, whose_teams_sourcefalls back to a glob instead of crashing onmax([]). So the read layer 200s with empty data rather than 500ing until the first pipeline cycle writes Parquet.Serve /v3 from DuckDB behind API_BACKEND flag (
src/api/backend.py+ the six routers). A one-line facade dispatches tosrc.db_duckdborsrc.db.readat import time. Only the public/v3routers switch;/v3/siteand the pipeline stay on the DB. Default is the relational DB.Parity (rig, seeded 2026)
In-process, comparing
db.read.get_*(...).to_dict()againstdb_duckdb.get_*(...).to_dict()across a representative sweep (team, teams, year, team_year, team_years with filters/sort/pagination, event, events, team_event, team_events, match, matches with filters):teams(country=USA, active)full setteam_years(2026, country=USA)full setThe only response-level differences are the order of equal-sort-key rows in paginated queries (
ORDER BY <metric>with no unique tiebreaker — the current API contract, engine-agnostic). Confirmed by re-sorting the full result by(metric, pk): 0 field mismatches. The effect scales with how tied the metric is —norm_epahas 2490/2944 duplicate values (integer-rounded) so most pages differ;epa_max(rarely tied) → 0 pages differ. Not a DuckDB defect; the DB shows the same under a plan change.Shared smoke suite in duckdb mode (
--base-url→ DuckDB-backed server): 9/9 pass, consistency probe max EPA diff 0.0000.Latency (rig, alru-cache bypassed via varied params, warmed; ms)
team/{n}teams?filter+sortyear/2026team_year/{n}/2026team_years?2026+epa+pageevent/{key}events?2026+pageteam_event/{}team_events?teammatch/{key}matches?eventmatches?team+yearDuckDB is faster on nearly every class and dramatically so on offset pagination (
team_yearsp95 322ms → 43ms), where CockroachDB'sOFFSETscan dominates. Point lookups onteam_yearare marginally slower (full-file scan, no index) but well within budget. Parquet publish adds ~2.3s/cycle (columnar pyarrow; content-gated reruns ~1.6s) — in line with the existing ~2–18s "Write Storage" step.Storage/httpfs note: the reader uses a local cache of the Parquet blobs rather than DuckDB
httpfs. Local files give predicate pushdown plus the glob/listing the fake-gcs emulator can't serve over httpfs; against real GCS this becomes thegcs/httpfsextension with the same SQL. One knob (DUCKDB_SYNC_TTL).What full cutover would look like
Remaining to drop CockroachDB entirely (beyond this PR):
/v3/siteat the DuckDB layer too (the pre-joined_read_*shaping is unchanged; it just needs the sameget_*).etagsand the EPA/watermark state fully into the snapshot ([blob-store 3/5] Pipeline state as a snapshot blob; DB becomes a downstream consumer #9) so the pipeline never reads the DB — the cross-year EPA seed read is the last DB dependency.data/router becomes internal.httpfs(or keep the cache with a cold-start prefetch) in production.src/db/,sqlalchemy-cockroachdb, and collapse the three App Engine services to one Cloud Run service.After those, the relational DB has no readers and no writers and can be deleted; Parquet blobs + the snapshot become the whole source of truth, and new UI surfaces are new SQL over existing blobs — no new blob types.
Verification is rig-only; no staging touched.