Skip to content

#3867 a batched handler participates in partitioned sequential processing - #3868

Merged
jeremydmiller merged 6 commits into
mainfrom
cw949-group-id-batching
Aug 7, 2026
Merged

#3867 a batched handler participates in partitioned sequential processing#3868
jeremydmiller merged 6 commits into
mainfrom
cw949-group-id-batching

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #3867. Driver: CritterWatch#949.

A batched handler could not participate in PartitionProcessingByGroupId sequential ordering. Two independent causes, both fixed here.

Stated plainly, because it surprised two separate reviewers: GlobalPartitioned did not give you a single writer per group id if any participating message type was batched. The affected CritterWatch deployment already ran GlobalPartitioned with 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":

// PartitionedMessagingExtensions.SlotForProcessing
var groupId = rules.DetermineGroupId(envelope);
if (groupId == null) return Random.Shared.Next(1, numberOfSlots) - 1;   // <-- random, not "unpartitioned"

DefaultMessageBatcher<T> groups only by TenantId, 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:

  • The key is (tenant, group), never group alone. Members settle against the batch envelope, so merging tenants would lose the tenant each member arrived under.
  • Ungroupable envelopes batch together and stay ungrouped. The batcher does not invent an identity.
  • Rules are injected, not resolved. MessagePartitioningRules isn't available when BatchingOptions is configured, so ProcessorBuilder.Build supplies it via the internal IRequirePartitioningRules.

Part 2 — the batch executed on the wrong queue

BatchingProcessor.processEnvelopes hardcoded grouped.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: BatchingOptions targets 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 a GlobalPartitioned (companion local queues) or PublishToPartitionedLocalMessaging topology.
  • IBatchExecutionQueues selects the queue per assembled batch, so ProcessorBuilder.Build's single ILocalQueue resolve becomes N. PartitionedBatchExecutionQueues uses SlotForSending — the same hash GlobalPartitionedRoute and PartitionedMessageTopology.SelectSlot use, so the batch agrees with where its group's other messages went. (SlotForSending and SlotForProcessing deliberately use different hashes; getting this wrong would silently reintroduce the race, so there's a test pinning it.)
  • 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 any batch it emits without a group id falls 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 an internal SetDefaultLocalExecutionQueueName so it doesn't read as a user choice.

Automatic rather than opt-in because GlobalPartitionLocalQueueUri is internal — users can't wire this themselves — and because an app that declared GlobalPartitioned for 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: HandleAsync runs on the slot block but only posts into _batchingBlock, while processEnvelopes runs 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:

slot block (bounded, DOP 1) → BatchingProcessor.HandleAsync
  → BatchingChannel._inner (bounded) → addItem → _processingBlock (bounded)
  → processEnvelopes → queue.EnqueueAsync → back into the same slot block

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.HostsBatchExecution is set on every slot endpoint a batch targets, and DurableReceiver gives those an unbounded execution block. EnqueueAsync into an unbounded slot never blocks, so _processingBlock never stalls and the cycle can't close — which also removes the cross-group head-of-line coupling, without the drop risk of a non-blocking Post. Back-pressure is preserved by BatchingPendingCounts, which counts members against the originating external listener. BufferedReceiver already passes unbounded for local queues, so only the durable path needed changing.

The two other checks that were asked for

  • Does the batch chain resolve on the companion queues? Yes. ExecutorFor(T[], slot) falls through to the default chain; and for Separated mode with sticky Handle(T[]) handlers, HandlerGraph.HandlerFor already special-cases a local queue with UsedInShardedTopology and builds a fanout.
  • Does BatchingPendingCounts.SettleBatch still fire once per batch? Yes, by construction. Both DurableReceiver.CompleteAsync and BufferedReceiver's channel callback settle on envelope.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.EnqueueReducedBatchAsync copied Destination, MessageType and TenantId but not GroupId, so a ProbeIndividuallyAfter or ApplyItemException probe lost the batch's identity and scattered the survivors across slots. Already wrong with part 1 alone.

What this does NOT cover

Configuration Unbatched handlers run on Covered?
GlobalPartitioned companion local queue global-{base}{slot} yes
PublishToPartitionedLocalMessaging local queue {base}{slot} yes
Plain listener + PartitionProcessingByGroupId the listener receiver's own ShardedExecutionBlock no

The 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.Separated with multiple sticky Handle(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.cs is the pure-Wolverine acceptance test the issue asked for, and it needs no brokerExplicitRouting already sends a batched element type to its topology slots, so PublishToPartitionedLocalMessaging reproduces 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 with SlotForSending), BatchReplayTests (group id survives a probe), and the 5 existing GroupIdMessageBatcherTests.

Full CoreTests, net9.0:  2315 passed / 0 failed / 2 pre-existing skips
dotnet build wolverine.slnx -c Release -f net9.0:  clean, 0 warnings

Not yet run: the broker-backed global_partitioned_sharded_processing suites (RabbitMQ, Kafka, SQS, Postgres, SqlServer), which need docker 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" in docs/guide/messaging/partitioning.md. Both spell out the random-slot behavior explicitly and state the uncovered case above.

GH-3867-BATCHING-PARTITIONING-HANDOFF.md is updated to a record of what was built and what is still open.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JKfy5EzLX1i149gjUb3Tfg

jeremydmiller and others added 6 commits August 7, 2026 08:23
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
@jeremydmiller jeremydmiller changed the title #3867 part 1: BatchingOptions.GroupByGroupId() so a batched handler can carry a group id #3867 a batched handler participates in partitioned sequential processing Aug 7, 2026
@jeremydmiller
jeremydmiller merged commit 5ffcb4e into main Aug 7, 2026
37 checks passed
This was referenced Aug 10, 2026
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.

BatchMessagesOf() cannot compose with partitioned sequential processing: the batch envelope is never routed

1 participant