Fan the agent command drain out per destination so a pass places work on every node (GH-3698) - #3723
Merged
Conversation
… time (GH-3698) The leader's agent command drain executed one command at a time across the whole cluster. Combined with NodeAgentController.batchCommands, which groups a wave's AssignAgent commands by destination and appends each destination's chunks contiguously, that made the drain destination-at-a-time: every chunk for node A was awaited to completion before node B was sent its first. Cluster-wide agent start concurrency was therefore capped at a single node's MaxAgentStartParallelism no matter how many nodes were in the cluster, so adding nodes did nothing for convergence time. On a cluster of ~5,240 slow-starting Marten subscription agents that is hours of one node working while its peers sit idle. And because executeHealthChecks awaits the drain before the next EvaluateAssignmentsAsync, the leader also stopped writing AssignmentChanged records for the whole duration -- which is why the reported cluster looked stalled rather than merely slow, with no errors and every node healthy. IAgentCommand gains a `Guid? DestinationNodeId` default interface member, so existing and third-party commands stay source-compatible. The new AgentCommandDrain partitions each round into one lane per destination: serial within a lane (one chunk in flight per node, which is what the WO-5 pending-assignment ledger assumes), concurrent across lanes. Commands naming no single destination share one strictly-serial lane, exactly as before. Cascades are deferred to the next round rather than appended to the producing lane, so "a cascade runs after its producer" still holds while being re-keyed onto the right lane. A failing command no longer discards the rest of the wave -- previously any exception, most realistically a TimeoutException from an AssignAgents chunk that outran its reply window, unwound out of the drain and dropped every command still queued for every other node. Failures are returned rather than thrown so ApplyRestrictionsAsync, which answers to an operator, still surfaces them as an AggregateException once the whole wave has run instead of silently reporting success. Measured on the new SlowTests repro (900 agents, 3 nodes, 1s per agent start, shipped defaults): peak cluster-wide concurrent starts 10/30 -> 30/30, time to converge 125.5s -> 57.2s, and all three nodes now gain agents in lockstep instead of one at a time. Still open, tracked separately: the drain continues to block the assignment evaluation itself, so the leader's AssignmentChanged gap shrinks from 103s to 35s but does not go away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tests GH-3702 reinstated the xUnit1051 analyzer on main after this branch was cut, and CI builds the merge commit, so these six WaitAsync calls and one Task.Delay became hard build errors the moment #3720 landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ojaHNzLcE8m5krwfTNPxw
jeremydmiller
added a commit
that referenced
this pull request
Jul 30, 2026
#3723 landed on main as a squash, so this branch's own merge of `gh-3698/fanout` conflicted with the squashed copy. Three conflicts, all resolved in favour of this branch, which supersedes the fanout work rather than diverging from it: * `ReassignAgent.DestinationNodeId` — keep `StopInSourceLane`, so the stop half can be ordered in OriginalNode's lane and queue behind a still-pending start instead of racing ahead of it. main's version keys unconditionally to ActiveNode, which predates pending-dispatch state. * `WolverineRuntime.executeHealthChecks` — keep the `_dispatcher.Enqueue` hand-off. main still awaited `executeAgentCommandsAsync` inline, which is precisely the coupling this PR removes. `executeAgentCommandsAsync`/`buildDrain` stay for `ApplyRestrictionsAsync`, which still needs the synchronous drain to report failures back to the operator. * `agent_assignment_at_scale` — keep this branch's assertions, a superset: main's convergence budget plus `longestEvaluationGap`, the assertion that the leader keeps evaluating while starts are in flight. Budget stays at this branch's measured 100s rather than main's 75s because the decoupled drain pays a control-queue round-trip per chunk per lane. Verified: `dotnet build wolverine.slnx -c Release` clean with 0 warnings; CoreTests 2139 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the first half of #3698. Single commit, deliberately narrow — see "Scope" at the bottom for what was split out and why.
The reporter's own description of what they need:
This PR is (1). (2) turned out to need a design change and is being developed separately in draft #3719.
Reproduces on 6.24.0 (the issue's original "6.23.0" was a misread tag out of a persisted
pause_reason).The defect
executeAgentCommandsAsyncexecuted one command at a time across the whole cluster. BecauseNodeAgentController.batchCommandsgroups a wave'sAssignAgentcommands by destination and appends eachdestination's chunks contiguously, the drain was destination-at-a-time: every chunk for node A was awaited
to completion before node B was sent its first.
Cluster-wide agent start concurrency was therefore capped at a single node's
MaxAgentStartParallelismno matter how many nodes were in the cluster — adding nodes did nothing for convergence time. This is
also why the reporter's
AgentStartBatchSize50 → 1500 change helped without changing the shape: biggerchunks mean fewer control-queue round-trips per node, but the starts inside them were funnelled through one
node either way.
WO-4 of the GH-3604 work chunked the batches and parallelised starts within a chunk; it never parallelised
across destinations.
Reproduction
src/Testing/SlowTests/Agents/agent_assignment_at_scale.cs— three Postgres-backed nodes, 900 agents whoseStartAsynctakes real wall-clock time (standing in for a Marten daemon shard spin-up under aGateSideEffectsBehindPriorVersionreplay), shipped defaults forAgentStartBatchSize(50) andMaxAgentStartParallelism(10). It reproduced every reported symptom on the first run:The change
IAgentCommandgains aGuid? DestinationNodeIddefault interface member (null = "no single destination"),so existing and third-party commands stay source-compatible. The new
AgentCommandDrainpartitions eachround into one lane per destination: serial within a lane (one chunk in flight per node, which is what
the WO-5 pending-assignment ledger assumes), concurrent across lanes. Commands naming no single
destination share one strictly-serial lane, exactly as before.
Cascades are deferred to the next round rather than appended to the producing lane, so "a cascade runs after
its producer" still holds while being re-keyed onto the right lane.
A failing command no longer discards the rest of the wave — previously any exception, most realistically a
TimeoutExceptionfrom anAssignAgentschunk that outran its reply window, unwound out of the drain anddropped every command still queued for every other node. Failures are returned rather than thrown so
ApplyRestrictionsAsync, which answers to an operator, still surfaces them as anAggregateExceptiononcethe whole wave has run instead of silently reporting success.
On ordering across lanes: a stop and a start for the same agent on the same node land in the same lane and
so stay ordered, and the only cross-node pairing the leader emits for one agent — a split-brain
StopRemoteAgentagainst the older copy alongside a reassignment away from the newer one — targets twodifferent nodes with two idempotent stops, and defers its start to the next round.
Result
Three runs, each against a freshly recreated Postgres container.
Tests
SlowTests/Agents/agent_assignment_at_scale.cs— end-to-end repro, red-baselined againstorigin/mainat125.5s. SlowTests is not wired into any Nuke target, so this is a local investigation tool rather than a CI
gate.
CoreTests/Runtime/Agents/per_destination_command_drain.cs— the drain's contract, for CI. The twobehavioural tests were red-checked by temporarily forcing the drain back to
MaxDegreeOfParallelism = 1and removing the per-command catch.CoreTests 2107/2107. Full
wolverine.slnxRelease build clean.RavenDbTests.LeaderElection28 of 28 on this commit, against 1 of 28 failing on cleanorigin/main(the known
spin_up_several_nodes_take_away_original_nodeflake) — so this commit is if anything cleanerthan main there.
PersistenceTests.Agents.durability_modes.start_in_balanced_modefails, and reproduces on cleanorigin/mainwith these changes stashed, so it is pre-existing.Scope — what was split out
This started as a larger branch that also decoupled the assignment evaluation from the drain (the reporter's
ask 2). Bisecting the RavenDb leadership-election suite showed that half regresses 7 tests:
The failure dump shows the database holding a clean 3/3/3/3 while the runtimes disagree — two agents each
running on two nodes at once. Once the evaluation stops waiting for the drain, a dispatched-but-unconfirmed
agent looks entirely unassigned (
Agent.OriginalNodecomes from persistedActiveAgents, and the assignmentrow only appears once the agent is running), so a later pass emits a plain
AssignAgentto a different nodewith no stop for the copy already starting elsewhere.
Fixing that needs pending assignment to become a first-class grid state rather than a TTL'd suppression list,
which is a bigger change than belongs in this PR. It continues in draft
#3719, which should be rebased once this lands.
Also found, not in this PR
NodeStartedrecords carry a randomnode_number.DurabilitySettings.AssignedNodeNumberdefaults toGuid.NewGuid().ToString().GetDeterministicHashCode(), andStartLocalAgentProcessingAsynccalls_observer.NodeStarted()one line before assigning the real number. This is the reporter's1827159984/
-1841074440values.DormantNodeEjectedfires for nodes that were never ejected.ejectStaleNodescontinues past thedelete for nodes the Exclusive listener agent does not reassign reliably to a survivor after its node leaves the cluster #3604 hysteresis deliberately spares, then calls
_observer.StaleNodes()on thewhole stale list.
AssignmentsChangedis observed beforebatchCommands, so telemetry writes one row per agent whilethe wire carries one message per chunk — 901 rows for 19 chunks in the repro, ~5,366 rows per pass in the
reported cluster.
30s + chunkSizereply window no longer discards the wave, but still burns thefull timeout in its lane. Arming the pending-assignment ledger on dispatch rather than emission would help.
🤖 Generated with Claude Code