Skip to content

GH-3975: add a declarative way to run work AFTER the transactional commit - #3976

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3975-after-commit-hook
Aug 17, 2026
Merged

GH-3975: add a declarative way to run work AFTER the transactional commit#3976
jeremydmiller merged 1 commit into
mainfrom
gh-3975-after-commit-hook

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #3975.

After reads like a post-handler hook that runs at the end. It does not run after the commit. The commit is itself a postprocessor — each provider's ApplyTransactionSupport appends one — and After methods are inserted at the front of that list, so they run before the write is durable. There was no supported way to express the other side of it, even though Wolverine uses that position itself for FlushOutgoingMessages.

What's added

AfterCommit / AfterCommitAsync as a conventional method name, and [WolverineAfterCommit] for methods named something else. Same parameter binding After already has; works on message handlers, sagas and HTTP endpoints.

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);
}

The position is structural, not positional

This is the part that matters. Frames go into a new IChain.PostCommitPostprocessors list that is concatenated after every postprocessor at frame-assembly time — not appended to Postprocessors from a policy sequenced after the persistence policy.

That's the issue's actual complaint: an application that gets the position right by luck of policy ordering gets it wrong the moment that ordering changes, silently, with every test still green. A separate list can't drift that way.

After's pre-commit position is deliberately unchanged, and there's a compatibility guard per provider asserting it stays there. Plenty of post-handler work has nothing to do with durability.

Verified per provider, not once

Each of Marten, Polecat, Fisher, EF Core, RavenDb and CosmosDb gets a codegen test asserting the emitted AfterCommit call really does land after that provider's commit frame, plus the guard that After still lands before it. List membership alone would only prove the frame went into the right list; the guarantee is about where it ends up relative to a frame contributed from a completely different code path, and only the emitted source shows that.

These are pure codegen assertions, so none needs a live server — including RavenDb, whose DocumentStore.Initialize() doesn't open a connection.

One provider difference worth flagging: CosmosDb has no commit postprocessor at all. Its ApplyTransactionSupport puts a TransactionalFrame in the middleware (the outbox enlistment) and only FlushOutgoingMessages in the postprocessors. Its test asserts against the flush rather than a SaveChangesAsync it never emits.

Core behaviour pinned

  • the frame lands in PostCommitPostprocessors and not in Postprocessors
  • the attribute works without the conventional name
  • observed call order is Handle → After → AfterCommit
  • an after-commit method does not run when an earlier postprocessor throws — frames are concatenated without a try/finally, so the exception unwinds past them. The issue asked for this to be pinned so it stays true.

Docs

The issue's nice-to-have was that the After name "points the wrong way for anyone thinking about durability." docs/guide/handlers/middleware.md now shows the emitted order explicitly, with the two caveats: these don't run when the commit throws, and they run after the outbox flush, so a cascade from one isn't atomic with the write.

Verification

  • CoreTests middleware area: 33/33; new after_commit_middleware 4/4
  • Marten / Polecat / Fisher / EF Core / RavenDb / CosmosDb: 2/2 each
  • Full wolverine.slnx builds clean pinned to net9.0

🤖 Generated with Claude Code

https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC

…mmit

"After" reads like a post-handler hook that runs at the end. It does not run after the
commit. The transactional commit is itself a postprocessor -- each persistence provider's
ApplyTransactionSupport appends one -- and After methods are INSERTED at the front of the
postprocessor list, so they run before the write is durable. There was no supported way to
express the other side of it, even though Wolverine uses that position itself for
FlushOutgoingMessages.

Adds an AfterCommit / AfterCommitAsync convention and a [WolverineAfterCommit] attribute,
with the same parameter binding After already has, on message handlers, sagas and HTTP
endpoints.

The position is STRUCTURAL, not positional. Frames go into a new
IChain.PostCommitPostprocessors list that is concatenated after every postprocessor at
frame-assembly time, rather than being appended to Postprocessors from a policy sequenced
after the persistence policy. That distinction is the whole point of the issue: an
application that gets the position right by luck of policy ordering gets it wrong the
moment that ordering changes, silently and with every test still green.

After's pre-commit position is deliberately unchanged. Plenty of post-handler work has
nothing to do with durability, and moving it would be a silent behaviour break.

Verified per provider rather than once. Each of Marten, Polecat, Fisher, EF Core, RavenDb
and CosmosDb gets a codegen test asserting the emitted AfterCommit call really does land
after that provider's own commit frame, plus the compatibility guard that After still lands
before it. These are pure codegen assertions, so none of them needs a live server -- including
RavenDb, whose DocumentStore.Initialize() does not open a connection.

Worth noting CosmosDb has NO commit postprocessor at all: its ApplyTransactionSupport puts a
TransactionalFrame in the MIDDLEWARE and only FlushOutgoingMessages in the postprocessors, so
its test asserts against the flush instead of a SaveChangesAsync it never emits.

Core tests pin the rest: the frame lands in PostCommitPostprocessors and NOT in Postprocessors,
the attribute works without the conventional name, the observed call order is
Handle -> After -> AfterCommit, and an after-commit method does NOT run when an earlier
postprocessor throws (frames are concatenated without a try/finally, so the exception unwinds
straight past them -- pinned so it stays true).

Docs call out the distinction directly, since the issue's point is that the name currently
points the wrong way for anyone thinking about durability.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
@jeremydmiller

Copy link
Copy Markdown
Member Author

Full CoreTests regression on this branch: 2410 passed, 2 skipped, 0 failed (2412 total).

Per-provider after-commit tests: Marten 2/2, Polecat 2/2, Fisher 2/2, EF Core 2/2, RavenDb 2/2, CosmosDb 2/2. Full wolverine.slnx builds clean pinned to net9.0.

@jeremydmiller
jeremydmiller merged commit e63bdac into main Aug 17, 2026
37 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.

No declarative way to run work AFTER the transactional commit — After methods insert at the front, so they run before it

1 participant