feat(sequences): add detection sampling, plus S3 bucket and presigned-URL caching - #661
feat(sequences): add detection sampling, plus S3 bucket and presigned-URL caching#661Acruve15 wants to merge 5 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #661 +/- ##
==========================================
+ Coverage 93.73% 93.83% +0.10%
==========================================
Files 59 59
Lines 3143 3197 +54
==========================================
+ Hits 2946 3000 +54
Misses 197 197 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
c393579 to
5a02acb
Compare
Two separate costs on every detection fetch: S3Service.get_bucket built a fresh S3Bucket per call, and S3Bucket.__init__ does a blocking head_bucket round-trip, so each request paid one synchronous S3 call on the event loop (10ms against localstack, more against a real endpoint). Bucket instances are one per organization and effectively static, so they are now built once and reused. boto3 stamps the current clock into every signature, so presigning the same key twice returns two different strings. The browser keys its cache on the full URL, so the player re-downloaded every frame on every poll even when the image had not changed. URLs are now reused for a window derived from S3_URL_EXPIRATION, with the window slot in the cache key so no rollover invalidation is needed. Uploads also set Cache-Control, without which browsers only cache heuristically and the stable URLs would not pay off. That applies to newly uploaded objects only; existing frames stay on heuristic caching. Stability is per-process, which is fine while a single uvicorn worker runs, and degrades to one URL per worker per window rather than breaking if that changes.
Closes #660. GET /sequences/{id}/detections takes a sampling=N parameter that keeps one detection in N, so the player can load a whole timeline in one request instead of paging through every frame. Sampling runs in SQL via row_number(), not as a Python slice of a full fetch, which would keep the scan and the serialization cost and only shrink the payload. Row numbers are always computed ascending on created_at, so the sampled frame set does not depend on desc: desc only flips the output order. That also keeps the set stable as a sequence grows, since a new detection lands last and cannot renumber earlier rows, so the player keeps hitting the same frames and the same cached URLs across polls. (rn - 1) % sampling == 0 keeps the first detection, so any non-empty sequence returns at least one row, and offset pages the sampled set rather than raw rows. sampling=1 delegates to the existing unsampled path, so default behaviour is unchanged. The limit ceiling goes from 100 to 500, since the issue's own example (1000 detections at sampling=10) saturated 100 exactly. Also drops a redundant DetectionRead round-trip in the endpoint: it is a bare subclass of Detection, so the extra validate and dump per row was an identity detour.
5a02acb to
530dc9c
Compare
fe51
left a comment
There was a problem hiding this comment.
LGTM on the approach (I need to test it soon)
The interval-based choice over target-count is the right one, and the key design decision, row_number() computed ascending from the start of the sequence regardless of desc (src/app/crud/crud_detection.py:43-52), is what makes the sampled frame set stable as a sequence grows. Combined with the stable presigned URLs, that's what lets a polling player actually hit its cache.
The two commits are more coupled than the split suggests: without sampling, a full 1,000-detection load burns ~2,000 URL cache entries and the cache is effectively useless.
To communicate to the frontend before they build against this
sampling and limit are independent. ?sampling=10 alone, with the desc=true / limit=10 defaults, returns the 10 most recent sampled frames — not a spread across the sequence. Full coverage needs limit >= ceil(detections_count / sampling), and limit caps at 500 (so sampling >= detections_count / 500).
Worked example for a 20/20/20 player load on a 1,000-detection sequence:
?desc=false&limit=20 # first 20
?desc=false&sampling=48&offset=1&limit=20 # 20 across the middle
?desc=true&limit=20 # last 20, reverse client-side
General form for M middle frames: sampling = floor((N - 40) / M), offset = ceil(20 / sampling), limit = M. detections_count is already returned on the sequence object, so the frontend has everything it needs — no extra endpoint required.
Non-blocking follow-ups
Neither is a problem today; both are tracked separately so they don't hold this PR.
-
#672: purge stale-window cache entries. The window
slotis part of the cache key (src/app/services/storage.py:155), so entries from previous windows are permanently unreachable but stay resident until size-evicted. Effective capacity is roughly half the nominal 8192. Purging them is strictly safe — unlike a fullclear(), which the existing comment rightly argues against. -
#671: cache sizing under multiple workers.
_URL_CACHE_MAXSIZEis per-S3Bucket, and bucket instances are now process-lifetime (src/app/services/storage.py:208), so the bound isn_workers × n_active_orgs × ~8 MBrather than flat. Since we run multiple workers in prod, URL stability is also per-worker: a client sees up to W distinct URLs per frame, so each frame downloads up to W times instead of once. Still a large win over re-signing on every poll, just divided by W.
…r claim Review feedback on #661. sampling and limit are independent, which is easy to get wrong: `?sampling=10` alone, with the desc=true and limit=10 defaults, returns the 10 most recent sampled frames rather than a spread across the sequence. Spanning the whole sequence in one call needs limit >= ceil(detections_count / sampling), and since limit caps at 500 that also means sampling >= detections_count / 500. Both the endpoint description and the client docstring now say so, since the frontend is about to build against this. Also corrects the _stable_presign docstring, which claimed the repo runs a single uvicorn worker. That was inferred from the dev compose files passing no --workers, but production runs several and its deployment config is not in this repo. URL stability is therefore per-worker in production: a client sees up to W distinct URLs per frame, so a frame is fetched up to W times instead of once, still far better than re-signing on every poll but divided by W. See #671.
|
Thanks, both follow-ups are fair and the worker one corrects something I had wrong. Pushed 76ee7fd. The I verified your 20/20/20 recipe against the endpoint rather than just the arithmetic, on a seeded 1,000-detection sequence: Your formula is exactly right ( On workers, you are right and my docstring was wrong. I had asserted "the repo currently runs a single worker (docker-compose.yml passes no On #672, agreed, and it is a consequence of my choosing to put the window On the coupling between the two commits: agreed, and your framing is sharper than mine. Without sampling, a 1,000-detection load burns ~2,000 entries against a 8,192 bound, so ~4 sequences evict each other and the cache stops paying off. Worth noting that makes #672 and #671 more than cosmetic once the frontend starts requesting large 🤖 Addressed by Claude Code |
Guardrail for the trap fe51 spotted in review. sampling thins the candidate set but limit still truncates it, and truncation was invisible: `?sampling=10` on a 1000-detection sequence returned 10 frames from the last 10% of the sequence, which looks like a coarse timeline but is only its tail. Nothing errored. limit is now optional. When sampling is set and limit is omitted it defaults to whatever spans the whole sampled set (capped at 500), so `?sampling=10` returns 100 frames across the sequence instead of 10 at the end. An explicit limit is still honoured, since paging a sampled set is legitimate. Either way a sampled response now carries X-Sampled-Total and X-Sampled-Truncated, so a caller can tell whether what it got covers the sequence rather than having to derive it. Paging to the end of the set is not reported as truncation. Costs one extra COUNT, only when sampling > 1, reusing the existing get_detection_counts_by_sequence_ids so the denominator matches the rows the endpoint actually returns (continuity rows included). Unsampled requests keep the historical limit=10 default, take no extra query, and get no headers.
codecov/patch caught a real gap: making limit optional added a conditional that only forwards it when set, and the client integration test called fetch_sequences_detections without a limit, so that line never ran. Adds two calls on the existing sequence: one with an explicit limit, which is the branch that was uncovered, and one with sampling and no limit, which checks the API sizes the response to the sampled set and returns the X-Sampled-Total / X-Sampled-Truncated headers through the client.
|
Thanks for the follow-ups — the omitted-limit default plus the two headers is a good answer to the truncation trap, and the worker docstring reads accurately now. One thing I'd like to discuss before this merges, on What I noticed
Concretely, on the 1,000-detection sequence from your verification: Twenty is read as twenty grid points, so it skips 20 × 48 = 960 detections and lands near the end. The flip side is that skipping the first 20 detections requires What I'd expect instead
Two reasons I lean this way:
What it seems to costFrom reading As far as I can tell the two properties that matter both survive:
The questionIs there something that makes this more expensive than it looks, or a case I'm not seeing where counting in sampled units is the more useful behaviour? You've been closer to this query than I have. If it's not technically limiting, this feels like the more natural shape, and now is the cheapest possible moment to pick it — (There's a middle option, keeping a sequence-wide grid and treating @Acruve15 @MateoLostanlen what do you think ? |
Closes #660.
The issue asks for detection sampling so the player can load a sequence without pulling every frame, and names two causes: API response time and client-side caching. Investigating
GET /sequences/{id}/detectionsturned up three further problems, two of which hit harder than sampling itself, so this PR does three things, with the fourth split out into #664.What changed
1. Sampling (the issue's ask).
sampling=Nkeeps one detection in N, pushed into SQL viarow_number()rather than slicing a full fetch in Python (which would keep the scan and the serialization cost and only shrink the payload).sampling=1delegates to the existing path, so default behaviour is unchanged.2.
S3Bucketinstance caching.get_bucket()built a fresh instance per call andS3Bucket.__init__does a blockinghead_bucket, so every request paid one synchronous S3 round-trip on the event loop.3. Stable presigned URLs +
Cache-Control. boto3 stamps the current clock into every signature, so presigning the same key twice returns two different strings. The browser keys its cache on the full URL, so the player re-downloaded every frame on every poll even when the image had not changed. This is the client-side-caching half of the issue, and sampling alone does nothing for it.Performance, before vs after
The index this depends on lives in #664
The composite index on
detections(sequence_id, created_at)was originally part of this PR, but it turned out to be exactly one of the three indexes proposed in #663, so it moved to #664 to keep a single owner. The DB numbers below assume #664 has landed. Without it, the sequence read stays on a sequential scan and this PR's gain is limited to the S3 items.Measured there: 34.8 ms to 0.42 ms at 2M detections (roughly production today), and 63.6 ms to 1.1 ms at 5M.
Per request, 100 frames returned, 5M-row table
head_bucket(once per request)On top of that, sampling changes the shape of the work
Loading a 1000-frame timeline previously meant 10 requests (the old
limitceiling was 100), each paying the ~85 ms above. It is now one request atsampling=10, and the browser fetches 100 images instead of 1000.Worth stating plainly: most of that last factor is returning a tenth of the data by design, not the same work done faster. The honest split is roughly 20x from the fixes (of which the DB share needs #664) and 10x fewer bytes and images from sampling.
The browser-cache win is the one number missing here. Every poll after the first goes from re-downloading N images to N cache hits, which is probably the largest perceived gain for the player, but quantifying it needs the frontend rather than the API.
How this was measured, and two corrections
Numbers come from a seeded 5M-row database (50k sequences) on the dev compose stack. Before/after uses the same data, forcing the pre-change plan with
enable_indexscan=offrather than dropping and rebuilding the index, so the two measurements are directly comparable.Two measurement traps worth recording, since both changed the answer materially:
limitlocated a ~45 ms penalty that vanished between 60.9KB and 65.0KB of response body: the 64KB socket buffer, i.e. a 40 ms delayed-ACK stall on loopback inside the container. It affects before and after equally and is not a property of the API, but it makes absolute sub-64KB latencies from that harness meaningless, so the table above is built from measured components instead. Above 64KB timings go cleanly linear at ~0.03 ms per frame.API compatibility
No breaking change. Only one route's signature moved, and only additively:
samplingadded, default1= exactly the previous behaviourlimitceiling widened 100 to 500, so any previously valid request stays validdescriptionstrings rewordedThe response model is untouched. No routes were added, removed, or renamed, and auth is unchanged. The one behavioural change reaching every route that returns a presigned URL is that URLs are now stable within a window instead of unique per request, which is the point of item 3.
For pyro-platform: nothing is required, and items 2 and 3 speed up the existing call with no frontend work. To get the sampling win it needs to pass
sampling, and alsolimit:getDetectionsBySequencecurrently sends onlydesc, so it receives the API default of 10 detections.samplingandlimitare independent, which is the easy thing to get wrong:?sampling=10alone, with thedesc=trueandlimit=10defaults, returns the 10 most recent sampled frames, not a spread across the sequence. Spanning the sequence in one call needslimit >= ceil(detections_count / sampling), and sincelimitcaps at 500,sampling >= detections_count / 500.detections_countis already on the sequence object, so no extra endpoint is needed. Both the endpoint description and the client docstring now spell this out.A 20/20/20 player load on a 1000-detection sequence, verified against the endpoint rather than derived on paper:
60 distinct frames, no overlap between the three calls. General form for
Mmiddle frames:sampling = floor((N - 40) / M),offset = ceil(20 / sampling),limit = M. Credit to @fe51 for the recipe.Interaction with #624 (now merged)
Rebased onto main after #624 landed. Two points a reviewer should know:
GET /sequences/{id}/detectionsreturn continuity rows (frames attached withbbox="[]"where the object was not detected), and its own route description tells clients to filter onbboxif they only want real detections. Sampling thins that combined set rather than treating continuity rows specially, which keeps it consistent with the documented endpoint behaviour and withdetections_count, which feat(detections): accept empty bboxes and keep sequence frames continuous #624 also made include them.crud_detection.pyconflicted and was resolved by hand. feat(detections): accept empty bboxes and keep sequence frames continuous #624 addedget_latest_with_bbox, which needs sqlmodel'sselect(a single-entitySelectOfScalaris what makessession.execreturn aDetectionrather than aRow). The sampled query needs sqlalchemy'sselectfor its two-entity numbering subquery. Both now coexist:selectstays sqlmodel's, as feat(detections): accept empty bboxes and keep sequence frames continuous #624 wrote it, and sqlalchemy's is imported asselect_sa, so no line of feat(detections): accept empty bboxes and keep sequence frames continuous #624's code changed.Semantics worth reviewing
These are the decisions a reviewer should push on:
sampling=N)created_atdesc=trueanddesc=false;desconly flips output order. It also means an appended detection cannot renumber earlier rows, so the player keeps hitting the same frames and the same cached URLs across polls(rn - 1) % sampling == 0offsetlimitceilingsampling=10) saturated 100 exactly, sosampling=5would have been silently truncatedDeliberately not
id % N: detection ids are global rather than per-sequence, so the stride would be non-uniform and small sequences could return zero rows.Two known limitations:
limitcannot push down throughrow_number(), so the scan is proportional to sequence length regardless oflimit(inherent to interval sampling, and why the index matters); and sampling is uniform increated_at, notrecorded_at, so out-of-order uploads make it non-uniform in capture time.Verification
ty, deps-check). No new dependency.popitemwithclearfails it).Known limits of the URL cache
Both raised in review and tracked separately, neither blocking:
--workers, production runs several, and its deployment config is not in this repo. Corrected in the docstring.clear()the code comment argues against.These matter more once the frontend starts requesting large
limitvalues: a 1000-detection load burns ~2000 entries against a 8192 bound, so a handful of sequences evict each other and the cache stops paying off.Depends on
#664 for the
detections(sequence_id, created_at)index. This PR is functionally independent and its tests pass without it, but the sequence read stays on a sequential scan until it lands.Out of scope
GET /detections/is an unboundedfetch_allwith no limit or offset at all. Real problem, separate issue.Cache-Controlonto already-uploaded objects.deschere andorder_descon the alerts endpoints.