Skip to content

fix(batching): run both a direct and a batch handler under MultipleHandlerBehavior.Separated#3075

Closed
outofrange-consulting wants to merge 2 commits into
JasperFx:mainfrom
outofrange-consulting:fix/separated-handler-batch-and-single
Closed

fix(batching): run both a direct and a batch handler under MultipleHandlerBehavior.Separated#3075
outofrange-consulting wants to merge 2 commits into
JasperFx:mainfrom
outofrange-consulting:fix/separated-handler-batch-and-single

Conversation

@outofrange-consulting

@outofrange-consulting outofrange-consulting commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem

When a message type T has both a direct Handle(T) handler and a BatchMessagesOf<T>()
configuration (with a Handle(T[]) batch handler), the direct handler silently shadows the batch —
the batched handler never runs, with no error or warning at startup or codegen.

Root cause: routing and executor resolution only consult the batch definitions when there is no
direct handler for the element type:

  • LocalRouting.FindRoutes returns early when HandlerGraph.CanHandle(messageType) is true; the
    batch-queue route lives only in the else branch.
  • Executor.Build(...) and WolverineRuntime IExecutorFactory.BuildFor(Type, Endpoint) resolve
    HandlerFor(...) first and fall back to a BatchDefinition only when that is null.

Because the default BatchMessagesOf<T>() queue is the element type's own convention queue
(FindQueueForMessageType(T)), the batch and the direct handler also collide on a single local
queue, and a local queue pipeline resolves exactly one executor per message type.

This came up in a real app that batches domain events for an integration-event publisher while also
keeping a per-message telemetry handler for the same events.

Fix

Scoped to MultipleHandlerBehavior.Separated, where the per-message handler and the batch handler
are naturally two independent consumers of the element type. Classic mode behavior is unchanged.

  1. Dedicated batch queue (WolverineRuntime.HostService): when an element type has both a direct
    handler and a batch definition under Separated mode, move the batch onto its own local queue
    (<convention-name>-batch) so it no longer collides with the direct handler's queue. Only the
    currently-broken conflict case is touched.
  2. Additive local routing (LocalRouting.FindRoutes): fan the element type out to the batch
    queue in addition to the direct handler's queue.
  3. Endpoint-aware executor (ExecutorFactory.BuildFor(Type, Endpoint)): the dedicated batch
    queue always resolves to the BatchingProcessor, even when a direct handler exists. Also, an
    element type in this conflict case arriving from an external transport listener is relayed
    to both local queues via a fan-out handler, so external arrivals run both handlers too.
  4. HandlerGraph.BuildFanoutHandler: small refactor extracting the FanoutMessageHandler<T>
    construction so both the existing sticky-handler fan-out and the new external-arrival relay share it.

Net effect: under Separated mode a T — whether published in-process or received from an external
listener — runs both the direct handler and the batch handler independently.

Tests

src/Testing/CoreTests/Bugs/Bug_separated_batch_and_single_handler.cs:

  • end-to-end local publish runs both the direct and the batch handler;
  • the batch lands on its own -batch queue, routing fans T out to both queues, and the batch
    queue resolves to BatchingProcessor<T>;
  • a T arriving from an external (non-local) listener resolves to a fan-out to both local queues.

Existing batch_processing, sticky_message_handlers, routing, partitioning, local-transport and
executor suites pass unchanged. Full wolverine.slnx builds clean in Release.

Docs

Added a "Combining a direct handler with batching" section to docs/guide/handlers/batching.md
documenting the Separated-mode behavior and the dedicated -batch queue.


Update — multiple batched handlers under Separated

Follow-up commit extending the same Separated-mode scope.

Problem

Under Separated, a batched array type with more than one Handle(T[]) handler has its handlers
split onto per-handler sticky queues. The BatchingProcessor re-enqueues a single produced batch
onto the batch's own execution queue, which is none of those sticky queues, so
ExecutorFactory.BuildFor threw NoHandlerForEndpointException and no batch handler ran. This is
orthogonal to the direct-handler collision above — it reproduces even with no direct Handle(T).

Fix

In ExecutorFactory.BuildFor, when the endpoint is the batch's local execution queue and the produced
batch-message type's chain has multiple Separated sticky handlers, resolve a FanoutMessageHandler<T[]>
that relays the produced batch to every sticky Handle(T[]) queue, so each runs independently. Reuses
the existing HandlerGraph.BuildFanoutHandler. Covers custom batch message types too (keyed off
IMessageBatcher.BatchMessageType, not just arrays). Classic mode is unchanged — multiple
Handle(T[]) handlers are still combined into one logical chain.

Tests

src/Testing/CoreTests/Bugs/Bug_separated_multiple_batch_handlers.cs:

  • produced_array_fans_out_to_each_sticky_handler_queueBuildFor(T[], batchQueue) resolves to FanoutMessageHandler<T[]>;
  • separated_multiple_batch_handlers_all_run — two Handle(T[]) handlers both run;
  • separated_direct_handler_plus_multiple_batch_handlers_all_run — direct Handle(T) and both batch handlers all run.

Regression suites (batch, sticky, separated, partitioning, routing, local queues, executor) pass.
Full wolverine.slnx builds clean in Release (176 projects, 0 warnings).

Geoffrey MARC added 2 commits June 11, 2026 16:42
…mode

When a message type has both a direct Handle(T) handler and a BatchMessagesOf<T>() configuration, the direct handler silently shadowed the batch: routing (LocalRouting.FindRoutes) and executor resolution (Executor.Build / ExecutorFactory.BuildFor) only consulted the batch definitions when there was no direct handler for the element type, so the batch handler never ran.

Under MultipleHandlerBehavior.Separated, treat the per-message handler and the batch handler as independent consumers of the element type: move the batch onto a dedicated '-batch' local queue so it no longer collides with the direct handler's queue, fan the element type out to both queues for in-process publishes, and relay to both local queues for messages arriving from an external transport listener.
…ed mode

Under MultipleHandlerBehavior.Separated, when the batched array type has more
than one Handle(T[]) handler, Wolverine splits them onto per-handler sticky
queues. The BatchingProcessor re-enqueues a single produced batch onto the
batch's own execution queue, which is none of those sticky queues, so executor
resolution threw NoHandlerForEndpointException and no batch handler ran.

Resolve the batch's execution queue to a fan-out handler that relays the
produced batch (T[] or a custom batch message type) to every sticky Handle(T[])
queue, so each batch handler runs independently. Works both with and without a
direct Handle(T) handler (the dedicated -batch queue case). Classic mode is
unchanged: multiple Handle(T[]) handlers are still combined into one chain.
@jeremydmiller

Copy link
Copy Markdown
Member

Superseded by #3077, which keeps your two commits (and co-authorship) and adds a small refactor — extracting the Separated batch-resolution checks in BuildFor / LocalRouting.FindRoutes into intention-named methods — plus relocates the regression tests out of CoreTests/Bugs into the batching (Acceptance.batching_with_separated_handlers) and routing (Runtime.Routing.separated_batch_routing) suites. Thanks @outofrange-consulting!

@jeremydmiller

Copy link
Copy Markdown
Member

Just superseded by #3077

jeremydmiller added a commit that referenced this pull request Jun 11, 2026
…upersede

fix(batching): run direct + batch (and multiple batch) handlers under Separated mode (supersedes #3075)
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.

2 participants