Repository navigation
Epic: DurabilityMode.ServerlessWithOutbox — a transactional outbox for serverless functions #5016
Description
Summary
Wolverine's DurabilityMode.Serverless keeps the cold-start story lean, but it has no transactional outbox. Users on serverless platforms (Cloud Run, AWS Lambda, Azure Functions) who want Marten or EF Core transactional middleware and guaranteed delivery of cascaded messages have no supported option today. This epic adds a new DurabilityMode.ServerlessWithOutbox: a real outbox that works without any always-on background process, recovered by an externally triggered "durability pass" (cron, scheduler, job), with latency traded for cost.
This came out of a conversation with a community user running Wolverine + Marten (and a second service on EF Core) on Google Cloud Run with Google Cloud Pub/Sub, on a tight budget. They've volunteered to test it. That scenario is the driving use case, but the design is transport- and provider-agnostic.
The problem today
DurabilityMode.Serverlessdrops the outbox without saying so.ServerlessEndpointsMustBeInlinePolicy(src/Wolverine/Configuration/IEndpointPolicy.cs) coerces every endpoint toEndpointMode.Inline. AUseDurableOutbox()endpoint is downgraded with no warning (onlyNativeAckgets one). Cascaded messages are then sent after the transaction commits with no outbox row behind them. If the commit succeeds and the send fails, or the instance is frozen or killed between the two, the message is lost.- Background sending can't be relied on. On platforms that throttle CPU once the HTTP response is written, anything left to a background
BufferedSender/DurableSendingAgentmay never run. - Nothing runs the durability work in this mode. No
DurabilityAgent, no scheduled-job polling (WolverineRuntime.Agents.cs), and recurring schedules are refused at startup. - Scheduled sends partly break.
MessageRouterBasefalls back toTransportConstants.DurableLocalUrifor scheduled envelopes, which throwsUnknownTransportExceptionafterOptions.Transports.RemoveLocal(). The TODO inWolverineRuntime.HostService.csalready notes this. - Wolverine.Http's existing HTTP transport receiver can't work here.
HttpTransportExecutor.ExecuteBatchAsync()hands envelopes to a local queue (AgentForLocalQueue), and Serverless strips local queues.
The pieces of one recovery pass already exist, though. The RDBMS DurabilityAgent.buildOperationBatch() runs incoming and outgoing recovery plus BumpStaleOutgoingEnvelopesOperation, and RunScheduledMessagesOperation releases due scheduled messages. They are only driven by timers inside a long-running agent.
Proposed design
1. New DurabilityMode.ServerlessWithOutbox
A new mode value, chosen for discoverability over a flag on Serverless:
opts.Durability.Mode = DurabilityMode.ServerlessWithOutbox;- Same lean runtime as
Serverless: no node registration, no node agent controller, no leader election, no local queues, no listeners auto-started, no background durability timers. - But the message store's outbox stays active: outgoing envelopes are persisted inside the handler's transaction (Marten, EF Core, or the RDBMS providers).
- Existing
Serverlessbehavior doesn't change (no breaking changes). EveryswitchonDurabilityModemust handle the new value. This widens the test matrix; that's accepted.
2. owner_id = 0 from the start, plus a stale-time guard
In this mode, outgoing (and scheduled) envelope rows are written with owner_id = 0 (any node) from the beginning, since no node record exists to own them.
- Recovery only claims owner-0 rows older than
DurabilitySettings.OutboxStaleTime. This avoids a race where a scheduled durability pass resends a row the originating request is still sending. OutboxStaleTimeis nullable today.ServerlessWithOutboxneeds a sensible default (for example 30–60 seconds, to be decided). That default is the recovery latency floor for messages whose inline send failed.- The stale predicate on owner-0 rows needs a timestamp check that the current recovery queries don't have (today,
owner_id = 0means "immediately claimable"). This must be added per provider without changing Balanced/Solo behavior.
3. MessageContext.SendAllOutgoingMessagesInlineAsync(): a transport-agnostic flush
A new method on MessageContext that, after the transaction commits:
- Sends every outgoing envelope right now, through an inline sender for its destination endpoint, regardless of how that endpoint is configured (buffered, durable, or inline). It awaits each send so all work finishes inside the request's CPU allocation.
- Deletes the matching outbox rows for every envelope that was sent successfully, in one batched call per message store (not one round trip per envelope).
- Leaves failed envelopes in the outbox (still
owner_id = 0) for the next durability pass, and logs them.
In ServerlessWithOutbox, Wolverine's transactional middleware and the Wolverine.Http endpoint flush call this instead of the normal FlushOutgoingMessagesAsync() handoff to sending agents. It should also be public, so a hand-written function entry point can call it.
Open question, "DurableInline": should this be expressed as a real sending model, a DurableInline endpoint mode (persist in the transaction, send inline after commit, batch-delete), that we recommend for ServerlessWithOutbox, or that the mode applies quietly to every endpoint? Needs discussion. Batching the "mark sent / delete" calls applies either way.
4. One bounded durability pass, triggered from outside
A single core API that runs one pass of durability work and returns:
var report = await host.RunDurabilityPassAsync(maxDuration: 30.Seconds());
// report: outgoing sent / failed / remaining, scheduled released, incoming recovered, ...The pass:
- recovers and sends stale owner-0 outgoing rows, batching the deletes;
- releases due scheduled messages: external destinations are sent; local message types are executed inline by the pass, since there are no local queues;
- recovers incoming rows;
- runs expiry cleanups (handled envelopes, dead letters, deduplication claims);
- is limited by both batch size and elapsed time, and reports what is left so the caller can loop;
- takes a non-blocking try-lock on the database (advisory lock or equivalent), so overlapping triggers skip instead of double-sending;
- has an extension point (for example an
IDurabilityPassStep) for later additions, such as catching up Marten / Polecat async projections. That is out of scope for v1.
Three ways to trigger it:
- HTTP:
app.MapWolverineDurabilityEndpoints()in Wolverine.Http, for Cloud Scheduler, EventBridge Scheduler, or timer-triggered functions. The docs must say to protect it (Cloud Run IAM/OIDC, function keys, and so on). - CLI: for example
dotnet run -- storage flush(or a new verb), for Cloud Run Jobs, scheduled Lambda invocations, and Kubernetes CronJobs. - Programmatic: call the API directly.
Scheduled-message latency becomes the trigger interval (Cloud Scheduler's minimum is one minute). That's an explicit, documented trade-off.
5. Inbound: a transport-agnostic push receiver in Wolverine.Http
Serverless platforms deliver broker messages by push (Pub/Sub push subscriptions, EventBridge/SNS to HTTP, Event Grid, and so on). Add a receiver to Wolverine.Http that:
- accepts a push request, maps it to an
Envelopethrough a pluggable, transport-supplied mapper (Pub/Sub would reuse itsIPubsubEnvelopeMapper), and executes the handler inline; - calls
SendAllOutgoingMessagesInlineAsync()before responding; - returns 2xx to ack, and non-2xx so the broker retries with its own backoff and dead-letter topic. Wolverine's "move to error queue" writes to the store's dead-letter table and acks;
- optionally uses the durable inbox to dedupe at-least-once redelivery by message id.
Exact API shape and how transports register mappers are TBD in the sub-issues.
6. Warnings
When Serverless (not ServerlessWithOutbox) downgrades a UseDurableOutbox() endpoint to Inline, log a warning saying the outbox guarantee no longer applies and pointing at ServerlessWithOutbox. (Warning vs. error is still to be discussed; leaning toward warning.)
Scope: every supported outbox
This is a slower epic. ServerlessWithOutbox must work against every message store that supports the outbox, each with compliance coverage:
- PostgreSQL (
Wolverine.Postgresql) - Marten (
Wolverine.Marten) - EF Core on PostgreSQL and SQL Server (
Wolverine.EntityFrameworkCore) - SQL Server (
Wolverine.SqlServer) - Polecat (
Wolverine.Polecat) - MySQL (
Wolverine.MySql) - Oracle (
Wolverine.Oracle) - SQLite (
Wolverine.Sqlite) - Fisher (
Wolverine.Fisher) - RavenDB (
Wolverine.RavenDb) - Cosmos DB (
Wolverine.CosmosDb) - Multi-tenanted message databases (pass iterates every tenant database)
Suggested order: core mode + SendAllOutgoingMessagesInlineAsync() + durability pass on PostgreSQL / Marten first (that's what the early tester runs), then fan out per provider, then the push receiver.
Open questions
- A
DurableInlinesending model: explicit and recommended, or applied quietly underServerlessWithOutbox? - Warning vs. error when plain
Serverlessdowngrades a durable endpoint. - Default
OutboxStaleTimefor the new mode. - Whether any provider needs a schema change for the stale owner-0 predicate (an index on
timestamp). If so, it needs an opt-in or migration. - The push receiver's mapper registration API.
Activity
jeremydmiller commentedon Oct 14, 2026
The open questions are settled in #5017. In short: DurableInline is applied quietly under
ServerlessWithOutbox(internal, no new publicEndpointMode); plainServerlesslogs one startup warning when it downgrades a durable endpoint; the defaultOutboxStaleTimein the new mode is 60 seconds; and an index on the outgoingtimestampcolumn is opt-in, with no schema change by default. The push-mapper registration API will be proposed in the implementing PR.Record the settled ServerlessWithOutbox design decisions (JasperFxGH-…