Publishing
There is no annotation and no template on the publishing side. Inject
AceMq and ask it for a publisher.
@Service
class Orders {
private final AceMq mq;
Orders(AceMq mq) {
this.mq = mq;
}
void place(Order order) {
mq.publisher("orders", "order.created", Order.class).send(order);
}
}Why there is no
AceTemplate is a separate argument. This page is about
what the publisher does once you have it.
A publisher is worth keeping
mq.publisher(...) returns a
DefaultPublisher<T>, and building one is cheap but
not free: it resolves the codec, and on a confirming connection it
registers for confirms. A service that publishes the same type to the
same place on every request should make it once.
The obvious place is a @Bean:
@Configuration
class OrderPublishers {
@Bean(destroyMethod = "close")
Publisher<Order> orderPublisher(AceMq mq) {
return mq.publisher("orders", "order.created", Order.class);
}
}The bean type is Publisher<Order> — the library's
interface — rather than DefaultPublisher<Order>.
Inject it as Publisher<Order> and a test can
substitute something else without a subclass.
destroyMethod = "close" refuses further sends once the
context is shutting down. It does not wait for anything: the connection
belongs to AceMq, and a publisher holds no resource of its
own. What waits for messages still in flight is the shutdown of the
connection itself — AceMq is a bean whose destroy method is
close, and Spring destroys it after every
SmartLifecycle has stopped.
Declare DefaultPublisher<Order> instead when you
want the fluent methods — .as(...),
.with(...), .replyingTo(...) — on the injected
bean rather than at the call site.
What send gives back
PublishResult result = publisher.send(order);
result.messageId(); // the id the envelope was given
result.routed(); // whether the broker matched it to a queue
result.latency(); // how long the confirm tooksend returns when the broker has confirmed the message,
which is what acemq.publisher-confirms: true buys and is
the default. It throws PublishFailedException when the
confirm does not arrive inside acemq.confirm-timeout, or
arrives as a negative acknowledgement.
acemq.publisher-confirms: false applies to your
publishes, not the library's. What the library publishes on
your behalf — retry hops, dead-lettering and parking, the outbox relay,
replay, the scheduler, and pipeline and routing-slip hops — is always
confirmed, even with confirms turned off, because each of those settles
or deletes the original only once the broker has the copy.
routed() is false, not an exception, for a
message nothing was listening for. A publish to a routing key
with no matching binding is confirmed by the broker and then discarded.
That is usually a topology mistake rather than a runtime one, so it is
reported rather than thrown — but a service that has no business
publishing into a void should check it:
PublishResult result = publisher.send(order);
if (!result.routed()) {
log.error("nothing is bound for order.created; the order was dropped");
}PublishOptions.allowUnroutable() turns that check off at
the broker level, for a publisher that genuinely does not care whether
anyone is listening — an audit feed nobody has subscribed to yet,
say.
Options
mq.publisher("orders", "order.created", Order.class,
PublishOptions.defaults()
.expiringAfter(Duration.ofMinutes(5))
.withPriority(4));| Method | What it does |
|---|---|
PublishOptions.defaults() |
Persistent, mandatory, no expiry, no priority |
PublishOptions.transientDelivery() |
Not written to disk; lost on a broker restart |
.allowUnroutable() |
Do not report a message nothing is bound for |
.expiringAfter(Duration) |
Per-message time to live |
.withPriority(int) |
Priority, on a classic queue declared with
x-max-priority |
transientDelivery() is the one worth thinking about
twice. It is faster because the broker does not fsync, and it means a
broker restart loses whatever had not yet been handed to a consumer. For
a metrics sample that is the right trade; for an order it is not.
Priority needs a classic queue with x-max-priority,
which means acemq.topology.queues[].type: classic and the
argument set — see Configuration. A priority on a
message bound for a quorum queue is accepted by the broker and
ignored.
The envelope
Every message carries an Envelope: an id, a type, a
version, a correlation id, an attempt count, the time it was first seen.
The publisher fills one in. Pass your own when a message belongs to a
conversation that started somewhere else:
void onPaymentTaken(Message<PaymentTaken> incoming) {
Envelope outgoing = Envelope.of("order.confirmed")
.correlationId(incoming.envelope().correlationId())
.causationId(incoming.envelope().id())
.header("tenant", tenantOf(incoming))
.build();
mq.publisher("orders", "order.confirmed", OrderConfirmed.class)
.send(confirmation, outgoing);
}correlationId is the conversation;
causationId is the single message that caused this one.
Carrying the first and setting the second is what makes a trace across
six services readable afterwards, and it is two lines that nobody writes
unless they are shown.
Envelope.of(type) returns a builder.
incoming.envelope().causing("order.confirmed") returns a
builder that has already carried the correlation across and set the
causation, which is the same thing in one call.
Batches
List<PublishResult> results = publisher.sendAll(orders);sendAll publishes the whole collection and then waits
once for all of the confirms, rather than waiting for each in turn. For
a thousand small messages over a connection with any latency at all that
is the difference between one round trip's wait and a thousand.
When every message is confirmed, the list is positional:
results.get(3) is the result for the fourth payload. When
any of them fails, sendAll throws
PublishFailedException instead of returning, and the
message says how many of how many failed:
23 of 1000 messages were not confirmed; 977 were. The first failure was: ...
The count is there because a half-succeeded batch is the ordinary
outcome of a broker problem partway through, and a caller told only that
it failed will resend the 977 that arrived. There is no partial list to
read: the results of the successful sends are not returned alongside the
failure. A batch that must be reconciled message by message wants
sendAsync and its own list of futures, below.
Asynchronously
CompletableFuture<PublishResult> future = publisher.sendAsync(order);sendAsync returns as soon as the message is on the wire;
the future completes when the confirm arrives. This is the shape for a
request handler that has other work to do, and for a loop that wants
many publishes in flight at once:
List<CompletableFuture<PublishResult>> inFlight = orders.stream()
.map(publisher::sendAsync)
.toList();
CompletableFuture.allOf(inFlight.toArray(CompletableFuture[]::new)).join();sendAll is the same thing with one wait and less code,
and it is the right default when you have the collection in hand. Reach
for the futures instead when the publishes do not arrive together, or
when you need to know exactly which of them failed — which
sendAll does not tell you.
Note that sendAsync is asynchronous about the
confirm, not about the send. A publish that has reached
max-outstanding-publishes, or one onto a blocked connection
with a non-zero blocked-timeout, still waits in the calling
thread; see below.
Back pressure and blocked connections
A broker running short of memory or disk stops reading from its publishers. This is a RabbitMQ feature, not a fault: it is how the broker protects itself from a producer that is faster than its consumers.
Two properties decide what a publisher does about it:
| Property | Default | What it does |
|---|---|---|
acemq.blocked-timeout |
0s |
How long a publish waits for a blocked broker to resume before it is refused |
acemq.max-outstanding-publishes |
10000 |
Unconfirmed publishes allowed in flight at once, on the asynchronous path |
A publish into a blocked broker is refused at once.
While the broker is blocking publishes, send throws
ConnectionBlockedException, whose message names the
broker's own reason — memory or disk — and
whose mayHaveBeenPublished() is false: nothing
was written, so the message can be sent again once the alarm clears,
without a duplicate. The publish is counted as refused, not failed. The
one exception is the first publish into a new alarm, written before the
broker said it was blocked: that one fails within
confirm-timeout as possibly published, because the broker
may have it.
To wait instead, give blocked-timeout a
length. A publish then waits up to that long for the alarm to clear and
is refused only after it; confirm-timeout is not applied
while waiting, because a broker under a resource alarm is not a slow
broker:
acemq:
blocked-timeout: 30sChanged in 0.3.1. The default was 30s,
a wait, until 0.3.1, which follows acemq-java-amqp 0.7.15
in refusing at once, as the Python, .NET and Ruby libraries do. An
application that relied on send riding out a short alarm
sets blocked-timeout: 30s to keep that behaviour.
max-outstanding-publishes is the only back pressure an
asynchronous publisher has. The slot is taken before the
message is written, and a sendAsync or sendAll
that finds no slot free waits up to confirm-timeout for one
and then throws TransportException — "the broker is not
keeping up; publish more slowly rather than buffering more". Without
that bound a caller publishing faster than the broker confirms
accumulates unconfirmed messages until the process dies, which looks
exactly like throughput right up to the moment it does not.
Both defaults are deliberately survivable rather than fast: a refusal the caller can see and retry, and a bound on what is held in memory. The alternative to a bound is an application that is killed for memory, at which point the messages are gone and there is nothing left to say how many there were.
A service can ask before it publishes:
@Service
class OrderIntake {
private final AceMq mq;
boolean accepting() {
return !mq.isBlocked();
}
String why() {
return mq.blockedReason().orElse("not blocked");
}
long unconfirmed() {
return mq.inFlight();
}
}That is what the health indicator reports, and it is the right thing to put behind a readiness probe's decision to shed load — and the wrong thing to put behind a liveness probe's decision to restart. A blocked broker is not fixed by restarting the application into the same broker; it is fixed by the broker recovering, and a restart loop meanwhile turns one problem into two.
mq.pausePublishing() and
mq.resumePublishing() stop and start the publishing side by
hand, which is how a maintenance endpoint or a feature flag drains a
producer without stopping it. A send while paused throws
PublishingPausedException rather than blocking, so a caller
can tell "we are deliberately not publishing" from "the broker is not
reading".
Publishing from inside a transaction
Do not. A send inside @Transactional
publishes a message about a database change that may still roll back,
and the message has no rollback. The fix is the transactional
outbox, which writes the message to the same database in the same
transaction and publishes it afterwards.
Interceptors
PublishInterceptor sees every message on its way out,
which is where a tenant header, an audit hook or a metric that the
library does not already emit belongs. See Interceptors.