Skip to content

Wolverine 6.29.0

Latest

Choose a tag to compare

@jeremydmiller jeremydmiller released this 17 Aug 23:39
d16a743

A feature release. Three of the five items fix silent failure modes — work that acted on a write which could still roll back, a convention mirror that installed a relay over a real handler, and two concurrent writers to one entity — so the notes below say what the old behaviour looked like, not just what changed.

AfterCommit — run work after the transactional commit

After reads like a post-handler hook that runs at the end. It does not run after the commit (#3976, closes #3975).

The commit is itself a postprocessor contributed by the persistence provider, and After methods are inserted at the front of that list. So an After method observing a write is observing one that is not durable yet and may still roll back — and there was no supported way to ask for the other side of it, even though Wolverine uses that position itself for the outbox flush.

public static class RaiseAlertHandler
{
    public static void Handle(RaiseAlert command, IDocumentSession session)
        => session.Events.Append(command.Id, new AlertRaised(command.Reason));

    // Only runs if the append above actually committed
    public static void AfterCommit(AlertLatch latch, RaiseAlert command)
        => latch.MarkRaised(command.Id);
}

Use the AfterCommit / AfterCommitAsync convention or [WolverineAfterCommit], on message handlers, sagas and HTTP endpoints. Parameters bind exactly as After already does.

The position is structural, not positional — frames go into a new IChain.PostCommitPostprocessors list concatenated after every postprocessor at frame-assembly time, rather than being appended from a policy sequenced after the persistence policy. Getting the position right by luck of policy ordering is precisely what breaks silently later.

Two behaviours worth knowing:

  • They do not run when the commit throws. Frames are concatenated without a try/finally, so the exception unwinds straight past them. That is the point — the reason to want "after the commit" is usually that the side effect must not happen for a write that did not land.
  • They run after the outbox flush as well, so a message cascaded from an after-commit method is not atomic with the write. Cascade from the handler if it has to be.

After's pre-commit position is unchanged and stays that way. Verified per provider: Marten, Polecat, Fisher, EF Core, RavenDb and CosmosDb each have a codegen test asserting the emitted call lands after that provider's own commit frame.

A store-agnostic EventsToAppend return type

Wolverine.Marten.Events, Wolverine.Polecat.Events and Wolverine.Fisher.Events are identical but store-named, so a handler that wanted to be store-agnostic could not name any of them (#3969, closes #3941).

The store-agnostic path did exist — a bare IEnumerable<object> return is picked up by a fallback — but that fallback is positional. IEnumerable<T> is covariant, so every reference-typed collection in a return tuple is a candidate and the first one wins. Nothing failed at codegen and nothing failed at runtime; the wrong collection simply became the appended events.

The type is EventsToAppend, not Events. Naming it Events would have been a source-breaking collision (CS0104) for any handler importing both the core event-sourcing namespace and a store integration — that is, on the very declaration the feature exists for.

Ask what will be handled, and how a batch is shaped

Discovery materializes after options time, so an extension installing fallback handlers could not ask "will this message type have a handler?" and had to hand-roll a mirror of Wolverine's own discovery convention (#3977, closes #3974).

Such a mirror drifts, and it drifts silently: one that scanned a single assembly stopped seeing handlers that moved to a second, and installed a bare relay over a real handler — the exact defect the guard existed to prevent, with every codegen test still passing.

opts.OnHandlersDiscovered(handlers =>
{
    if (!handlers.Handles<ServiceUpdates>())
    {
        // safe to install a fallback
    }
});

Separately, IMessageBatcher.BatchMessageType is a free-form Type — a custom batcher need not produce T[], and Wolverine deliberately leaves an application-supplied batcher alone so it can assemble its own shape. Consumers were inferring the handled type from array-ness, which is wrong for exactly those batchers. WolverineOptions.TryFindBatchMessageType(elementType, out var batchMessageType) and WolverineOptions.BatchMappings now expose the real mapping.

Startup tells you when a batch cannot be sequenced

Following up #3867: a batched element type that also has unbatched handlers has two independent execution paths writing the same entity — the assembled batch on its own local queue, and the unbatched siblings inside the listener's own execution block (#3978, closes #3973).

A partitioned topology resolves that. Without one, nothing does — and Sequential() on the batch queue does not close it, because that serializes the batch against itself and against nothing else.

The asymmetry is the real hazard: with a GlobalPartitioned topology the configuration is safe, and without one — embedded hosts, single-node deployments, most test fixtures — the same code has two concurrent writers and surfaces as intermittent stream-version collisions under load. A defect can therefore be unreachable in the configuration your tests use and reachable in the one that ships.

Wolverine now warns at startup naming the message type, the queue the batch landed on, the fix, and the wrong fix. To make it a startup failure instead of a load-dependent one:

opts.AssertBatchExecutionIsSequenced();

SignalR: coalesce outgoing messages

Wolverine.SignalR had no batching or buffering of any kind, and Wolverine offers no sender-side hook for it — so an application that wanted it had to route outbound messages through a local queue, which makes that queue a cascade target for its own handlers (#3979, closes #3972). A handler forwarding with SendAsync then re-sends onto the very queue it was read from.

opts.PublishAllMessages().ToSignalR()
    .CoalesceOutgoing(o =>
    {
        o.FlushInterval = 100.Milliseconds();
        o.MaxBatchSize  = 200;
    });

Nothing round-trips a queue here, so there is no queue to re-enter, and the buffer sits after the outbox rather than before it — which removes the "never use it for a message that tells the client to go and read something" caveat an application-level accumulator carries.

Buffers are keyed by destination, so a message bound for one connection is never coalesced with one bound for another. Batches carry the individual CloudEvents documents in arrival order — each item keeps its own message type, since the CloudEvents envelope is per-outer-message — and are delivered on a dedicated ReceiveCoalescedMessages client operation. A batch holding a single message goes out on the normal operation, so the trickle case needs no client change. Anything still buffered is flushed at shutdown.

⚠️ Browser clients must handle ReceiveCoalescedMessages to receive coalesced batches — the operation is deliberately distinct so an un-updated client fails obviously rather than receiving a payload it tries to read as a single document. See the SignalR guide for the unwrap snippet. Wolverine's own SignalR client transport handles it automatically.