Exchanges, queues and bindings

Queues

if err := mq.DeclareQueue(ctx, "orders"); err != nil {
    return err
}

Durable by default, and — because it is durable — a quorum queue. Options:

acemq.Transient() does not survive a broker restart
acemq.AutoDelete() removed when its last consumer goes away
acemq.Exclusive() only this connection can use it
acemq.OfType(t) QueueClassic, QueueQuorum or QueueStream
acemq.DeadLetterTo(exchange) a dead-letter exchange of your own, instead of acemq.dlx
acemq.QueueArg(name, value) any other broker argument
err := mq.DeclareQueue(ctx, "orders",
    acemq.DeadLetterTo("orders-dead"),
    acemq.QueueArg("x-max-length", 100000))

Quorum, classic and streams

orders above is declared with x-queue-type: quorum, and so is any durable queue Topology.Queue(...) adds. The type is part of the queue's identity as far as the broker is concerned, so this is a cross-language contract rather than a preference: two services consuming orders both declare orders, and the second one to disagree about the type is refused with PRECONDITION_FAILED and cannot consume at all. Java has declared source queues quorum since before the other libraries existed, and a quorum queue cannot be redeclared as anything else, so quorum is what all five agree on.

Three kinds of queue are left classic:

acemq.OfType(acemq.QueueClassic) sends no x-queue-type argument, because that is what classic is on the wire in Java, .NET, Python and Ruby. Asking for classic and inheriting classic therefore produce the identical argument table, which matters wherever two tables are compared rather than two brokers consulted — the in-memory transport here, and anybody diffing two topologies.

A queue that is already classic on the broker is not converted. Its type cannot be changed after it is created, so the declaration is refused; drain the queue and recreate it, or declare it acemq.OfType(acemq.QueueClassic).

Redeclaring

Declaring is idempotent while the settings match. When they do not, RabbitMQ refuses:

acemq: cannot declare queue "orders": Exception (406) Reason: "PRECONDITION_FAILED
- inequivalent arg 'durable' for queue 'orders'"

That refusal is passed straight on rather than swallowed. It means the code and the broker disagree about what the queue is — usually because somebody changed the declaration without migrating the queue — and carrying on regardless would leave a service running against a queue it does not think it has.

It also kills the channel it happened on, which is why the error surfaces immediately rather than at the next publish.

Exchanges

if err := mq.DeclareExchange(ctx, "events", "topic"); err != nil {
    return err
}

Durable by default; acemq.TransientExchange() and acemq.ExchangeArg(...) adjust it. The kind is direct, topic, fanout or headers.

Bindings

if err := mq.Bind(ctx, "eu-orders", "events", "orders.*.eu"); err != nil {
    return err
}

Both the queue and the exchange must already be declared.

The default exchange

Publishing with an empty exchange name uses the default exchange, which routes to the queue whose name matches the routing key. No exchange, no binding:

pub := acemq.NewPublisher[OrderPlaced](mq, "", "orders")

Right for a work queue. Wrong as soon as a second consumer wants the same messages, because it can only ever reach one queue.

Topic patterns

* is exactly one word. # is zero or more.

Pattern Matches Does not match
orders.created orders.created orders.updated
orders.* orders.created orders.created.eu
orders.# orders, orders.created, orders.created.eu shipments.created
orders.*.eu orders.created.eu orders.eu
orders.#.eu orders.eu, orders.created.paid.eu orders.created.us
# everything, including the empty key

Note orders.# matching the bare word orders: a trailing # matches nothing at all, which is the case most implementations get wrong.

The in-memory transport matches word by word rather than by translating the pattern into a regular expression. The translation looks shorter and is wrong on exactly the patterns nobody writes until they do — a trailing #, a # beside a *, a bare #. Twenty-six cases pin it, and a test against a real broker checks that RabbitMQ agrees; if the two disagreed, the in-memory transport would be certifying behaviour that does not happen.

Fanout

Every bound queue gets a copy and the routing key is ignored:

if err := mq.DeclareExchange(ctx, "broadcast", "fanout"); err != nil {
    return err
}
for _, q := range []string{"audit", "search-index"} {
    if err := mq.DeclareQueue(ctx, q); err != nil {
        return err
    }
    if err := mq.Bind(ctx, q, "broadcast", ""); err != nil {
        return err
    }
}

A shape that usually works

One topic exchange per bounded context, one queue per consuming service, bound to the patterns it cares about:

if err := mq.DeclareExchange(ctx, "orders-events", "topic"); err != nil {
    return err
}

if err := mq.DeclareQueue(ctx, "shipping-orders",
    acemq.DeadLetterTo("shipping-dead")); err != nil {
    return err
}
if err := mq.Bind(ctx, "shipping-orders", "orders-events", "order.placed"); err != nil {
    return err
}
if err := mq.Bind(ctx, "shipping-orders", "orders-events", "order.cancelled"); err != nil {
    return err
}

The publisher names an event, not a destination. A new consumer is a new queue and a new binding, and the publisher never learns about it.

Declaring it all at once

Call-at-a-time works, and stops working the moment somebody needs to know what a service will do to a broker before it does it.

topology := acemq.NewTopology().
    Exchange("orders-events", "topic").
    Queue("shipping-orders", acemq.DeadLetterTo("shipping-dead")).
    Queue("shipping-dead").
    Binding("shipping-orders", "orders-events", "order.placed").
    Binding("shipping-orders", "orders-events", "order.cancelled")

if err := topology.Apply(ctx, mq); err != nil {
    return err
}

Exchanges, then queues, then bindings — the order a broker needs.

The queues a consumer needs to fail into

A consumer that gives up on a message republishes it to {queue}.dlq with the reason attached, one that cannot decode a body republishes it to {queue}.parked, and one whose policy has long waits republishes into a {queue}.retry.{delay} rung. All three have to exist, or the publish is refused and the message goes back to the broker with nothing recorded about why:

policy := acemq.ExponentialRetry(6, 10*time.Second, 0)

topology := acemq.NewTopology().
    Queue("orders").
    DeadLetters("orders").     // orders.dlq, orders.parked, acemq.dlx and the wiring
    Retries("orders", policy)  // orders.retry.40s, .80s, .160s and their way home

acemq.Consume declares all of them itself when it starts, from the policy it was given, with the same arguments this plan uses. The two agree in either order, so a service that applies its topology and then starts a consumer is not refused and neither is a consumer starting against a broker somebody else set up. Write the plan anyway — it is what a person reviews, and it carries the source queue's own dead-letter arguments, which no consumer declares — but the failure it used to be the only guard against is gone: a publish into a queue nobody declared is unroutable, and the broker discards an unroutable message without a trace.

Printing it is the point — this is what topology.String() gives:

Topology: 2 exchanges, 6 queues, 3 bindings
  declare exchange acemq.dlx (direct)
  declare exchange acemq.retry (direct)
  declare queue orders (durable, quorum, x-dead-letter-exchange=acemq.dlx, x-dead-letter-routing-key=orders.dlq)
  declare queue orders.dlq (durable, classic)
  declare queue orders.parked (durable, classic)
  declare queue orders.retry.40s (durable, classic, x-dead-letter-exchange=acemq.retry, x-dead-letter-routing-key=orders, x-message-ttl=40000)
  declare queue orders.retry.80s (durable, classic, x-dead-letter-exchange=acemq.retry, x-dead-letter-routing-key=orders, x-message-ttl=80000)
  declare queue orders.retry.160s (durable, classic, x-dead-letter-exchange=acemq.retry, x-dead-letter-routing-key=orders, x-message-ttl=160000)
  declare binding orders.dlq (from acemq.dlx on orders.dlq)
  declare binding orders.parked (from acemq.dlx on orders.parked)
  declare binding orders (from acemq.retry on orders)

DeadLetters puts two arguments on orders itself, and they are the reason it has to declare the queue rather than only its dead letters: they are part of that declaration, and every other AceMQ library writes the same two. Two services consuming orders both declare it, so a disagreement here is a PRECONDITION_FAILED for whichever started second. Asking for dead letters on a queue this topology does not declare is refused for the same reason — there would be nowhere to put them.

Both acemq.dlx and acemq.retry are shared. However many queues use them, each appears once in the plan, and neither is deleted by anything: they belong to the broker rather than to a service.

Retries takes the policy rather than a list of delays because the rungs a consumer publishes into are derived from the policy it runs. A second list would be free to drift from the first, and the way that drift shows up is a retry published into a queue nobody declared, at the moment the service is already failing. See reliability for what a rung is and why the waiting happens there.

It is checked before the broker sees it

A binding naming a queue the topology does not declare is refused here rather than by the broker:

acemq: binding orders-events -> a-queue-nobody-declared (order.placed) names
queue "a-queue-nobody-declared", which this topology does not declare

The broker would have accepted it, whenever that queue happened to exist already — and the service would then depend on something nothing declares, until a fresh environment where it fails at start-up for reasons nobody can see.

Reading it before applying it

fmt.Println(topology)
Topology: 1 exchanges, 2 queues, 2 bindings
  declare exchange orders-events (topic)
  declare queue shipping-orders (durable, quorum, x-dead-letter-exchange=shipping-dead)
  declare queue shipping-dead (durable, quorum)
  declare binding shipping-orders (from orders-events on order.placed)
  declare binding shipping-orders (from orders-events on order.cancelled)

A deployment that changes a broker should be something somebody can read first. This is a statement of intent, not a diff against the live broker — AMQP cannot enumerate what is there without the management API, and a plan that quietly guessed would be worse than one honest about what it is.

Reading it against the broker it will change

ApplyWith takes a mode. DryRun asks the broker about each queue and changes nothing:

plan, err := topology.ApplyWith(ctx, mq, acemq.DryRun)
for _, action := range plan {
    log.Println(action)
}
exchange orders-events: topic — unknown, AMQP cannot report exchanges
queue shipping-orders: durable, quorum, x-dead-letter-exchange=shipping-dead — differs:
  the broker refused the declaration: Exception (406) PRECONDITION_FAILED
queue shipping-dead: durable, quorum — would create
binding shipping-orders: from orders-events on order.placed — unknown, AMQP
  cannot report bindings

Three things are worth knowing about that output:

A transport that cannot be asked — a custom one that implements neither DriftChecker nor QueueInspector — reports every queue as unknown rather than pretending.

Drift

A service and its broker disagreeing about a queue is the failure that shows up as messages going somewhere nobody is looking: a dead-letter exchange that was changed, a durability that was not.

reports, err := topology.Check(ctx, mq)
for _, r := range reports {
    log.Printf("drift: %s", r)
}
drift: queue shipping-orders: the broker refused the declaration:
Exception (406) PRECONDITION_FAILED - inequivalent arg 'x-dead-letter-exchange'

PRECONDITION_FAILED is the only way AMQP will report this without the management API. Two consequences worth knowing:

Asking about a queue, and removing one

count, err := mq.MessageCount(ctx, "shipping-orders")
exists, err := mq.QueueExists(ctx, "shipping-orders")
err := mq.DeleteQueue(ctx, "shipping-orders")

MessageCount is a number for a dashboard or a test, not a decision to make in a handler: it is a snapshot of a queue that is still moving, and by the time it is read somewhere else it is already wrong.

QueueExists creates nothing — it is a passive declare, which is the only read-only question AMQP offers.

DeleteQueue takes the messages with it, so it is for tests and for tools. A service that deletes queues has usually confused a queue with a session. Deleting one that is not there is not an error: the caller asked for it to be gone and it is, which is what makes cleaning up after a test something other than guessing what ran.

Where to declare

At start-up, in the service that owns the queue, before consuming from it. Declaring on every publish costs a round trip; declaring nowhere means the first deployment to a fresh broker fails. Consumers declare their own queues and bindings; publishers declare the exchange they publish to.