Context
Two caches landed together in #: S3Service._buckets keeps one S3Bucket instance per organization for the process lifetime (src/app/services/storage.py:208, :233-236), and each of those instances carries its own _url_cache capped at _URL_CACHE_MAXSIZE = 8192 (src/app/services/storage.py:27).
Caching the bucket instance is what makes the URL cache long-lived enough to ever hit, so the two are intentional. But together they mean the memory bound is not flat.
Problem 1 — the bound scales with orgs and workers
s3_service is a module-level singleton (src/app/services/storage.py:294), so it is one per process, not one per machine. The real ceiling is:
n_workers × n_active_orgs × ~8 MB
(~1 KB per entry: a SigV4 URL runs 600-900 bytes plus dict overhead. Only organizations with actual playback in the window fill their cache, so the multiplier is active orgs, not total.)
With several workers in production this is no longer a small number, and it is not a transient peak , the cache never drains.
Problem 2 — multi-worker also dilutes the benefit
URL stability is per-process. A polling client is load-balanced across workers, and each worker signs its own URL for the same object, so the browser sees up to W distinct URLs per frame and downloads it up to W times instead of once.
This is still a clear win over the previous behavior (a new URL on every poll, so a re-download every time) — it is bounded at W rather than unbounded. But we pay W× the memory for 1/W of the available benefit, which is the wrong trade to leave in place.
Proposal
Move the cache to S3Service and add the bucket name to the key. The bound becomes global per process — n_workers × ceiling, independent of org count — which allows raising the ceiling meaningfully while lowering the worst case.
If cross-process stability turns out to matter, a shared store (Redis) keyed the same way would work. Note that the existing code comment at src/app/services/storage.py:150-158 suggests this needs a shared signing timestamp, which isn't quite right: caching the resulting URL string in Redis sidesteps the X-Amz-Date problem entirely, since workers read a string rather than each deriving one. Worth scoping separately — the in-process fix above is likely enough.
Quick mitigation if needed sooner
Lowering _URL_CACHE_MAXSIZE to ~1024 is a one-line change that caps things at n_workers × n_orgs × 1 MB. It still holds several sequences per org per window, and a miss only costs a local signature.
Sizing input
A player load under the new sampling scheme is ~60 frames, ×2 entries when with_crop=true (url and crop_url are separate presigns, src/app/api/api_v1/endpoints/sequences.py:112-117) — so ~120 entries per sequence viewed. At 8192 that is ~68 sequence-views per window per org, or ~34 once stale-slot entries are accounted for.
Happy to discuss it :)
Context
Two caches landed together in #:
S3Service._bucketskeeps oneS3Bucketinstance per organization for the process lifetime (src/app/services/storage.py:208,:233-236), and each of those instances carries its own_url_cachecapped at_URL_CACHE_MAXSIZE = 8192(src/app/services/storage.py:27).Caching the bucket instance is what makes the URL cache long-lived enough to ever hit, so the two are intentional. But together they mean the memory bound is not flat.
Problem 1 — the bound scales with orgs and workers
s3_serviceis a module-level singleton (src/app/services/storage.py:294), so it is one per process, not one per machine. The real ceiling is:(~1 KB per entry: a SigV4 URL runs 600-900 bytes plus dict overhead. Only organizations with actual playback in the window fill their cache, so the multiplier is active orgs, not total.)
With several workers in production this is no longer a small number, and it is not a transient peak , the cache never drains.
Problem 2 — multi-worker also dilutes the benefit
URL stability is per-process. A polling client is load-balanced across workers, and each worker signs its own URL for the same object, so the browser sees up to W distinct URLs per frame and downloads it up to W times instead of once.
This is still a clear win over the previous behavior (a new URL on every poll, so a re-download every time) — it is bounded at W rather than unbounded. But we pay W× the memory for 1/W of the available benefit, which is the wrong trade to leave in place.
Proposal
Move the cache to
S3Serviceand add the bucket name to the key. The bound becomes global per process —n_workers × ceiling, independent of org count — which allows raising the ceiling meaningfully while lowering the worst case.If cross-process stability turns out to matter, a shared store (Redis) keyed the same way would work. Note that the existing code comment at
src/app/services/storage.py:150-158suggests this needs a shared signing timestamp, which isn't quite right: caching the resulting URL string in Redis sidesteps theX-Amz-Dateproblem entirely, since workers read a string rather than each deriving one. Worth scoping separately — the in-process fix above is likely enough.Quick mitigation if needed sooner
Lowering
_URL_CACHE_MAXSIZEto ~1024 is a one-line change that caps things atn_workers × n_orgs × 1 MB. It still holds several sequences per org per window, and a miss only costs a local signature.Sizing input
A player load under the new sampling scheme is ~60 frames, ×2 entries when
with_crop=true(urlandcrop_urlare separate presigns,src/app/api/api_v1/endpoints/sequences.py:112-117) — so ~120 entries per sequence viewed. At 8192 that is ~68 sequence-views per window per org, or ~34 once stale-slot entries are accounted for.Happy to discuss it :)