Retries, duplicates and shutdown
Three things that decide whether a consumer survives contact with production.
Retry policies
Without one, a handler that throws is retried after a fixed delay, forever. A policy bounds that:
await mq.ConsumeAsync<OrderPlaced>(
"orders.placed",
ConsumerOptions.Defaults().WithRetry(
RetryPolicy.Exponential(5, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))),
Handle);Five attempts, doubling from a second, capped at a minute. Each retry
republishes the message with x-acemq-attempt advanced
rather than handing it back to the broker unchanged, so the count
travels with the message instead of living in one process. After the
last attempt it is republished to {queue}.dlq with the
reason attached, rather than occupying a consumer indefinitely.
RetryPolicy.None() // one attempt
RetryPolicy.Fixed(3, TimeSpan.FromSeconds(5))
RetryPolicy.Exponential(5, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))
.GiveUpAfter(TimeSpan.FromHours(6))
.WithJitter(0.3)Why jitter is on by default
A fixed delay is fine for a dependency that is briefly unavailable. It is the wrong shape for one that is overloaded: every consumer that failed at the same moment retries at the same moment, and the dependency that was struggling gets the whole herd at once. That is how a brief problem becomes a sustained one.
Jitter defaults to 0.2 — each delay is spread ±20%, both ways. One-sided jitter only ever delays, which turns a thundering herd into a slower thundering herd rather than dispersing it. Set it to zero only when you want reproducible timings in a test.
Where the waiting happens
A wait shorter than thirty seconds is spent in the consumer, which
holds the delivery for the duration. A wait of thirty seconds or more is
spent in the broker: the message is republished into a
{queue}.retry.{delay} queue whose
x-message-ttl is the wait and whose dead-letter target is
the queue it came from, and the broker hands it back when the time is
up.
RetryPolicy.Exponential(6, TimeSpan.FromSeconds(10))
.WaitInBrokerFrom(TimeSpan.FromMinutes(1)) // move the line
.WaitInBrokerFrom(TimeSpan.Zero); // or wait for everything hereThe reason for the split is that a consumer sleeping on a five-minute backoff is holding an unacknowledged message. Restart it — a deploy, a crash, an autoscaler — and the broker redelivers at once, so a five-minute policy delivers in none. Below thirty seconds that costs seconds and one prefetch slot; above it, it costs the whole wait, which is the bug worth spending a queue on.
Broker waits are never jittered: a rung's time-to-live is fixed when the queue is declared, so a moved delay would name a queue that is not there — and the spread comes free anyway, because each message's time-to-live starts when it arrives rather than when the batch failed.
policy.Schedule() lists the delays without jitter and
policy.BrokerRungs() lists the ones that need a queue,
which is what to read when deciding whether a policy is the one you
meant. Topology.Define().QueueWithRetry(name, policy)
declares them, and a consumer declares them again when it starts — a
rung that does not exist loses the message rather than reporting
anything.
Giving up by age, not only by attempts
.GiveUpAfter(TimeSpan.FromHours(6))Attempts alone cannot express "this has stopped being worth doing". A
message retried five times over a weekend is usually no longer useful,
however few attempts that took. The age is measured from the envelope's
x-acemq-first-seen, which the original publisher set.
Duplicates
Every broker worth using delivers at least once. A consumer will eventually see the same message twice — a redelivery after a crash, a retry that actually succeeded, an outbox relay that published before recording it. If handling a message twice is not safe, something has to remember:
var store = InMemoryIdempotencyStore.ForOneDay();
await mq.ConsumeAsync<OrderPlaced>(
"orders.placed", ConsumerOptions.Defaults().Idempotent(store), Handle);The message id — the envelope's — is the key.
Three states, not two
Claim, Confirm, Release. A
store with only "seen" and "not seen" cannot tell a message that
failed from one that succeeded, so it
drops the retry of a message that was never handled. The claim is taken
before the handler runs and released if it fails, so a retry is not
mistaken for a duplicate.
What the in-memory store is and is not
It deduplicates what one running consumer sees, which is most redeliveries. It is per process and lost on restart, so it cannot deduplicate across instances or across a deploy.
For that, use the database-backed store:
var store = new DbIdempotencyStore(() => new SqlConnection(connectionString),
TimeSpan.FromDays(1));store.CreateTableSql() gives you the table to put in a
migration — the library hands you the statement rather than running DDL
against your database uninvited.
The claim is an insert, so the primary key does the mutual exclusion: two consumers racing for the same message, one insert succeeds and the other is refused by the database. A select-then-insert would have a window between the two.
Retention is the window duplicates are caught in. Too short and a redelivery after a long outage is handled twice; too long and the store grows. A day covers what a broker actually produces.
A claim is a lease, not a fact
A handler that throws releases its claim, so an ordinary failure is retried. A handler whose process dies releases nothing — nothing runs when a process is killed — so the claim has to expire on its own:
var store = new DbIdempotencyStore(() => new SqlConnection(connectionString),
TimeSpan.FromDays(1), // retention
"acemq_idempotency", "@",
TimeSpan.FromMinutes(5)); // the claim windowFive minutes by default —
InMemoryIdempotencyStore.DefaultClaimTimeout, the same
figure Java, Go, Python and Ruby use, so a message that is stuck is
stuck for the same length of time whichever library was holding it.
Set it comfortably above your slowest handler, or a
slow handler's message is handed to a second consumer while the first is
still working on it.
Taking over a stale claim is a conditional UPDATE, so
the database does the mutual exclusion there too: two consumers that
both find a claim expired cannot both proceed, because the second
updates zero rows. A confirmed message is never retaken
— that would hand out completed work again, which is the duplicate the
store exists to stop.
Until 0.7.5 there was no claim window: a row meant "somebody has this", whoever left it and whenever, and the only expiry was retention. A consumer killed mid-handler therefore suppressed that message's redelivery for a day, and the work never happened — not a duplicate, which this pattern may produce, but a silent loss, in the pattern that exists to prevent exactly that.
Done, or still being done
A refused claim means one of two things, and they are not handled alike:
| the store says | the consumer |
|---|---|
ClaimResult.Claimed — no claim, or its lease ran
out |
runs the handler |
ClaimResult.Duplicate — confirmed |
accepts it, handler not run |
ClaimResult.InProgress — claimed, not confirmed, lease
live |
puts it back, handler not run |
In progress is neither run nor accepted. Somebody is
still working on it — or failed and could not release the claim, because
the store was unreachable when the release ran. Accepting that
redelivery, as every version up to 0.7.8 did, lost the message. Instead
it waits ConsumerOptions.InProgressDelay (five seconds by
default; WithInProgressDelay changes it), is republished to
its own queue with its attempt unchanged, and the
original is acknowledged — or, if the republish is not routed, returned
to the broker with requeue. The retry policy is not asked, so it spends
no attempt and is never dead-lettered for waiting. Once the claim is
confirmed the next look is a duplicate; once its lease runs out the
message is taken over and handled. It is counted as
outcome="in_progress".
ConsumerOptions.Defaults()
.Idempotent(store)
.WithInProgressDelay(TimeSpan.FromSeconds(2)); // keep it well under the claim windowThe wait is spent in the consumer, holding the delivery, as a short retry's is. Closing the connection does not wait it out: the delivery is handed back with requeue at once.
Both shipped stores implement
IClaimingIdempotencyStore.TryClaimAsync, which answers in
one call. A custom store that implements only
IIdempotencyStore gets the same answer from
ClaimAsync followed by IsConfirmedAsync;
ClaimAsync is unchanged. A pipeline step with
Idempotent follows the same rule, with the default
delay.
Shutting down
await using, not using.
DisposeAsync drains first; Dispose does not.
Dispose is not instant either: on RabbitMQ it waits — up to
five seconds — for the handlers already running to return, because the
client closes a connection only after its handlers are done.
Neither way of closing waits for a delivery held at the pause gate.
Once the consumers are cancelled — so the broker sends them nothing more
— held deliveries are handed back with requeue, never acknowledged and
never dead-lettered, and the close finishes as soon as the last running
handler does. Before 0.7.6 a held delivery sat out its full thirty
seconds first, which made await using take longer than
Kubernetes' default grace period.
Disposing one consumer while its handler is at work
closes that consumer's channel only once the handler has returned, so
whatever the handler is waiting on — the confirm for a retry hop, a move
to {queue}.dlq, its own publish — still arrives, and the
rest of the connection keeps working. A delivery the client had buffered
but not yet handed over goes back to the queue when the channel
closes.
A handler cancelled on the way out hands its message back at once. When a consumer is disposed or its connection closes — the hosting integration's drain running out of time and cancelling the handler token is the usual case — a retry delay or in-progress delay the handler was sitting out ends immediately, the message is requeued with its attempt count unchanged, and the channel and connection close straight away. (0.7.10 left that message unacknowledged, and the connection open, until the process exited.)
A handler that ignores its cancellation is abandoned, not
waited for. Closing the connection waits up to five seconds for
the handlers still running. A handler still going after that — one that
never passes its CancellationToken to what it awaits — is
left behind: the connection and its socket close anyway, the broker
hands the message back to the queue at once, and when the handler
finally returns its acknowledgement is dropped rather than sent, so the
message is never settled twice. Its side effects are another matter: the
message is redelivered, so whatever the abandoned handler goes on to do
happens a second time. Pass the token to everything the handler awaits.
(Before 0.7.12 such a handler kept the connection, and its
unacknowledged message, until the process exited.)
await using var mq = await AceMqConnection.ConnectAsync("amqp://localhost");Visual Basic has neither await using nor the ability to
Await a ValueTask, so the same work is on
CloseAsync — the name the other four libraries use:
Await mq.CloseAsync()That spends up to AceMqConnection.DefaultDrainTimeout —
twenty seconds, the same figure Java and Ruby use, chosen to fit inside
Kubernetes' default terminationGracePeriodSeconds of 30 —
pausing consumption and waiting for the handlers already running. Java,
Go, Python and Ruby all wait on the way out, and this is how .NET spells
it.
Drain explicitly when you want the answer rather than only the tidy-up:
bool finished = await mq.DrainConsumersAsync(TimeSpan.FromSeconds(30));
mq.Dispose();Drain stops new messages being handed over and waits for the ones in
progress. It returns false if they did not finish in
time, rather than throwing — shutting down anyway is a legitimate
choice, and the caller needs to know which one it is making.
DisposeAsync discards that answer, because a teardown has
nobody to report it to; if you want it logged, drain first as above.
An orchestrator's deadline is not this timeout, so there is an overload that takes the orchestrator's token:
await mq.DrainConsumersAsync(TimeSpan.FromSeconds(30), stoppingToken);Cancelling abandons the wait, not the work. Handlers
already running are not interrupted — nothing here can interrupt them —
and consuming stays paused, because resuming on the way out hands more
messages to a process that is leaving. Cancellation throws
OperationCanceledException where the timeout returns
false, deliberately: false is the library
saying it was given long enough, cancellation is the caller changing its
mind, and a caller that cannot tell them apart logs the wrong one.
mq.PauseConsuming(); // stop taking new work; nothing is lost
mq.ResumeConsuming(); // the same message is handed over again
mq.PausePublishing(); // further publishes throw PublishingPausedException
mq.InFlight // messages inside a handler right now
mq.Held // fetched, waiting at the pause gate, never handledPausing loses nothing. A message the broker has delivered but which was never handed to a handler stays unacknowledged, so it is redelivered — to this consumer when it resumes, or to another instance.
What a successful drain does and does not mean
true means every handler finished. It
does not mean every message the broker sent was handled: a delivery that
arrived while paused is decoded and then held at the pause gate, and
InFlight counts handlers, which that delivery never
reached. Held is the number sitting there. Nothing is lost
either way — none of them was acknowledged, so the broker redelivers
them and no work was half done — but a caller deciding whether a
shutdown was clean should read both.
How many can pile up is bounded by the transport's dispatch concurrency, not by the prefetch: the rest of a prefetch window stays in the client's buffer undecoded. The other libraries differ here. Go runs every fetched delivery through a handler, so a Go drain is bounded by the prefetch and can be much longer; Python nacks the unstarted ones with requeue. All three end with the message back on the broker.
Health
Everything above shows up in the health report, and in the actuator's
/acemq-health:
var health = mq.Health();
health.Status // Up, Degraded or Down
health.Reports // per componentOrdered queues register themselves, so a halted partition appears here — which matters, because a halted partition is a consumer that has stopped without the connection or the process noticing.
A halted partition reports Degraded, not Down, and the actuator answers 200 for it. The other partitions are still working, and a process that reports itself down gets restarted — which loses the held message and fixes nothing. Down is reserved for a connection that is actually closed.
A blocked connection is Up, with the reason
RabbitMQ blocks a connection when it is low on memory or disk. The
connection report says Up, with
blocked and blockedReason among its
details:
var connection = mq.Health().Reports.Single(r => r.Name == "connection");
connection.Status // Up
connection.Details["blocked"] // "true"
connection.Details["blockedReason"] // "low on memory"Back pressure is the broker protecting itself. An application that
fails its own health check for it is an application an orchestrator
restarts into the same blocked broker, having thrown away whatever it
was holding — and a fleet doing that together stops draining the queues
at the moment the broker most needs them drained. Java's Spring Boot
health indicator reports it the same way, and
acemq-go-amqp's lifecycle guide documents the same
trap.
This changed in 0.7.0; before it, the connection report said Degraded. Because an aggregate takes the worst report, that reading overruled any more careful one a caller composed alongside it, and callers were reconstructing the connection report by hand to get out of its way. To get the old reading back, make it your own policy:
sealed class BlockedIsDegraded : IHealthContributor
{
private readonly AceMqConnection _mq;
public BlockedIsDegraded(AceMqConnection mq) => _mq = mq;
public string Name => "broker-pressure";
public HealthReport Report() =>
_mq.IsBlocked
? new HealthReport(Name, HealthStatus.Degraded,
new Dictionary<string, string> { ["reason"] = _mq.BlockedReason ?? "" })
: HealthReport.Up(Name);
}
mq.RegisterHealth(new BlockedIsDegraded(mq));Registered, it is one component's opinion that a reader can see the name of, rather than a verdict on the connection itself that nothing can get out from under.
HealthStatus
collides with the framework's
AceMq.Amqp.HealthStatus and
Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus
share a name, and the collision has a sharp edge. Inside a
namespace that is itself under AceMq.Amqp —
AceMq.Amqp.Hosting, say — the enclosing namespace beats a
file-level using alias, so an unqualified
HealthStatus resolves to this library's however the file
aliases it. Outside AceMq.Amqp.* the alias wins as written
and there is nothing to think about.
Alias both, or alias this one and leave the framework's bare:
using AceHealthStatus = AceMq.Amqp.HealthStatus;
using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus;They are not interchangeable in any case: Up/Degraded/Down against Healthy/Degraded/Unhealthy, and the mapping between them is a decision — see above for why blocked is not Degraded here.