Skip to content
efureevPublic

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

go-outbox

Test Go Reference Go Report Card License

English | Русский

Your application writes a message in the same transaction as the business change. This delivers it — to RabbitMQ, to Kafka, to several of each — and does not lose it.

A Transactional Outbox dispatcher: a sidecar that reads a database table and publishes what it finds. No client library to embed, no framework to adopt, nothing in your application beyond one INSERT.

Why this one

  • Nothing is lost. A row becomes sent only after the broker acknowledges it — a publisher confirmation on AMQP, acks=all on Kafka. Not when the bytes leave the process.

  • Scale sideways, safely. Run as many replicas as you like against one table. Claims are taken with FOR UPDATE SKIP LOCKED and carry a lease token that every write-back must present, so a replica whose lease expired mid-flight cannot overwrite what another already delivered. The schema enforces it, not a convention.

  • Fast, and you can check. ~7 300 msg/s through RabbitMQ with confirms on; ~12 000 with workers and the channel pool widened together; ~46 000 with the broker taken out of the picture — the dispatcher is never the bottleneck. make bench reproduces every figure on your own hardware.

  • Milliseconds, not poll intervals. A trigger wakes the pipeline the moment a row is written — ~105 ms end to end on the shipped defaults, ~5 ms once the debounce window and the replica jitter are tuned away. Polling stays on as reconciliation, so a lost notification costs one interval and never a message.

  • Many brokers at once. Four streams across three RabbitMQ instances and a Kafka cluster is a configuration, not a fork. A producer picks its destination with one column, and each stream gets its own pipeline, so a broker that goes down while running delays only the streams pointed at it. (Every broker must be reachable at startup: one that is not fails the boot rather than starting half a dispatcher.)

  • Recovers on its own. A replica killed mid-batch leaves rows leased; the lease expires and another picks them up. A clean shutdown does not even wait for that — it hands unfinished claims straight back.

  • An outage costs you latency, not an evening. The retry budget counts times a broker refused a message. A broker that cannot be reached never saw it, so it spends nothing: the message waits and goes out when the broker returns, however long that takes. No table full of failed rows to requeue by hand after a twenty-minute restart.

  • Tells you what happened. 20 Prometheus metrics, including the one that actually matters: how long the oldest undelivered message has waited. Failed messages are listable and requeueable over HTTP, so nobody writes an UPDATE by hand.

  • Visible inside a trace. Point it at an OpenTelemetry collector and each message gets an outbox.publish span between the producer's and the consumer's, so the wait between committing a row and handing it to a broker stops being a number nobody can explain. Off by default, and off costs nothing: no exporter, no span, no allocation.

  • Boring to operate. A 29 MB scratch image with no shell and no package manager, or a static binary. Delivered rows are swept on a retention you set. Ten direct dependencies, no web framework, no DI container.

  • Fails at boot, not at 3am. The whole configuration is read and validated before anything connects, and every problem is reported together — a bad threshold, a misspelled driver key, three drivers each missing a DSN — so a misconfigured deployment takes one restart to diagnose rather than one per mistake.

  • Proven, not asserted. 214 tests, half of them against real PostgreSQL, RabbitMQ and Redpanda, because a set of concurrency rules expressed in SQL cannot be checked by a mock that matches query text.

Install

Container image

docker pull ghcr.io/efureev/go-outbox:1.0.0

Published on every tag for linux/amd64 and linux/arm64. Tags are the exact version, the minor line (1.0), and latest; a pre-release moves neither of the last two. The image is scratch plus a CA bundle — no shell, no package manager, nothing for anything that gets in to use.

Binary

Prebuilt archives for Linux and macOS, amd64 and arm64, on the releases page:

VERSION=1.0.0
curl -fsSLO "https://github.com/efureev/go-outbox/releases/download/v$VERSION/outbox_${VERSION}_linux_amd64.tar.gz"
curl -fsSLO "https://github.com/efureev/go-outbox/releases/download/v$VERSION/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS

tar -xzf "outbox_${VERSION}_linux_amd64.tar.gz"
sudo install -m 0755 "outbox_${VERSION}_linux_amd64/outbox" /usr/local/bin/outbox
outbox version

The binary is static: no runtime to install, no libc to match.

With Go

go install github.com/efureev/go-outbox/cmd/outbox@latest

Convenient, but it stamps no version — outbox version reports dev. Prefer a release archive or the image where knowing what is deployed matters.

From source

git clone https://github.com/efureev/go-outbox.git && cd go-outbox
make build              # ./bin/outbox
make image              # a local container image
make dist               # release archives for every platform

Quick start

docker compose up -d                       # PostgreSQL, RabbitMQ, Redpanda
cp .env.example .env
go run ./cmd/outbox migrate up
go run ./cmd/outbox run

Write a message the way a producer would:

INSERT INTO outbox.messages (id, stream, topic, payload, target)
VALUES (gen_random_uuid(), 'local', 'orders.placed',
        '{"order":"A-1"}'::text::bytea, '{"key":"customer-1"}'::jsonb);

or, from Go, in the transaction that carries the business change:

client := outboxclient.Default()

tx, err := pool.Begin(ctx)
if err != nil {
    return err
}
defer tx.Rollback(ctx)

if _, err := tx.Exec(ctx, `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, id); err != nil {
    return err
}

if err := client.Enqueue(ctx, tx, outboxclient.Message{
    Stream:  "local",
    Topic:   "account.debited",
    Payload: payload,
    Headers: map[string]string{"traceparent": traceparent},
}); err != nil {
    return err
}

return tx.Commit(ctx)

How it works

producer transaction                 go-outbox                     broker
┌────────────────────┐        ┌────────────────────────┐        ┌─────────┐
│ UPDATE accounts …  │        │ claim (SKIP LOCKED,    │        │         │
│ INSERT outbox row  │───────▶│        lease token)    │───────▶│ RabbitMQ│
│ COMMIT             │        │ publish (confirmed)    │        │  Kafka  │
└────────────────────┘        │ write back (lease-     │        │         │
                              │        checked)        │        └─────────┘
                              └────────────────────────┘

The producer's message and its business change commit or roll back together. That single property is what the pattern buys; everything else here exists to deliver on it without losing, duplicating or stalling messages once the transaction has committed.

Delivery is at-least-once. A replica can die between the broker accepting a message and the database recording that, so a consumer must be idempotent — every message carries its outbox id for that purpose (MessageId on AMQP, the message_id header on Kafka).

Ordering is not guaranteed. Replicas publish concurrently, workers within a replica publish concurrently, and a retried message lands after messages written later. A consumer needing per-key order has to establish it itself.

The full contract, including what is deliberately not promised, is in docs/PublicContract.md.

Configuration

Every setting is an environment variable under OUTBOX_, read at startup and validated before anything connects. A malformed value stops the process with every problem listed at once, rather than producing a service that starts and is permanently unready.

OUTBOX_DB_DSN=postgres://user:pass@db:5432/app?sslmode=require
OUTBOX_STREAMS=local
OUTBOX_STREAM_LOCAL_DRIVER=rmq
OUTBOX_DRIVER_RMQ_TYPE=rabbitmq
OUTBOX_DRIVER_RMQ_DSN=amqp://user:pass@rabbit:5672/

A stream is a logical destination a producer names in a row; a driver is a connection to one broker. There may be as many drivers as there are brokers to reach — several RabbitMQ instances, several Kafka clusters, or a mix of both — and a producer picks between them with one column. Each stream gets its own pipeline, so a broker that is down delays only the streams pointed at it.

Full reference: docs/Config.md.

Operating it

Endpoint Purpose
GET /health Liveness. Does not probe dependencies.
GET /ready Readiness: every module reports healthy.
GET /api/v1/stats Backlog counts, configured streams and drivers, settings.
GET /api/v1/messages/failed Page through what stopped, and why. ?stream= narrows it to one.
POST /api/v1/messages/requeue Return failed messages to the queue. Requires OUTBOX_HTTP_ADMIN_TOKEN.
GET /metrics (port 9100) Prometheus.

The last three are also subcommands — outbox stats, outbox failed, outbox requeue — reaching the database directly instead of the pod. Same store calls, different authorisation: the endpoints take a token, the commands take the database credentials.

Returning failed messages to the queue, from anywhere:

curl -X POST -H 'Authorization: Bearer $TOKEN' \
  localhost:8085/api/v1/messages/requeue \
  -d '{"failed_before":"2026-01-01T00:00:00Z"}'
SELECT outbox.requeue(ARRAY['…']::uuid[]);

Metrics, starting alert rules and a Grafana dashboard to import (dashboards/outbox.json): docs/MetricsAndAlerts.md.

Documentation

  • Use cases — six complete recipes, one page each: Laravel under Docker, a VDS with supervisord, Kubernetes with autoscaling, bare metal with systemd, four streams across three brokers, and a Go producer on database/sql.
  • Public contract — the columns a producer writes, the routing rules, and what is explicitly not guaranteed.
  • Configuration — every environment variable.
  • Metrics and alerts — every metric, starting alert rules, and the dashboard.
  • Operations — deploying, scaling, and what to do when something is wrong.
  • Roadmap — what has shipped, what comes next in the order it would be built, and what is deliberately not planned.
  • Driver spec — what adding a broker takes, and what each candidate costs in binary size.
  • Benchmarks — every figure the README and the changelog quote, with the benchmark that produced it.

Development

make up                 # start PostgreSQL, RabbitMQ and Redpanda
make test               # unit tests
make test-integration   # integration tests against the real thing
make soak SOAK=1h       # the same failures, under load, for as long as you like
make bench              # the numbers quoted in the changelog
make mutation           # mutation testing, where it pays
make lint               # golangci-lint, in a container pinned to the CI version
make fmt

lint and fmt run in a container rather than against whatever is on PATH, because a linter that differs between a developer's machine and CI reports different things — and the way that shows up is a green local run turning into a red pipeline. make lint-host uses the binary on PATH when a faster loop matters more than matching CI exactly.

The integration tests carry the weight of the suite on purpose: this is a set of concurrency and ownership rules expressed in SQL, and those cannot be checked by asserting that a query string has not changed.

Coverage sits at 79.7% across internal/ and pkg/, and 100% is neither reachable nor the goal: main, runServer and the module constructors are only exercised by starting the process, and a test on them would measure net/http. The gap is concentrated in one package — internal/app at 49.4% — which is module lifecycle: twenty lines of wiring per hook, failing visibly on the first start. Everything that carries a rule is above 87%, and internal/core, internal/dispatch and internal/tracing are above 95%.

make mutation runs gremlins over internal/config, internal/core and the circuit breaker — the three places where the logic is pure enough for it to say something. Efficacy is 96%, 79% and 90%. Every mutant that survives is written down in the test file for its package, with the reason it is not worth killing; most are equivalent.

Requirements

  • Go 1.26
  • PostgreSQL 13 or newer
  • RabbitMQ 3.8+ and/or Kafka 2.4+

License

MIT.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages