Skip to content

GH-3973: say out loud when a batch cannot be sequenced against its unbatched siblings - #3978

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3973-batch-group-lane
Aug 17, 2026
Merged

GH-3973: say out loud when a batch cannot be sequenced against its unbatched siblings#3978
jeremydmiller merged 1 commit into
mainfrom
gh-3973-batch-group-lane

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #3973. Follow-up to #3867, taking the second of the two acceptance paths the issue offers.

The gap

On a host with BatchMessagesOf<T> and no partitioned topology there are two independent execution paths writing the same entity:

  • the assembled batch, on its own local queue
  • the unbatched sibling handlers, inside the listener receiver's own execution block

#3867 already resolves this when a topology existsresolveBatchExecutionTopologies points the batch at the same slots, so every writer for one group id really is a single writer. Without one, findPartitionedExecutionSlots returns null and nothing sequences them. Its own closing comment says so:

A sharded topology over an EXTERNAL transport has no local queue to enqueue a batch onto — its unbatched handlers execute inside the listener's own sharded block. Nothing to target.

Sequential() on the batch queue does not close it: that serializes the batch against itself, and against nothing else.

Why a startup message and not documentation

The asymmetry is the hazard. With a GlobalPartitioned topology the configuration is safe; without one — embedded hosts, single-node deployments, most test fixtures — the same code has two concurrent writers to one event stream, surfacing as intermittent stream-version collisions under load. A defect is therefore unreachable in the configuration the tests use and reachable in the one that ships, or the reverse.

The docs already described this limitation in prose, which is exactly the state the issue is complaining about ("currently documents it in prose rather than enforcing it"). Startup now says it:

Unsequenced batch execution for message type 'Telemetry.ServiceUpdates': it has BOTH unbatched
handlers (ServiceUpdatesHandler.Handle()) and a BatchMessagesOf<ServiceUpdates>() batch handler
(ServiceUpdates[]), and Wolverine could not sequence them against each other. The assembled batch
executes on local queue 'telemetry.serviceupdates-batch' while the unbatched handlers execute on the
listener's own execution block, so both may write the same entity concurrently -- which typically
surfaces as intermittent concurrency or stream-version collisions under load rather than as an
obvious failure. To sequence them, put 'ServiceUpdates' into a GlobalPartitioned message topology,
which lets Wolverine choose the batch's execution slot from the batch's group id. Note Sequential()
on the batch queue does NOT close this: it serializes the batch against itself only. (Call
opts.AssertBatchExecutionIsSequenced() to make Wolverine throw on this instead of warning.)

It names the fix and heads off the wrong fix, both asserted in tests. opts.AssertBatchExecutionIsSequenced() escalates it to a startup throw.

Scoping decisions

  • Only under MultipleHandlerBehavior.Separated. Under the default Classic behaviour the direct handler wins and the batch never runs at all, so there is only one writer — that shape is already reported by warnOrAssertBatchHandlerConflicts and must not be double-reported.
  • A new opt-in rather than reusing AssertNoBatchHandlerConflicts. That flag is documented as having no effect under Separated; widening it would start throwing for someone who already set it.
  • Option 1 is not attempted. Making the listener's ShardedExecutionBlock an addressable lane a batch can join means restructuring the receivers — the block is not a queue and cannot be enqueued to. The issue explicitly accepts option 2 as closing it, and says "Option 2 is a perfectly good answer if option 1 is structurally awkward."

Tests

Five in unsequenced_batch_execution_validation — the throw and its message content, silence when a partitioned topology does sequence them, silence with no unbatched sibling, silence under Classic, and warn-not-throw by default.

Batching regression: 123/123.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC

…batched siblings

Follow-up to GH-3867, taking the second of the two acceptance paths the issue offers.

On a host with BatchMessagesOf<T> and NO partitioned topology there are two independent
execution paths writing the same entity: the assembled batch on its own local queue, and
the unbatched sibling handlers inside the listener receiver's own execution block. GH-3867
already resolves this when a topology exists -- resolveBatchExecutionTopologies points the
batch at the same slots so every writer for one group id really is a single writer. Without
one, findPartitionedExecutionSlots returns null and nothing sequences them.

Sequential() on the batch queue does not close it: that serializes the batch against itself
and against nothing else.

The asymmetry is what makes this worth a startup message rather than documentation. With a
GlobalPartitioned topology the configuration is safe; without one -- embedded hosts,
single-node deployments, most test fixtures -- the same code has two concurrent writers to
one event stream, surfacing as intermittent stream-version collisions under load. So a defect
is unreachable in the configuration the tests use and reachable in the one that ships, or the
reverse. The silent version is the bug.

Startup now logs a warning naming the message type, the queue the batch landed on, the fix
(put the element type into a partitioned topology) and the wrong fix it must not be confused
with (Sequential). opts.AssertBatchExecutionIsSequenced() escalates it to a startup throw.

Deliberately scoped to MultipleHandlerBehavior.Separated. Under the default Classic behaviour
the direct handler wins and the batch never runs at all, so there is only one writer -- that
shape is already reported by warnOrAssertBatchHandlerConflicts and must not be double-reported.

A new opt-in rather than reusing AssertNoBatchHandlerConflicts: that flag is documented as
having no effect under Separated, and widening it would start throwing for someone who set it.

Option 1 from the issue -- making the listener's ShardedExecutionBlock an addressable lane a
batch can join -- is not attempted here. The issue explicitly accepts this option as closing
it, and the block is not a queue and cannot be enqueued to without restructuring the receivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
@jeremydmiller
jeremydmiller merged commit 4a12985 into main Aug 17, 2026
38 checks passed
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.

Make the listener's sharded execution block an addressable lane so a batched handler can join it (follow-up to #3867)

1 participant