Unify retention onto one 13-month horizon (#133) #157
No reviewers
Labels
No labels
area:ai
area:ci-cd
area:notifications
area:observability
area:public-pages
backlog
bug
duplicate
enhancement
help wanted
invalid
question
type:decision
type:feature
type:infra
type:maintenance
type:security
v1.0.1
v1.1.0
v1.2.0
v1.3.0
v2.0.0
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/WeatherBot!157
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/unified-retention-horizon"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #133. Follow-up measurement split out to #158. Follows the capture phase (#129, #130, #131, #132) in the v2.0.0 Historical Explorer epic (#22).
Grounded in measured production data, not the arithmetic estimates. Full measurement set is on #133; it changed several of the issue's premises, and this PR follows the measurements rather than the original task list.
Problem
Retention windows disagreed, so an older event rendered with holes. At 100 days a row still had its alert record (90 d) and its snapshot (180 d) but had already lost its AI summary (30 d) and its SPC context (30 d past
retained_until) — history rotting in inconsistent, confusing ways exactly where the Explorer needs it whole.Change
HISTORY_HORIZON_DAYS(400 ≈ 13 months) is now the single horizon every history table follows, chosen so a year-over-year comparison always has both sides. Each per-tableRETENTION_*setting defaults toNone, meaning "follow the horizon"; a number is a deliberate exception that must carry a reason. Env var names are unchanged, so a deployment that already pins one keeps exactly what it pinned.sent_alertscleared_atcoalesce(cleared_at, expires, sent_at)nws_alert_snapshotsai_summary_records/_attemptsspc_outlook_pages(+ children)retained_untilalert_radar_framesnotification_deliveriesupdated_atredirect_tokenscoalesce(last_used_at, created_at)lightning_clusterspublic_tokensThree decisions worth reviewing
The
sent_alertsbasis change is the substantive fix. Pruning oncleared_atmeant a row that never got cleared was never pruned — and that is not an edge case: production measured 225 of 1,708 rows (13.2%) withcleared_at IS NULL, all of them immortal. An alert ends up uncleared whenever the clearing path doesn't complete (feed drops it, it's superseded, process restarts mid-cycle). The fallbacks answer "when did this stop mattering?" in descending order of trustworthiness: when we cleared it, else when NWS said it expired, else when we sent it. A long-running hazard with a futureexpiresis still protected —coalescepicks the expiry, not the oldsent_at.product_freshness_samplesstays outside the horizon, at its own inline 14 days. It is the largest table in production — 63 MB of a 101 MB database, 155,672 rows, 78% of itspc_fetch— but it is observability, not history the Explorer replays. Putting it on the horizon would cost roughly 1.8 GB for no Explorer benefit. Called out because #133 listed it as unpruned; it is in fact pruned at observability.py:108-110, just not viaretention.py.AI records move 30 d → horizon. The "large payloads" worry that motivated the short window isn't borne out — production holds 0 rows and 64 kB — and #23 (historical display of AI summaries) needs a summary to survive as long as the event it describes. Pin it again if payload size ever becomes real; the override exists.
Corrections to the issue's task list
Two of the four tables #133 listed as unbounded were already pruned:
product_freshness_samples(14 d, inline) andsent_forecasts(bysent_date, in forecast_delivery.py:45). Onlynotification_deliveriesandredirect_tokensgenuinely weren't.The suspicion that
spc_outlook_pages.product_textwas "likely the largest avoidable share of the growth" is disproved by measurement: 2,614 bytes per issuance, 18 kB across all pages. Truncating it would save nothing.Safety
A horizon below 1 day is now rejected at config load, and negative per-table overrides are rejected too. Every history table follows the horizon, so a typo there is not a small misconfiguration — it is unrecoverable data loss the daily job would carry out silently.
This deploy cannot delete anything on its first run. Every changed window is longer, so those tables only retain more. The two newly-bounded tables are safe by inspection:
redirect_tokensis empty, andnotification_deliveries(32 rows) cascades fromsent_alerts, whose oldest row is 92 days old — well inside a 400-day window. Verified separately that production's.envpins no retention keys, so the new defaults will actually take effect there rather than being silently overridden.Verification
ruff check .python -m compileall apptests/test_retention.pycovers eachcoalescefallback independently, the future-expireslong-running-hazard case, both newly-bounded tables (including that a recently used redirect token created long ago survives), and asserts the horizon invariant itself rather than leaving it to comments.tests/test_config.pycovers the new validation.Added one Postgres-tier test:
_cleanup_notification_deliveriesis bounded on its own window on the stated grounds that most rows leave viaON DELETE CASCADEfromsent_alerts— a DB-level guarantee SQLite cannot verify, so the claim is now asserted where FKs are actually enforced rather than left as a comment the bulk tier would happily pass without.Deliberately not claimed
#133's "size the result at 13 months" task is not done here — it is split out to #158. Production holds 3 snapshots, 31 radar frames and 0 lightning clusters (capture ungated only hours before this measurement), so there is no basis for a 13-month projection of the capture tables. #158 carries that, plus the
sent_alertsbloat andradar_snapshot_pathwatch items, to be revisited after a real convective season. What could be measured is posted on #130 (radar frames ≈ 73–113 MB/location/year, below the 165 MB estimate) and #132 (still unmeasurable at 0 rows).🤖 Generated with Claude Code