Media storage abstraction (disk backend now, object storage later) #131
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#131
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Parent: #22. Resolves the "object storage as first target or later enhancement?" open question — later, but behind an interface added now.
Media currently writes to
settings.radar_cache_dirthrough direct filesystem calls scattered acrossapp/services/radar.py(live radar cache, SPC plot-220 frames, assembled SPC GIFs) andapp/services/alert_processor.py(alert_snapshots/). Serving happens inapp/api/media.py.Change
A small storage interface —
put(key, bytes, content_type),get(key),exists(key),delete(key),url_for(key)— with aLocalDiskBackendas the only implementation.No Backblaze/S3 work in v2.0.0. The point is that adding a backend later is a config change plus one new class, not an edit to every call site. Media volume today is radar PNGs and small; paying for object storage before the volume justifies it is premature, but so is scattering more direct filesystem calls.
Tasks
LocalDiskBackend; keep the existing on-disk layout so nothing needs migrating.app/api/media.pyreads throughget/url_for.prune_spc_radar_cacheand_cleanup_alert_radar_snapshotsontodeleteplus enumeration.Explicitly out of scope: implementing a B2/S3 backend.
Picking this up on branch
feat/media-storage-abstraction.Revising one instruction in this issue's own task list — the "keep it synchronous-disk-simple" line, which I wrote. On reflection it is wrong, and following it would undermine the point of the issue.
The stated goal is that adding a B2/S3 backend later is "a config change plus one new class, not an edit to every call site." With a synchronous interface that is not true. A future object-storage backend does network I/O, and behind a sync interface that blocks the shared asyncio event loop — the exact failure mode
CLAUDE.mdwarns about and that #128 was parked over. Avoiding it would mean making the call sites async at that point, i.e. editing every call site. The abstraction would have bought nothing.So the interface is async from the start, implemented synchronously by the disk backend. The cost now is close to zero, because the real consumers are already async:
get_radar_image— asyncfetch_spc_outlook_png/fetch_spc_outlook_gif— asyncrun_retention_cleanup— asyncThe only sync functions involved are the private helpers this issue replaces outright (
_cache_path,_is_cache_valid,_cache_outcome,_spc_cache_path/_valid/_outcome/_read/_write) plusprune_spc_radar_cache, whose sole caller is async.Interface
age_secondsanditer_keysare not speculative — without them the TTL checks and the two retention sweeps cannot move offPath.url_forreturnsNonefor disk, which is the hook a presigned-URL backend fills in;media.pyfalls back togetwhen it isNone.Keys are paths relative to
radar_cache_dir, so the on-disk layout is unchanged and nothing needs migrating:KLSX_png.png,spc220_1C_..._dpi100.cache,alert_snapshots/NWS_..._loc.png.Bonus: centralising path-traversal defence
Today each consumer re-implements its own guard — a regex plus a
resolve()-containment check, duplicated three times inmedia.py. The backend now owns key validation (reject absolute paths,.., backslashes; resolve-check containment on every operation), so every call site inherits it. The existing per-route checks stay as defence in depth rather than being the only guard.Invariant that must survive
The mutual protection between the two retention sweeps (#130):
_cleanup_alert_radar_snapshotstreats any filename referenced by a liveAlertRadarFrame.pathas referenced, and_cleanup_alert_radar_framesleaves files still pointed at bySentAlert.radar_snapshot_path. The first frame deliberately shares its filename with the snapshot, so dropping either guard resurfaces that bug.Done in #155 (merged to
main). CI green in 4m19s.Shipped:
app/services/storage.pywith an asyncMediaStoragecontract andLocalDiskBackend. All five consumers migrated —radar.py(live radar + SPC cache +prune_spc_radar_cache),alert_processor.py,radar_frames.py,retention.py(both sweeps), andapi/media.py(all three routes). No B2/S3 backend, as scoped.On-disk layout verified unchanged, so production's existing cache needs no migration:
Verified locally in Docker: ruff clean,
compileallOK, 838 SQLite-tier tests (up from 815), migrations clean on Postgres 16, 4 Postgres-tier tests. The #130 two-way retention protection passes unchanged.Three things worth carrying forward:
iter_keys()being non-recursive is a correctness guarantee, not a detail. It is what keepsprune_spc_radar_cachefrom reaching intoalert_snapshots/, which a different sweep owns. Previously that came from a non-recursiveglob; it is now part of the documented contract and should stay that way.get_media_storage(), never construct a backend inline. Retention passes its injectedSettingsthrough the factory for exactly this reason — an inlineLocalDiskBackendworks today and would silently keep that consumer on disk after a swap.Also closes the deferred task on #130 (routing frame writes through the abstraction).