Repository navigation
Conversation
|
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 91.30% 93.87% +2.57%
==========================================
Files 3 59 +56
Lines 138 3217 +3079
==========================================
+ Hits 126 3020 +2894
- Misses 12 197 +185
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ffa6a2d to
1f0a9b7
Compare
…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.
1f0a9b7 to
69692c3
Compare
|
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
left a comment
There was a problem hiding this comment.
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 headon the real asyncpg path — all three come outindisvalid = true. Theautocommit_blockdoes 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_invalidfires,DROP INDEX CONCURRENTLYis emitted for each, all three come back valid. It works as described. alembic downgrade -1drops all three cleanly.- Revision chain is linear and single-headed,
down_revisionis 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 perPOST /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:653—sequence_id IS NULL AND created_at > cutoffon 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.
The deploy stops the backend before migrating (docker compose stop backend && docker compose up -d --wait, .github/workflows/push.yml), so nothing writes to detections while these build. CONCURRENTLY was buying availability nobody could use, and paying for it with two table passes. Dropping it takes the autocommit block, if_not_exists and the whole _ensure_absent_or_replaceable guard with it: a plain CREATE INDEX is transactional, so it cannot leave an invalid index to self-heal, and there is no IF NOT EXISTS matching on the name alone to be lied to. The upgrade comment now lists all five consumers of (sequence_id, created_at), and says out loud that (bucket_key) buys one rare admin delete at the price of index maintenance on the busiest table.
ix_sequences_validation_due_at existed only in migration c5e2f7a8b1d0, so create_all-built databases never had it and `alembic check` reported it as a removed index. Same drift this branch is closing. The predicate needs pinning separately, because autogenerate compares index columns but not postgresql_where: removing the WHERE here is silent, and turns the queue's handful of due rows into an entry per sequence. Verified by mutation, with the predicate dropped alembic still reports no new operations while test_models fails.
d367d9a to
933b57f
Compare
Merging main brought in d6f3a8b2c4e1, which also revises c4e9f1a2b3d5, so the chain had two heads and `alembic upgrade head` refused to run. Repointed onto it and redated the filename to match, which it can be since this revision has never been applied anywhere. Main also carries the fix that made the bucket CORS policy best-effort (#685). Without it the backend exited at boot on this branch, which is what took out all four container-backed CI jobs.
|
Hey @fe51 👋 Feedback taken into account:
One extra from doing it: alembic check ignores postgresql_where, so |
|
Hi @Acruve15 , sorry, I'm closing #664. We took too long to review it and coordinated badly with @frgfm , who opened #705 building on your indexes. Both PRs create the same indexes, so we could only merge one. Thanks a lot for your work on this, your analysis and benchmarks really laid the groundwork 🙏 |
|
@MateoLostanlen it's indeed a shame and it could have been better handled. My main frustration is the time it took to review this PR vs the one opened by @frgfm. The overlap was flagged on #705 the day it was opened, so it could have been built on top of this PR, adding only the extra partial index. One thing that would help next time: when a new PR overlaps an open one, ping the original author before merging, so they can merge, rebase or close their own PR Anyway, glad the indexes landed 🙂 |
|
@Acruve15 I completely understand, and you're right. Your PR waited way too long for review on our side, and since the overlap was flagged early, pyronear/pyro-api#705 should have been built on top of yours with just the extra partial index. Pinging the original author before merging an overlapping PR is a good rule, we'll follow it from now on. @frgfm please keep this in mind next time. Thanks again for the work and for taking it so well 🙏 |
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}Rows 1 and 3 are the figures from #663, not independently reproduced. Row 2 is the shape I measured, below.
(sequence_id, created_at)has five consumersFull list from @fe51's review, and the strongest argument in this PR:
get_latest_with_bbox, once per candidate sequence perPOST /detectionsendpoints/sequences.pyservices/validation.pycrud_sequence.py, on every completed validation jobendpoints/detections.py:sequence_id IS NULLplus acreated_atcutoff. B-trees index NULLs, so that is an exact prefix-plus-range match, and an unindexed hot query this PR fixes for free.ix_detections_bucket_keyis the opposite case: its only consumer is the adminDELETE /detections/{id}. It buys a rare operation at the price of index maintenance on the highest-write table. Keys are short and roughly append-ordered per camera so the write cost is acceptable, but it is a real cost.Measured by table size
Index scan vs forced seq scan (
enable_indexscan=off) on byte-identical data, for a 1000-frame sequence:detectionsrowsThe seq-scan side grows with the whole table while the index scan stays flat, so the gain keeps widening.
Caveat worth recording, because it moved the number by nearly 3x: a first attempt showed 160x, because the test sequence's 1000 rows were inserted consecutively and occupied only 14 heap pages. Real detections arrive interleaved with the rest of the fleet's writes, so it was rebuilt 1-in-24 across a 24k-row window (321 pages). The table above uses the interleaved layout.
Implementation notes
Plain
CREATE INDEX, notCONCURRENTLY..github/workflows/push.ymlrunsdocker compose stop backend && docker compose up -d --wait, anddeploy-devis the only deploy job in the repo. The backend is stopped before the migration runs, so nothing writes todetectionswhile these build, andCONCURRENTLYwas paying two table passes for availability no writer could use. Removing it takes the autocommit block,if_not_existsand the invalid-index self-heal with it: a plainCREATE INDEXis transactional, so it cannot leave an invalid index behind. It also closes a hole, sinceif_not_existsmatched on the index name alone and would have skipped silently over an index defined on the wrong columns. Correcting the earlier version of this description: that lock isSHARE, notACCESS EXCLUSIVE. It blocks writes, not reads.ix_sequences_validation_due_atis now declared on the model too, alongside the three new ones. It existed only in migrationc5e2f7a8b1d0, socreate_all-built databases never had it: the same drift this PR exists to close. Its partial predicate needs pinning separately from its columns, because autogenerate compares index columns but notpostgresql_where. Verified by mutation: with the predicate removed,alembic checkstill reports no new operations, so nothing but the test would catch a partial index quietly becoming a full one over all 56k sequences.Verification
alembic downgrade -1 && alembic upgrade headround-trips on the real asyncpg path, now in a single transaction.(a, b)is useless forbalone, so a reordered index serves none of these queries while looking like harmless tidying, and the next--autogeneratewould propagate the reorder into a real migration.pg_indexespasses whether or notmodels.pydeclares them, because the test database is migrated. Two tests now pin each side against one canonical list, both confirmed to fail in the right direction.Sequencing
#624 has merged, so the dependency the issue mentions is satisfied, and it raises the value here: the continuity pass doubles how often the
(camera_id, pose_id, last_seen_at)query runs, andget_latest_with_bbox(added by #624) is exactly the shape the second index serves. Rebased onto main, no conflicts,down_revisionis still the current head (c4e9f1a2b3d5).#661 originally carried
ix_detections_sequence_id_created_aton its own. 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.Deploying
No manual pre-creation. An earlier version of this description recommended creating the three indexes by hand first; that is the one path where a human types the column lists at a keyboard, which is exactly what made the old
if_not_existsdangerous. At 266 MB it saves under a minute. Instead, both from @fe51:\timing on), then let the migration run during a quiet evening.EXPLAIN ANALYZEthe three shapes in production. Postgres will happily ignore an index it does not think is worth it, and that is the only real proof this worked.