Delivery Setup
Overview
The Delivery setup registers DeliveryHostedService, a background worker that reads
OutboxDelivery records from the database and forwards them to the broker transport
(e.g., RabbitMQ). It operates independently of the outbox staging layer and can run in
the same process or in a dedicated worker service.
When to use
- You have Outbox Setup configured and need a delivery worker
- You want to run the delivery worker in a separate process from the outbox-writing service
- You need retry backoff, stuck-message recovery, and a health check for the delivery backlog
Prerequisites
Outbox Setup — the OutboxEvent and
OutboxDelivery tables must exist and be populated by IOutboxService<TContext>.
NuGet packages
| Package | Purpose |
|---|---|
| Juice.Messaging.Outbox.Delivery | DeliveryHostedService, AddDelivery() sub-builder, intent strategies |
| Juice.EventBus.RabbitMQ | RabbitMQ transport producer (ITransportPublisher) |
DI Registration
Minimal setup
services.AddMessaging()
.AddDelivery(delivery =>
{
delivery.AddDeliveryProcessor<AppDbContext>("rabbitmq"); // default intents applied automatically
delivery.AddDeliveryPolicies(configuration.GetSection("DeliveryPolicies"));
delivery.EventBus.AddRabbitMQ(rabbitMQ =>
{
rabbitMQ.AddConnection("rabbitmq", configuration.GetSection("RabbitMQ"))
.AddProducer("rabbitmq", "rabbitmq");
});
});Per-processor policies
Use the delegate overload of AddDeliveryProcessor<TContext> to configure a policy
that applies only to that processor, independently of the global policy:
delivery.AddDeliveryProcessor<AppDbContext>("rabbitmq", proc =>
{
proc.AddDeliveryPolicies(opts =>
{
opts.DefaultPolicy = new PolicyConfiguration
{
BatchSize = 50,
Interval = TimeSpan.FromSeconds(10)
};
});
});Processor-scoped policies sit below global key-matched entries in the resolution chain
but above the global DefaultPolicy. See Delivery Policies for
the full resolution order.
Important:
AddDelivery()automatically callsservices.AddEventBus()in its constructor. Do not need to callservices.AddEventBus()separately when usingAddDelivery().
Intent Strategies
Intent strategies define what work DeliveryHostedService performs on each polling cycle.
Pass them to AddDeliveryProcessor<TContext>() in the order you want them evaluated.
| Strategy | Trigger condition | Purpose |
|---|---|---|
send-pending |
OutboxDelivery.State == NotPublished |
Pick up newly staged messages and publish them |
retry-failed |
State == Failed AND NextAttemptOn ≤ UtcNow |
Re-attempt delivery after the backoff delay has elapsed |
recover-timeout |
State == InProgress AND stuck for > configured timeout |
Reset timed-out in-progress records to Failed so they can be retried |
All three strategies are registered automatically when using AddDeliveryProcessor<TContext>(publisher).
Delivery Policies
A DeliveryPolicy controls how a processor polls and retries messages. Every field has
a built-in default; you only need to specify the fields you want to override.
Built-in defaults
| Field | Default | Description |
|---|---|---|
Interval |
5 seconds | Polling interval between batch processing cycles |
BatchSize |
10 | Records processed per cycle |
Timeout |
5 minutes | Age threshold before a stuck InProgress record is recovered |
InitialRetryDelay |
5 seconds | Delay before the first retry attempt |
RetryDelayMultiplier |
2.0 | Exponential backoff multiplier |
MaxRetryAttempts |
3 | Number of retry attempts before the record stays in Failed |
Policy resolution order
When DeliveryHostedService requests a policy for a given context, IDeliveryPolicyResolver
checks sources in this order and uses the first match:
| Priority | Source | Key format |
|---|---|---|
| 1 (highest) | Global Policies — exact |
"{publisher}:{intent}:{contextName}" |
| 2 | Processor Policies — intent-specific |
"{intent}" (1-segment; publisher and context are implicit) |
| 3 | Processor DefaultPolicy |
— |
| 4 | Global Policies — wildcard context |
"{publisher}:{intent}:*" |
| 5 | Global Policies — wildcard intent |
"{publisher}:*:*" |
| 6 | Global DefaultPolicy |
— |
| 7 (lowest) | Built-in defaults | DeliveryPolicy.Default |
Only the global exact match (priority 1) outranks processor-scoped policies. Global wildcard entries (priorities 4–5) are catch-alls that fall below any explicit processor configuration.
Each matched entry inherits unset fields from the next source down:
- A processor
Policiesentry inherits from the processorDefaultPolicy, which inherits from the globalDefaultPolicy. - A global
Policiesentry inherits directly from the globalDefaultPolicy.
Global policies via configuration
Configure shared policies in appsettings.json. In JSON, replace : in key names
with __ (double underscore):
{
"DeliveryPolicies": {
"DefaultPolicy": {
"Interval": "00:00:05",
"BatchSize": 10
},
"Policies": {
"rabbitmq__send-pending__AppDbContext": {
"BatchSize": 20,
"Interval": "00:00:03"
},
"rabbitmq__send-pending__*": {
"BatchSize": 15
},
"rabbitmq__*__*": {
"Interval": "00:00:10"
}
}
}
}Register the section with AddDeliveryPolicies:
delivery.AddDeliveryPolicies(configuration.GetSection("DeliveryPolicies"));AddDeliveryPolicies is safe to call multiple times — each call merges its entries
into the shared options.
Global policies via code
delivery
.AddDeliveryPolicies(opts =>
{
opts.DefaultPolicy = new PolicyConfiguration { BatchSize = 20 };
opts.Policies["rabbitmq:retry:*"] = new PolicyConfiguration { BatchSize = 5 };
})
.AddDeliveryPolicies(opts =>
{
opts.Policies["rabbitmq:send-pending:*"] = new PolicyConfiguration { Interval = TimeSpan.FromSeconds(3) };
});Per-processor code policy
A processor-scoped policy applies only to that processor type. It ranks above global
wildcard policies — only the global exact match can override it. Use it when a specific
TContext needs its own policy without touching the global configuration.
The processor Policies dictionary uses intent-only keys (1-segment) because the
publisher and context are already implied by the processor registration:
delivery.AddDeliveryProcessor<SlowDbContext>("rabbitmq", proc =>
{
proc.AddDeliveryPolicies(opts =>
{
// Intent-specific overrides — just the intent name, no publisher prefix
opts.Policies["send-pending"] = new PolicyConfiguration { BatchSize = 20, Interval = TimeSpan.FromSeconds(5) };
opts.Policies["retry-failed"] = new PolicyConfiguration { BatchSize = 10, Interval = TimeSpan.FromSeconds(10), MaxRetryAttempts = 3 };
opts.Policies["recover-timeout"] = new PolicyConfiguration { BatchSize = 10, Interval = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromMinutes(10) };
// Fallback for any intent not listed above
opts.DefaultPolicy = new PolicyConfiguration { Interval = TimeSpan.FromSeconds(30) };
});
});Unset fields in a processor Policies entry inherit from the processor DefaultPolicy,
which in turn inherits from the global DefaultPolicy. Only a global exact entry
(priority 1) can override processor-scoped policies.
Retry Backoff Schedule
The default policy uses exponential backoff:
delay = InitialRetryDelay × RetryDelayMultiplier^(attempt - 1)
With built-in defaults (InitialRetryDelay = 5s, RetryDelayMultiplier = 2.0):
| Attempt | Delay before retry |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 10 seconds |
| 3rd retry | 20 seconds |
Records that exhaust MaxRetryAttempts (default 3) remain in Failed state for audit.
Override the retry behaviour via a processor or global policy:
delivery.AddDeliveryPolicies(opts =>
opts.DefaultPolicy = new PolicyConfiguration
{
InitialRetryDelay = TimeSpan.FromSeconds(30),
RetryDelayMultiplier = 3.0,
MaxRetryAttempts = 5
});OutboxDelivery State Machine
NotPublished
│
▼ (send-pending — picks up new records)
InProgress
│
├──► Published (broker ACK received — terminal state)
│
├──► Failed (publish error; NextAttemptOn = UtcNow + backoff delay)
│ │
│ └──► InProgress (retry-failed — when NextAttemptOn ≤ UtcNow)
│
└──► Failed (recover-timeout — InProgress for > configured timeout is reset to Failed)
DeliveryState values: NotPublished=0, InProgress=1, Published=2, Failed=3, Skipped=4Messages that fail permanently remain in Failed state for audit. Skipped is used
when a publishing policy has no matching route.
Health Check
Register AddOutboxDeliveryHealthCheck to expose a live health endpoint that alerts
when messages are stuck in InProgress longer than the configured threshold:
services.AddHealthChecks()
.AddOutboxDeliveryHealthCheck<AppDbContext>(opts =>
{
opts.StuckMessageThresholdMinutes = 15;
opts.MaxStuckMessages = 50;
}, tags: new[] { "live" });Monitoring guidance: Include the live tag in your liveness probe. If
AddOutboxDeliveryHealthCheck returns Unhealthy, investigate whether:
recover-timeoutintent is registered and running- The broker connection is available
- The
DeliveryHostedServiceitself is alive (checkIHostedServiceregistration)
Partial-setup health check (outbox without delivery worker):
If you run only the Outbox setup (no delivery worker in this process), skip the health
check — there are no InProgress records to monitor in a process that doesn’t run
DeliveryHostedService.
See also
- Local Transport — in-process dispatch via
"local-channel"and"local"routes - Consumption Setup — add a RabbitMQ consumer to the same service
- Full Setup — combine outbox + delivery + consumption in one registration