#3867 a batched handler participates in partitioned sequential processing - #3868
Merged
Conversation
DefaultMessageBatcher groups only by TenantId, so a batch envelope can span many group ids and carries none of its own. DetermineGroupId then falls back to the configured rules, which cannot find an identity on a T[]. That is not a benign no-op. SlotForProcessing returns a RANDOM slot when it cannot determine a group id, so a batched handler on a PartitionProcessingByGroupId endpoint draws a different slot on every trigger — it silently opts out of the sequential guarantee its unbatched siblings have, with no configuration error and nothing in the logs. GroupByGroupId() opts into a GroupIdMessageBatcher<T> that groups by (TenantId, GroupId) and stamps the group id onto each batch envelope, so the batch is a member of exactly one group. Tenancy is still honoured — the key is (tenant, group), never group alone, because members settle against the batch envelope and merging tenants would lose the tenant each member arrived under. Envelopes whose group id cannot be determined batch together and stay ungrouped; this does not invent an identity for them. The partitioning rules are injected in ProcessorBuilder.Build through an internal IRequirePartitioningRules, since the runtime is not available at the point BatchingOptions is configured. This is half the fix. The batch envelope is still never routed — BatchingProcessor.processEnvelopes hardcodes grouped.Destination = Queue.Uri — so nothing can yet act on the stamp. That is part 2 of #3867. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
Records what is on this branch (part 1), what is not (part 2, the routing gap), the three candidate shapes with the things to check before choosing between them, and the verified facts that save re-deriving — notably that PartitionProcessingByGroupId IS available on a local queue, and that GlobalPartitionLocalQueueUri being internal rules out any shape that expects users to bridge a listener themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
…tion Adds a "Batching by group id with GroupByGroupId" section to the batching guide and a "Partitioned Processing with Batched Handlers" section to the partitioning guide. Both spell out the non-obvious part: a batch envelope with no group id draws a RANDOM partition slot rather than being left unpartitioned, so a batched handler silently opts out of the sequential guarantee. Both also state the remaining limitation honestly -- this serializes batches against each other, not against the unbatched handlers for the same group id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg
The note recorded that the affected CritterWatch console had clustering off because it is a single replica. Wrong — the shipped console wires GlobalPartitioned unconditionally (5 sharded slots across RabbitMQ, SQS and Azure Service Bus). So the failing deployment already has the strongest partitioning Wolverine offers and still sees ~20 stream-concurrency exceptions/min, because global partitioning sequences every participating message type except the batched one — which is 59% of the failures. That decides the preference between the three candidate shapes: let BatchingOptions target a partitioned topology, so a batched handler is a first-class participant in global partitioning rather than an exception to it. Reasoning for rejecting the other two recorded alongside. Also posted to the issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
The requirement: a batched message must execute on the same local, partitioned queue that an unbatched message of the same group id would execute on. That is what buys one writer per group id across batched and unbatched handlers alike. Adds the two implementation notes worth having up front, both verified against the source rather than assumed: it cannot self-deadlock (processEnvelopes runs on its own block, not the slot block that produced the batch), and the real hazard is head-of-line blocking, since _processingBlock is shared across every group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
Implements shape 3 from the issue: BatchingOptions targets the partitioned topology its element type already belongs to instead of a single LocalExecutionQueueName, and the slot comes from the batch's group id. Before this, BatchingProcessor.processEnvelopes hardcoded grouped.Destination = Queue.Uri, so the assembled batch always executed on one dedicated local queue while the unbatched handlers for the same group id executed on a topology slot -- a different execution block. The batch raced the very handlers the topology had just sequenced. GlobalPartitioned did not give you a single writer per group id if any participating message type was batched. - resolveBatchExecutionTopologies() at bootstrap records the slot endpoints for any batched element type matching a GlobalPartitioned or PublishToPartitionedLocalMessaging topology. - IBatchExecutionQueues selects the queue per assembled batch. PartitionedBatchExecutionQueues uses SlotForSending -- the same hash GlobalPartitionedRoute and PartitionedMessageTopology.SelectSlot use, so the batch agrees with where its group's other messages went. - The built-in batcher is swapped for GroupIdMessageBatcher, since slotting requires a batch to belong to exactly one group. An application-supplied batcher is left alone and its ungrouped batches fall back to the dedicated queue rather than drawing a random slot. - Opt out with ExecuteOnDedicatedLocalQueue(), or by naming LocalExecutionQueueName. Wolverine's own default assignment goes through SetDefaultLocalExecutionQueueName so it does not read as a user choice. Endpoint.HostsBatchExecution gives the targeted slots an unbounded execution block in DurableReceiver. Those queues are now their own cascade target, and a bounded block closes a deadlock cycle across the slot block, the batching channel and the processing block. Same trade GH-3287 made for local queues; back-pressure is preserved by BatchingPendingCounts, which counts members against the originating external listener. Also fixes BatchReplay.EnqueueReducedBatchAsync dropping GroupId, which made a ProbeIndividuallyAfter or ApplyItemException probe scatter the survivors across slots. That was already wrong with part 1 alone. Not covered: a plain listener with only PartitionProcessingByGroupId, where the unbatched handlers run inside the listener's own ShardedExecutionBlock. That is not a queue and cannot be enqueued to. Documented as such. batching_with_partitioned_processing.cs is the pure-Wolverine acceptance test the handoff asked for -- no broker needed, and it fails on main with 12 violations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg
This was referenced Aug 10, 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 #3867. Driver: CritterWatch#949.
A batched handler could not participate in
PartitionProcessingByGroupIdsequential ordering. Two independent causes, both fixed here.Stated plainly, because it surprised two separate reviewers:
GlobalPartitioneddid not give you a single writer per group id if any participating message type was batched. The affected CritterWatch deployment already ranGlobalPartitionedwith 5 slots and still saw ~20 stream-concurrency exceptions/min, the batched type accounting for 59% of them.Part 1 — the batch envelope carried no group id
And a null group id means a random slot, not "no partitioning":
DefaultMessageBatcher<T>groups only byTenantId, so a batch spans group ids and has none of its own.BatchingOptions.GroupByGroupId()opts into a batcher that groups by(TenantId, GroupId)and stamps that id onto every batch envelope.Deliberate behaviors:
(tenant, group), never group alone. Members settle against the batch envelope, so merging tenants would lose the tenant each member arrived under.MessagePartitioningRulesisn't available whenBatchingOptionsis configured, soProcessorBuilder.Buildsupplies it via the internalIRequirePartitioningRules.Part 2 — the batch executed on the wrong queue
BatchingProcessor.processEnvelopeshardcodedgrouped.Destination = Queue.Uri, so the assembled batch always ran on one dedicated local queue — while the unbatched handlers for the same group id ran on a topology slot. Different execution blocks, so the batch raced the very handlers the topology had just sequenced.This implements shape 3:
BatchingOptionstargets the partitioned topology its element type already belongs to, and the slot comes from the batch's group id.resolveBatchExecutionTopologies()at bootstrap records the slot endpoints for any batched element type matching aGlobalPartitioned(companion local queues) orPublishToPartitionedLocalMessagingtopology.IBatchExecutionQueuesselects the queue per assembled batch, soProcessorBuilder.Build's singleILocalQueueresolve becomes N.PartitionedBatchExecutionQueuesusesSlotForSending— the same hashGlobalPartitionedRouteandPartitionedMessageTopology.SelectSlotuse, so the batch agrees with where its group's other messages went. (SlotForSendingandSlotForProcessingdeliberately use different hashes; getting this wrong would silently reintroduce the race, so there's a test pinning it.)GroupIdMessageBatcher, since slotting requires a batch to belong to exactly one group. An application-supplied batcher is left alone, and any batch it emits without a group id falls back to the dedicated queue rather than drawing a random slot.ExecuteOnDedicatedLocalQueue(), or by namingLocalExecutionQueueName. Wolverine's own default assignment goes through an internalSetDefaultLocalExecutionQueueNameso it doesn't read as a user choice.Automatic rather than opt-in because
GlobalPartitionLocalQueueUriisinternal— users can't wire this themselves — and because an app that declaredGlobalPartitionedfor a message type already stated the intent the batch was silently exempting itself from.The deadlock this had to handle
The note on the issue was right that there's no direct self-deadlock:
HandleAsyncruns on the slot block but only posts into_batchingBlock, whileprocessEnvelopesruns on a separate_processingBlock. The head-of-line hazard flagged alongside it is real though, and it's worse than a stall — it closes a cycle across three bounded buffers:Saturate all three and every worker in the ring is blocked on the next. This didn't exist before, because the batch's dedicated local queue is unbounded (GH-3287).
Endpoint.HostsBatchExecutionis set on every slot endpoint a batch targets, andDurableReceivergives those an unbounded execution block.EnqueueAsyncinto an unbounded slot never blocks, so_processingBlocknever stalls and the cycle can't close — which also removes the cross-group head-of-line coupling, without the drop risk of a non-blockingPost. Back-pressure is preserved byBatchingPendingCounts, which counts members against the originating external listener.BufferedReceiveralready passes unbounded for local queues, so only the durable path needed changing.The two other checks that were asked for
ExecutorFor(T[], slot)falls through to the default chain; and for Separated mode with stickyHandle(T[])handlers,HandlerGraph.HandlerForalready special-cases a local queue withUsedInShardedTopologyand builds a fanout.BatchingPendingCounts.SettleBatchstill fire once per batch? Yes, by construction. BothDurableReceiver.CompleteAsyncandBufferedReceiver's channel callback settle onenvelope.Batch != null, keyed off the batch envelope and independent of which queue it landed on. Each grouped envelope still goes to exactly one queue — this is slot selection, not fan-out — so no double-count and no lost settle.Also fixed
BatchReplay.EnqueueReducedBatchAsynccopiedDestination,MessageTypeandTenantIdbut notGroupId, so aProbeIndividuallyAfterorApplyItemExceptionprobe lost the batch's identity and scattered the survivors across slots. Already wrong with part 1 alone.What this does NOT cover
GlobalPartitionedglobal-{base}{slot}PublishToPartitionedLocalMessaging{base}{slot}PartitionProcessingByGroupIdShardedExecutionBlockThe third row is not a queue and cannot be enqueued to. The docs say so rather than implying the composition is complete.
One more edge, noted rather than solved: under
MultipleHandlerBehavior.Separatedwith multiple stickyHandle(T[])handlers, the batch fans out from the slot queue to each sticky handler's own queue, so those handlers execute off-slot.Tests
src/Testing/CoreTests/Acceptance/batching_with_partitioned_processing.csis the pure-Wolverine acceptance test the issue asked for, and it needs no broker —ExplicitRoutingalready sends a batched element type to its topology slots, soPublishToPartitionedLocalMessagingreproduces the whole thing in memory. One batched and one unbatched message type share a group id; it asserts no intra-group overlap while still observing cross-group parallelism, so a fix that merely serialized everything would not pass.It fails on
main— 12 violations of exactly the described shape.Also
batch_execution_topology_resolution.cs(bootstrap resolution and both opt-outs),BatchExecutionQueuesTests(slot selection, including agreement withSlotForSending),BatchReplayTests(group id survives a probe), and the 5 existingGroupIdMessageBatcherTests.Not yet run: the broker-backed
global_partitioned_sharded_processingsuites (RabbitMQ, Kafka, SQS, Postgres, SqlServer), which needdocker compose up -d. The CritterWatch soak harness (./build.sh IngestSoak,many_services_many_nodes) remains the field acceptance test and has not been re-run.Docs
New sections in both guides, which previously didn't mention each other: "Batching by group id" and "Batching inside a partitioned topology" in
docs/guide/handlers/batching.md, and "Partitioned Processing with Batched Handlers" indocs/guide/messaging/partitioning.md. Both spell out the random-slot behavior explicitly and state the uncovered case above.GH-3867-BATCHING-PARTITIONING-HANDOFF.mdis updated to a record of what was built and what is still open.🤖 Generated with Claude Code
https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg