perf(db): add hot-path indexes for sequence matching, latest-bbox and shared frame lookups - #664
Open
Acruve15 wants to merge 1 commit into
Open
perf(db): add hot-path indexes for sequence matching, latest-bbox and shared frame lookups#664Acruve15 wants to merge 1 commit into
Acruve15 wants to merge 1 commit into
Conversation
Collaborator
Author
|
Split out of #661, which originally carried |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #664 +/- ##
=======================================
Coverage 93.73% 93.73%
=======================================
Files 59 59
Lines 3143 3146 +3
=======================================
+ Hits 2946 2949 +3
Misses 197 197 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…okups Closes #663. Three query shapes on the detection hot path had no index behind them, so each seq-scanned a growing table: - a pose's recently-seen sequences (camera_id, pose_id, last_seen_at), run on every POST /detections during spatial matching - the latest real bbox of a sequence (sequence_id, created_at), run once per candidate sequence per detection, and the same shape the player's sequence reads sort on - sibling rows sharing a frame object (bucket_key), on DELETE /detections/{id} Measured on the (sequence_id, created_at) shape with production-scale synthetic data, index scan vs forced seq scan on identical rows: 7.6ms -> 0.45ms at 200k detections, 34.8ms -> 0.42ms at 2M (roughly production today), 63.6ms -> 1.1ms at 5M. The seq-scan side grows linearly with the table while the index scan stays flat, so the gap widens as detections accumulate. Built with CREATE INDEX CONCURRENTLY inside an autocommit block: detections is the highest-write table and a plain build would hold ACCESS EXCLUSIVE against camera ingest for its whole duration. The migration also self-heals, dropping an index left INVALID by a cancelled build, which if_not_exists would otherwise skip while the upgrade reported success and the planner ignored it. The indexes are declared in models.py as well as the migration so create_all built test databases match production, with tests pinning both sides against one list since drift between them is otherwise invisible.
Acruve15
force-pushed
the
alexis/hot-path-indexes
branch
from
July 31, 2026 09:22
ffa6a2d to
1f0a9b7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #663.
Three query shapes on the detection hot path had no index behind them, so each one seq-scanned a table that keeps growing (~2.6M rows in
detections, ~56k insequencestoday).camera_id,pose_id,last_seen_at >)POST /detectionssequence_id,created_at DESC, limit 1)bucket_keyDELETE /detections/{id}Attribution: rows 1 and 3 are the figures from #663 and I have not independently reproduced them. Row 2's query shape is the one I did measure, in more detail below. Happy to benchmark the other two if that is worth it before merge.
The
(sequence_id, created_at)index, measured by table sizeIndex scan vs forced seq scan (
enable_indexscan=off) on byte-identical data, so the two are directly comparable, for a 1000-frame sequence:detectionsrowsThe seq-scan side grows linearly with the whole table while the index scan stays flat, so this gain keeps widening as detections accumulate. Note this index serves two independent hot paths: the per-detection latest-bbox lookup described in #663, and the sequence reads the player pages through.
One measurement caveat worth recording, because it moves the number by nearly 3x: a first attempt showed 160x, because the test sequence's 1000 rows had been inserted consecutively and so occupied only 14 heap pages (~71 rows/page). Real detections arrive interleaved with the rest of the fleet's writes, so the sequence was rebuilt 1-in-24 across a 24k-row window (321 pages, ~3 rows/page). The table above uses the interleaved layout.
Implementation notes
CONCURRENTLY, in an autocommit block.detectionsis the highest-write table, and a plainCREATE INDEXholds ACCESS EXCLUSIVE against camera ingest for the whole build.CONCURRENTLYcannot run inside a transaction andenv.pywraps the migration run in one, henceop.get_context().autocommit_block().The migration self-heals. A cancelled
CONCURRENTLYbuild (deploy timeout, dropped connection) leaves an INVALID index behind.if_not_existswould then see the relation and skip creating it, the revision would stamp, and the upgrade would report success while the planner ignored the invalid index, silently leaving these queries on seq scans. So each index is checked inpg_indexand dropped first if invalid. The existence check joinspg_classby name rather than casting toregclass, since that cast raises when the relation is absent (the common case) instead of returning no rows.Declared in
models.pytoo. Per the issue,__table_args__declarations keepcreate_all-built test databases matching production.Verification
env.pydrives an async engine throughrun_sync, soautocommit_block()flips isolation on a greenlet-backed sync facade. All three indexes come outindisvalid = trueafteralembic upgrade head.indisvalid = false, re-ran the migration, and confirmed all three came back valid.pg_indexespasses whether or notmodels.pydeclares them, because the test database is migrated. So there are now two tests pinning each side against one canonical list. Removing an index frommodels.pyfails the declaration test; removing it from the migration fails the database test. Both were confirmed to fail.Coordination with #661
#661 (detection sampling for the player, issue #660) originally carried
ix_detections_sequence_id_created_aton its own, since the player's sequence reads need exactly that index. It has been rebased to drop that migration and now depends on this PR, so the index has a single owner and stays independently revertable, which is the point of having split it out of #624.Worth noting the shared index is probably more valuable here than there: on this path it runs several times per
POST /detections, once per candidate sequence.Sequencing
#624 has now merged, so the dependency the issue mentions is satisfied. That also raises the value of this PR: the continuity pass doubles how often the
(camera_id, pose_id, last_seen_at)query runs, andget_latest_with_bbox(added by #624,src/app/crud/crud_detection.py) is exactly the(sequence_id, created_at DESC, limit 1)shape the second index serves, so it is now unindexed on every detection request.Rebased onto main after #624; no conflicts, and
down_revisionis still the current head (c4e9f1a2b3d5, since #624 added no migration).One deployment decision needs a human call: the container start command runs
alembic upgrade head, so on the first deploy the concurrent builds run at boot and delay the healthcheck in proportion to table size. Pre-creating the three indexes manually beforehand makes the migration a no-op (if_not_exists), which may be the calmer path for production.