Skip to content

Fix #21: start the fetch lease when the instance is claimed; run the queue-race validations - #35

Merged
Affan Dar (affandar) merged 4 commits into
mainfrom
waldemort/fetch-lease-and-race-validations
Oct 12, 2026
Merged

Affan Dar (affandar) merged 4 commits into
mainfrom
waldemort/fetch-lease-and-race-validations

Conversation

@waldemort-auto

@waldemort-auto waldemort-auto Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Opened on behalf of Affan Dar (@affandar).

Fixes #21. This is the provider side of microsoft/duroxide#46 (see also microsoft/duroxide#47).

Requires duroxide 0.1.32, the release that contains microsoft/duroxide#67. It is on crates.io, and both manifests use it from there. There is no [patch] entry.

The SDK drafts microsoft/duroxide-node#24 and microsoft/duroxide-python#26 build against this branch. duroxide-node built from it also passed the end-to-end PilotSwarm check described in microsoft/duroxide#67.

1. fetch_orchestration_item never returns an already-expired lease (migration 0025)

Problem

The lease was p_now_ms + p_lock_timeout_ms. The caller reads p_now_ms before the call: before taking a pooled connection, and before the advisory-lock wait inside the call. When those waits were longer than the lock timeout, the item was returned with a lease that had already expired. Every ack then failed with Invalid lock token. That includes the poison ack at max_attempts, so the message was retried at the lock-timeout interval for as long as the contention lasted.

Change

Migration 0025 redefines fetch_orchestration_item exactly as in 0019, with one change. The lease is computed when the instance is claimed:

v_lease_base_ms := GREATEST(
    p_now_ms + (EXTRACT(EPOCH FROM (clock_timestamp() - statement_timestamp())) * 1000)::BIGINT,
    (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT
);
v_locked_until := v_lease_base_ms + p_lock_timeout_ms;
  • The first term covers waits inside the call and stays in the caller's clock. ack_orchestration_item checks the lease against the caller's clock.
  • The second term covers a p_now_ms that was already stale when the call started.
  • GREATEST can only make the lease longer. When the database clock is ahead of the worker's, a crashed worker's item is recovered later by that skew.

Nothing else changes. The locking, the candidate selection, the re-check and the predicates stay as in 0019. migrations/0025_diff.md was generated with scripts/generate_migration_diff.sh 25.

Making the claim non-blocking, bounding the candidate loop and refreshing the caller clock in the predicates are performance improvements. They are tracked in #34.

Compatibility

  • Signature and result shape are unchanged. There is no Rust change. CREATE OR REPLACE keeps the function's OID and grants.
  • 0022 and 0024 are used by open pull requests. The runner applies every pending embedded version in order, so the gap is harmless.
  • fix(orchestrator): never return an already-expired lock lease #22 should be closed rather than merged. Its 0024 also redefines this function, and the runner would apply it after 0025.
  • As with any new migration, a binary without 0025 refuses a database that has it.

Tests

tests/fetch_lease_regression_tests.rs reproduces both cases from #21:

Test On main With 0025
stale_caller_timestamp_never_yields_an_expired_lease: p_now_ms 10 s stale on arrival, both procedure branches Fail: lease −5,020 ms when returned Pass: about 5,000 ms left; the ack commits
hot_instance_contention_never_yields_an_expired_lease_and_poison_commits: 16 dispatchers wait 4 s behind a session holding the instance (lock timeout 2 s); the message is at max_attempts Fail: lease −2,006 ms when returned; the poison ack fails with Invalid lock token Pass: about 2,000 ms left; the poison ack commits and the queue drains (the fetches still wait about 4 s behind the holder)

2. Run the queue-race provider validations on PostgreSQL

Wraps the provider validations added by microsoft/duroxide#67, so that PostgreSQL runs them. They cover race replay, continue-as-new transition delivery for both version stamps, a poisoned continue-as-new successor, duplicate starts, hidden-start attempt preservation, and the delayed-abandon locked-row contract. PostgreSQL already met them, so no provider code changes.

Test results

PostgreSQL 15.19 in a disposable container, Rust 1.97.1. The first two rows were run against duroxide 0.1.32 from crates.io. The others were run earlier, against microsoft/duroxide#67.

Check Result
cargo nextest run --all-features 359 passed, 0 failed, 9 skipped
cargo package passes
cargo test --workspace 367 passed, 0 failed, 9 ignored
Long-running ignored tests (-- --ignored, live Entra test excluded) 8 passed, 0 failed
The 2 regression tests without migration 0025 (schemas at migration 0023) both fail as shown above
Every test schema that applied 0025 fetch_orchestration_item has the new lease and the unchanged advisory lock
cargo fmt --check clean
cargo clippy --all-targets --all-features -- -D warnings no findings in files this PR changes; 5 existing findings on main

Commits

Commit Summary
cd974bb fix(orchestrator): start the fetch lease when the instance is claimed
80ffb5a test: run the queue-race provider validations on PostgreSQL
8df07b9 build: build against the duroxide v0.1.32 tag
f15f9c6 build: use duroxide 0.1.32 from crates.io

Waldemort Agent and others added 2 commits October 10, 2026 21:48
fetch_orchestration_item set the lease to p_now_ms + p_lock_timeout_ms.
The caller reads p_now_ms before the call, before any connection-pool
wait and before the advisory-lock wait inside the call. When those
waits were longer than the lock timeout, the item was returned with an
already-expired lease, so every ack, including the max_attempts poison
ack, failed with 'Invalid lock token' and the message was retried
forever (microsoft/duroxide#46).

Migration 0025 redefines the procedure as in 0019 with one change: the
lease is GREATEST(p_now_ms + time spent in the call, clock_timestamp())
+ p_lock_timeout_ms, computed when the instance is claimed. The first
term covers waits inside the call, the second a p_now_ms that was
already stale on arrival. Locking, candidate selection and predicates
are unchanged.

tests/fetch_lease_regression_tests.rs reproduces both cases: a caller
timestamp that is 10 s stale, and 16 dispatchers on a hot instance at
max_attempts whose poison ack must commit.

Fixes #21

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>
Wraps the new duroxide provider validations (race replay, continue-as-new
transition delivery for both version stamps, duplicate starts, hidden-start
attempt preservation, delayed-abandon locked rows) so PostgreSQL runs them.
Builds against the duroxide queue-race fixes branch until they are released.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>
Point the temporary [patch.crates-io] entry at the v0.1.32 tag instead of
the queue-race-fixes branch, and require duroxide 0.1.32 in both
manifests. CI built an older commit of that branch, which lacks
race_replay::test_continue_as_new_poisoned_successor_is_own_execution.

Remove the patch once duroxide 0.1.32 is on crates.io.
duroxide 0.1.32 is published, so remove the temporary [patch.crates-io]
entry that built against the v0.1.32 tag. Record the duroxide
0.1.30 -> 0.1.32 bump in the changelog.
@affandar
Affan Dar (affandar) marked this pull request as ready for review October 12, 2026 12:20
@affandar
Affan Dar (affandar) merged commit 4b94d51 into main Oct 12, 2026
4 checks passed
Affan Dar (affandar) added a commit to microsoft/duroxide-node that referenced this pull request Oct 12, 2026
* Update duroxide to 0.1.31 (race fixes)

duroxide 0.1.31 fixes dropped queue reads and signal waits, the
continue-as-new carry of unread queue messages, inputs that arrive between
two executions, duplicate starts, and the KV key order used by prune.
The SDK compiles unchanged: it does not use TurnResult or any other
changed public item.

- Require duroxide 0.1.31.
- Temporarily patch duroxide and duroxide-pg to their unreleased fix
  branches (microsoft/duroxide#67, microsoft/duroxide-pg#35). Remove the
  patch and pin the released crates before release.
- Document the new contract: a read that loses a race takes no message,
  continueAsNew carries exactly the unread messages, getKvAllKeys returns
  keys in key order.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>

* test: race dequeueEvent against a timer; KV key order

Add regression tests for the duroxide 0.1.31 fixes, ported from the Node
reproduction of the queue/timer race:

- A drain loop races ctx.dequeueEvent() against a 20 ms timer while 300
  numbered messages arrive with 0-30 ms gaps. Every message must arrive
  once and in order, and the timer must win at least once.
- The same race with a 10 ms timer, where every timer win continues as
  new. Every message must still arrive once and in order, so a dropped
  read must not count as a consumer of the carried messages.
- Eight KV keys set out of order are listed in key order and pruned,
  and the next turn replays the writes and the prune. Before
  microsoft/duroxide#57 was fixed, both used hash order, which differs
  between replays, so the replay failed with
  "nondeterministic: kv clear mismatch".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>

* Update duroxide to 0.1.32 and duroxide-pg to 0.1.36

Both are on crates.io, so remove the temporary [patch.crates-io] block
that built against their fix branches. 0.1.31 was reserved and never
published.

Changelog: add the duroxide-pg lock fix and the shutdown change. Docs:
Runtime.shutdown now returns as soon as the runtime stops; the timeout is
a grace period, not a fixed delay.

---------

Co-authored-by: Waldemort Agent <waldemort@noreply.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>
Co-authored-by: Affan Dar <affandar@hotmail.com>
Affan Dar (affandar) added a commit to microsoft/duroxide-python that referenced this pull request Oct 12, 2026
* Update duroxide to 0.1.31 (race fixes)

duroxide 0.1.31 fixes dropped queue reads and signal waits, the
continue-as-new carry of unread queue messages, inputs that arrive between
two executions, duplicate starts, and the KV key order used by prune.
The SDK compiles unchanged: it does not use TurnResult or any other
changed public item.

- Require duroxide 0.1.31.
- Temporarily patch duroxide and duroxide-pg to their unreleased fix
  branches (microsoft/duroxide#67, microsoft/duroxide-pg#35). Remove the
  patch and pin the released crates before release.
- Document the new contract: a read that loses a race takes no message,
  continue_as_new carries exactly the unread messages, get_kv_all_keys
  returns keys in key order.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>

* test: race dequeue_event against a timer; KV key order

Add regression tests for the duroxide 0.1.31 fixes, the same as in
duroxide-node, ported from the Node reproduction of the queue/timer race:

- A drain loop races ctx.dequeue_event() against a 20 ms timer while 300
  numbered messages arrive with 0-30 ms gaps. Every message must arrive
  once and in order, and the timer must win at least once.
- The same race with a 10 ms timer, where every timer win continues as
  new. Every message must still arrive once and in order, so a dropped
  read must not count as a consumer of the carried messages.
- Eight KV keys set out of order are listed in key order and pruned,
  and the next turn replays the writes and the prune. Before
  microsoft/duroxide#57 was fixed, both used hash order, which differs
  between replays, so the replay failed with
  "nondeterministic: kv clear mismatch".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>

* Update duroxide to 0.1.32 and duroxide-pg to 0.1.36

Both are on crates.io, so remove the temporary [patch.crates-io] block
that built against their fix branches. 0.1.31 was reserved and never
published.

Changelog: add the duroxide-pg lock fix and the shutdown change. Docs:
Runtime.shutdown now returns as soon as the runtime stops; the timeout is
a grace period, not a fixed delay.

---------

Co-authored-by: Waldemort Agent <waldemort@noreply.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Affan Dar <1480118+affandar@users.noreply.github.com>
Co-authored-by: Affan Dar <affandar@hotmail.com>
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.

fetch_orchestration_item writes an already-expired lock lease when the advisory lock wait exceeds the lock timeout

1 participant