v0.9.13: per-stream archived-events retention sweep

Hourly supervisor sweep deletes archived events older than their stream's
config.streams.max_age_s (the /streams 1/7/14/30/365d buttons -- the per-stream
source-of-truth). Explicit STREAM_CATEGORY_DOMAINS map (category domains do not
equal stream subject domains for the traffic family); fail-safe skip on
missing/<=0 max_age_s; unmapped-domain warning so a new adapter cannot silently
dodge retention.

TimescaleDB hypertable gotcha: events.ctid is chunk-local, NOT globally unique --
DELETE batching keys on the composite PK (id, time). See the inline NOTE in
config_store.delete_events_older_than and the PR body for the incident write-up.

- config_store: delete_events_older_than (batched on (id,time)) + unmapped_event_domains
- supervisor: STREAM_CATEGORY_DOMAINS, _sweep_events_retention, _events_retention_loop
- streams_list.html: max-age also governs archived-events retention
- 9 tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-27 02:31:11 +00:00
commit 9f67b4c1f2
4 changed files with 274 additions and 0 deletions

View file

@ -0,0 +1,122 @@
"""Archived-events retention sweep (v0.9.13).
The supervisor deletes events older than their stream's max_age_s (the per-stream
/streams retention). Mapping events.category -> stream is explicit because category
domains don't equal stream subject domains for the traffic family. Fail-safe:
streams with missing/<=0 max_age_s are skipped (never deleted), and any category
domain the map doesn't cover is surfaced as a warning.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from central.supervisor import (
STREAM_CATEGORY_DOMAINS,
EVENTS_RETENTION_INTERVAL_S,
Supervisor,
_all_mapped_domains,
)
from central.streams import STREAMS as STREAM_REGISTRY
# Every category first-token domain observed in the events table (the coverage
# guard: a new adapter domain must be added to STREAM_CATEGORY_DOMAINS or this fails).
KNOWN_EVENT_DOMAINS = {
"wx", "fire", "quake", "space", "disaster", "hydro",
"incident", "closure", "work_zone", "flow", "camera",
}
class TestRetentionMap:
def test_map_covers_all_known_domains(self):
assert KNOWN_EVENT_DOMAINS <= _all_mapped_domains()
def test_map_keys_are_event_bearing_streams(self):
event_streams = {s.name for s in STREAM_REGISTRY if s.event_bearing}
assert set(STREAM_CATEGORY_DOMAINS) <= event_streams
def test_central_meta_not_in_map(self):
# CENTRAL_META is status-only (not event-bearing) -> no events rows to sweep.
assert "CENTRAL_META" not in STREAM_CATEGORY_DOMAINS
def test_no_domain_mapped_to_two_streams(self):
seen, dupes = set(), set()
for domains in STREAM_CATEGORY_DOMAINS.values():
for d in domains:
(dupes if d in seen else seen).add(d)
assert not dupes
def test_interval_is_hourly(self):
assert EVENTS_RETENTION_INTERVAL_S == 3600
def _stream(name, max_age_s):
s = MagicMock()
s.name = name
s.max_age_s = max_age_s
return s
def _supervisor_with_store(store):
sup = Supervisor.__new__(Supervisor) # bypass __init__/connections
sup._config_store = store
return sup
@pytest.mark.asyncio
async def test_sweep_deletes_per_event_bearing_stream():
store = AsyncMock()
store.list_streams.return_value = [
_stream(name, 604800) for name in STREAM_CATEGORY_DOMAINS
]
store.delete_events_older_than.return_value = 5
store.unmapped_event_domains.return_value = []
sup = _supervisor_with_store(store)
await sup._sweep_events_retention()
assert store.delete_events_older_than.await_count == len(STREAM_CATEGORY_DOMAINS)
# CENTRAL_TRAFFIC carries the multi-domain traffic family.
calls = {c.args[0][0]: c.args for c in store.delete_events_older_than.await_args_list}
assert "incident" in [d for c in store.delete_events_older_than.await_args_list for d in c.args[0]]
@pytest.mark.asyncio
async def test_sweep_skips_missing_and_nonpositive_max_age():
store = AsyncMock()
# WX present/valid; FIRE max_age_s=0 (skip); others absent (skip).
store.list_streams.return_value = [_stream("CENTRAL_WX", 86400), _stream("CENTRAL_FIRE", 0)]
store.delete_events_older_than.return_value = 0
store.unmapped_event_domains.return_value = []
sup = _supervisor_with_store(store)
await sup._sweep_events_retention()
swept_domains = [c.args[0] for c in store.delete_events_older_than.await_args_list]
assert ["wx"] in swept_domains # WX swept
assert ["fire"] not in swept_domains # FIRE (max_age 0) skipped
assert store.delete_events_older_than.await_count == 1
@pytest.mark.asyncio
async def test_sweep_passes_max_age_to_delete():
store = AsyncMock()
store.list_streams.return_value = [_stream("CENTRAL_WX", 123456)]
store.delete_events_older_than.return_value = 0
store.unmapped_event_domains.return_value = []
sup = _supervisor_with_store(store)
await sup._sweep_events_retention()
args = store.delete_events_older_than.await_args
assert args.args == (["wx"], 123456)
@pytest.mark.asyncio
async def test_sweep_warns_on_unmapped_domains_but_does_not_raise():
store = AsyncMock()
store.list_streams.return_value = [_stream("CENTRAL_WX", 86400)]
store.delete_events_older_than.return_value = 0
store.unmapped_event_domains.return_value = ["mystery_domain"]
sup = _supervisor_with_store(store)
# Should complete without raising even though an unmapped domain exists.
await sup._sweep_events_retention()
store.unmapped_event_domains.assert_awaited_once()