Skip to content

perf(queue-storage): asynchronous claim commits and claimer-lease reuse (0.6) - #722

Merged
hardbyte merged 4 commits into
release/0.6.6from
claude/beautiful-dijkstra-vqdyqy
Nov 11, 2026
Merged

hardbyte merged 4 commits into
release/0.6.6from
claude/beautiful-dijkstra-vqdyqy

Conversation

@hardbyte

Copy link
Copy Markdown
Owner

0.6 port of #507 and #508, fix-first on the 0.6 line per the branch policy in #383. No schema, configuration or protocol change.

Why

On 0.6.16 a claim holds its lane's queue_claim_heads row lock until it commits, and the post-commit cursor advance takes the same row. Both committed synchronously, so every claim spent a WAL flush inside the lane lock and claims queued behind each other while neither PostgreSQL nor the worker was saturated.

What changes

  • Asynchronous claim commits (perf(queue-storage): commit claim transactions asynchronously #507). claim_runtime_batch, claim_batch and the claim-cursor advance begin with BEGIN; SET LOCAL synchronous_commit = off. Enqueues, completions, heartbeats and every other transaction stay synchronous. The setting is local to those transactions.
  • Cursor advances need claim evidence (perf(queue-storage): commit claim transactions asynchronously #507). An advance that follows a claim applies only while that claim is visible on the server it runs against, so a retry that lands on a promoted standby cannot move the cursor past a claim that never reached it. The AwaAsyncClaimCursor TLA+ model and its unguarded counterexample come with it.
  • Claimer-lease reuse (perf(queue-storage): reuse the claimer lease across claims #508). A claim reuses this instance's last read or refreshed claimer lease for up to 250 ms, never within a second of its expiry, instead of re-reading the claimer target and lease row on every call.

This is not a mechanical port

The claim path on 0.6 differs from main where #507 touches it, because compact deadline claim batches (#410) are not on 0.6.

  • The evidence guard was wrong as ported, and is fixed here. perf(queue-storage): commit claim transactions asynchronously #507 treats a claim with no claim batch id as a legacy claim and looks for its leases row. On main every receipt claim is compact, so that holds. On 0.6 a receipt claim with a deadline, the default, is written to lease_claims with no batch id and has no leases row. Its advance never applied, and the cursor moved only through spent-prefix recovery. Four tests in queue_storage_runtime_test failed on the straight port. The guard now keys on the receipt id: both receipt variants on 0.6 carry one and both write a ready_claim_attempt_batches row in the claim transaction.
  • Left out. The squashed main commits also carried perf(queue-storage): unnest INSERT for enqueue batches; skip COPY below 2048 rows #506's enqueue rewrite and its 2,048-row COPY cutoff, and sqlx 0.9's audited_sql in the tests. Neither is in this PR.

Why it is safe on 0.6

  • A lost claim is lost whole. The claim transaction writes the attempt batch, the claim row and the unique-claim state change. A crash before the WAL flush loses all of them and the job is claimable again.
  • The cursor cannot outlive its claim on one server. The cursor moves with an exact setval, written to WAL after the claim's records. Recovery and streaming replication both apply a WAL prefix, so a surviving advance implies a surviving claim.
  • The window is short. Any later synchronous commit, such as a completion, a heartbeat or an enqueue, flushes every earlier claim.
  • Evidence does not disappear early. ready_claim_attempt_batches rows are insert-only on 0.6 and are removed only by prune or reset.
  • A reissued receipt id cannot close another job's claim. Receipt ids come from a sequence that can rewind after a crash. Completion and heartbeat match on job id, run lease and receipt id together.
  • The claimer lease is a hint. The cache skips two reads; job ownership still comes from the claim transaction. Worst case two instances claim one queue for up to 250 ms after a slot is taken over or the target is lowered. Nothing else passes through the gate on 0.6 (no pause, shard or runtime-control state).

A behaviour change to know about

A worker still running a claim that was lost before its WAL flush is not fenced. The job's next claim reuses the lost claim's run lease, so that worker's heartbeat or completion can be accepted against the new attempt. Delivery stays at-least-once. Before this change it needed a failover with asynchronous replication; a primary crash can now cause it, until the next synchronous commit or WAL writer flush. docs/architecture.md and the changelog say so.

With synchronous replication, an acknowledged claim is no longer guaranteed to have reached a standby.

Measurements

test_throughput_rust_workers_queue_storage in release mode: 20,000 no-op jobs, 16 workers, one queue, PostgreSQL 18 in a container on a 4-core host. base is 0515f339; backport is this branch before the rebase onto #720. Two runs per cell, end-to-end jobs/s.

WAL flush Deadline base backport
Native (about 0.25 ms fsync) none 1,161 / 783 1,262 / 1,312
Native 300 s 818 / 1,012 1,237 / 1,235
Emulated 5 ms none 491 / 506 1,713 / 1,666
Emulated 5 ms 300 s 479 / 502 1,285 / 1,324
  • The slow flush is emulated with commit_delay = 5000 and commit_siblings = 0, which makes every synchronous commit wait 5 ms before flushing. It stands in for a slow disk; it is not one.
  • The base figures under the emulated flush come from the test's 30 s limit: it completed about 14,700 to 15,200 of 20,000 jobs. The benchmark's own 3,000 jobs/s threshold is tuned for 100 workers, so every run exits non-zero; the throughput lines are what is reported here.
  • The base runs on native flush vary by about 40% between repetitions, so the native-flush gain is indicative only.
  • The deadline column needed a one-line local change to the benchmark to read a deadline from the environment. It is not in this PR.

Tests

  • queue_storage_runtime_test: 268 pass on PostgreSQL 18, including test_queue_storage_cursor_advance_requires_claim_evidence for receipt and legacy claims and the claimer-cache tests. The receipt-claim case fails on the straight port.
  • awa-model lib tests pass; cargo fmt and clippy (awa-model, awa, awa-worker, all targets and features) are clean.
  • The suites were re-run after rebasing onto fix(admin): bound queue-storage available counts and keep tombstone probes correlated #720; that run was still in progress when this was opened.
  • Not run locally on PostgreSQL 17.

Docs

docs/architecture.md, docs/configuration.md, correctness/storage/MAPPING.md and README.md, and the changelog.


Generated by Claude Code

hardbyte and others added 4 commits November 11, 2026 06:50
* perf(queue-storage): commit claim transactions asynchronously

A claim transaction holds its lane's queue_claim_heads row lock until it
commits, and the post-commit cursor advance takes the same row. Both
committed synchronously, so every claim spent a WAL fsync inside the lock.
Under load each freed worker permit claims one job, which made claiming one
fsync-bounded, lane-serialised transaction per job.

The claim, claim_batch and cursor-advance transactions now begin with
`SET LOCAL synchronous_commit = off`, sent in the same round trip as BEGIN.
A claim lost to a server crash leaves its job claimable again, which
at-least-once delivery already allows. A synchronous commit flushes all
earlier WAL, so a completion or enqueue can never be durable while the
claim it depends on is not. Enqueues, completions and every other
transaction stay synchronous.

Job-queue bench, 2 replicas x 200 jobs/s offered, 8 workers each,
PostgreSQL 18.6 on a SATA SSD (~7 ms fsync), stock sqlx 0.9.0:

                     completed/s  claim p50  claim p99  backlog
  before                     320    6468 ms    9761 ms     3494
  after                      383      10 ms      67 ms        3

LWLock:WALWrite samples fell from 137 to 38 over the same window.

* perf(queue-storage): write enqueue batches with a single unnest INSERT

The INSERT writers for ready_entries, ready_segments and deferred_jobs built
multi-row VALUES statements with one bind per column per row, so every batch
size produced new SQL text to parse and plan. They now send each column as
one array through a fixed-text INSERT ... SELECT FROM unnest(...).

The COPY entry points (QueueStorage::enqueue_params_copy, insert_many_copy
and the Python *_copy methods) route batches below COPY_MIN_ROWS (2048) to
that INSERT. Below the threshold the INSERT is faster than COPY, and with
sqlx-core 0.9.0 every COPY also pays a ~40 ms Nagle/delayed-ACK stall: the
driver no longer sets TCP_NODELAY and sends CopyData and CopyDone as
separate small writes (fixed upstream in transact-rs/sqlx#4336, not yet
released). Large batches keep COPY.

Measured on PostgreSQL 18.6, one connection, stock sqlx-core 0.9.0
(ms per transaction):

  rows   COPY   unnest      (with TCP_NODELAY: COPY / unnest)
     5   41.4    0.55                          0.46 / 0.36
   128   41.8    0.93                          1.01 / 0.89
   512    2.3    1.77                          1.87 / 1.36
  2048   19.6    5.07                          2.87 / 3.32
  8192   28.7   12.96                          8.13 / 11.73

Producer call p50 in the job-queue bench (2 replicas x 200 jobs/s) drops
from 86 ms to ~6 ms on stock sqlx.

The existing COPY tests now exercise the INSERT path; new tests at the
threshold keep the CSV encoder covered for both the canonical and
queue-storage COPY writers.

* fix(queue-storage): advance a claim cursor only past recorded claims

The post-claim cursor advance ran in its own transaction after the claim
committed, and moved the lane's claim sequence unconditionally. If the
primary failed after acknowledging a claim but before that claim reached a
standby, the advance could retry through the pool against the promoted
standby and move its cursor past a ready row whose claim never happened
there; the job would then be skipped permanently. Asynchronous claim commits
widen that window for synchronous-replication setups; with asynchronous
replication it existed already.

Advances derived from a claim now carry the claimed row's ready slot,
generation and lane position, and only apply while a
ready_claim_attempt_batches row covering that position is visible (an
index probe on one partition). A lagging cursor is already safe: the next
claim's spent-prefix path repairs it.

* docs: keep the SQLx TCP_NODELAY release gate explicit

* fix(queue-storage): guard legacy cursor replay and model async failover
* perf(queue-storage): commit claim transactions asynchronously

A claim transaction holds its lane's queue_claim_heads row lock until it
commits, and the post-commit cursor advance takes the same row. Both
committed synchronously, so every claim spent a WAL fsync inside the lock.
Under load each freed worker permit claims one job, which made claiming one
fsync-bounded, lane-serialised transaction per job.

The claim, claim_batch and cursor-advance transactions now begin with
`SET LOCAL synchronous_commit = off`, sent in the same round trip as BEGIN.
A claim lost to a server crash leaves its job claimable again, which
at-least-once delivery already allows. A synchronous commit flushes all
earlier WAL, so a completion or enqueue can never be durable while the
claim it depends on is not. Enqueues, completions and every other
transaction stay synchronous.

Job-queue bench, 2 replicas x 200 jobs/s offered, 8 workers each,
PostgreSQL 18.6 on a SATA SSD (~7 ms fsync), stock sqlx 0.9.0:

                     completed/s  claim p50  claim p99  backlog
  before                     320    6468 ms    9761 ms     3494
  after                      383      10 ms      67 ms        3

LWLock:WALWrite samples fell from 137 to 38 over the same window.

* perf(queue-storage): write enqueue batches with a single unnest INSERT

The INSERT writers for ready_entries, ready_segments and deferred_jobs built
multi-row VALUES statements with one bind per column per row, so every batch
size produced new SQL text to parse and plan. They now send each column as
one array through a fixed-text INSERT ... SELECT FROM unnest(...).

The COPY entry points (QueueStorage::enqueue_params_copy, insert_many_copy
and the Python *_copy methods) route batches below COPY_MIN_ROWS (2048) to
that INSERT. Below the threshold the INSERT is faster than COPY, and with
sqlx-core 0.9.0 every COPY also pays a ~40 ms Nagle/delayed-ACK stall: the
driver no longer sets TCP_NODELAY and sends CopyData and CopyDone as
separate small writes (fixed upstream in transact-rs/sqlx#4336, not yet
released). Large batches keep COPY.

Measured on PostgreSQL 18.6, one connection, stock sqlx-core 0.9.0
(ms per transaction):

  rows   COPY   unnest      (with TCP_NODELAY: COPY / unnest)
     5   41.4    0.55                          0.46 / 0.36
   128   41.8    0.93                          1.01 / 0.89
   512    2.3    1.77                          1.87 / 1.36
  2048   19.6    5.07                          2.87 / 3.32
  8192   28.7   12.96                          8.13 / 11.73

Producer call p50 in the job-queue bench (2 replicas x 200 jobs/s) drops
from 86 ms to ~6 ms on stock sqlx.

The existing COPY tests now exercise the INSERT path; new tests at the
threshold keep the CSV encoder covered for both the canonical and
queue-storage COPY writers.

* fix(queue-storage): advance a claim cursor only past recorded claims

The post-claim cursor advance ran in its own transaction after the claim
committed, and moved the lane's claim sequence unconditionally. If the
primary failed after acknowledging a claim but before that claim reached a
standby, the advance could retry through the pool against the promoted
standby and move its cursor past a ready row whose claim never happened
there; the job would then be skipped permanently. Asynchronous claim commits
widen that window for synchronous-replication setups; with asynchronous
replication it existed already.

Advances derived from a claim now carry the claimed row's ready slot,
generation and lane position, and only apply while a
ready_claim_attempt_batches row covering that position is visible (an
index probe on one partition). A lagging cursor is already safe: the next
claim's spent-prefix path repairs it.

* perf(queue-storage): reuse the claimer lease across claims

Every claim_runtime_batch_with_aging_for_instance call read the queue's
claimer target and this instance's claimer lease before claiming: two extra
round trips and two transactions per claim. With few free worker permits
each claim returns about one job, so that was two transactions per job.

The lease row is now cached per (queue, instance) for 250 ms and never used
within a second of its expiry. A successful heartbeat refresh updates the
cached row, and a lost lease (refresh matches no row, or acquisition finds
no free slot) drops it. The adaptive claimer target is a 500 ms control
signal, so reading it at most every 250 ms changes nothing material.

Depth-target bench, W=4, 1 ms jobs, PG 18.6: transactions per completed
job 2.35 -> 1.39, throughput 358 -> 392 jobs/s.

* fix(queue-storage): forget cached claimer leases on reset

reset() truncates queue_claimer_leases; a claim within the cache TTL could
otherwise reuse a lease that no longer exists and bypass the bounded-claimer
gate.

* docs: keep the SQLx TCP_NODELAY release gate explicit

* fix(queue-storage): guard legacy cursor replay and model async failover

* test(queue-storage): group claimer cache coverage for stacked merge safety

* docs: clarify COPY fallback and async claim durability boundaries
…ch on 0.6

The cursor-advance guard from #507 took a claim with no claim batch id to be a legacy claim and looked for its leases row. On main every receipt claim is compact, so that holds. On 0.6 a receipt claim with a deadline, the default, is written to lease_claims with no batch id and has no leases row, so its advance never applied and the cursor moved only through spent-prefix recovery. The guard now keys on the receipt id, and the regression's lost-claim fixture also removes lease_claims rows.
A claim lost before its WAL flush leaves the job claimable again, and the next claim reuses the lost claim's run lease. The worker still running the lost claim can therefore have its heartbeat or completion accepted against the new attempt. Asynchronous claim commits make this reachable after a primary crash, not only after a failover with asynchronous replication.
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8586cb78-b458-4649-90ee-6a0204f38715

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@hardbyte hardbyte added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Nov 11, 2026 — with Claude
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T07:04:42.903034Z 6968d02 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@hardbyte
hardbyte merged commit 2029d96 into release/0.6.6 Nov 11, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full CI matrix (Python build+test, E2E) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant