Skip to content

perf(db): add hot-path indexes for sequence matching, latest-bbox and shared frame lookups - #664

Open
Acruve15 wants to merge 2 commits into
mainfrom
alexis/hot-path-indexes
Open

Acruve15 wants to merge 2 commits into
mainfrom
alexis/hot-path-indexes

Conversation

@Acruve15

@Acruve15 Acruve15 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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 in sequences today).

CREATE INDEX CONCURRENTLY ix_sequences_camera_pose_last_seen ON sequences (camera_id, pose_id, last_seen_at);
CREATE INDEX CONCURRENTLY ix_detections_sequence_id_created_at ON detections (sequence_id, created_at);
CREATE INDEX CONCURRENTLY ix_detections_bucket_key ON detections (bucket_key);
Query Call site Without index With index
Recently-seen sequences of a pose (camera_id, pose_id, last_seen_at >) spatial matching and continuity, on every POST /detections 8 ms 0.07 ms
Latest real bbox of a sequence (sequence_id, created_at DESC, limit 1) spatial matching, once per candidate sequence per detection 128 ms 0.08 ms
Sibling rows by bucket_key shared-frame check in DELETE /detections/{id} 48 ms 0.10 ms

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 size

Index scan vs forced seq scan (enable_indexscan=off) on byte-identical data, so the two are directly comparable, for a 1000-frame sequence:

detections rows On disk Seq scan Index scan Speedup
200k 29 MB 7.6 ms 0.45 ms 17x
1M 140 MB 18.0 ms 0.53 ms 34x
2M (roughly production today) 266 MB 34.8 ms 0.42 ms 83x
5M 672 MB 63.6 ms 1.14 ms 56x

The 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. detections is the highest-write table, and a plain CREATE INDEX holds ACCESS EXCLUSIVE against camera ingest for the whole build. CONCURRENTLY cannot run inside a transaction and env.py wraps the migration run in one, hence op.get_context().autocommit_block().

The migration self-heals. A cancelled CONCURRENTLY build (deploy timeout, dropped connection) leaves an INVALID index behind. if_not_exists would 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 in pg_index and dropped first if invalid. The existence check joins pg_class by name rather than casting to regclass, since that cast raises when the relation is absent (the common case) instead of returning no rows.

Declared in models.py too. Per the issue, __table_args__ declarations keep create_all-built test databases matching production.

Verification

  • 630 tests pass on the rebased branch, ruff lint and format clean.
  • The concurrent build was verified on the real asyncpg path, which was the one genuine unknown: env.py drives an async engine through run_sync, so autocommit_block() flips isolation on a greenlet-backed sync facade. All three indexes come out indisvalid = true after alembic upgrade head.
  • The self-heal was verified end to end, not just read: forced all three to indisvalid = false, re-ran the migration, and confirmed all three came back valid.
  • The new sync tests were mutation-checked in both directions. My first attempt at this test was tautological and I want to flag it, since it is an easy trap: asserting the indexes exist in pg_indexes passes whether or not models.py declares them, because the test database is migrated. So there are now two tests pinning each side against one canonical list. Removing an index from models.py fails 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_at on 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, and get_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_revision is 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.

@Acruve15

Copy link
Copy Markdown
Collaborator Author

Split out of #661, which originally carried ix_detections_sequence_id_created_at on its own for the player's sequence reads. Same index, same name, so it now has one owner here and stays independently revertable.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.87%. Comparing base (81b7793) to head (f79c80d).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #664      +/-   ##
==========================================
+ Coverage   93.75%   93.87%   +0.12%     
==========================================
  Files          59       59              
  Lines        3152     3217      +65     
==========================================
+ Hits         2955     3020      +65     
  Misses        197      197              
Flag Coverage Δ
backend 93.99% <100.00%> (+0.12%) ⬆️
client 91.30% <ø> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Acruve15
Acruve15 requested a review from MateoLostanlen July 30, 2026 13:49
@Acruve15 Acruve15 self-assigned this Jul 30, 2026
@Acruve15
Acruve15 marked this pull request as ready for review July 30, 2026 13:49
@Acruve15
Acruve15 force-pushed the alexis/hot-path-indexes branch from ffa6a2d to 1f0a9b7 Compare July 31, 2026 09:22
@Acruve15
Acruve15 requested a review from fe51 August 22, 2026 13:28
…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
Acruve15 force-pushed the alexis/hot-path-indexes branch from 1f0a9b7 to 69692c3 Compare August 22, 2026 13:52
@Acruve15

Acruve15 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

hey @fe51 - this is an easy one to review and should also help speed up things 😇 if you have time to (approve) review! thx

@fe51 fe51 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — the self-heal reasoning is sound, and the measurement caveat about heap-page clustering (1-in-24 interleaving, 321 pages instead of 14) is the kind of thing most benchmarks quietly get wrong. Flagging that it moved the number by 3x is worth more than the number itself.

I verified the mechanics rather than just reading them, on a scratch Postgres 15:

  • alembic upgrade head on the real asyncpg path — all three come out indisvalid = true. The autocommit_block does survive the greenlet-backed sync facade.
  • The self-heal, end to end: forced all three to indisvalid = false, rewound the stamp, re-ran. _drop_if_invalid fires, DROP INDEX CONCURRENTLY is emitted for each, all three come back valid. It works as described.
  • alembic downgrade -1 drops all three cleanly.
  • Revision chain is linear and single-headed, down_revision is correct.
  • ruff clean, the 4 new tests pass.

Two things did not survive, and one of them is the reason for the main comment below.


The main thing: I'd drop CONCURRENTLY

CONCURRENTLY buys availability, not speed — it's slower, two passes over the table plus a wait for in-flight transactions between them. What the extra time purchases is that writes keep flowing during the build.

But the start command is alembic upgrade head && python app/db.py && uvicorn ..., so uvicorn doesn't come up until the builds finish. The API refuses connections for the whole duration either way. There are no writes to protect, so we're paying the cost and getting nothing back.

The volumes make it moot regardless. 2.6M detections / 56k sequences is ~46 detections per sequence — a burst of frames at one per 30s. At a few sequences a day across ~50 cameras that's on the order of 2k detections/day, so 1-2 inserts per minute on average, maybe 10/min with several bursts overlapping. Not per second. A plain CREATE INDEX on a 266MB table takes seconds and holds a SHARE lock; at that rate it would block one or two inserts, which then proceed normally. That's the full extent of what CONCURRENTLY is defending here.

So:

def upgrade() -> None:
    for index_name, table_name, columns in INDEXES:
        op.create_index(index_name, table_name, columns, unique=False)


def downgrade() -> None:
    for index_name, table_name, _ in reversed(INDEXES):
        op.drop_index(index_name, table_name=table_name)

That removes the autocommit_block (and the asyncpg question with it), if_not_exists, and _drop_if_invalid — a plain CREATE INDEX is transactional, so it cannot leave an invalid index behind. There's nothing to self-heal.

The condition is that nothing else writes to detections during the build, which holds with a stop-then-start deploy. If there's a rolling strategy I'm not seeing, where the old container keeps serving while the new one migrates, then CONCURRENTLY earns its keep and we just fix the next point instead. You'd know better than me — what's the actual deploy shape?

My general feeling is that CONCURRENTLY is worth writing the day it's needed, properly, once. Adding it pre-emptively costs real complexity, and the proof is that there's a hole in it:


if_not_exists can make the migration lie

CREATE INDEX ... IF NOT EXISTS matches on the name alone — Postgres never compares the definition. Reproduced:

CREATE INDEX ix_detections_bucket_key ON detections (crop_bucket_key);   -- wrong column

alembic upgrade head
  → "Running upgrade c4e9f1a2b3d5 -> e8f3a6c9d1b7"
  → no error
  → alembic_version = e8f3a6c9d1b7
  → ix_detections_bucket_key ON detections USING btree (crop_bucket_key)

Clean success, revision stamped, wrong index left in place, bucket_key lookups on a sequential scan permanently, and nothing will ever say so. Same outcome if any non-index relation squats the name — I parked a plain CREATE TABLE ix_detections_bucket_key (x int) there and the migration skipped it with a notice and stamped.

_drop_if_invalid doesn't fire because the index is perfectly valid. It's just the wrong index.

This is exactly the failure the docstring argues against — "the upgrade would report success while the planner ignored the invalid index" — reached through a different door. And it's live specifically on the manual pre-creation path the description recommends: the one scenario where these indexes exist before the migration runs, and the one where a human types the column list by hand.

If CONCURRENTLY stays, the guard needs widening from "invalid" to "invalid or not the index we want". The expected columns are already in INDEXES, so it's a matter of comparing them to pg_index.indkey, plus restricting the lookup to the current schema and the target table — right now it joins pg_class on relname alone, so a same-named index elsewhere can be matched and .first() picks arbitrarily. Happy to push that as a commit if you want to keep CONCURRENTLY.


The tests pin names, not columns

Worth doing either way, and it's about two lines.

I swapped the column order in models.py to Index("ix_detections_sequence_id_created_at", "created_at", "sequence_id") — an index that serves none of these queries, since a B-tree on (a, b) sorts by a first and is useless for b alone — and all 4 tests stayed green.

Column order is what decides whether an index gets used at all, so it's the drift most worth catching, and it's also the one that looks most like harmless tidying to someone who hasn't thought about B-trees. The propagation path is the concerning part: editing models.py alone doesn't change production, but the next alembic revision --autogenerate sees the difference and generates a migration that drops and recreates the index in the wrong order — inside a file nobody reads closely because it's auto-generated.

EXPECTED_INDEXES = {
    "detections": {
        "ix_detections_sequence_id_created_at": ["sequence_id", "created_at"],
        "ix_detections_bucket_key": ["bucket_key"],
    },
    "sequences": {
        "ix_sequences_camera_pose_last_seen": ["camera_id", "pose_id", "last_seen_at"],
    },
}

Model side becomes {idx.name: [c.name for c in idx.columns] ...}; database side reads the columns out of pg_index.indkey instead of just indexname.

One caveat worth a comment in the file: the database-side test is only non-tautological because the container runs alembic upgrade head before pytest. async_session calls create_all, so against a fresh unmigrated database that test would pass off the model declarations alone. It works — it just isn't self-evident from reading it.


Smaller notes

ix_detections_sequence_id_created_at has five consumers, not two — worth putting in the description, it strengthens the case:

  • get_latest_with_bbox, once per candidate sequence per POST /detections
  • the player's sequence reads, sequences.py:96
  • the validation worker, validation.py:116
  • the frame-count subquery in crud_sequence.py:165, on every validation job completion
  • and incidentally detections.py:653sequence_id IS NULL AND created_at > cutoff on the new-sequence path. B-trees index NULLs, so that's an exact prefix-plus-range match. An unindexed hot query this PR fixes for free.

ix_sequences_validation_due_at (from c5e2f7a8b1d0) is in neither models.py nor EXPECTED_INDEXES. It's partial, so create_all-built databases don't have it — the same drift this PR is closing, still open. Out of scope here, but it means the list isn't quite canonical yet.

ix_detections_bucket_key's only consumer is the admin DELETE /detections/{id}. Keys are short and roughly append-ordered per camera so the write cost is fine, but worth saying out loud that this one buys a rare admin operation at the price of index maintenance on the highest-write table.

Small correction: a plain CREATE INDEX takes a SHARE lock, not ACCESS EXCLUSIVE — it blocks writes but not reads. Doesn't change the argument, just so we don't propagate it.

Cosmetic: the migration filename dates don't follow the graph order (c4e9f1a2b3d5 is dated 05-27 but comes after c5e2f7a8b1d0 dated 06-10). Pre-existing, but it makes the chain unreadable from ls.


On the deployment question

I'd argue against pre-creating the indexes by hand: it's what opens the if_not_exists hole above, it requires typing column lists at a keyboard, and at 266MB it saves under a minute. Better to measure the real build times once on a restored backup (\timing on, run the three statements) and then just let the migration do it during a quiet evening.

Worth doing the morning after either way: EXPLAIN ANALYZE on the three query shapes in production, to confirm the planner is actually using them. Postgres will happily ignore an index it doesn't think is worth it, and that's the only real proof it worked.


Over to you

None of the above is a blocker in my mind, and I'd rather ask than assume: what do you think about dropping CONCURRENTLY?

I might missed context here. There may be a deploy shape I'm not seeing, a plan to scale detections well past the current volume, an incident that motivated the caution in the first place, or simply a reason you'd rather have the machinery in place before it's needed than write it under pressure later. Any of those would change my answer, and you're closer to this than I am — so push back if I've got it wrong.

The one point I'd still want addressed whichever way we go is the if_not_exists silent no-op, since the description recommends the manual pre-creation path that walks straight into it. The rest is cheap enough to fold into the same pass.

Co-reviewed with Claude Code

Review from @fe51 on #664. Three real holes, all independent of the
CONCURRENTLY question still open on that thread.

CREATE INDEX IF NOT EXISTS matches on the name alone: Postgres never compares
the definition. So a same-named index over the wrong columns made the create a
silent no-op, stamped the revision, and left the queries on sequential scans
with nothing to say so. @fe51 reproduced it by pointing ix_detections_bucket_key
at crop_bucket_key. The old guard only looked at indisvalid, which is no help
because a wrong index is perfectly valid.

The guard now compares the built column list against INDEXES and drops on any
mismatch, so that repro ends with btree (bucket_key) instead of
btree (crop_bucket_key). It also scopes the lookup to the current schema and
checks the owning table, since the old query joined pg_class on relname alone
and .first() would pick arbitrarily between same-named indexes. A non-index
relation squatting the name now raises rather than being skipped: dropping
someone's table to make room for an index is not a repair.

Writing that guard needed two passes. relkind is Postgres "char" and arrives as
bytes over this driver, so the first version compared b'i' to 'i' and raised on
every legitimate index. Caught by running the repro, not by reading it.

The index tests pinned names only. @fe51 swapped a column order in models.py and
all four stayed green, which matters because a B-tree on (a, b) is useless for b
alone, so a reorder serves none of these queries while looking like tidying, and
the next --autogenerate propagates it into a real migration. Both sides now
compare ordered column lists, the database side reading pg_index.indkey. The
same swap now fails.

Also corrects the lock claim: a plain CREATE INDEX takes SHARE, which blocks
writes but not reads, not ACCESS EXCLUSIVE as the comment said.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add hot-path indexes for sequence matching, latest-bbox lookups and shared frame objects

2 participants