Skip to content

perf(daemon): serve Work/Workflow requests off native workers - #4052

Merged
ScriptedAlchemy merged 2 commits into
masterfrom
perf/4046-native-writer-waits
Oct 15, 2026
Merged

ScriptedAlchemy merged 2 commits into
masterfrom
perf/4046-native-writer-waits

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes #4046

Work and Workflow requests no longer park native runtime workers on SQLite writer waits. Since #4003, broker-socket connections run on native workers. Each Work or Workflow request then called synchronous storage ports on that worker, and every writer wait parked it. Across //crates/tracedecay:daemon_suite that was 594 parked waits on current master. With this change it is 0.

What changed

  • native_runtime::off_native_thread(future) (tracedecay-rusqlite-runtime) is new. Called from a native runtime thread, it moves the whole future to one blocking-pool thread and polls it there with a waker that unparks that thread. Called from any other thread, it polls the future in place. Native wakes reach the pool thread through an ordinary Waker, so the handler's awaits keep working.
  • execute_work_application and execute_workflow_application run their whole handler through it. This is option (b) from the issue: the dispatch boundary, with no change to any storage port contract. Every caller goes through these two functions, including guided-job steps that recurse into Work dispatch. On the pool thread that recursion runs in place, so a request uses at most one pool thread.
  • work_fan_out_binding is called while draining retained invocation responses in connection_serving. It runs its one port call through the same helper.

Layer choice

Two design candidates were compared.

  • Splitting dispatch_work_application into a sync dispatcher on the pool plus async heads for its five async arms. This needs surgery in leak adjudication, evidence retrieval, experience and guided jobs.
  • Moving the whole handler future off the native worker (this PR). The async arms move unchanged, and the change lives in one helper plus three call sites.

Per-call wrapping, option (a), would have meant dozens of sites and a pool hop for every port call. Async ports, option (c), would have changed contracts across four crates. native_runtime::block_on on the pool thread was ruled out: it makes the thread count as native again.

Behavior notes

  • One blocking-pool thread per in-flight Work or Workflow request (the pool ceiling is 512). Workflow fan-out runs its storage calls one after another on that same thread, so fan-out width does not multiply threads.
  • Dropping a request's future now abandons only the wait, and the handler runs to completion. Workflow handlers were already uncancellable after holder admission, since everything after it was synchronous. Work's async arms (for example a guided job) now finish their current command after a client disconnects, bounded by the request deadline. This matches the native runtime's rule that accepted work outlives a cancelled request.
  • If the pool cannot take the request or the handler panics, the request answers Unavailable and the failure is logged.

recv_writer_reply guard

The detector and the native_thread_writer_waits counter stay. Section 4 of the T2 scope plans to delete them after a full suite run reads zero. Since that plan was written, about a dozen tests across daemon-service, dashboard-api, the CLI and the new test below have started using the counter as their check. #4046 also asks for a test that holds the counter at zero. Deleting the counter would leave all of those tests with nothing to assert. Only the doc pointer changed, which now names off_native_thread.

Measurements

Hazard counts come from #4045's instrumentation, which logs every native-thread writer wait with its frames. It ran on a scratch build and is not committed. The run used the full //crates/tracedecay:daemon_suite, with child daemons inheriting the log path.

storage port master f9c2cf26af this PR
WorkflowEffectAuthorityPortV1 171 0
WorkflowRunStoragePort 117 0
WorkRunControlStoragePort 92 0
WorkProductAttemptAdmissionPortV1 61 0
WorkflowFanOutCensusStoragePort 48 0
WorkProductEventPortV1 48 0
WorkAttemptStoragePort 28 0
graph publication (frames truncated) 13 0
WorkRetryStoragePortV1 7 0
WorkflowDefinitionAuthorityPort 6 0
WorkflowArtifactStorePort 3 0
total 594 0

Paired latency used the #4003 method. Release builds of base f9c2cf26af and this branch ran as two private daemons side by side, each with an isolated HOME. Each of 8 workers sent every request to both daemons in alternating order, one CLI process per call. That gave 240 pairs per operation over a workflow-heavy mix, at host load 30 to 33.

operation base p50 / p95 ms fix p50 / p95 ms median fix/base base/base control
workflow_register_definition 155.1 / 175.6 154.2 / 179.3 0.988 0.988
workflow_list_definitions 155.2 / 172.9 154.6 / 173.8 0.996 1.002
workflow_get_run 152.2 / 170.3 150.6 / 169.6 0.992 1.000
work_list_attempts 151.4 / 168.3 150.2 / 169.4 0.994 0.999

The fix-over-base ratios fall inside the base-over-base control (load 28), so the pool hop adds no measurable per-request cost. CLI process startup takes most of each call, so this probe cannot resolve sub-millisecond differences.

advanced_workflow_journey_test::mounted_fan_out_recovers_then_synthesizes_and_hands_off failed under load in #4045's gate. Here it was stressed with --runs_per_test and the same number of --local_test_jobs.

  • Base: 8 of 8 and 16 of 16 runs passed. A second 8-run batch also passed.
  • This PR: 8 of 8 and 16 of 16 runs passed.
  • One batch on each side (base at 8 runs, this PR at 16) took the same extra 10 to 13 s in every run at once. A delay that hits every concurrent run equally matches a host-wide stall, not a cause inside any one daemon.
  • The failure did not reproduce on either side, so this PR has not been shown to fix it.

Tests

  • New: daemon::invocation_tests::work_tests::work_and_workflow_dispatch_never_park_a_native_worker_on_the_writer. On a native worker it drives a workflow definition registration, a run read, a Work attempt list and a fan-out binding read through DaemonInvocationService. It asserts that the counter does not move. On master it fails with left: 21, right: 0. It passes with the fix.
  • New: native_runtime::tests::off_native_thread_moves_a_native_caller_and_keeps_its_awaits_working. A native caller is moved off its thread, and native timers and spawn_blocking still complete from the pool thread. A plain thread polls in place.

Gates

At 43569e8a41:

  • //crates/tracedecay:unit_test PASSED (896 tests).
  • :daemon_suite PASSED.
  • :mcp_suite PASSED.
  • //crates/tracedecay-rusqlite-runtime:unit_test PASSED.
  • //crates/tracedecay-daemon-service:unit_test PASSED.
  • bazel build --config=clippy passed for //crates/tracedecay-rusqlite-runtime:all, //crates/tracedecay-daemon-service:all and //crates/tracedecay:all.
  • rustfmt_check_2024 passed for all three crates.
  • One unit_test run at host load 86 failed scheduler_config::concurrent_reenable_creates_one_live_scheduler_owner, an automation scheduler test this change does not touch. The rerun passed 896 of 896.

Devin Review

Drive a workflow registration, a run read, a Work attempt list and a fan-out binding read through the daemon invocation service on a native worker, and assert native_thread_writer_waits() does not move. On master it moves by 21.
Work and Workflow storage ports are synchronous and wait on the SQLite writer by parking their thread. Since #4003 broker-socket connections run on native workers, so every such wait parked a native worker (594 waits across daemon_suite).

native_runtime::off_native_thread moves a future from a native thread to one blocking-pool thread and polls it there with a thread-park waker; other callers poll in place. execute_work_application, execute_workflow_application and work_fan_out_binding run through it, so every caller (including guided-job steps that recurse into Work dispatch) leaves the native workers in one place, one pool thread per request.

Fixes #4046
@changeset-bot

changeset-bot Bot commented Oct 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 43569e8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment on lines +108 to +111
if Runtime::current_handle().is_none() {
return Box::pin(future.map(Ok));
}
spawn_blocking(move || poll_on_current_thread(future))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Detached requests outlive project admission

When an invocation is aborted, off_native_thread keeps its handler running after the caller releases its project lease. Project quiescence can then retire the runtime while that handler still uses it.

Learn more

A project request lease keeps its runtime admitted until the invocation finishes. ProjectRequestRuntimesV1 owns that lease in the caller's invocation future, while drain_roots waits for leases before retiring runtimes. The new blocking-pool handoff owns only the Work or Workflow handler. If the caller drops its wait, the lease disappears but the queued or running handler continues. The runtime can therefore retire before the handler completes its storage operation.

Example: A client starts a Work effect, then cancels its invocation while the handler waits on the writer. The invocation releases the project lease; quiescence observes no requests and retires the project. The blocking-pool handler resumes against that retired runtime instead of finishing within its admission.

Recommended fix: Keep a clone of the exact project admission lease with detached Work and Workflow handlers until they finish, or make the handoff cancel and join safely before releasing admission. Cover cancellation during a writer wait followed by project quiescence.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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.

perf(daemon): Work/Workflow storage ports park native workers on writer waits

1 participant