Publishing
A publisher per message type
pub := acemq.NewPublisher[OrderPlaced](mq, "orders-exchange", "orders.created")The two strings are the exchange and the routing key. Leave the exchange empty to publish straight to a queue by name — on RabbitMQ the default exchange routes to the queue whose name matches the routing key:
pub := acemq.NewPublisher[OrderPlaced](mq, "", "orders")A publisher is safe for concurrent use and holds nothing but configuration. Build one per message type at start-up and keep it; building one per message achieves nothing and reads worse.
Why it is a function
NewPublisher takes the connection rather than hanging
off it, because a method cannot have its own type parameter before Go
1.27. The module targets 1.23. See the overview
for the reasoning.
Sending
if err := pub.Send(ctx, OrderPlaced{OrderID: "o-1", TotalCents: 4250}); err != nil {
return err
}The envelope is built for you: an identifier, a type defaulting to the routing key, a correlation defaulting to the identifier, the connection's origin, and the current time.
Options override any of it:
err := pub.Send(ctx, order,
acemq.MessageID("order-o-1"),
acemq.CorrelationID(incoming.Envelope.CorrelationID),
acemq.CausationID(incoming.Envelope.ID),
acemq.SchemaVersion(2),
acemq.Header("x-tenant", "acme"))| Option | |
|---|---|
MessageID |
the identifier, and the default idempotency key. Generated when unset. |
MessageType |
overrides the type, which otherwise comes from the routing key |
SchemaVersion |
the payload's version. Starts at 1. |
CorrelationID |
propagated unchanged across hops. Defaults to the message id. |
CausationID |
the message that caused this one |
Origin |
the publishing process. Defaults to the connection's. |
Header |
one of your own headers |
Attempt, FirstSeen,
DeadLetterReason |
for a message being replayed or moved by hand |
Confirms, and what the library publishes for you
Send returns once the broker has confirmed the message.
A connection dialled with
rabbitmq.Config{WithoutConfirms: true} turns that off for
your publishers: Send returns once the
message is written, Confirmed is false, and
Routed is always true.
What the library publishes on your behalf is confirmed and published
mandatory whatever the connection was dialled with — retry, rung,
{queue}.dlq and {queue}.parked hops, the
outbox relay, routing-slip, pipeline and Then hops, the
scheduler's hops and deliveries, and Replay. A connection
without confirms opens one extra channel, in confirm mode, for them.
Each of those settles a message or marks a record done on the strength
of its publish, so one that reaches no queue is a failed publish and the
message is kept — retried, dead-lettered or left in the outbox — never
acknowledged and lost. Details in reliability.
Publishing a batch
A loop over Send pays a full broker round trip per
message: publish, wait for the confirm, publish the next. A thousand
messages is a thousand waits, one after another, and the broker spends
nearly all of that time idle.
SendAll publishes every message first and waits for all
the confirms afterwards:
results, err := pub.SendAll(ctx, orders)
if err != nil {
return err
}The results come back in the order the payloads were given, whatever order the broker answered in, and there is one per payload:
for i, r := range results {
log.Printf("%s went out as %s, routed=%v", orders[i].OrderID, r.MessageID, r.Routed)
}Options apply to every message in the batch, which is what makes a shared correlation worth having here:
results, err := pub.SendAll(ctx, orders,
acemq.CorrelationID(incoming.Envelope.CorrelationID),
acemq.Header("x-tenant", "acme"))Do not pin MessageID for a batch. Every message would go
out under the same identifier, which is an instruction to a
deduplicating consumer to keep one of them and discard the rest. Leave
it to be generated.
It is not atomic
There is no such thing in AMQP. There is no way to publish a hundred
messages so that all or none arrive, and a library offering one would be
lying about what the protocol can do. What SendAll promises
is narrower and still worth having: every message was attempted, and
every message was waited for.
So a batch can fail halfway. That is the ordinary outcome of a broker problem partway through, and it is why the error carries counts rather than just a reason:
results, err := pub.SendAll(ctx, orders)
var failed *acemq.BatchPublishFailedError
if errors.As(err, &failed) {
log.Printf("%d of %d confirmed", failed.Confirmed, failed.Total)
for i, err := range failed.Errors {
if err != nil {
resend(orders[i]) // only the ones that did not arrive
}
}
}Total |
how many payloads were in the batch |
Confirmed |
how many the broker took |
Failed |
how many it did not |
First |
the first failure in payload order, not the first the broker answered. The error unwraps to it. |
Errors |
one entry per payload, in payload order, nil where the
message was confirmed |
results is returned alongside the error and is always as
long as the batch, so results[i],
failed.Errors[i] and payloads[i] are the same
message.
The message reads:
2 of 5 messages were not confirmed; 3 were. The first failure was: ...
word for word what the Java and .NET libraries produce for the same failure, so one line in a runbook covers a fleet in three languages.
A failure early in the batch does not cut the rest short. Everything is awaited before the error is returned — stopping at the first failure is what loses the count of what did arrive, and the count is the whole reason to report it.
What bounds it
SendAll does not open a second route to the broker.
Every message goes out through the same path as Send.
What bounds it is a number you can set. The RabbitMQ transport writes
to one channel under a lock, because an AMQP channel is not safe for
concurrent writes — but it waits for the confirms outside that lock, so
a batch costs one round trip's latency rather than one per message. How
many publishes may be waiting for a confirm at the same time is
MaxOutstandingPublishes:
transport, err := rabbitmq.Dial(ctx, url, rabbitmq.Config{
MaxOutstandingPublishes: 200,
})
mq, err := acemq.NewConn(transport)It defaults to 1000, which is Java's number and
.NET's. Each outstanding publish holds a body the broker has not
acknowledged yet, so raising it buys latency hiding and costs memory;
setting it to 1 gives back the round trip per message a
transport without pipelining pays.
A publish that finds every slot taken waits for one, and when its context is done it fails rather than waiting for ever:
acemq: 1000 publishes are already waiting for a confirm and none completed in
time; the broker is not keeping up, so publish more slowly rather than buffering
more (rabbitmq.Config.MaxOutstandingPublishes)
That refusal is the point of the bound. A publisher blocked for ever on a broker that has stopped confirming is indistinguishable from a wedged process; a publisher told it cannot get a slot can shed load, slow down, or fail the request.
Mandatory publishes keep working with a batch outstanding. The broker
sends basic.return before the confirm for the same message,
so each publish finds its own return and no other's — a return that
arrives while another publish is draining is filed under its message id
and waits for the publish it belongs to.
Publish interceptors run on one goroutine per message during a batch, so an interceptor keeping state of its own has to be safe for concurrent use.
Carrying context forward
Correlation is what lets somebody follow one business action across a dozen services. Propagate it from the message you are handling, and record what caused what:
sub, err := acemq.Consume(ctx, mq, "orders",
func(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
err := shipments.Send(ctx, ShipmentRequested{OrderID: m.Payload.OrderID},
acemq.CorrelationID(m.Envelope.CorrelationID),
acemq.CausationID(m.Envelope.ID))
if err != nil {
return acemq.Retry(err)
}
return acemq.Accept()
})Correlation stays the same the whole way along. Causation changes at every hop and points back one step, so the chain can be walked in either direction.
Your own headers
acemq.Header("x-tenant", "acme")x-acemq- is reserved. A header in that
namespace belongs to the engine: it is read onto the envelope if this
version knows it, and dropped before the application sees it either way.
Rather than accept one and silently discard it, Send
refuses:
acemq: header "x-acemq-id" is in the reserved "x-acemq-" namespace and would be
dropped on consume; use a namespace of your own, such as x-yourcompany-
Use your own prefix for anything that has to survive the round trip.
Durability
Messages are published persistent by default — the broker writes them to disk. That is a bargain worth keeping unless you are moving telemetry that nobody will miss:
pub := acemq.NewPublisher[Heartbeat](mq, "", "heartbeats",
acemq.NotPersistent[Heartbeat]())Persistence is only half the promise. A persistent message on a queue that is not durable still dies with the broker, so declare the queue durable too — which is the default.
A different codec for one publisher
pub := acemq.NewPublisher[Telemetry](mq, "", "telemetry",
acemq.PublishWith[Telemetry](myCodec))One connection can send JSON to one queue and something denser to another. See codecs.
Building the envelope yourself
When one message's metadata is derived from another's in a way the options do not cover:
env, err := acemq.NewEnvelope("order.placed",
acemq.MessageID(id),
acemq.FirstSeen(originallySeenAt),
acemq.Attempt(3))
if err != nil {
return err
}
if err := pub.SendEnvelope(ctx, order, env); err != nil {
return err
}Mostly useful for replaying a message out of a dead-letter queue with its history intact.
What can go wrong
Send returns an error when the payload will not encode,
when a header name is refused, or when the broker will not take the
message. All three are worth handling: a publish that fails and is
ignored is a message that never existed, and nothing downstream will
ever notice.