Consuming
A handler receives a decoded message and returns what should happen to it.
using var consumer = await mq.ConsumeAsync<OrderPlaced>("orders.placed", async message =>
{
await _orders.RecordAsync(message.Payload);
return Ack.Accept();
});Disposing the consumer stops delivery.
The disposition is a return value, not an exception
Five things can happen to a message, and the handler says which:
Ack.Accept() |
Handled. The broker may forget it. |
Ack.Retry(reason) |
Failed in a way another attempt might survive. The policy decides when. |
Ack.DeadLetter(reason) |
Will never succeed. Stop now and keep the evidence, in
{queue}.dlq. |
Ack.Park(reason) |
A person has to look at this one. Goes to
{queue}.parked. |
Ack.Release() |
Give it back for someone else, without counting an attempt. |
An exception escaping the handler is still handled — it becomes a retry — but returning the disposition says what was meant. An exception cannot distinguish "the payment service is down, try shortly" from "this order references a customer that does not exist and never will", and those need opposite treatment. Retrying the second forever is how one bad message becomes an outage.
return await _payments.ChargeAsync(order) switch
{
ChargeOutcome.Ok => Ack.Accept(),
ChargeOutcome.Declined => Ack.DeadLetter("card declined"),
ChargeOutcome.Unreachable => Ack.Retry(TimeSpan.FromSeconds(30), "payments unreachable"),
_ => Ack.Release(),
};Throwing AceFatalException from a handler dead-letters
the message, which is the shorthand for the same decision when you are
deep in a call stack.
Both are reported as rejected, not
dead_lettered. The message goes to
{queue}.dlq either way; the word says who decided.
dead_lettered is kept for the engine giving up — a
RetryPolicy running out of attempts — because a decision
somebody took about this message and a dependency that stayed down for
six attempts want different people looking at them. See Observability for what that
means on a dashboard.
What arrives with the message
message.Payload // decoded
message.Envelope // identity, correlation, causation, attempt
message.Headers // application headers, without the reserved namespace
message.Attempt // 1 on the first delivery
message.IsFirstAttempt
message.RoutingKey
message.Queue
message.ReceivedAtAttempt is the counter to branch on when a message keeps
coming back:
if (message.Attempt >= 5) return Ack.DeadLetter("five attempts, giving up");The number is read off the wire, from x-acemq-attempt,
not counted in this process. A retry republishes the message with it
advanced, so it means the same thing to every consumer on the queue,
survives a restart, and does not start again at one when a message moves
between instances.
A message that cannot be decoded is not retried
If the body does not parse as the handler's type, the message is sent
to {queue}.parked with the reason attached, and the handler
is never called. It would not parse on the next attempt either, and a
poison message on an infinite retry loop looks exactly like throughput
until someone reads the queue depth.
Parked rather than dead-lettered, because they are different problems: a message that failed five times wants the person who owns the dependency, and a message nothing could read wants the person who owns the publisher. Whoever drains the dead letters should not have to sort them by hand.
A park is reported as parked, on the
span and on acemq.consume.total — both for a body that will
not decode and for a handler calling Ack.Park. It still
counts in acemq.messages.dead.lettered.total under
outcome = parked, so the "how much is this queue giving up
on" total stays one number and the tag splits it. See Observability, which has the
dashboard note.
Prefetch
await mq.ConsumeAsync<OrderPlaced>(
"orders.placed", ConsumerOptions.Prefetch(50), Handle);Twenty by default. This is how many unacknowledged messages the broker will hand this consumer at once, and it is deliberately modest: an unbounded prefetch gives one consumer the whole queue, which turns a rolling deploy into a stall while a single instance works through everything it was handed.
Options
ConsumerOptions.Defaults()
.WithPrefetch(50)
.WithRetryDelay(TimeSpan.FromSeconds(10)) // when a handler throws
.As(new BytesCodec()) // decode differently from the connection
.RequeueingOnFailure() // hand it straight back to the brokerRequeueingOnFailure is off by default, and should stay
off unless a queue's failures really do mean "somebody else should take
this". Requeueing a message that fails deterministically produces a hot
loop that shows up on a dashboard as work being done — and it hands the
broker back the bytes it gave out, so x-acemq-attempt never
advances and the retry policy never reaches its limit.
Where a dead letter goes
Ack.DeadLetter republishes the message
to {queue}.dlq with the reason on
x-acemq-error, and only then acknowledges the original.
Acknowledging a failure looks wrong and is what makes it reliable: by
the time the acknowledgement happens the message is already somewhere
else.
Rejecting it instead — basic.nack with no requeue —
would hand it to whatever dead-lettering the queue happens to be
declared with, and if the queue has none the broker discards
it. Nor can a broker write onto a message it is rejecting, so
the one thing whoever finds it actually needs — what the handler was
unable to do — would not be there.
Nothing has to be declared for this to work. A consumer
declares acemq.dlx, {queue}.dlq and
{queue}.parked when it starts, before it
subscribes and before anything has failed — with or without a retry
policy, because giving up is not something a retry policy switches on.
They are there in the management UI from the moment the service
connects, which is what makes them something an operator can watch
rather than something that appears on the day of the first incident.
The same declarations, argument for argument, are what a topology makes, so it is safe to do both and in either order:
await mq.ApplyAsync(
Topology.Define()
.QueueWithRetry("orders.placed", RetryPolicy.Exponential(6, TimeSpan.FromSeconds(10)))
.Build());Declaring the same queue twice with the same arguments is
how AMQP is meant to be used; declaring it twice with different ones is
a PRECONDITION_FAILED that stops the second consumer
starting at all. That is why the consumer's declaration and
QueueWithRetry's come from one place: both queues classic,
durable, and with no arguments of their own — a dead-letter queue that
dead-letters is a loop.
These were declared on the settle path until ADR-032 — the first time
a message was actually dead-lettered or parked. The end state was the
same; the window before it was not, and Java's
RetryTopology.declare had been declaring them at start-up
all along, which is what the shared contract fixture records.
Retries, duplicates and shutdown
ConsumerOptions also takes a retry policy and an
idempotency store:
ConsumerOptions.Defaults()
.WithRetry(RetryPolicy.Exponential(5, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1)))
.Idempotent(InMemoryIdempotencyStore.ForOneDay())Without a policy a failing handler is retried forever; without a store a redelivery is handled twice. Reliability covers both, and how to drain consumers before shutting down.
Concurrency
The handler may be invoked concurrently up to the prefetch, so it must be safe to call from more than one thread. If your handler mutates shared state, it needs its own locking — the library does not serialise you, because doing so would quietly cap your throughput at one message at a time.