Skip to content

fix(queue-storage): promote each enqueue lane in its own transaction and hand busy lanes to a waiter (0.6) - #640

Merged
hardbyte merged 14 commits into
release/0.6.6from
brian/promote-per-lane-0.6
Nov 6, 2026
Merged

hardbyte merged 14 commits into
release/0.6.6from
brian/promote-per-lane-0.6

Conversation

@hardbyte

@hardbyte hardbyte commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Backport of #636 to the 0.6 line.

Problem

On queue storage, promotion moves due scheduled and retryable rows from deferred_jobs into the ready ring. Re-enqueueing a row reserves lane sequence numbers under the lane's (queue, priority, shard) advisory lock, which producers also take and hold until they commit. Two things turned one busy lane into a leader-wide stall:

  • A promotion batch was one transaction across every queue it touched, so due rows of unrelated queues in that batch waited for the busy lane's lock too.
  • The maintenance leader runs promotion, rescue, rotation and prune one after another on one task, so while promotion waited, every other branch waited with it. Rescue itself never takes the lane lock; it was only queued behind promotion.

Change

Store (awa-model). QueueStorage::promote_due_lanes(pool, state, batch_size, lane_lock_timeout, skip) selects the batch's due rows (same ORDER BY run_at, priority, job_id LIMIT as before, so the same index and batch limit apply) with FOR UPDATE OF deferred SKIP LOCKED, groups them by (queue, priority) lane, and promotes each lane's share in its own transaction. With a lane_lock_timeout, each lane's transaction sets that lock_timeout; a lane that hits it rolls back, keeps its rows deferred, and is returned in PromoteOutcome::contended, and the other lanes go ahead. skip leaves named lanes out of the selection. The selection skips locked rows, so rows another transaction holds, such as a waiter left running by a former leader, cannot fill every batch of a tick. It runs as its own statement, so its row locks end with it. promote_due_lane promotes the oldest due rows of a single lane. promote_due keeps its signature and now promotes lane by lane with no timeout.

Leader (awa-worker). A per-leadership-term QueueStoragePromoter runs each promotion tick with a 50 ms lane lock timeout. A contended lane is skipped for the rest of the tick and handed to a dedicated waiter task, which promotes that lane in batches with a 30 s lock timeout. PostgreSQL grants a lock to its waiters in arrival order, so the waiter is served after the producers already queued, however busy the lane stays. That is the fairness bound: a permanently saturated lane is promoted once per drain of its lock queue, and the leader never waits on it for longer than one 50 ms timeout per newly contended lane per tick. While a lane has a waiter, the leader's ticks skip it. Each waiter holds a pool connection while it waits, so the leader runs at most one per eight pool connections, never fewer than one or more than four. A 10-connection pool therefore gives up at most one extra connection to a busy lane. A further contended lane is retried on the next tick. Dropping the promoter, on step-down or shutdown, aborts its waiters, and an aborted waiter's transaction rolls back, leaving its rows deferred.

Option 1 alone (a transaction per lane) still runs the busy lane's wait on the leader task, and option 2 alone (a timeout with an escalating budget) either never wins a lock queue that is never empty or eventually blocks the leader for the whole queue drain. Handing the lane to a waiter (option 3, scoped to the lane) gives the fairness bound without the leader paying for it. Promotion as a whole stays on the leader loop, so its tick and overrun accounting are unchanged.

Constraints kept:

Metric. awa.maintenance.promote.lane_contention (awa_maintenance_promote_lane_contention_total{awa_job_state, awa_job_queue, awa_promote_outcome}) has the outcomes handed_off, waiter_limit (every waiter busy) and waiter_timed_out (a waiter's 30 s ran out). Waiter batches are recorded in the existing promote_batches / promote_batch_size / promote_duration instruments.

Docs. CHANGELOG [Unreleased], architecture (enqueue/promotion), configuration (deferred promotion), the Grafana metric reference, and the operations skill.

Measurements (0.6)

Same harness and method as #636: private postgres:17-alpine / postgres:18-alpine containers, two rounds with the before and after binaries run in alternating order. Before = release/0.6.6 (promote_due batches until short); after = this branch (the leader's QueueStoragePromoter). Cells are round 1 / round 2, in ms unless marked. The second round of the before binary ran on a busier host, which is why its spread is wide.

PG Scenario Leader tick max, before → after Unrelated-queue lag p99, before → after Rescue start lateness max, before → after Hot backlog drain, before → after
17 16 producers 2991 / 8169 → 104 / 118 1225 / 2848 → 530 / 438 2245 / 7426 → 23 / 21 3.2 / 8.4 s → 4.3 / 3.8 s
17 32 producers 6605 / 12095 → 112 / 108 1823 / 4563 → 364 / 449 5858 / never ran* → 2 / 1 6.9 / 12.4 s → 6.5 / 6.4 s
17 held 3 s 2691 / 2701 → 87 / 115 2834 / 2731 → 375 / 481 1944 / 1953 → 2 / 19 2.9 / 3.0 s → 3.0 / 3.3 s
18 16 producers 3081 / 10468 → 117 / 160 1039 / 3634 → 411 / 541 2336 / 9725 → 20 / 52 3.3 / 10.7 s → 3.3 / 4.9 s
18 32 producers 5967 / 26982 → 105 / 156 1595 / 7323 → 464 / 624 5220 / never ran* → 36 / 73 6.2 / 27.2 s → 5.8 / 7.0 s
18 held 3 s 2668 / 3057 → 83 / 109 2819 / 2525 → 378 / 1814 1920 / 2310 → 11 / 242 2.9 / 3.3 s → 3.0 / 3.8 s

* Rescue did not run at all during the 12 s and 27 s windows: one promotion tick held the leader for the whole run.

With no producers, the unrelated-queue lag p99 was 334–847 ms before and 378–580 ms after. As on main, the lag left after the change comes from the leader's own rescue run on the same task, not from the hot lane. The one after-change outlier (1814 ms, PG18 held, round 2) coincides with a 1.24 s rescue body.

Validation

  • cargo fmt --all, SQLX_OFFLINE=true cargo clippy --all-targets --all-features -- -D warnings.
  • New tests in queue_storage_runtime_test (PG17 and PG18 locally):
    • test_queue_storage_promotion_leaves_a_contended_lane_deferred_and_in_order: with the hot lane's lock held, a batch promotes the unrelated queue, returns the hot lane as contended after one lock timeout (bounded with scaled_timeout), and leaves its rows scheduled. A batch that skips the lane does not touch it. Promoted later, two rows at a time, the lane's rows take lane_seq in due order (job ids deliberately in the opposite order), and a claim returns all of them.
    • test_queue_storage_runtime_busy_lane_holds_back_neither_promotion_nor_rescue: a full client, with two sessions taking turns holding the hot lane's lock for 5 s each so that the lane is never free. A due job on another queue completes, and an abandoned attempt is rescued, within scaled_timeout(2 s). The hot queue's due jobs still complete while the lane stays busy, which is the fairness bound. The test fails without the lock timeout (the unrelated job times out), and it fails without the hand-off (the hot jobs are never promoted).
    • test_queue_storage_runtime_locked_due_rows_do_not_starve_promotion: another session holds row locks on the 4,160 oldest due rows, more than one batch selects, as an orphaned waiter would. A due job on another queue still completes within scaled_timeout(2 s), and the locked rows are promoted once released. Without SKIP LOCKED on the selection, every batch reselects the locked rows and the other job never runs.
  • Unit tests for the new counter's attributes and for the pool-relative waiter limit.
  • test_queue_storage_runtime_promotion_not_suppressed_after_overrunning_bodies (fix(worker): never back promotion off, and make branch cooldowns wall-clock #620) slowed promotion with a statement trigger on the queue-storage deferred_jobs DELETE. That DELETE now runs only for lanes with due rows, so the trigger moved to the canonical-plane promotion DELETE (awa.scheduled_jobs), which every promotion body still runs once per state.
  • Full CI: https://github.com/hardbyte/awa/actions/runs/42164814614

Notes

  • The selection query skips handed-off lanes with a filter on the (state, run_at, ...) index. While a lane with a large due backlog is waiting, each tick's selection scans past that backlog. In the 24k-row runs that cost about 5–12 ms per tick, and the old path paid far more by blocking on the lane.

Differences from #636

  • SQL is built with &format!, since 0.6 has no audited_sql. The lane DELETE is bound to a local because its binds depend on the row selection.
  • The canonical-resolved leader's mirror promotion uses 0.6's active_standby_queue_storage() guard.
  • The new tests use the runtime test file's own scaled_timeout; 0.6 has no awa/tests/ci_timing.rs.
  • The operations skill doesn't exist on 0.6, so only the CHANGELOG, architecture, configuration and Grafana metric reference change.
  • The coupling harness is included with its leader loop on QueueStoragePromoter.

There is no schema change and no migration. Only the elected leader promotes, so in a mixed 0.6/0.7 fleet the behaviour follows whichever version holds leadership.

Independent review and validation

Includes the small #651 point-read fix. After a priority or queue move crossed a ready-ring wrap, retained tombstoned source rows could make get_job return old fields even after the batch succeeded. The available-candidate query now excludes full-identity tombstones (a 13-line SQL change), with priority and queue ring-wrap regressions. Both regressions fail before the fix and pass after it on PostgreSQL 17 and 18; existing aged-read and batch cases also pass. Required formatting, Clippy across all targets/features, and workspace build checks pass for the prepared source.

Full CI passed all 29 jobs for e8fbc4367c27edb81c7a03e9624c1812eca44b52.

A lane's enqueue and claim sequences were created by whichever role first
touched the lane, through SECURITY INVOKER helpers, so only that role and
the roles its default privileges named could use them. Another runtime
role then failed with "permission denied for sequence" on enqueue, claim
or promotion.

Each queue-storage schema now has two SECURITY DEFINER functions owned by
the schema owner, provision_lane and provision_lane_sequences. The lane
helpers create a missing sequence through them, and each new sequence gets
the privileges of the head table it backs. ensure_lane_inserts calls
provision_lane when it exists and is executable, inside a savepoint, and
otherwise writes the lane rows as before.

The definer_lane_provisioning schema patch refreshes the helpers and
repairs existing lane sequences one at a time, reassigning and granting
only what the applying role may and reporting the rest.
awa.lane_sequence_problems and the new `awa storage lane-sequences` command
list what remains with the statements to run; awa.repair_lane_sequences and
`--repair` rerun the repair.
…vision_lane

Queue storage takes any queue name, including an empty one, and any smallint priority, so the provisioning functions only reject a null argument or a shard outside 0-63. The released-binary rehearsal now expects a 0.6.9 producer without CREATE to create a new lane, owned by the schema owner.
…and hand busy lanes to a waiter (0.6)

Backport of #636.

Promotion re-enqueues due scheduled and retryable rows into their lane, which
takes the lane's reservation lock that producers hold until they commit. One
promotion batch was a single transaction across every queue it touched, and
the maintenance leader runs its branches one after another, so a lane kept
busy by producers delayed due jobs of unrelated queues in the same batch and
started rescue, rotation and prune late.

QueueStorage::promote_due_lanes selects a batch without locking it and
promotes each (queue, priority) lane's share in its own transaction, under an
optional lock_timeout; a lane that hits it rolls back and is reported as
contended. promote_due_lane promotes the oldest due rows of one lane.
promote_due keeps its signature and promotes lane by lane with no timeout.
Within a lane, promoted rows take lane sequence numbers in due order.

The leader's promoter runs each tick under a 50 ms lane lock timeout, skips
contended lanes for the rest of the tick, and hands each to a dedicated
waiter task (at most four at once) that promotes the lane under a 30 s lock
timeout. PostgreSQL grants a lock to its waiters in arrival order, so the
waiter is served after the producers queued ahead of it however busy the
lane stays. awa.maintenance.promote.lane_contention counts handed_off,
waiter_limit and waiter_timed_out by state and queue.

The overrunning-promotion test slowed the queue-storage DELETE, which no
longer runs when nothing is due; it now slows the canonical-plane promotion
DELETE that every body runs.
@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T01:59:56.522507Z 2ce3a31 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.

@coderabbitai

coderabbitai Bot commented Nov 6, 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: aa3eeb6c-5264-43fe-a1d9-b77803cb725b

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ce3a31cf6

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +887 to +894
match store
.promote_due_lane(
pool,
*state,
lane,
PROMOTE_BATCH_SIZE,
Some(PROMOTE_LANE_WAITER_LOCK_TIMEOUT),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep lane waiters from exhausting the worker pool

When the worker pool has only one connection—or four waiters consume all available connections—this call begins a transaction and can hold its connection for the full 30-second advisory-lock wait. If the lane lock is held by a producer using another pool or runtime, subsequent claims, rescues, and maintenance branches block acquiring a connection, so the hand-off still stalls the worker and can repeat indefinitely on timeout. Reserve pool capacity by making the waiter limit depend on the configured pool size, or run these waits through a separate bounded pool.

Useful? React with 👍 / 👎.

Comment on lines +14522 to +14523
ORDER BY run_at ASC, priority ASC, job_id ASC
LIMIT $2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip locked rows during the initial due-row selection

If the oldest selected deferred row is locked by another transaction, this query still counts it toward LIMIT; the later per-lane DELETE ... FOR UPDATE SKIP LOCKED then skips it. For example, promote_due(..., 1) returns zero while a newer due row is unlocked, and repeated ticks keep selecting the same locked row; with the maintenance batch size, 4,096 locked leading rows similarly prevent all later lanes from being promoted. The previous atomic selection skipped locked rows before applying the limit, so the initial selection needs equivalent lock-aware behavior.

Useful? React with 👍 / 👎.

Once a handler returned, the executor removed the attempt from the
heartbeat set and finalized it in a detached task that Client::shutdown
did not wait for. When the last handler returned as the drain ended,
shutdown cancelled the completion batcher before that finalize reached
it, and the direct-finalize fallback raced the process exit. The attempt
stayed open with no heartbeat, so stale-heartbeat rescue ran the
finished job again after heartbeat_staleness.

Track finalizers in a TaskTracker and wait for them, within the drain
timeout, before stopping the completion batcher, heartbeat and
maintenance services. This is the shutdown part of #521 on main.
…ound lane waiters by pool size

The batch selection was a plain SELECT ... LIMIT and only the per-lane DELETE
skipped locked rows. When the oldest due rows were locked by another
transaction, such as a lane waiter left running by a former leader, every
batch of a tick reselected them and promoted nothing until the lock went
away. The selection now takes FOR UPDATE OF deferred SKIP LOCKED. It runs as
its own statement, so the row locks end with it, and deferred_jobs is not
partitioned, so no run-time pruning meets them.

Each lane waiter holds a pool connection while it waits. The leader now runs
one waiter per eight pool connections, at least one and at most four, and
the configuration docs say so.
…ded lock wait

The definer_lane_provisioning patch reassigned lane sequences inside the
patch transaction. ALTER SEQUENCE ... OWNER takes the sequence's ACCESS
EXCLUSIVE lock until commit, with no lock_timeout, so an open enqueue on one
lane could stall the patch and every repaired lane waited for it to commit.

The patch now only refreshes the helpers. After it commits, awa migrate
repairs each lane sequence with its own statement through
awa.repair_lane_sequence(schema, sequence), which has lock_timeout = 300ms;
on 55P03 the sequence is left unchanged and reported as busy. The results
are in MigrationReport::lane_sequences. `awa storage lane-sequences --repair`
uses the same path, and the set-returning awa.repair_lane_sequences(schema)
is gone.

Reassigning needs the current owner's privileges, which the schema owner
lacks for sequences the migration role or an app role created. A migration
role connected with role set to the owner therefore could not reassign its
own sequences. The repair now retries such a sequence as the login role,
lane_sequence_problems reports repair_roles (the login roles that can repair
each sequence), and leftovers are reported with the next step to take. The
docs state which role to run the repair as.
@hardbyte hardbyte added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Nov 6, 2026
@hardbyte
hardbyte merged commit c2c9135 into release/0.6.6 Nov 6, 2026
30 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