Skip to content

Let held long polls wait without the transition lock - #516

Merged
rmcdaniel merged 8 commits into
mainfrom
rust-325-lock-free-waiting
Oct 20, 2026
Merged

rmcdaniel merged 8 commits into
mainfrom
rust-325-lock-free-waiting

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Part of #325: the idle mixed long-poll performance gate.

Problem

The October 20 matched comparison found native idle cost growing by about 0.035 cores per waiting poll, two-thirds of it in MySQL. At 24 waiting polls native used about 0.86 cores against PHP's 0.60, which misses the gate. Each held native poll repeated its full claim every 50 ms: a transaction under the global transition lock that read the registration, deleted expired poll receipts and selected candidates.

Change

  • Lock-free waiting. A held poll still claims under the lock on its first probe. Afterwards it claims only when a lock-free read finds something it could claim or must refuse (rust/src/runtime/poll_readiness.rs):
    • workflow and activity polls: the poll's registration is missing or on another queue, a receipt exists for the poll's own poll_request_id, or TASK_CANDIDATES_SQL returns a row;
    • query polls: the registration proof the poll started with has changed, or the existing query readiness read finds a pending or expired-lease query on the queue.
      A false positive costs one empty claim, and a commit racing a read is seen on the next read. A wake no longer sends every waiting poll to the lock.
  • Paced reads. The measurement below showed that the read rate, not the lock, was the main idle cost. A held poll now reads 50 ms after a probe and then at doubling intervals up to 250 ms. A commit on the same node still wakes it to read at once and starts again from 50 ms. Work committed by another server process is observed within 250 ms, where it was 50 ms before. No wait runs past the poll's deadline.

Tests

rust/tests/execution/lock_free_waiting.rs, on SQLite, PostgreSQL and MySQL:

  • held_polls_reach_their_deadline_while_a_transition_holds_the_lock holds the transition lock from a connection outside the runtime, as a transition on another node would, and checks that idle workflow, activity and query polls still end at their deadline with no task. With the previous probing it fails (Elapsed) because each probe waits for the lock.
  • held_polls_observe_work_and_removed_registrations_from_another_node checks that a poll held on one runtime claims a workflow task and a query committed by a second runtime on the same database, and refuses with worker_not_registered after the second runtime removes the registration.

Full native suite at 98ef1af7, locally in the pinned Rust 1.99.0 image: 116 library, 279 execution and 2 further tests pass on each of SQLite, PostgreSQL 17 and MySQL 8.0.

Measurement

Idle-only windows (--idle-only, added to #513) on one disposable vhp-4c-8gb-amd host with the October 20 profile, limits and mixed idle probe. Each window holds the polls for 10 s, with no SDK worker and no work. Figures are average cores over the window, runtime plus MySQL, as the mean of three windows. Each native build ran twice, and the two runs are given as a range. These are development observations on one host, not a qualified performance result.

Waiting polls main 150f3fbc lock-free reads every 50 ms (d5d880fd) paced reads (98ef1af7) PHP 2.6.2
6 0.30–0.31 0.28 0.13–0.14 0.51
12 0.48 0.43 0.17–0.18 0.55 (4 of 12 refused)
24 0.81–0.83 0.72–0.74 0.26 0.59–0.61 (16 of 24 refused)
  • Native answered every poll with an empty result and every health check, and polls ended within 0.04 s of their timeout.
  • With paced reads, at 24 polls MySQL uses 0.16 cores and the native process 0.10, against 0.53 and 0.29 on main.
  • This host's PHP run lost its 24-poll samples to a sampler race, now fixed in Add a matched PHP/native comparison driver and record a dedicated-host run #513. That row is the October 20 run on the same host type. This host's PHP 6- and 12-poll figures agree with October 20.

Full workload with 98ef1af7

The same host then ran the full October 20 workload with this build. All 737 workflows completed and verified, with no errors. At 1/s, 119–120 of 120 workflows closed inside each window.

Rate p50 s p95 s Runtime core-s/workflow MySQL core-s/workflow
0.25/s before 0.955–1.050 (Oct 20: 0.821–0.879) 1.548–1.704 0.206–0.209 (Oct 20: 0.294–0.298) 0.415–0.423 (Oct 20: 0.636–0.656)
0.5/s 0.711–0.837 (Oct 20: 0.689–0.890) 1.247–1.609 0.129–0.132 (Oct 20: 0.157–0.161) 0.280–0.288 (Oct 20: 0.354–0.365)
1/s 0.593–0.669 (Oct 20: 0.545–0.581) 1.250–1.882 (Oct 20: 1.110–1.339) not sampled not sampled
  • CPU per workflow at low rates falls by about a third, because waiting polls no longer cost that much between workflows.
  • Latency is within the variation of a different host instance. It does not improve: at low rates the client start still takes 0.21 s to reach WorkflowStarted on this host type, in both builds, against 0.065 s for PHP. A short local run under the same Compose limits measured 0.046 s for that stage with this build. So the delay is specific to the profile host and not caused by waiting polls. Its cause is still open; the next host run will add server-side request timing.
  • The sampler stopped at the start of the 1/s windows because of the race fixed in Add a matched PHP/native comparison driver and record a dedicated-host run #513, so 1/s CPU was not measured.

🤖 Generated with Claude Code

durable-workflow-ops and others added 4 commits October 20, 2026 07:41
Each held native poll used to repeat its full claim every 50 ms: a
transaction under the global transition lock that read the registration,
deleted expired poll receipts and selected candidates. With no work, idle
cost grew by about 0.035 cores per waiting poll, two-thirds of it in MySQL,
and every probe queued behind, and delayed, real transitions.

A held poll now claims under the lock on its first probe. Afterwards it
claims only when a lock-free read finds something it could claim or must
refuse: an eligible task or query, a receipt for its own poll request, or a
removed, moved or replaced registration. The read runs at once when a commit
on the same node wakes the poll, and otherwise every 50 ms, which is how a
poll still observes other server processes. A false positive costs one empty
claim, and a commit racing a read is seen on the next read.

New tests hold the transition lock from outside the runtime and check that
idle workflow, activity and query polls still end at their deadline (each
fails with the previous probing), and that a held poll still claims a task
or query, and refuses a removed registration, committed by another node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the October 20 profile host, lock-free reads every 50 ms cut MySQL's
idle cost at 24 waiting polls only from about 0.53 to 0.46 cores, and the
native process stayed near 0.28 cores. The cost is the read rate, 20 per
second per poll, not the lock.

A held poll now reads 50 ms after a probe and then at doubling intervals up
to 250 ms. A same-node wake still reads at once and starts again from 50 ms,
so same-node work is unaffected, and work committed by another server
process is observed within 250 ms. No wait runs past the poll's deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The task read now sits next to the task claim in store.rs and the query
read next to the query claim in queries.rs, inside their existing Store
impls. poll_readiness.rs keeps only the read pacing. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Branch updated with main after #509 merged. The new head 3a46764e0 has the same tree as 1e6541297, which already merged #509 and resolved the queries.rs conflict (#516's readiness loop, with #509's leased status). So run 39412849857 on 1e6541297 qualifies it, and the duplicate run on the update is cancelled. The full SQLite execution suite passed locally on the merge (279 tests).

@rmcdaniel

Copy link
Copy Markdown
Member Author

Parity run 39412849857 on 1e6541297 passed every job. Updated with main again for #531, which changes only PHP files (git diff 3a46764e0 4d9264afa touches no Rust or parity paths), so the duplicate parity run is cancelled; merging once PHPUnit and Sonar pass.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Updated with main after #508 (worker contracts) merged. Fresh-build local suites on the merge pass on SQLite: lib 116, execution 282 (including #508's worker-contract tests and this PR's lock-free waiting tests). Parity on this PR's code already passed (run 39412849857), so the duplicate run is cancelled; merging once PHPUnit passes.

@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel merged commit 8957ba2 into main Oct 20, 2026
9 of 14 checks passed
@rmcdaniel
rmcdaniel deleted the rust-325-lock-free-waiting branch October 20, 2026 13:30
rmcdaniel added a commit that referenced this pull request Oct 20, 2026
Record two repeated matched PHP/native pairs after #516
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.

2 participants