Skip to content

Make the presigned-URL cache bound global instead of per-bucket, and size it for multi-worker #671

Description

@fe51

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 :)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions