Listeners
@AceListener consumes a queue with a method.
@Component
class OrderListener {
@AceListener(queue = "orders.new")
void onOrder(Order order) {
// ...
}
}The annotation goes on a method of any bean. Nothing else is needed: no container factory, no registrar, no interface to implement.
What the method may take
Exactly one parameter, in one of two shapes.
The payload, decoded with the configured codec:
@AceListener(queue = "orders.new")
void onOrder(Order order) { }The whole message, when the envelope matters:
@AceListener(queue = "orders.new")
void onOrder(Message<Order> message) {
Order order = message.payload();
int attempt = message.attempt();
String correlation = message.envelope().correlationId();
Map<String, Object> headers = message.headers();
}Message<T> carries the payload, the envelope — id,
type, correlation and causation ids, attempt count, first-seen time,
origin — the raw headers, the queue it came from and the time it was
received.
Two parameters is an error, and so is a raw Message
without a type argument. Both fail at startup with the method in the
message, because the codec needs a concrete type to decode into and
guessing Object produces a LinkedHashMap that
fails at the handler's first field access instead.
Per-listener settings
@AceListener(
queue = "${orders.queue}",
id = "orders",
prefetch = 40,
concurrency = 4,
autoStartup = "${orders.consume:true}")
void onOrder(Order order) { }| Attribute | Default | Notes |
|---|---|---|
queue |
required | Property placeholders are resolved |
id |
beanName#methodName |
Used in logs and by AceListenerRegistry.get(id) |
prefetch |
acemq.listener.prefetch |
Unacknowledged messages allowed at once |
concurrency |
acemq.listener.concurrency |
Consumers on this queue |
autoStartup |
acemq.listener.auto-startup |
A string, so it can be a placeholder |
declare |
none | @AceQueue: declare the queue before consuming it — see
below |
Concurrency above 1 gives up ordering. Messages on one queue are handled in parallel, which is usually what a service wants and occasionally the cause of a bug that only appears under load. When order matters per key rather than globally, the library's ordered queues do that properly; this annotation does not try to.
Two listeners cannot share an id. The clash fails at startup rather than leaving whichever one lost invisible in the registry.
When a handler throws
Returning normally acknowledges the message. Throwing rejects it, and what happens then is configuration:
| Configuration | What happens to a failed message |
|---|---|
| Nothing set | Dead-lettered, if the queue has a dead-letter exchange, and dropped otherwise |
acemq.listener.requeue-on-failure: true |
Requeued, and retried immediately, and probably failing again |
acemq.listener.retry.enabled: true |
Republished with a delay, on the ladder |
The ladder is the reason to prefer this library, so it is worth being precise about what it does. A retry written by hand usually looks like this:
@AceListener(queue = "orders.new")
void onOrder(Order order) throws InterruptedException {
try {
downstream.call(order);
} catch (TransientFailure e) {
Thread.sleep(2000); // do not do this
downstream.call(order);
}
}That sleep blocks the consumer's channel. With prefetch 100, up to a hundred messages are sitting in that consumer's buffer, unavailable to any other consumer, waiting for a thread that is doing nothing. Ten seconds of backoff across four consumers is forty seconds of stalled throughput and a queue that looks busy while nothing moves.
The ladder republishes instead: the message goes back to the broker with a delay and an incremented attempt count, the channel is free, and the next message is handled now. Set it up in properties:
acemq:
listener:
retry:
enabled: true
max-attempts: 5
initial-delay: 2s
max-delay: 30s
jitter: 0.2and throw:
@AceListener(queue = "orders.new")
void onOrder(Order order) {
downstream.call(order); // throws, the ladder handles it
}message.attempt() tells a handler which attempt it is
on, which is how a fifth attempt can do something different from the
first.
There is no return value that acknowledges. The library has an ack-aware handler interface that nothing in the library currently uses. An annotation wired to an unused code path is a starter growing a feature its own library does not have, so this one does not.
Retries, dead letters and replay is the whole of this subject: where a message goes when the ladder gives up, how to get it back, and how to survive the redelivery.
What the annotation does not reach
The annotation has six attributes and there will not be many more.
Three things it cannot do, each with the same answer — declare that
consumer as a ConsumerGroup bean and use the library's own
API:
| An idempotency store | ConsumerOptions.idempotent(store) |
| A retry ladder for this queue only | ConsumerOptions.withRetry(policy) |
| A codec for this queue only | ConsumerOptions.as(codec) |
All three are ConsumerOptions, and the registry that
starts annotated listeners builds those from
acemq.listener.* alone — which is a property file, and none
of the three is a value a property file can hold. Idempotency has the bean form in
full, and Patterns is the map of which
patterns need it.
Starting and stopping
Listeners are started by AceListenerRegistry, a
SmartLifecycle, after the rest of the context is built.
Starting a consumer while the beans it calls are still being created is
how a message arrives at a half-built service, and it happens on exactly
the deployments where the queue already has a backlog.
On shutdown the registry drains every listener before closing any of them:
WARN o.a.s.b.AceListenerRegistry : listener orders still had 3 message(s) in flight after PT30S; they will be redelivered
Draining is not the same as emptying the queue. A listener stopped with a backlog leaves the backlog; what it drains is the messages already inside handlers. Everything else stays on the queue.
A queue that does not exist
A listener on a queue nobody created is caught as the application starts, not at the broker's first refusal:
@AceListener orders (bean 'orderListener', method onOrder) listens on queue 'orders.nwe', which does not exist
Declare the queue under acemq.topology.queues or with @AceListener(declare = @AceQueue), create it on the broker, or set acemq.listener.missing-queue=warn to start without these listeners.
The check runs when listeners start, which is after the declared
topology has been applied and after every bean that declares a queue in
its own initialisation has run. A queue under
acemq.topology.queues is taken as provided for and not
asked about — unless acemq.topology.apply is
dry-run, which creates nothing. Every other queue is asked
about passively: nothing is created, and an existing queue is not
changed whatever its type or arguments. Every missing queue is named in
the one failure, so three typos cost one restart.
With acemq.listener.missing-queue: warn the application
starts, each missing queue is logged as a warning, and those listeners
stay stopped. Start one later with
registry.start("orders"), or through the actuator endpoint; both
check the queue again and refuse while it is still missing.
A listener with autoStartup = "false" is not checked
until it is started.
Declaring the queue
A listener can declare the queue it consumes instead of relying on something else to:
@AceListener(queue = "orders.new", declare = @AceQueue)
void onOrder(Order order) { }
@AceListener(queue = "audit.log", declare = @AceQueue(
type = Kind.CLASSIC, // AceMqProperties.Topology.Queue.Kind
arguments = "x-message-ttl=${audit.ttl:60000}",
deadLetter = true))
void onAudit(AuditLine line) { }@AceQueue has the options of an entry under
acemq.topology.queues, with the same names and defaults,
and is declared through the same topology:
| Attribute | Default | Same as |
|---|---|---|
type |
QUORUM |
acemq.topology.queues[].type; the queue is durable
either way |
arguments |
none | acemq.topology.queues[].arguments, written
name=value; classic queues only, refused on a quorum
queue |
deadLetter |
false |
acemq.topology.queues[].dead-letter: also
{queue}.dlq, {queue}.parked and
acemq.dlx |
An argument value that is a whole number is sent as a number, and
true or false as a boolean —
x-message-ttl as the string "60000" is refused
by the broker. Placeholders are resolved.
It follows acemq.topology.apply. With
create-only the queue is created if it is missing and left
alone if it is there; with validate it is only checked, and
a missing one fails the startup; with dry-run nothing is
declared and the listener is checked like one that declares nothing. The
declaration happens as the listener starts, before the missing-queue check, so a
self-declaring listener passes it — and a listener with
autoStartup = "false" declares when it is started by
id.
One queue, one declaration. A queue declared both
here and under acemq.topology.queues, or by two listeners,
must be declared the same way — the same type and the same arguments,
dead-letter ones included — or the startup fails naming both:
@AceListener audit (bean 'auditListener', method onAudit) declares queue 'audit.log' differently from acemq.topology.queues: arguments {x-message-ttl=60000} here, {x-dead-letter-exchange=acemq.dlx, x-dead-letter-routing-key=audit.log.dlq, x-message-ttl=60000} there. Declare it the same way in both, or in one place only.
The broker would refuse the second declaration with
PRECONDITION_FAILED anyway, but only when it got there, and
closing the channel. When the two agree, the topology's declaration is
the one applied. A queue already on the broker with different settings
fails the same way, through the library's drift check — AMQP cannot
change a queue in place.
The retry ladder is not part of it. With
acemq.listener.retry.enabled the consumer declares its own
rungs, .dlq and .parked as it starts, whether
or not the listener declares its queue. What deadLetter
adds is the broker-side half: the source queue's
x-dead-letter-exchange, which the consumer cannot set
because it does not declare the source queue. Use both together for a
queue whose failures must never be dropped.
The registry
Inject AceListenerRegistry to reach a running
listener:
@Service
class OrderThrottle {
private final AceListenerRegistry listeners;
OrderThrottle(AceListenerRegistry listeners) {
this.listeners = listeners;
}
void slowDown() {
listeners.get("orders").ifPresent(group -> {
group.scaleTo(1);
group.prefetch(10);
});
}
long acknowledged() {
return listeners.get("orders").map(ConsumerGroup::acknowledged).orElse(0L);
}
}get(id) returns the library's own
ConsumerGroup, which is what carries scaleTo,
prefetch, drainTimeout, drain,
close and the counters — acknowledged,
rejected, retried, inFlight, plus
queue, size and prefetch as
readers. It is a library type rather than a wrapper of this starter's,
because a wrapper would be one more thing to keep in step.
Two things are not on it, and are worth knowing before looking for them.
Pausing. A group has no pause of its own.
mq.pauseConsuming() and mq.resumeConsuming()
stop and start every consumer on the connection at once, which is the
right tool for shedding load and the wrong one for quietening a single
noisy listener. scaleTo(1) is the floor —
scaleTo(0) is refused with "a group needs at least one
consumer" — so the way to stop one listener entirely is
registry.stop("id"), and registry.start("id")
to resume it.
Note also that scaling down blocks: the consumers
being removed are drained first, up to drainTimeout. A
scaleTo that returned instantly while abandoning work in
the background would be the more convenient lie.
Dead letters. Not a group counter.
acemq.messages.dead.lettered.total, tagged by queue, is the
metric, and
mq.messageCount("orders.new.dlq") is the depth — see Dead letters.
Every listener is a group, even at concurrency one. One type means one set of counters and one shutdown path, and a group of one costs nothing.
A listener registered with autoStartup = "false" is not
running; registry.start("id") starts it.
registry.stop("id") stops one — draining it first, as
shutdown does — and start brings it back. The acemq actuator
endpoint calls the same two methods.
There is no template
The publishing side has no annotation and no
AceTemplate. Inject AceMq:
mq.publisher("orders", "order.created", Order.class).send(order);A template wrapping that would be a second API to document, a second one to keep in step with the first, and the place where a starter starts making decisions the library deliberately left to the caller — which codec, which options, whether the send is synchronous. The publisher is already small, already typed, and already documented in the library's own guide.
Publishing is the page: keeping a
publisher as a bean, what send returns, batches, and what
happens when the broker stops reading.