GH-4592: make the capability-constrained distribution paths capacity-aware - #4598
Merged
Merged
Conversation
…aware #4297 reached one distribution method. The deployment that motivated the whole feature -- a multi-database event store with thousands of async daemon agents -- goes through a different one, DistributeByGroupAffinity, which ignored node load entirely. So did the blue/green path and the durability-affinity spread. The governing decision, and the reason this is not just "filter out the overloaded nodes": in these three methods capacity is a PREFERENCE and never a filter. DistributeEvenly can refuse to place onto an overloaded node because every node is a candidate there, so refusing some of them can only ever delay an agent. Here the candidate set has already been narrowed -- by declared capabilities, by the GH-3341 rescue for a partition no node declares, by the GH-4562 per-member grandfathering -- and stacking a second hard constraint on top can empty it. An empty candidate set in these methods is not "the agent waits"; it is a shard database that stops projecting with no running agent, no log and no self-heal until a restart. Memory pressure is transient. A stalled shard is not. So: a node with headroom always wins, and an overloaded node still beats nothing. Group affinity also sheds, at partition granularity, since a partition is precisely what this method refuses to split. An overloaded incumbent gives up a whole shard database only when another candidate can take the entire thing, and at most OverloadShedBatchSize partitions per evaluation -- a node hosting twenty databases that shed all twenty at once would hand the cluster a connection-pool stampede and twenty rounds of catch-up to relieve pressure that is re-sampled every heartbeat anyway. The affinity path is deliberately the weakest of the three. Capacity decides the ORDER its capable nodes fill in, not how many agents each may hold: the even ceiling still applies, and the explicit GH-3785 co-location preference still outranks load, because overriding it would trade the memory problem for the connection-pool problem that preference exists to solve. All three collapse to their previous behavior when no node advertises a load, which is a test rather than a claim. Of the 15 new tests, 7 fail against the pre-change code and 8 are invariant guards -- never park a group, never split one, keep the GH-3341 rescue, keep the even ceiling, keep the affinity preference. Both categories are labelled as such. Full wolverine.slnx Release build on net9.0 clean; CoreTests 3077, MartenTests.MultiTenancy 56, PolecatTests.Distribution 9 -- all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hable fallback Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 24, 2026
Merged
This was referenced Sep 24, 2026
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.
Closes #4592. Follows #4596.
#4297 reached one distribution method. The deployment that motivated the whole feature — a multi-database event store with thousands of async daemon agents — goes through a different one,
DistributeByGroupAffinity, which ignored node load entirely. So did the blue/green path and the durability-affinity spread. As merged, the people most likely to enable capacity-aware assignment got nothing from it.The decision this turns on
In these three methods capacity is a preference and never a filter — the opposite of
DistributeEvenly, and deliberately so.DistributeEvenlycan hard-refuse an overloaded node because every node is a candidate there, so refusing some of them can only ever delay an agent. In the capability-aware paths the candidate set has already been narrowed — by declared capabilities, by the GH-3341 rescue for a partition no node declares, by the GH-4562 per-member grandfathering — and stacking a second hard constraint on top can empty it. An empty candidate set in these methods is not "the agent waits". It is a shard database that stops projecting with no running agent, no log and no self-heal until a restart: precisely the failure GH-3341 exists to prevent.Memory pressure is transient. A stalled shard is not. So a node with headroom always wins, and an overloaded node still beats nothing.
Group affinity
Ordering aside, this path also sheds — at partition granularity, because a partition is exactly what the method refuses to split. An overloaded incumbent gives up a whole shard database only when another candidate can take the entire thing, and at most
OverloadShedBatchSizepartitions per evaluation.Both conditions earn their place. Detaching with nowhere to put it would stop the database outright — the mistake #4590 fixed in the even path, applied to an indivisible unit. And a node hosting twenty databases shedding all twenty at once would hand the cluster a connection-pool stampede plus twenty rounds of projection catch-up, to relieve pressure that is re-sampled every heartbeat anyway.
The affinity path is the weakest of the three, on purpose
Worth stating plainly rather than letting it read as finished. In
DistributeEvenlyWithAffinity, capacity decides the order the capable nodes fill in, not how many agents each may hold:Overriding either would trade the memory problem for the connection-pool problem those mechanisms exist to solve. Making that path actually cap an overloaded node needs a separate decision about which of the two costs wins, and I did not want to make it silently inside this change.
Tests
15 new, in two labelled categories:
The guards matter as much as the discriminating ones here: most of the ways this change could go wrong are ways it could remove a placement, and a test that only proves the new preference works would not catch any of them.
Two of my own expectations were wrong and the code was right — the affinity remainder correctly caps each node at its even share regardless of load, and I had asserted both agents would pile onto the idle node. Corrected rather than worked around.
Verification
dotnet build wolverine.slnx -c Release -f net9.0— 0 warnings, 0 errorsCoreTests— 3077 passed, 0 failedMartenTests.MultiTenancy— 56 passed (real Postgres)PolecatTests.Distribution— 9 passed (real SQL Server)Still open from the #4297 review: #4593 (the other message stores — a multi-database deployment on anything but PostgreSQL still advertises nothing, so this change is inert there), #4594 (docs), JasperFx/CritterWatch#1343, JasperFx/ai-skills#233.
🤖 Generated with Claude Code