Fix EPA consistency across pages - #417
Open
chondl wants to merge 8 commits into
Open
Conversation
The partial-update write filter compared objects via __str__ methods that cover only a subset of columns (TeamYear omits rank, percentile, and norm_epa; TeamEvent omits component EPAs). Drift in the omitted fields never reached the DB, so the public API disagreed with the website, which rebuilds its blobs from in-memory state every cycle (avgupta456#413). attrs-generated equality compares every column and benchmarks at ~0.1s per cycle across all 30K objects.
A 1000-row team_years upsert batch renders to ~15 MiB on average (measured; 18 MiB observed in practice), which exceeds CockroachDB's default 16 MiB sql.conn.max_read_buffer_message_size and fails with a ProtocolViolation. Full-content write gating makes large team_years batches routine, so cap batches at 250 rows (~4 MiB average).
TBA sometimes posts a match score before the score breakdown. The match was ingested as completed with all component values imputed to zero, cratering component EPAs for its six teams until the breakdown arrived on a later cycle. For 2016+, treat such a match as upcoming (predictions still publish) until the breakdown arrives or the match is 24 hours old, so matches at events that never post breakdowns are still processed.
The event/{key} blob upload was gated on Event.__str__, which contains
no EPA fields, so during an event's registration window team EPAs kept
updating in the DB while the event page blob stayed frozen for weeks
(the stale-EPA reports on ChiefDelphi). Gate on the content of what
the blob renders instead: the year, event, match, and team_event rows
that _read_event serializes. Stale blobs from the old gate need a
one-time partial=False run to resync.
F1: dropping the year_changed short-circuit stops all ~215 event blobs from republishing every partial cycle (year stats churn constantly in-season). Event blobs now republish only when their own event/match/team_event content changes; the embedded year refreshes with them. F2: nan_safe_eq treats NaN==NaN so a NaN-bearing float field no longer forces a perpetual rewrite/republish. Also consume the upload executor results so per-blob upload failures surface.
F3: writing storage before the DB means a failed/partial GCS upload raises and aborts the cycle before the DB baseline moves, so the next cycle re-diffs against the same baseline and retries the stale blobs instead of silently skipping them.
F4: 200 x 69KB max row ~= 13.5 MiB < 16 MiB (CRDB default message buffer), vs 250 x 69KB ~= 17.25 MiB which could trip ProtocolViolation on a dense batch.
F5: a deferred match no longer carries a real final score while flagged Upcoming; aggregates already gate on Completed so they are unaffected.
|
@chondl is attempting to deploy a commit to the avgupta456's projects Team on Vercel. A member of the Team first needs to authorize it. |
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.
Every EPA a user can see should agree across the event page, team page, teams list, and public API after each update cycle. Today three defects break that, all verified against
master(a2cea55) and reproduced end-to-end on a local rig (CockroachDB + fake-gcs + full 2026 season, 3724 teams / 215 events / 18372 matches).1. Event pages serve weeks-stale EPAs
Symptom: During an event's registration window, the event page's team list shows EPAs frozen at the last registration change while team pages and the teams list are current. Reported on ChiefDelphi (2026-04-12, therekrab, with screenshots: team 254 at
2026cancmp— 311.5 on the event page vs 360.8 everywhere else).Cause: the
event/{key}blob — the event page's data source — is re-uploaded only whenstr(event)changes (src/google/storage.py:80), andEvent.__str__(src/db/models/event.py:88) contains key, status, num_teams, current_match, qual_matches — no EPA fields. Every cycle recomputes all team EPAs in memory and refreshes them in the DB, but nothing that changesEvent.__str__happens between registration changes, so the blob never republishes.Change: gate the upload on the event-specific content the blob renders — the event row plus its match and team_event rows — compared field-wise (NaN-stable) against the cycle-start DB state. An event blob republishes exactly when its own content changes. The blob also embeds a snapshot of the year object, refreshed whenever the event republishes; but year-level stat drift alone (percentiles/counts/means move every partial cycle in-season) deliberately does not trigger a republish. Gating on the year too would re-upload all ~215 event blobs every partial cycle and defeat the immutable edge cache — the tradeoff is that an idle event's embedded year snapshot can lag by up to one event-content change, a cosmetic staleness in the event page's color-scale thresholds.
Verified on the rig: planted a stale
event/{key}blob (EPAs zeroed) with drifted team_event rows in the DB. A baseline cycle healed the DB but left the blob frozen at zeros (the reported bug); a branch cycle republished it, and every team's EPA in the event blob equaled theteam_years/{year}blob exactly. A registered-teams/no-matches fixture event also converged: event-blob EPA == team_years-blob EPA for all teams after one cycle. On a no-change cycle the gate publishes zero event blobs (same as baseline).Deploy note: the gate compares against DB state at cycle start, so it cannot retroactively fix blobs that are already stale. Run one
partial=Falsecycle (reset_curr_year) after deploying to resync current-year event blobs once.2. Public API disagrees with the website
Symptom: rank / percentile / normalized EPA differ between the REST API (reads the DB) and the site (reads blobs rebuilt from memory each cycle). Upstream issue #413.
Cause: the partial-update write filter (
changed()insrc/data/utils.py:57) drops a row unlessstr(obj)changed, and the__str__methods are hand-picked subsets:TeamYear.__str__omits rank, percentile, and norm_epa;TeamEvent.__str__omits component EPAs;Match.__str__omits most breakdown fields. Drift in the omitted fields never reaches the DB.Change: compare every column with the attrs-generated equality instead of
__str__.__str__remains for logging. The comparison is NaN-stable — a float column that is NaN is treated as equal to NaN — so a row whose EPA model emits a NaN does not upsert (and republish its event blob) every cycle forever.Verified on the rig: perturbed
norm_epaon 50 team_years rows, then ran one cycle per code version. Baseline: 50/50 rows still wrong afterwards. Branch: 0/50 — all healed. The first branch cycle on the long-running rig DB also wrote a one-time catch-up of 529 team_years rows of accumulated silent drift.Cost (measured): the full-field comparison over all ~30K objects adds ~0.1 s (attrs
__eq__;Write DBstep 0.07 s → 0.3–0.6 s including the healed rows). Total partial-cycle time is unchanged: 13.0–13.3 s baseline vs 13.1–13.2 s branch on identical data. Steady-state row writes converge to the same ~0 as baseline on no-change cycles; extra rows appear only when real drift exists (bounded by actual drift, e.g. the 50 perturbed rows).3. Component EPAs transiently crater mid-event
Symptom: right after a match, a team's component EPAs collapse, then correct on a later cycle (ChiefDelphi reports: Raysine 2026-04-12, Barnav 2026-05-02, Isaac-The-Pro 2026-05-01).
Cause: TBA sometimes posts the score before the score breakdown.
get_event_matches(src/tba/read_tba.py) marks a match completed as soon as both scores are >= 0, andclean_breakdownreturns an all-zero breakdown whenscore_breakdownis null — so the EPA update consumes imputed zeros.Change: for 2016+ (breakdown-bearing years), a match with a final score but no red/blue breakdown is treated as still upcoming — predictions publish as before, but the season replay does not consume it — until the breakdown arrives or the match is 24 hours old. The age fallback guarantees no match is ever stuck, including events that never post breakdowns. The demoted copy also blanks its red/blue score, so a match flagged
Upcomingnever carries a real final score in the published match/event blobs.Verified on the rig by intercepting the TBA response for a real match (
2026caasv_qm10, 161–64) and nulling itsscore_breakdown:Upcoming, predictions present; the six teams' EPAs were identical (to the 0.005 pt comparison threshold) to a replay with the match absent — nothing consumed.Completedwith zero components; team EPAs cratered, e.g. 11096: 26.4 → 13.6, 7607: 41.2 → 28.4, 2543: 44.1 → 33.9.Completed) — no stuck matches.4. Upsert batches exceed CockroachDB's message limit
Surfaced while measuring item 2: a 1000-row team_years upsert batch renders to ~15 MiB on average (69 KB max row — rows carry per-match JSON), and a full team_years write fails on a default-configured CockroachDB with
ProtocolViolation: message size 18 MiB bigger than maximum allowed message size 16 MiB(sql.conn.max_read_buffer_message_size). This can already bite today on full rebuilds; honest write gating makes large partial batches routine.CUTOFFdrops 1000 → 200, sized on the worst-case row, not the average: 200 × 69 KB ≈ 13.5 MiB stays under the 16 MiB limit even for a dense batch of near-max rows (250 × 69 KB ≈ 17.25 MiB could still tripProtocolViolation). Verified at the default 16 MiB limit: baseline code fails, this branch succeeds. No measurable cycle-time impact (the rows go into the same single transaction introduced by the Junewrite_allrework).5. Error while publishing blobs can corrupt pages
The gate compares in-memory objects against the cycle-start DB state, and the DB upsert (which advances that baseline) previously ran before the GCS upload. A failed or partial upload was therefore silently swallowed (
executor.map's iterator was never consumed) while the DB moved on, so the next cycle's diff saw no change and never retried the stale blob. This branch (a) consumes the upload results so a per-blob failure surfaces, and (b) writes storage before the DB, so a failed upload aborts the cycle before the baseline advances — the next cycle re-diffs from the same baseline and retries.Safety: EPA math untouched
The changes affect when rows and blobs publish, never what EPA computes. A full-season in-memory replay over the same DB state produces a byte-identical SHA-256 (year + all team_years, events, team_events, matches serialized) on baseline and branch.
Measured cycle summary (local rig, static 2026 season)
a2cea55The event-blob rows are measured on the rig by perturbing year-level stats between
two cycles with identical event content (215 → 0) and by mutating one match's score
(→ 1 event blob). "215 (year-inclusive gate)" is the cost had the gate included the
churning year, the naive fix the reviewer flagged.
Tests for the new logic (write-gate equality, deferral policy) live on a separate branch (
epa-consistency-tests) to keep this diff minimal, since the repository currently has no test infrastructure.