Skip to content

perf: order the expired-lease index like the fetch - #240

Merged
lesnik512 merged 1 commit into
mainfrom
perf/leased-index
Oct 17, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
perf/leased-index

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Closes #220. Ships in 0.17.0 because it changes the schema; the release is separate.

Why

The fetch's expired-lease branch orders by (next_attempt_at, id), but the only index over leased rows was <table>_lease_idx (queue, acquired_at). So each fetch read and sorted every expired lease on the queue, and fetch time grew with the number of hung handlers. ADR 0003 left this open until it was measured; the triage on #220 measured it.

The change

  • The index: <table>_leased_idx (queue, next_attempt_at, id) WHERE acquired_token IS NOT NULL replaces <table>_lease_idx. The expired check (acquired_at older than the TTL) becomes a filter on an ordered scan that stops after limit rows. The fetch SQL is unchanged.
  • Replacing, not adding: the table keeps the same number of indexes with the same partial predicate, so claims pay no extra write. With both indexes present the planner also sometimes picked badly (3.69 ms at 10k expired in the triage run).
  • The name length: _leased_idx is 11 characters, shorter than _pending_idx and _timer_id_uq, so the longest accepted table name stays 51 bytes. The length guard now lists the new suffix in place of the old one.
  • validate_schema():
    • It checks the new index's predicate, as it does for the other partial indexes.
    • When _leased_idx is missing, the error message also links to the new upgrade section, the same way the 0.15 _pending_idx hint works.
    • A table that still has the old index passes the predicate check unchanged, because extra indexes are ignored.

Measured

1M ready rows, 50k expired leases spread at random over the backlog, 2k in-flight leases at the head of the queue. Each fetch claims 100 rows inside a transaction that's rolled back. Median of 20 fetches, with the plan from EXPLAIN (ANALYZE, FORMAT JSON) of the real statement:

old (queue, acquired_at) new _leased_idx
Postgres 17 9.7–11.3 ms; expired branch: Sort over an Index Scan reading 50,000 rows 1.35–1.40 ms; Index Scan on _leased_idx reading 100 rows, no Sort
Postgres 13 15.4 ms; same Sort over 50,000 rows 1.46 ms; Index Scan on _leased_idx reading 100 rows, no Sort

The triage table on #220 has more cases (0 to 50k expired, and 20k in flight), and the new index stays at about 1.4 to 1.5 ms across them.

Tests

  • test_fetch_reads_only_the_rows_it_claims_from_expired_leases is an invariant test: on 20k rows with 5k expired leases, the fetch reads at most twice limit rows from the table. With the old index columns it read 4,997. It shares a new _explain_fetch helper with the existing backlog test, which was extracted from that test unchanged.
  • test_validate_schema_reports_the_pre_0_17_lease_index: a table with the old index and without the new one fails validation with the upgrade link.
  • test_validate_schema_checks_the_leased_index_predicate: a non-partial index, or one with the wrong predicate, is reported.
  • The unit test for the index declaration now checks the new columns and predicate, and that the old name is gone.
  • The existing fetch tests and the client contract suite pass unchanged.

Docs

  • Alembic guide: a new "Upgrading to 0.17" section, with a create-then-drop migration using CONCURRENTLY and a downgrade. If a 0.16 health probe runs validate_schema(), it reports outbox_lease_idx missing once the old index is dropped, so the section says to move the drop into a second migration that runs after 0.17 is deployed. The autogenerate output and the index table show the new index.
  • ADR 0003: the closing sentence records the decision and the numbers.
  • Tutorial: the \d outbox listing shows the new index. It also fixes the stale outbox_pending_idx line, which was missing id since 0.15.

Verification

  • 744 tests pass in docker at 100% coverage.
  • The integration and contract suites also pass on postgres:13.
  • ruff and ty are clean, and mkdocs build --strict passes.
  • just bench-check passes and no gated counter moved.

Replace <table>_lease_idx (queue, acquired_at) with <table>_leased_idx
(queue, next_attempt_at, id) WHERE acquired_token IS NOT NULL, so the
fetch's expired-lease branch stops after `limit` rows instead of sorting
every expired lease on the queue. validate_schema() reports a table
without the new index and points at the upgrade section.

Closes #220
@github-actions

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 1954 1.000 6.71 921 242 5000 5000 10000
consumer/w1/b100 2082 1.000 7.11 1154 243 5000 5000 10000
consumer/w2/b10 2668 1.000 6.72 923 242 5000 5000 10000
consumer/w2/b100 3305 1.000 7.11 1154 243 5000 5000 10000
consumer/w4/b10 2742 1.000 6.74 949 242 5000 5000 10000
consumer/w4/b100 3239 1.000 7.11 1154 243 5000 5000 10000
consumer/w1/b100/tfbs100 9393 0.010 6.11 1130 243 5000 5000 10000
consumer/w1/b100/interval1-10 2379 1.000 7.11 1154 243 5000 5000 10000
producer/w1/b100 6009 0.000 3.04 597 1 0 0 0
producer/w1/b100/ppt1 2001 0.000 4.04 635 0 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls and other_calls within +2; wal_records within a 10% band; fetch_calls within 2x. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

@lesnik512
lesnik512 merged commit fa14dd9 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/leased-index branch October 17, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expired-lease fetch branch still sorts every expired lease

1 participant