Repository navigation
perf: claim rows with ordered per-queue scans; add id to _pending_idx - #229
Merged
Merged
Conversation
Benchmark gate✅ gate passed
Gated (fails the build): |
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 #217. For 0.15.0: a fetch now reads about
limitrows however large the backlog is, and_pending_idxbecomes(queue, next_attempt_at, id). Upgrading needs a concurrent index rebuild, and the docs give the migration.The problem
The claim statement filtered with
queue = ANY(:queues) AND (acquired_token IS NULL OR <expired lease>), then ranORDER BY next_attempt_at, id LIMIT n FOR UPDATE SKIP LOCKED. Postgres can't serve that OR or the= ANYwith an ordered index scan, and it doesn't push the row limit throughLockRows. So every fetch read and sorted every ready row on the queue. Draining a backlog got slower roughly with the square of its size, and it was worst exactly when the consumer was catching up.The rewrite
The new shape is the one measured in the issue:
unnest-ed.CROSS JOIN LATERALbranches, unleased rows and expired leases. Each branch is an orderedLIMIT n FOR UPDATE SKIP LOCKEDscan.UNION ALL, and an outerORDER BY next_attempt_at, id LIMIT npicks the final rows.UPDATE ... RETURNINGis unchanged.It's a SQLAlchemy Core statement built once per client, like the DLQ CTE from #226, with the queues, limit, lease TTL and new token as bind parameters. The SQL text is the same for any number of queues, so asyncpg's prepared-statement cache keeps working.
_pending_idxgainsidas its last column. Rows published in one transaction sharenext_attempt_at, and withoutida fetch reads the whole tie group.Measured:
EXPLAIN ANALYZE, limit 10, each run against its own index shape, best of three. "Rows read" is the largest number of rows any plan node read from the outbox table.The last row is the expired-lease branch. It still sorts every expired lease on its queue. An index for that branch stays deferred (#220) until it's measured with a realistic number of hung handlers.
The rewrite briefly locks up to
2 × limitrows per queue while claiming onlylimit. Other fetchers skip those rows until the claim commits, which affects fairness, not correctness.Drift, no grace period
As decided,
validate_schema()reports the old index shape as drift. Alembic's index comparison already catches the changed column list, so no new probe was needed. The error readsmissing index over ['queue', 'next_attempt_at', 'id'] (name='outbox_pending_idx', ...).Upgrade
docs/operations/alembic.mdhas a new "Upgrading to 0.15" section with the migration:CONCURRENTLYunder a temporary name, drop the old oneCONCURRENTLY, then rename;autocommit_block(), plus a matching downgrade.The section also covers:
INVALIDindex that has to be dropped before retrying.CONCURRENTLY.I ran the documented
upgrade()anddowngrade()through Alembic against real Postgres. The old shape failsvalidate_schema(), passes afterupgrade(), anddowngrade()restores the old index.Tests
Two tests failed before the change:
test_fetch_reads_only_the_rows_it_claims_from_a_backlogseeds 20,000 tied ready rows, captures the statementfetchexecutes, and runs it underEXPLAIN ANALYZEinside a rolled-back transaction. It asserts that no plan node reads more than2 × limitrows from the outbox. The old statement read 19,990. This checks rows read, not node names or timings, so it doesn't depend on which plan Postgres picks, and it also catches an index withoutid.test_validate_schema_reports_the_pre_0_15_pending_index_shapechecks the drift error for the old index.Two new contract tests pin the outer
ORDER BY ... LIMITin both clients: the oldest rows across several queues, and the oldest across pending rows and expired leases. They passed before and after.The invariant test on the statement's shape now checks the new form:
unnest-ed array;LATERALjoins.Docs
alembic.md: the initial migration, index descriptions, drift table, and the upgrade section.how-it-works.md: the query illustration and why it has this shape.just adr-checkpasses.Verification
function unnest(unknown) is not unique: SQLAlchemy 2.0.0 sends the array bind without a type, the same problem fix: flush buffered deletes before the lease expires and match them on (id, token) #223 hit. The queue array now has an explicit cast.just bench-checkpasses, and no gated counter moved.ruffis clean,tyshows the same 6 diagnostics asmain, and the docs build.Release note (0.15.0, needs a migration)