3. Never processing twice
25 minutes. Needs Docker.
"Exactly once" is a phrase brokers sell and none deliver. What is actually available is at-least-once delivery plus effects that happen once, and this is how to build the second half.
docker run -d --rm --name rabbit -p 5672:5672 -p 15672:15672 rabbitmq:4-management
go get modernc.org/sqliteSQLite because it needs no server; everything here works the same against Postgres or MySQL by changing the dialect and the driver.
Watch a duplicate happen
pub := acemq.NewPublisher[OrderPlaced](mq, "", "orders.placed")
env, err := acemq.NewEnvelope("order.placed", acemq.MessageID("dup-1"))
if err != nil {
log.Fatal(err)
}
order := OrderPlaced{OrderID: "A-1", TotalCents: 4250}
if err := pub.SendEnvelope(ctx, order, env); err != nil {
log.Fatal(err)
}
if err := pub.SendEnvelope(ctx, order, env); err != nil { // the same envelope
log.Fatal(err)
}charged A-1
charged A-1
The customer was charged twice. That is not a contrived case — it is what a broker does after a consumer crashes between doing the work and acknowledging, and what an outbox relay does when it publishes and dies before recording that it did.
SendEnvelope is used here to force the point: an
envelope built once and sent twice is two deliveries of one message
identity. Ordinarily Send generates a fresh identifier per
message, which is why pinning MessageID across a
SendAll batch is the one thing publishing tells you not to
do.
Remember what you handled
import "github.com/AceMQ-Company/acemq-go-amqp/patterns"
store := patterns.NewInMemoryIdempotencyStore(time.Hour)
sub, err := acemq.Consume(ctx, mq, "orders.placed",
patterns.Idempotent(store, func(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
log.Printf("charged %s", m.Payload.OrderID)
return acemq.Accept()
}))charged A-1
patterns.Idempotent wraps a handler; it is not a
consumer option and not a flag. That is the Go shape for this: a
Handler[T] in and a Handler[T] out, so it
composes with everything else that wraps a handler and there is no
registry deciding what applies to whom.
The key is m.Envelope.ID, which is stable across every
redelivery of one message — which is why sending the same envelope twice
is recognised as one message rather than two.
Use patterns.IdempotentBy when the natural key is in the
payload instead:
handler := patterns.IdempotentBy(store,
func(m acemq.Message[OrderPlaced]) string { return "order:" + m.Payload.OrderID },
handle)A duplicate is accepted, not rejected
The work was done, so the message has been handled. Dead-lettering it would raise an alarm about something that went right.
Three methods, because two lose work
Claim(ctx, key) (bool, error) // take it, if nobody has it
Confirm(ctx, key) error // the work is done
Release(ctx, key) error // it failed; let the retry runIdempotent claims, runs your handler, then confirms on
success and releases on failure.
The third method is the one worth explaining, because this library shipped without it until 0.8.0 and the omission lost work. With only a claim and a release, a row in the store means "somebody has seen this key" and nothing more — so a process killed mid-handler leaves a row indistinguishable from a finished one. Nothing releases it, because nothing runs. The broker redelivers, the store says "already handled", the message is accepted, and the work never happens. Not a duplicate, which this pattern is allowed to produce: a silent loss, which is the thing it exists to prevent.
A confirmed key suppresses redeliveries for the retention window. An
unconfirmed claim is honoured only for DefaultClaimTimeout
— five minutes — after which another consumer may take it, because the
only thing that abandons a claim is a process that died and its work
still needs doing. Set it above your slowest handler:
store.SetClaimTimeout(15 * time.Minute)None of this makes the pattern exactly-once. Between your handler finishing and the acknowledgement reaching the broker, a crash still leaves a message that will be delivered again — and the claim is confirmed by then, so the redelivery is correctly accepted without repeating the work. Only a store written in the same transaction as the work closes the remaining gap, which is the next section.
The in-memory one is not enough
It is per process. Run two instances of your consumer and a duplicate delivered to the other one is handled twice. It does not fail; it deduplicates less than it looks like it does.
import (
"database/sql"
_ "modernc.org/sqlite"
)
db, err := sql.Open("sqlite", "orders.db")
if err != nil {
log.Fatal(err)
}
store := patterns.NewSQLIdempotencyStore(db, patterns.SQLiteDialect)
if _, err := db.ExecContext(ctx, store.Schema()); err != nil {
log.Fatal(err)
}Schema() returns the DDL rather than running it. Running
it here is fine for a tutorial; in a real service put it in a migration,
because a library that runs migrations against your database on start-up
is a library fighting your migration tool.
The claim is an insert, so the primary key does the
mutual exclusion: two consumers racing, one insert succeeds, one does
not, and nobody has to hold a lock.
patterns.PostgresDialect, MySQLDialect and
SQLiteDialect differ in exactly two things — the
placeholder syntax and the spelling of "insert unless it is already
there" — and getting either wrong fails at runtime on one database and
not another.
Keys do not expire by themselves.
store.Prune(ctx, 30*24*time.Hour) on a schedule is the
housekeeping. Keep the cutoff well above the claim timeout: a cutoff
shorter than it would delete a claim somebody is still working
under.
Closing the gap properly
The store is only a guarantee when the claim commits with the work:
func handle(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return acemq.Retry(err)
}
defer func() { _ = tx.Rollback() }()
store := patterns.NewSQLIdempotencyStore(tx, patterns.SQLiteDialect)
first, err := store.Claim(ctx, m.Envelope.ID)
if err != nil {
return acemq.Retry(err)
}
if !first {
return acemq.Accept()
}
if err := charge(ctx, tx, m.Payload); err != nil {
return acemq.Retry(err)
}
// Confirmed inside the transaction, so the key is marked done by the same
// commit that does the work. A crash before the commit rolls both back and the
// redelivery reclaims cleanly; a crash after it finds the key confirmed.
if err := store.Confirm(ctx, m.Envelope.ID); err != nil {
return acemq.Retry(err)
}
if err := tx.Commit(); err != nil {
return acemq.Retry(err)
}
return acemq.Accept()
}patterns.DB is satisfied by both *sql.DB
and *sql.Tx, which is the whole point of the interface
being three methods rather than a concrete type. The claim and the
charge are now one commit: either both happened or neither did, and a
crash anywhere in between leaves the message to be delivered again and
correctly reclaimed.
The other half: publishing exactly once
Your service saves an order and publishes a message about it.
if err := saveOrder(ctx, db, order); err != nil { // committed
return err
}
if err := pub.Send(ctx, orderPlaced); err != nil { // the process dies here
return err
}The order exists and nobody was told. Swap the two and you announce an order that might still roll back.
Neither ordering works, because there are two systems and no transaction across them.
The outbox
Write the message in the same transaction as the order:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := saveOrder(ctx, tx, order); err != nil {
return err
}
record, err := patterns.Record(mq, "", "orders.placed", orderPlaced)
if err != nil {
return err
}
if err := patterns.NewSQLOutboxStore(tx, patterns.SQLiteDialect).Add(ctx, record); err != nil {
return err
}
return tx.Commit()Pass the transaction, not the database.
NewSQLOutboxStore(db, …) compiles just as well, opens its
own connection, and quietly gives up the entire guarantee. This is the
single most important line on the page, and the type system cannot help
you with it — both satisfy patterns.DB, which is what makes
the good version possible in the first place.
patterns.Record encodes the payload with the
connection's codec and builds the envelope, so what is stored is bytes
and headers rather than a Go value. The record outlives the process that
wrote it, and the type may not survive a deployment.
Then something publishes them:
relay := patterns.NewOutboxRelay(mq, patterns.NewSQLOutboxStore(db, patterns.SQLiteDialect),
patterns.RelayInterval(time.Second),
patterns.RelayBatch(100))
relay.Start(ctx)
defer relay.Close()The relay uses the database, not a transaction — it
is a separate loop that reads what has been committed.
relay.Sweep(ctx) runs one pass by hand and returns how many
it moved, which is what to call in a test rather than sleeping.
There is a test in this repository that rolls the transaction back and proves that neither the order nor the message survived.
Why this is still not exactly once
The relay publishes, then marks the record as published. A crash between the two republishes the message next sweep, because the record was never marked.
So delivery is still at least once, and it always will be. What you have built is the other half: the consumer recognises the duplicate and the effect happens once. That is what people mean when they say exactly once, and it is worth knowing it is two mechanisms you assembled rather than a broker feature you switched on.
Watch the lag rather than assuming it is zero:
acemq.outbox.lag how far behind the relay is, in seconds
acemq.outbox.total tagged published or failed
A relay that has stopped is a service whose messages have stopped, with no error anywhere and a database quietly filling up. See tutorial 4.
What you have
Duplicates that do nothing, and messages that cannot be lost between your database and the broker. Next: seeing what happens.