Patterns
Everything above publishing and consuming lives in one package:
import "github.com/AceMQ-Company/acemq-go-amqp/patterns"It is separate because none of it is needed to send a message, and a core that carries everything is a core nobody can read.
Request and reply
The short version. Request and reply is the whole page: timeouts against context cancellation, what a responder failure does to the request, the reply queue, and what is not measured.
requester, err := patterns.NewRequester[PriceRequest, PriceResponse](ctx, mq, "", "prices")
defer requester.Close()
response, err := requester.Do(ctx, PriceRequest{SKU: "abc"})And the other side:
responder, err := patterns.Serve(ctx, mq, "prices",
func(ctx context.Context, m acemq.Message[PriceRequest]) (PriceResponse, error) {
return price(ctx, m.Payload)
})
defer responder.Close()Replies are paired with requests by correlation identifier, so many can be in flight at once. There is a test that runs twenty concurrently and checks each caller gets its own answer.
This is a synchronous shape drawn on an asynchronous system, and that costs something. A caller blocked on a reply holds a goroutine, a connection and a deadline; a queue that backs up turns into a service that stops responding. Reach for it where a caller genuinely cannot proceed without the answer, and publish an event otherwise.
A responder that returns an error sends it back rather than swallowing it, so a blocked caller learns it failed instead of waiting out the timeout:
acemq: the responder failed: no such product
ErrRequestTimedOut says nothing about whether the
request was handled. A timeout is the absence of an answer, and the work
may well have been done — which is why a request that changes anything
should be idempotent.
The reply is published without mandatory,
deliberately. A generated reply queue is auto-deleting and goes away
with a requester that gave up, and a reply nobody is waiting for is not
a message worth keeping. A reply that reaches no queue is therefore
dropped, the request is accepted, and the requester — if it is still
there — sees ErrRequestTimedOut.
By default replies come back on an exclusive, auto-deleting queue
that goes away with the process. It is a classic queue and has to be:
RabbitMQ allows a quorum queue to be neither exclusive nor
auto-deleting. patterns.ReplyTo("queue") names one that
survives a restart, and a named one is durable, so it is quorum like any
other durable queue — see quorum, classic and
streams.
Where the reply address travels
Write both, read either. A request carries the reply queue twice, in the same two places in all five AceMQ libraries:
the acemq-reply-to application header |
travels through the envelope machinery and survives a hop through a service that rebuilds the message |
AMQP's own reply-to property |
what Java's and .NET's responders read |
patterns.Serve reads the header first and falls back to
the property when the header is absent, so it answers a request from any
of the five. Requester.Do writes both, to the same queue,
so any of the five can answer one of ours.
Header first because it is the half that survives a rebuild: a service that reconstructed the message kept the headers and lost the properties, so where the two disagree the header is the more recent of the two.
Publishing a request by hand rather than through a
Requester means writing them yourself:
err := acemq.NewPublisher[PriceRequest](mq, "", "prices").
Send(ctx, request,
acemq.ReplyTo("my-replies"),
acemq.Header(patterns.HeaderReplyTo, "my-replies"))A request carrying neither is dead-lettered rather than retried: retrying cannot make a reply address appear.
Idempotency
store := patterns.NewInMemoryIdempotencyStore(time.Hour)
sub, err := acemq.Consume(ctx, mq, "orders",
patterns.Idempotent(store, handle))A duplicate is accepted, not rejected: the work was done, so the message has been handled, and dead-lettering it would raise an alarm about something that went right.
When the handler fails the key is forgotten, so the retry actually runs. Remembering a message that then failed would mean the retry silently does nothing and the message is dropped after appearing to succeed.
Claimed, duplicate, or in progress
A redelivery finds the key in one of three states, and each gets a different answer. This is the same rule in all five AceMQ libraries:
| The store says | Meaning | What happens |
|---|---|---|
Claimed |
nobody had it, or the last claim's lease expired | the handler runs |
Duplicate |
the work was done and confirmed | accepted, handler not run |
InProgress |
a live claim nobody has confirmed | put back, handler not run, not accepted |
In progress is a handler still running elsewhere, or one that failed and could not release its claim because the store was unreachable. Until 0.9.5 Go treated it as a duplicate and acknowledged it, and in the second case the message was lost: the claim outlived the failure, and the retry was accepted as a duplicate of work that never happened.
Now Idempotent answers
acemq.InProgress(delay). The consumer waits
delay (five seconds by default), republishes the original
body and envelope to the same queue, and acknowledges the original. If
the republish is not routed, the original is nacked and requeued
instead. The attempt is not incremented and the retry
policy is not consulted, so a message is never dead-lettered for being
in progress. Once the lease ends, the next look claims it and runs the
handler. It is counted as
acemq.consume.total{outcome="in_progress"}, not as retried
or dead-lettered. A stream consumer refuses it as it refuses a
retry.
patterns.Idempotent(store, handle, patterns.InProgressDelay(2*time.Second))Keep the delay well under the store's claim timeout.
store.TryClaim(ctx, key) returns the three-way
patterns.ClaimResult. Both shipped stores implement it
through the patterns.ClaimReporter interface, and
Claim keeps its bool. A custom store that
implements only IdempotencyStore behaves as before: a
refused claim is read as a duplicate.
This is a guard against duplicates, not a guarantee of exactly-once. Between the handler finishing and the acknowledgement reaching the broker, a crash still leaves a message that will be delivered again. Only a store written in the same transaction as the work closes that gap.
NewInMemoryIdempotencyStore is for tests and for one
worker. With two, each has its own memory and both will believe they are
first.
patterns.IdempotentBy takes a key from the payload, for
when two different messages are about the same thing.
The outbox
A service that writes to a database and then publishes has two things that can fail independently, and the gap between them is where messages are lost or invented. Writing the message into the same transaction as the work removes the gap:
tx, _ := db.Begin()
placeOrder(tx, order)
record, _ := patterns.Record(mq, "orders-events", "order.placed", event)
store.Add(ctx, record) // the same tx
tx.Commit()A relay publishes what was committed:
relay := patterns.NewOutboxRelay(mq, store)
relay.Start(ctx)
defer relay.Close()A record is removed only after the broker confirms, so a crash in between republishes it. That is at-least-once by construction — which is why consumers of anything sent this way need to be idempotent. Removing first would lose messages instead, and a lost message is worse than a repeated one.
An OutboxStore is only worth having if Add
can join the caller's transaction. InMemoryOutboxStore
cannot, and so has none of the property the pattern exists for; it is
there for tests and for seeing the shape.
The relay publishes the bytes that were recorded rather than re-encoding, because a record outlives the process that wrote it and the Go type may not survive a deployment.
It publishes them mandatory, as Java's relay does. A
record nothing is bound to receive — the service that wants the event
has not declared its queue yet — is a failed publish and stays in the
outbox, rather than being dropped by the broker and marked as sent. Up
to 0.9.5 it was the second, and the event was gone. The same holds on a
connection dialled WithoutConfirms: the relay publishes on
the library's own confirm channel, so a record is marked sent only once
the broker has confirmed it reached a queue.
A record the broker will never take
A sweep stops at the first record that fails and leaves it in the outbox. That is right — the writer chose the order, and skipping ahead would deliver a later message before an earlier one — but it means a record that can never be published holds up everything behind it on every tick, for ever. An exchange somebody deleted, or a payload a policy will always refuse: one row, and the outbox stops.
So a failure is counted against the record. Both stores keep an
Attempts and a LastError on it, and once
Attempts reaches the store's maximum — ten by default, the
same ten Python and Ruby use — Pending stops offering the
record and the queue behind it moves again.
store.SetMaxAttempts(10) // the default; shorten it in a test
...
stuck, _ := store.Retired(ctx) // the in-memory store's takes no ctx
for _, record := range stuck {
log.Printf("nobody could publish %s: %s", record.ID, record.LastError)
}The record is kept, not deleted. One nothing could
publish is evidence: somebody has to read it, fix whatever refuses it,
and release it by putting its attempts back to zero.
Deleting it would be the silent loss this pattern exists to prevent,
reached from another direction. Nothing but Retired shows
these rows, because Pending exists to skip them — so graph
acemq.outbox.total{outcome=failed} and read
Retired when it climbs.
RecordFailure is deliberately not part
of the OutboxStore interface: adding a method would stop
every custom store compiling. The relay type-asserts for it, so a store
without one keeps the behaviour it has — blocking at a permanently
failing record. A custom store should add
RecordFailure(ctx, id, cause string) error and leave
records at the limit out of Pending.
The SQL table gained two columns in 0.9.0. A table
created before it needs the migration, and Pending fails on
the missing column until it has run. Both are additive with a default,
so the old code is unaffected by them and the migration can — and should
— go out before the deployment does:
ALTER TABLE acemq_outbox ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE acemq_outbox ADD COLUMN last_error TEXT;What a relay reports
Every sweep counts what it did through the connection's
acemq.Observer: acemq.outbox.total tagged
published or failed, and
acemq.outbox.lag — how long a record waited between being
committed and being published, measured from the commit rather than from
the sweep, because what a lag answers is how long somebody has been owed
this message. The tracing adapter's outbox.publish_failed
event is not written from here and cannot be: a sweep runs on a
goroutine of the relay's own with no span open. See observability.
Ordering
A queue delivers in order; acemq.Concurrency above one
stops honouring that. Usually the right trade, and the wrong one where
later messages about the same entity must not overtake earlier ones.
sub, err := acemq.Consume(ctx, mq, "orders",
patterns.Ordered(patterns.ByHeader[OrderEvent]("x-order-id"), handle),
acemq.Concurrency(16))Messages sharing a key are handled one at a time; different keys still run at once. Both halves are tested — ordering per key that became ordering overall would just be concurrency turned off.
What it does not do. It orders messages already delivered to this process. It cannot reorder what the broker delivered out of order, and with several consumers on one queue it orders only within each. Ordering across consumers needs the messages to reach the same consumer, which is a routing decision:
key := patterns.PartitionedRoutingKey("orders", order.ID, 8) // "orders.3"Pipelines
Declaring one
A flow with named steps, each its own queue and its own consumers:
pipeline, err := patterns.NewPipeline[Order](ctx, mq, "fulfilment",
patterns.PipelineStep("validate", validate),
patterns.PipelineStep("reserve", reserve,
patterns.StepConsumers(4),
patterns.StepRetry(acemq.ExponentialRetry(4, time.Second, time.Minute)),
patterns.StepDescribedAs("hold stock for 15 minutes so payment cannot oversell")),
patterns.PipelineStep("dispatch", dispatch))
if err != nil {
return err
}
defer pipeline.Close()
runID, err := pipeline.Send(ctx, Order{ID: "A-1"})A step's handler takes what the previous one produced and returns what the next one consumes:
func reserve(ctx context.Context, m acemq.Message[Order]) (Reservation, bool, error) {
if m.Payload.Digital {
return Reservation{}, false, nil // nothing to reserve; the run stops here
}
return stock.Reserve(ctx, m.Payload)
}NewPipeline declares the exchange, one queue per step
and the bindings, then starts the consumers — so a pipeline that returns
without an error is one whose topology exists and whose steps are
running. Declaring is idempotent and every service that declares the
same pipeline agrees about it, which is what lets two services run
different steps of one flow.
Each step gets {pipeline}.{step}, so a slow stage is a
deep queue you can see and scale on its own with
StepConsumers. A single consumer doing all four in sequence
gives you none of that.
The topology is Java's, to the letter: exchange
named for the pipeline and declared direct, queue
{pipeline}.{step}, binding key the step's name, and the
declared route carried on the message as x-acemq-route plus
a position. So a Go-declared pipeline can be continued by a Java, .NET,
Python or Ruby service, and a Go step can be one hop of a pipeline any
of them declared — see Being one step of a Java
pipeline for that direction, which needs no Pipeline at
all.
What Go cannot do, and says instead. Java's builder
threads the types through at compile time: a step producing a
Reservation can only be followed by one consuming a
Reservation. Go's generics cannot express that — a method
cannot introduce type parameters, so a fluent chain cannot carry the
previous step's output type forward. Each step is built on its own and
NewPipeline checks the chain with reflection when it
assembles it:
acemq: pipeline "fulfilment" step "reserve" produces patterns_test.Reservation
but the next step "dispatch" consumes patterns_test.Order
A worse guarantee than Java's, and the honest one: it fails at start-up rather than on the first message, and it names both steps and both types.
Nothing here logs. pipeline.Describe() returns the
one-line summary — steps and their descriptions — for you to log where
you log things, because a library that picks a logger picks it for every
application that imports it.
Wrapping one handler
handler := patterns.Chain(handle,
patterns.WithLogging[OrderPlaced](log.Printf),
patterns.WithTimeout[OrderPlaced](10*time.Second),
patterns.WithIdempotency[OrderPlaced](store))The first named is outermost, so logging records what the others decided.
WithTimeout reports a retry whenever the deadline
passed, whatever the handler says about itself. Between
retrying work that may have succeeded and accepting work that may have
failed, duplicates are a problem you can solve and a lost message is
not. It cancels the context, so a cooperative handler stops promptly;
one that ignores its context will not stop, and no wrapper can make
it.
patterns.Then publishes the result onwards, carrying the
correlation and recording causation:
step := patterns.Then(
acemq.NewPublisher[Shipment](mq, "shipping-events", "shipment.requested"),
func(ctx context.Context, m acemq.Message[OrderPlaced]) (Shipment, bool, error) {
if m.Payload.Digital {
return Shipment{}, false, nil // nothing to ship
}
return Shipment{OrderID: m.Payload.OrderID}, true, nil
})The input is accepted only once the output is published. If publishing fails the input is retried and the work runs again, so a step that changes anything should be idempotent.
Then's hop is always published
mandatory, whatever the publisher you give it was built with,
as are a declared pipeline's and FollowSlip's hops: a next
step nothing is bound to is a failed hop, retried and then
dead-lettered, never accepted. The publisher itself is not changed; your
own Send calls on it keep the mandatory setting you gave
it.
Every hop the library makes for you — Then's included —
is also published with publisher confirms, even on a connection dialled
WithoutConfirms, because a return is only known once the
broker has confirmed. See Publisher confirms.
Naming a step, so a finished run is visible
patterns.InPipeline and patterns.AtStep
name a Then or a FollowSlip, and that is what
makes it write a pipeline.run_finished event onto the
delivery's span:
step := patterns.Then(shipments, plan,
patterns.InPipeline("fulfilment"), patterns.AtStep("pick"))A step that returns false reports
ended_early — this message does not continue, a decision
rather than a failure, and otherwise a thing only a log could tell you.
FollowSlip reports completed when it finishes
the itinerary, and takes the step's name off the slip so it usually
needs only InPipeline. An unnamed step reports nothing at
all, rather than an event tagged with two empty strings.
Replay
What somebody actually does at three in the morning: a dead-letter queue has two thousand messages, the bug is fixed, and they need to go back — but not all of them, and not silently.
result, err := patterns.Replay(ctx, mq, patterns.ReplayFrom{
Queue: "orders-dead",
Exchange: "orders-events",
Limit: 500,
Filter: func(env acemq.Envelope, body []byte) bool {
return strings.Contains(env.Error, "timeout")
},
})
// moved 500, skipped 31 (limit)The reason is part of the answer: "moved 500" means something quite different when the limit was 500.
Messages the filter declines stay where they are. Each replayed
message is stamped acemq-replayed-from,
acemq-replayed-at and acemq-replay-count, so a
consumer that needs to treat them differently can.
Those are the names Java, Python and Ruby write too, and none
of them begins x-acemq-. That namespace is the
engine's, and a header in it that the engine does not put on the
envelope is dropped before a handler sees it — so a replay stamp written
there would arrive on the wire and vanish. Unprefixed, the three come
through in Envelope.Headers. acemq-replayed-at
is RFC 3339, not the epoch milliseconds x-acemq-first-seen
uses.
.NET is the outlier here. It writes
x-acemq-replayed-from,x-acemq-replayed-atandx-acemq-replay-count, and a Go consumer will not see them: the reserved prefix means this library drops all three on the way in. A message replayed by a .NET operator arrives, and it arrives without its stamps. That costs an audit trail rather than a message, and only for a replay somebody did by hand — but do not write a Go consumer that expects to be able to tell a .NET-replayed message from an original.
Attempts start again. A replayed message goes back
on attempt one with the reason it was dead-lettered cleared, because a
message dead-lettered on the last attempt of a five-attempt policy would
otherwise be dead-lettered again before any handler saw it — two
thousand messages moved from one queue to the same queue.
KeepAttempts: true puts back exactly what was there, for an
audit or for a queue read by something that counts attempts itself.
How it knows when to stop. It reads the queue a message at a time and stops when the broker has nothing left to hand over.
Messages the filter declines are held unacknowledged until the pass ends, and returned together at the end. Returning one immediately does not work: RabbitMQ puts a returned message back where it was, at the head of the queue, so the next read is handed the same message again and everything behind it is never looked at — a selective replay that silently stops at the first message it declines. Holding them takes them out of the way for the length of the pass, and the broker still has them, so a replay that dies half way through returns them rather than losing them.
A message is acknowledged only after the broker has confirmed the new copy. A crash in that gap replays it twice, which is the right way round for a dead-letter queue.
The new copy is published mandatory. A destination
nothing is bound to is confirmed by the broker and then dropped, so
until 0.9.5 a replay into it acknowledged the last copy of the message
it was asked to recover. Now that message is returned to the queue it
came from, the replay stops with Reason
failed, and the error is a
*acemq.PublishFailedError with Unroutable
set.
Bodies are read as bytes, not through the connection's codec. A body that will not decode is exactly the kind that ends up in a dead-letter queue.
Routing slips
An itinerary the message carries, instead of a central orchestrator:
slip := patterns.NewRoutingSlip().
Then("orders-events", "order.validate", "validate").
Then("orders-events", "order.charge", "charge").
Then("orders-events", "order.ship", "ship")
err := patterns.Start(ctx, mq, slip, order)Each service does its part and the message goes to the next stop:
sub, err := acemq.Consume(ctx, mq, "charge-queue",
patterns.FollowSlip(mq, func(ctx context.Context, m acemq.Message[Order]) (Order, error) {
return charge(ctx, m.Payload)
}))The route is decided once, by whoever started the work, and travels with the message rather than living in a component every service has to talk to.
What it costs: no single place knows the whole route at runtime, so a route that is wrong is discovered one hop at a time. Worth it when the steps vary per message; not worth it when every message goes the same way, where a fixed chain of consumers is simpler to follow.
Two forms on the wire, and both are read
The family writes a slip in two shapes. Go reads either and writes the JSON one unless asked otherwise.
acemq-routing-slip |
x-acemq-route + -position +
-id |
|
|---|---|---|
| Shape | JSON: every step's exchange and routing key | the step names, comma joined, and a position |
| Written by | Go, Python, Ruby | Java |
patterns.SlipForm |
FormJSON (the default) |
FormSteps |
| Needs a declaration | no | yes — a patterns.Route |
| Route varies per message | yes | no |
The JSON slip is self-describing: a message carries its whole itinerary, so any consumer can send it onwards with nothing declared anywhere, an operator reading a dead-letter queue can see where it was going, and the route can differ per message — which is the reason to use a slip rather than a fixed chain at all.
The declared form is smaller and stays readable in a management
console — validate,charge,ship at position 1
says where a message is without anybody decoding anything — at the cost
of meaning nothing without the declaration.
A slip keeps the form it arrived in. A Go step in a Java-declared pipeline answers in the shape the next Java step is looking for, without being told to.
Being one step of a Java pipeline
Java's Pipeline names its steps and resolves them
against a declaration: the pipeline's name is the exchange, a step's
name is the routing key, and the queue behind a step is
{pipeline}.{step}. Declare the same route in Go and a Go
consumer is one of its steps:
route := patterns.NewRoute("orders", "validate", "charge", "ship")
sub, err := acemq.Consume(ctx, mq, route.QueueFor("charge"),
patterns.FollowSlip(mq, func(ctx context.Context, m acemq.Message[Order]) (Order, error) {
return charge(ctx, m.Payload)
}, patterns.AlongRoute(route)))The Go step publishes to the orders exchange keyed on
the next step's name with the position advanced and the run identifier
carried through — exactly what the Java step after it is waiting
for.
patterns.AlongRoute also names the pipeline for run
reporting, so patterns.InPipeline is only needed to call it
something other than the route's own name.
Without
AlongRoute, a declared route is refused rather than followed. The names carry no destinations, so publishing with an empty exchange would send the message to a queue named for the step instead of to the pipeline's — quietly, and to the wrong place. The rejection is fatal, because a missing declaration will still be missing on the fourth attempt, and it namesAlongRoutein the error.
To start a run that Java steps will follow, use the route:
slip := route.Start() // at validate, with a fresh run id
err := patterns.Start(ctx, mq, slip, order)slip.AsSteps(route) converts a hand-built slip to the
declared form, and slip.AsJSON() converts one back —
neither is a one-way door.
Claim check
A payload too large for a broker goes to a store, and the message carries the key:
store := patterns.NewFilesystemClaimCheckStore("/mnt/claims")
checked := patterns.ClaimCheck(acemq.JSONCodec{}, store)
mq, err := acemq.Connect(ctx, url, acemq.WithCodec(checked))A scanned medical report is tens of megabytes. Putting it on a queue is possible and is a mistake: it fills the broker's memory, it is copied to every bound queue, it makes a dead-letter queue impossible to inspect, and it turns a broker into a filesystem with worse tools.
Only above the threshold
Below patterns.DefaultClaimCheckThreshold — 64 KiB — the
payload travels inline, exactly as it would without the codec. That
matters more than it sounds: offloading a two-hundred-byte message turns
one broker round trip into a store round trip and a broker
round trip, so an unconditional claim check makes the common case slower
to fix the rare one.
patterns.OffloadAbove(n) changes it; zero offloads
everything.
What is on the wire
0xAC 0x01 0x00 payload inline, and identical to what the delegate wrote
0xAC 0x01 0x01 key a claim check
Three bytes, and the third is how a consumer decides whether it is holding a payload or a reference to one. The framing rather than a header is the contract: a header can be stripped by a shovel or a federation link, and the body cannot — and a header that is present or absent cannot say whether a payload travelled inline, which is what makes the threshold changeable without a flag day.
These are the same three bytes Java, Python and Ruby write, and the key is the store's key as bare UTF-8 — not a URI, not a scheme, nothing wrapped around it. A Go consumer pointed at the same store reads a document a Java publisher checked in.
A body with no framing is read as the delegate would read it, which is what makes adding this codec to a live queue safe.
The content type is the delegate's, unchanged. A claim-checked message is still a document; it is a document that is somewhere else.
x-acemq-claimis not written by this codec. That header is reserved for an application that wants an operator reading a dead-letter queue to see where a payload went. Python and Ruby reserve it the same way. To read the key out of a body without fetching it — which object does this need, and is it still there? — usepatterns.ClaimKeyOf(body).
The store
type ClaimCheckStore interface {
Put(content []byte) (string, error)
Get(key string) (content []byte, found bool, err error)
Delete(key string) error
}Three methods, so a store in front of S3 or Azure Blob Storage is a small type. Nothing in it knows about messaging.
Get tells not found from
failed, and the codec treats them differently: a store
that timed out may well answer next time, so that is retryable; a key
the store does not hold never will be, so that is fatal and the message
stops rather than circling. The store failure comes back wrapped in
acemq.Retryable, and the consumer retries such a decode
error through the retry policy. Until 0.9.5 every decode error was
parked, the retryable one included.
Two are supplied. patterns.NewInMemoryClaimCheckStore()
is for tests — the payloads are in the publisher's own memory, so a
consumer in another process finds nothing, which is the pattern failing
rather than working.
patterns.NewFilesystemClaimCheckStore(dir) is a real store
where the filesystem is shared and durable; writes go to a temporary
file and are renamed into place, because a consumer fast enough to read
the key before the writer finished would otherwise get a truncated
payload.
There is no context on the store: acemq.Codec has none
to pass on. A store doing network I/O must carry its own
timeout, because without one a hung object store hangs a
consumer's whole delivery.
Retention is the part that goes wrong
The store and the queue have different lifetimes and nothing enforces a relationship between them. A message replayed a month later carries a key, and if the store expired that key the replay produces a message nobody can read — worse than a lost message, because it looks like a message and fails deep inside a consumer rather than visibly.
The store's retention must exceed every retention that could bring a message back: queue TTLs, dead-letter queues, and however long somebody might sit on a message before replaying it by hand. When in doubt, longer.
Deleteis never called by the codec. Deleting on read would break a second consumer of the same message and deleting on acknowledgement would break a replay, so when a payload may be removed is a retention decision — and those belong to whoever owns the data.
A key becomes a path segment in the filesystem store, and a key
arriving from a message is whatever a publisher put there. Every key the
store issues is a UUID, so one that is not is refused rather than
followed; ../../etc/passwd is a key too.
Sagas
Work that spans services, where a database transaction is not available and the alternative — doing half of it and hoping — is how a customer ends up charged for an order that was never placed:
saga, err := patterns.NewSaga("place-order", []patterns.SagaStep[*Order]{
{Name: "reserve-stock", Do: reserveStock, Undo: releaseStock},
{Name: "take-payment", Do: takePayment, Undo: refund},
{Name: "confirm", Do: confirm},
})
result := saga.Run(ctx, order)Steps run in order. When one fails, the steps that already completed are undone in reverse order, because that is the order the world was changed in and a compensation often depends on state a later step has not yet altered.
Undo is optional, and a completed step without one is
skipped rather than treated as an error — a step that only read
something needs no undo. It is a mistake on a step that changed
something, and nothing here can tell the two apart, which is the
argument for writing Undo first and Do
second.
Run returns a result rather than an error. A failed saga
is not an exceptional condition to a caller that has to decide what
happens next, and the interesting part is not the failure anyway:
if result.HasUnresolved() {
alert("could not undo: %v", result.Unresolved)
}Unresolved is the list to alert on. A
compensation that fails does not stop the others — stopping would leave
more undone than continuing — so it is reported, its step collected, and
the rest still run. What ends up in Unresolved is
real-world effects that happened, were meant to be undone, and were not.
Nothing else in the system knows about them and no retry resolves them;
a person has to.
The compensations run with the context Run was given. If
the failure you expect is a cancellation, hand them one that outlives it
with context.WithoutCancel, or every compensation lands in
Unresolved.
A step that panics is a step that failed, and everything before it is still compensated: unwinding past the compensation is the one outcome this pattern exists to prevent.
Nothing here touches a broker. It is in-process coordination, so a Go saga and a Java one are the same idea rather than two ends of one conversation. What matches across the libraries is the behaviour, not a wire format.
Consumer groups
group, err := patterns.NewConsumerGroup(ctx, mq, "orders", 4, handle)
defer group.Close()Several consumers over one queue, stopped together.
Concurrency or a group?
acemq.Concurrency runs several handlers on one consumer and
one channel. A group runs several consumers, each with its own channel
and prefetch. Use a group when handlers are slow enough that one
channel's prefetch is the limit, or when a fair share across processes
matters — the broker round-robins between consumers, so four here
compete evenly with four in another instance.
A group that cannot start closes what it already started, because a half-started group holds messages nothing is going to handle.
Schema registry
registry := patterns.NewInMemorySchemaRegistry()
schema, err := registry.Register(ctx, "order.placed", "avro", definition)Messages carry a small identifier and consumers look the schema up, which is what lets a producer add a field without every consumer being redeployed the same afternoon. The Avro codec uses it directly.
Definitions are fingerprinted, so a service registering on start-up
does not add a version per restart. A lookup that finds nothing returns
ErrSchemaNotFound rather than a zero value — a consumer
that cannot find its schema has a real problem and must not carry on
with an empty definition.
NewInMemorySchemaRegistry shares nothing between
processes, which is the whole point of a registry. Use the SQL one, or
Confluent's, for anything real.
Stores that survive a restart
The in-memory idempotency store and outbox are for tests and for one worker. The SQL ones are the point:
tx, _ := db.BeginTx(ctx, nil)
placeOrder(ctx, tx, order)
record, _ := patterns.Record(mq, "orders-events", "order.placed", event)
patterns.NewSQLOutboxStore(tx, patterns.PostgresDialect).Add(ctx, record)
tx.Commit()They take anything with ExecContext and
QueryContext, so *sql.Tx satisfies them and
the record commits with the work. There is a test that
rolls a transaction back and proves neither the order nor the message
survived.
Schema() returns the DDL rather than running it. A
library that runs migrations against your database on start-up is a
library fighting your migration tool — put it in a migration.
Dialects cover Postgres, MySQL and SQLite: placeholder syntax and the spelling of "insert unless it is already there" genuinely differ, and getting either wrong fails at runtime on one database and not another.
MySQL also reserves key, the idempotency table's column,
so MySQLDialect quotes it with backticks
(Dialect.Quote) and its no-op upsert names that column
(Dialect.InsertIgnoreOn). Its Schema()
declares the index inside the table and uses DATETIME(6).
The Postgres and SQLite schemas are unchanged, so no migration is needed
there; on MySQL the store did not work before 0.9.5, so there is no
older table to migrate.
Streams
A stream keeps its messages after they are read, so several consumers can each read from their own position:
err := patterns.DeclareStream(ctx, mq, "events", patterns.StreamRetention{
MaxAge: 7 * 24 * time.Hour,
})
sub, err := patterns.ReadStream(ctx, mq, "events", handle,
patterns.StreamOptions{Offset: patterns.FromFirst(), Prefetch: 100})It is not a queue with a longer memory.
Acknowledging advances this consumer's position rather than removing
anything, and rejecting dead-letters nothing, because there is nothing
to remove it from. A message that cannot be handled has to be dealt with
by the handler — logged, copied elsewhere, counted — and the stream
moves on regardless. Nothing is lost, and nothing is retried for you:
ReadStream refuses acemq.Retry, because on a
stream a retry appends a second copy of the message to the log.
Retention is unbounded by default, which for a stream means "until
the disk is full". Set MaxAge or MaxBytes on
anything that runs for long.
Streams is the whole page: where to start
reading, checkpointing, why acemq.Retry is refused on a
stream, and what the in-memory transport will not do.
Delayed delivery
Deliver a message later:
scheduler, err := patterns.NewScheduler(ctx, mq)
defer scheduler.Close()
scheduler.In(ctx, 4*time.Hour, "billing", "invoice.due", invoice)
scheduler.At(ctx, renewal, "policies", "policy.renew", policy)Why not a per-message time to live
The obvious implementation is to set an expiration on the message, drop it in a queue nobody consumes, and let it dead-letter to its destination. It is what most articles suggest and it is wrong for anything but a single fixed delay, because a classic queue expires messages only at its head. Put a four-hour message in, then a one-minute message behind it, and the one-minute message is delivered in four hours. Nothing reports it: the queue looks healthy, the message is not lost, it is simply late by a factor nobody predicted — and it fails in production under mixed load rather than in testing under uniform load.
What this does instead
A ladder of queues, each with a uniform time to live, and a message hops through them until it is due:
| Queue | x-message-ttl |
|---|---|
acemq.schedule.1h |
3600000 |
acemq.schedule.10m |
600000 |
acemq.schedule.1m |
60000 |
acemq.schedule.10s |
10000 |
acemq.schedule.1s |
1000 |
Every message in a rung has the same delay, so the head is always the
one due soonest and head-of-line expiry is harmless. Each rung is
classic and carries exactly three arguments — the time to live above,
x-dead-letter-exchange of acemq.schedule and
x-dead-letter-routing-key of
acemq.schedule.due — and is bound to the
acemq.schedule direct exchange under its own name. An
expired message lands on acemq.schedule.due, where the
scheduler either delivers it or puts it on the largest rung that does
not overshoot what is left. A one-day delay is twenty-four hops and a
one-minute delay is one, which is the right way round.
Both hops — onto a rung, and out to the destination — are published mandatory. A rung somebody deleted, or a destination nothing is bound to, fails the hop rather than being confirmed and dropped. Up to 0.9.5 such a message was acknowledged and gone.
A message that is due but reaches no queue — the target has nothing
bound for its routing key yet — is kept: it goes back through the
ten-second rung and is tried again every ten seconds until it is
delivered, with no attempt limit, as the Java, .NET and Ruby schedulers
do. At or In for a moment already past keeps
it the same way and returns no error. Delivered() counts
only messages that arrived, so Hops() climbing while
Delivered() stands still is the sign of a target nobody has
bound. Only when the message can be put on no rung at all — a rung
somebody deleted — does the control consumer set it aside on
acemq.schedule.due.dlq with the reason, and At
or In return the error. Up to 0.9.6 an unroutable due
message went to acemq.schedule.due.dlq on the first
attempt.
patterns.ScheduleTopology() is the whole thing, exported
so a deployment can declare it up front or compare it with what another
AceMQ library declares:
fmt.Println(patterns.ScheduleTopology())Those names and arguments are a cross-language
contract, not a preference. Java, .NET, Python, Ruby and this
library declare the same objects, and a queue redeclared with a
different argument table is answered PRECONDITION_FAILED —
the second service to start cannot consume at all.
What travels with the message
Four headers, and deliberately not in the reserved
x-acemq- namespace: that one is the engine's, and a header
carrying it is dropped from the application's view on the way in, so a
scheduler header using it would be written on publish and gone on
consume.
| Header | What it holds |
|---|---|
x-schedule-exchange |
the exchange the message is eventually for |
x-schedule-routing-key |
the routing key it will eventually carry |
x-schedule-due-at |
when it is due, as milliseconds since the Unix epoch |
x-schedule-content-type |
what the payload was encoded as |
The content type is carried because the scheduler republishes bytes
rather than values, and a consumer picks its codec from it. Publishing
pre-encoded bytes under application/octet-stream produces a
message the intended consumer cannot decode: it arrives, it is the right
bytes, and nothing can read it.
None of the four reaches the consumer. They are bookkeeping, and a consumer depending on them would be depending on how a message got to it.
What it costs
A long delay is several broker round trips rather than one, and delivery is accurate to about the smallest rung rather than to the second — the last hop is skipped when under a second is left, because another hop would cost more than the accuracy it buys. Something that must fire at 09:00:00.000 exactly is a scheduler, not a message broker.
The alternative is RabbitMQ's delayed-message-exchange plugin, which does this properly and is a plugin — so it is not available everywhere, and a library that silently required it would be a library that works on your laptop.