Skip to content

Fix EPA consistency across pages - #417

Open
chondl wants to merge 8 commits into
avgupta456:masterfrom
chondl:epa-consistency
Open

Fix EPA consistency across pages#417
chondl wants to merge 8 commits into
avgupta456:masterfrom
chondl:epa-consistency

Conversation

@chondl

@chondl chondl commented Jul 11, 2026

Copy link
Copy Markdown

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 when str(event) changes (src/google/storage.py:80), and Event.__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 changes Event.__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 the team_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=False cycle (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() in src/data/utils.py:57) drops a row unless str(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_epa on 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 DB step 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, and clean_breakdown returns an all-zero breakdown when score_breakdown is 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 Upcoming never 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 its score_breakdown:

  • Branch: match held as 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.
  • Baseline, same scenario: match ingested Completed with zero components; team EPAs cratered, e.g. 11096: 26.4 → 13.6, 7607: 41.2 → 28.4, 2543: 44.1 → 33.9.
  • Breakdown "arrives" (unmodified refetch): match processed once with correct components; all six teams' EPAs and the match row returned exactly to their original values.
  • Fallback: same missing-breakdown match older than 24 h is processed (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. CUTOFF drops 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 trip ProtocolViolation). 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 June write_all rework).

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)

baseline a2cea55 this branch
Partial cycle total 13.0–13.3 s 13.1–13.2 s
Write DB step 0.07–0.08 s 0.31–0.57 s
team_years rows/cycle (no drift) 0 0 (after one-time 529-row catch-up)
team_years rows/cycle (50-row drift) 0 (bug) 50
event blobs/cycle (no change) 0 0
event blobs/cycle (year stats drift, no event change) 215 (year-inclusive gate) 0
event blobs/cycle (one event's match changes) 0 (bug) 1
team_years rows/cycle (identical-NaN field) 1 (perpetual) 0
Full team_years upsert at 16 MiB limit ProtocolViolation succeeds

The 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.

chondl added 8 commits July 9, 2026 22:07
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.
@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

@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.

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