Add a media storage abstraction (#131) #155
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!155
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/media-storage-abstraction"
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 #131. Third capture-phase issue of the v2.0.0 Historical Explorer epic (#22).
Problem
Every cached image the app writes — live radar PNG/GIFs, assembled SPC plots, alert-time radar snapshots and the periodic frames (#130) — went through direct
pathlibcalls scattered acrossradar.py,alert_processor.py,radar_frames.py,retention.pyandapi/media.py. Newapp/services/storage.pyis the single seam they all go through instead.No object-storage backend is implemented, deliberately. Media volume today is small; paying for B2/S3 before the volume justifies it is premature.
LocalDiskBackendis the only implementation.The interface is async — revising this issue's own guidance
This issue's task list said "keep it synchronous-disk-simple". I wrote that, and it was wrong.
A synchronous interface would have defeated the entire purpose. An object backend does network I/O; behind a sync interface that blocks the shared asyncio event loop that also runs alert polling, the Discord bot and the Matrix client — the exact hazard
CLAUDE.mdwarns about and that #128 was parked over. Avoiding it later would mean making every call site async at that point, i.e. the "edit every call site" cost this abstraction exists to prevent.The cost of async now is close to zero: every real consumer (
get_radar_image,fetch_spc_outlook_png/_gif,run_retention_cleanup) is already a coroutine. Only the private helpers being replaced outright were sync.Design notes
age_seconds()anditer_keys()are not speculative — without them the TTL checks and both retention sweeps cannot leavePath.iter_keys()is deliberately NON-recursive. That is what still guaranteesprune_spc_radar_cacheonly matches root-level*.cacheand never wanders intoalert_snapshots/, which a different sweep owns. The old code got this from a non-recursiveglob; the guarantee now lives in the contract.url_for()returnsNonefor disk.media.pyfalls back to serving bytes itself; a presigned-URL backend fills this in..., backslashes, depth), so every consumer inherits traversal defence rather than re-implementing it —api/media.pyhad done so three times over. Those regexes stay as defence in depth and the 404 behaviour is unchanged.get_media_storage(), including retention, which passes theSettingsit was handed rather than constructing aLocalDiskBackendinline. Inline construction works today and would silently pin retention to disk the day a backend swap lands.On-disk layout is unchanged — nothing to migrate
Keys are paths relative to
radar_cache_dir. Verified empirically rather than assumed:Production's existing cache files are still found by the same keys.
#130 invariant preserved
The two-way retention protection survives: the orphan sweep still treats any filename referenced by a live
AlertRadarFrame.pathas referenced, and frame cleanup still leaves files still pointed at by a survivingSentAlert.radar_snapshot_path. Both regression tests pass unchanged.Verification
ruff check .python -m compileall appalembic upgrade headon fresh Postgres 16New
tests/test_media_storage.pycovers the backend directly: round-trips, missing keys,age_seconds, non-recursiveiter_keyswith suffix filtering,url_for, subdirectory creation, and every invalid-key form (../escape, absolute, backslash, empty, too-deep, dot segments).Unblocks
The deferred task on #130 — routing radar frame writes through the abstraction — is done here.
🤖 Generated with Claude Code