outbox is a high-performance transactional outbox library. It ships with a MySQL 8.0+ backend optimized for polling
with
READ COMMITTED + SKIP LOCKED, UUID v7 identifiers in BINARY(16), batch processing, and partition-based retention.
The MySQL adapter and command modules require Go 1.26+. The root library alone supports Go 1.25+.
go get github.com/velmie/outbox
go get github.com/velmie/outbox/mysqlInstall github.com/velmie/outbox/mysql when you use the MySQL adapter.
Create the outbox table before starting the service. Generate its DDL with mysql.Schema or
mysql.PartitionedSchema and apply it through your normal migration or bootstrap identity; the runtime application
identity should not need schema-management privileges.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox"
"github.com/velmie/outbox/mysql"
)
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store, err := mysql.NewStore(db)
if err != nil {
log.Fatal(err)
}
// Producer: enqueue within the same business transaction.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Fatal(err)
}
if _, err := store.Enqueue(ctx, tx, outbox.Entry{
AggregateType: "order",
AggregateID: "123",
EventType: "order.created",
Payload: json.RawMessage(`{"id":"123"}`),
}); err != nil {
_ = tx.Rollback()
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}
// Consumer: poll and publish.
handler := outbox.HandlerFunc(func(ctx context.Context, record outbox.Record) error {
// Publish to Kafka/Rabbit/NATS/etc.
return nil
})
relay := outbox.NewRelay(store, handler,
outbox.WithBatchSize(50),
outbox.WithWorkers(4),
outbox.WithPollInterval(50*time.Millisecond),
)
if err := relay.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
}- Your business transaction writes both domain data and an outbox entry.
- A relay polls outbox rows with
SELECT ... FOR UPDATE SKIP LOCKED. - Each record is handed to a handler and then marked
processed, leftpendingfor retry, or markeddead.
The polling predicate considers the entire pending backlog; pending records do not become ineligible as they age.
An opt-in durable retry delay temporarily excludes failed records until their stored deadline.
Delivery is at least once: publication may succeed before the relay can acknowledge and commit the row, so handlers
and downstream consumers must be idempotent. Publish Record.ID as the stable message identifier so downstream
consumers can deduplicate retries.
outbox(root): core abstractions (Relay,Handler,Consumer,Batch,IDGenerator,Clock).mysql/: MySQL 8.0+ adapter (schema helpers + polling consumer + enqueue).
Relay: batch size 50, poll interval 50ms, workers 1.MySQL: tableoutbox, max attempts 5, UUID v7 generator, JSON validation enabled.
To share retry timing between processes, create a JSON outbox table with mysql.RetrySchema("outbox")
and configure every consumer of that table with mysql.WithRetryDelay(5*time.Second):
store, err := mysql.NewStore(db,
mysql.WithTable("outbox"),
mysql.WithRetryDelay(5*time.Second),
)Apply the schema through a controlled migration before starting consumers. The option does not execute DDL. The existing schema helpers and stores without the option retain immediate retry eligibility and do not require the additional column. A zero delay disables scheduling; negative values are rejected. Positive durations round up to the next microsecond, matching MySQL's timestamp precision.
With scheduling enabled, Batch.Fail writes next_attempt_at together with the attempt count, error, and status
in the batch transaction. The deadline uses the database's UTC time when the failure update executes; time spent
before the transaction commits counts toward the delay. Each consumer fetches only pending rows with no deadline
or with a deadline at or before the database's current UTC time. Application clocks do not control eligibility.
Other eligible rows can proceed while a failed row waits. Fetches order eligible records by ID; concurrent workers
do not guarantee delivery completion order. Pending counts include delayed rows, and cleanup preserves them.
The deadline is ignored once the row is processed or dead.
The committed deadline survives process restarts. Rolling back the failure transaction rolls back both the attempt and deadline. If a process crashes before committing the failure, another process may retry sooner. Successful publication followed by a failed acknowledgement or commit can also repeat the same message; use its stable ID for downstream deduplication.
For an existing table, apply the following migration before enabling scheduling (replace outbox with its
configured table name):
ALTER TABLE outbox
ADD COLUMN next_attempt_at DATETIME(6) NULL,
ADD INDEX idx_status_next_attempt_id (status, next_attempt_at, id);Existing rows have no deadline and remain eligible. Stop consumers that ignore deadlines before any consumer starts
scheduling retries on that table, and configure every replacement consumer with a positive retry delay. A legacy
consumer, including a new store with scheduling disabled, would fetch delayed rows immediately. Producers can
continue using the existing enqueue contract. Missing scheduling schema causes a database error rather than a
fallback to immediate retries. RetrySchema creates a non-partitioned JSON table; existing binary or partitioned
tables can opt in through the explicit migration while retaining their payload and partition definitions.
Generate with mysql.Schema("outbox") or use the template below. Apply generated DDL through a controlled migration;
CREATE TABLE IF NOT EXISTS does not convert an existing table to InnoDB or change its partition layout.
The schema is tuned for UUID v7 + polling, with created_ts used as the partition key for retention.
CREATE TABLE IF NOT EXISTS outbox
(
id BINARY(16) NOT NULL,
aggregate_type VARCHAR(128) NOT NULL,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
headers JSON NULL,
status SMALLINT NOT NULL DEFAULT 0,
attempt_count INT NOT NULL DEFAULT 0,
last_error VARCHAR(1024) NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
processed_at TIMESTAMP(6) NULL,
created_ts BIGINT GENERATED ALWAYS AS (CONV(SUBSTR(HEX(id), 1, 12), 16, 10) DIV 1000) STORED,
PRIMARY KEY (id, created_ts),
INDEX idx_status_id (status, id)
) ENGINE=InnoDB;CREATE TABLE IF NOT EXISTS outbox
(
id BINARY(16) NOT NULL,
aggregate_type VARCHAR(128) NOT NULL,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
headers JSON NULL,
status SMALLINT NOT NULL DEFAULT 0,
attempt_count INT NOT NULL DEFAULT 0,
last_error VARCHAR(1024) NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
processed_at TIMESTAMP(6) NULL,
created_ts BIGINT GENERATED ALWAYS AS (CONV(SUBSTR(HEX(id), 1, 12), 16, 10) DIV 1000) STORED,
PRIMARY KEY (id, created_ts),
INDEX idx_status_id (status, id)
) ENGINE=InnoDB
PARTITION BY RANGE (created_ts) (
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);Always keep a MAXVALUE tail partition (pmax) as a safety net. The maintainer will split it to add new ranges.
If you do not want JSON payloads, use the binary schema helpers. This keeps headers in JSON for traceability while storing payload as raw bytes:
package main
import (
"fmt"
"log"
"github.com/velmie/outbox/mysql"
)
func main() {
schema, err := mysql.SchemaBinary("outbox")
if err != nil {
log.Fatal(err)
}
fmt.Println(schema)
}Partitioned variant:
package main
import (
"fmt"
"log"
"github.com/velmie/outbox/mysql"
)
func main() {
partitions := []mysql.Partition{
{Name: "pmax", LessThan: "MAXVALUE"},
}
schema, err := mysql.PartitionedSchemaBinary("outbox", partitions)
if err != nil {
log.Fatal(err)
}
fmt.Println(schema)
}When using binary payloads, disable payload validation:
package main
import (
"database/sql"
"log"
"os"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox/mysql"
)
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if _, err := mysql.NewStore(db, mysql.WithValidatePayload(false)); err != nil {
log.Fatal(err)
}
}Two options are supported:
- Embedded maintainer (inside your service) when the app can run
ALTER TABLE. - Standalone CLI (cron / Kubernetes CronJob) when
ALTERis not allowed in the app.
The maintainer uses GET_LOCK and reads information_schema. Partition creation needs SELECT, ALTER, CREATE,
and INSERT on the target table/schema. Retention additionally needs DROP and schema-level LOCK TABLES;
without Retention, those two privileges are not used.
Use one stable lock name for the same operation and table across all instances. LockName and -lock-name must contain
1-64 valid UTF-8 characters. The default is outbox:partitions:<table>; set a shorter explicit name when a qualified
table name would make the default too long.
Behavior overview:
- On startup it verifies the table is InnoDB and partitioned by
RANGE(created_ts)without subpartitions. - It creates missing partitions for the configured lookahead window using the chosen period.
- If
Retentionis set it drops eligible partitions only when all their rows are terminal (processedordead). Any non-terminal row aborts that destructive pass before DDL. - The terminal-state check and one exclusive, in-place
DROP PARTITIONrun behind a shortLOCK TABLES ... WRITEfence, so concurrent enqueue waits and cannot enter a checked partition. - It repeats this work every
CheckEveryinterval and exits on context cancel.
Embedded usage:
package main
import (
"context"
"database/sql"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox/mysql"
)
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
maintainer, err := mysql.NewPartitionMaintainer(db, mysql.PartitionMaintainerConfig{
Table: "outbox",
Period: mysql.PartitionDay,
Lookahead: 30 * 24 * time.Hour,
CheckEvery: time.Hour,
Retention: 7 * 24 * time.Hour,
})
if err != nil {
log.Fatal(err)
}
if err := maintainer.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
}CLI usage (run on schedule with a dedicated maintenance identity). Supply OUTBOX_DSN
through the process environment or a secret manager. The -dsn flag remains available
for compatibility in v0.2.0, but it is deprecated because command arguments can be inspected.
cd cmd/outbox-partitions
go run . \
-table outbox \
-period day \
-lookahead 720h \
-retention 168h \
-onceCLI details:
- The CLI performs the same plan as the embedded maintainer.
- It creates missing partitions for the requested lookahead and period.
- With
-retentionit deletes terminal-only partitions older than the retention cutoff. -lookahead=0uses 30 days for daily partitions or 90 days for monthly partitions;-retention=0disables removal.- With
-onceit runs a single maintenance cycle and exits. - Without
-onceit stays running and repeats every-check-every.
If you do not use partitioning, use batched deletes to keep the outbox table bounded:
package main
import (
"context"
"database/sql"
"log"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox/mysql"
)
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store, err := mysql.NewStore(db)
if err != nil {
log.Fatal(err)
}
result, err := store.Cleanup(context.Background(), mysql.CleanupOptions{
Before: time.Now().Add(-7 * 24 * time.Hour),
Limit: 10000,
IncludeDead: true,
})
if err != nil {
log.Fatal(err)
}
log.Printf("processed=%d dead=%d", result.Processed, result.Dead)
}For automation, run the embedded maintainer or the CLI:
package main
import (
"context"
"database/sql"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox/mysql"
)
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
maintainer, err := mysql.NewCleanupMaintainer(db, mysql.CleanupMaintainerConfig{
Table: "outbox",
Retention: 7 * 24 * time.Hour,
CheckEvery: time.Hour,
Limit: 10000,
IncludeDead: true,
})
if err != nil {
log.Fatal(err)
}
if err := maintainer.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
}Supply OUTBOX_DSN through the process environment or a secret manager. The
deprecated -dsn flag is retained for compatibility in v0.2.0.
cd cmd/outbox-cleanup
go run . \
-table outbox \
-retention 168h \
-limit 10000 \
-include-dead \
-onceCleanup requires a positive -retention. A zero -limit uses 10000 rows per batch. The cleanup identity needs
SELECT and DELETE, but no DDL privileges. Its default lock name is outbox:cleanup:<table> and follows the same
1-64 character rule as partition maintenance.
SELECT id, aggregate_type, aggregate_id, event_type, payload, headers, created_at, attempt_count
FROM outbox
WHERE status = 0
ORDER BY id ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;- Use
READ COMMITTEDfor polling sessions to avoid gap locks. - Use UUID v7 in
BINARY(16)to preserve time locality in the clustered index. - Prefer batch sizes between 50-200; too small increases round trips, too large holds locks longer.
- JSON validation is enabled by default. Use
mysql.WithValidatePayload/mysql.WithValidateHeadersormysql.WithValidateJSON(false)for fine-grained control. - Run multiple workers in parallel;
SKIP LOCKEDprovides safe work stealing. - Polling considers every pending row, including rows with old UUID timestamps.
WithPartitionWindow,RelayConfig.PartitionWindow, andFetchOptions.MinCreatedAtare deprecated and ignored. - A non-zero
Entry.IDmust be an RFC 9562 UUID v7. Its timestamp may be old; zero IDs use the configured generator. - Keep handler processing short; avoid network retries inside the DB transaction.
WithHandlerTimeoutgives each call a cooperative context deadline. The relay callsHandlesynchronously and waits for it to return, so the handler must observectxto finish near the deadline.- Relay-created failure details for returned errors, recovered handler panics, and errors returned after an elapsed
handler deadline are limited to
outbox handler failed,outbox handler panicked, andoutbox handler timed out. Panic values are discarded. WithErrorHandlerandWithFailureClassifierreceive the original returned handler error and full record for diagnostics and policy. These explicitly configured callbacks must not log or persist secrets blindly.- Use
WithFailureClassifierto mark non-retryable failures as dead immediately. - Raise
max_allowed_packetif your JSON payloads can be large.
Relay accepts optional logger and metrics interfaces. The Metrics interface can be wired to
Prometheus/StatsD/OpenTelemetry. Pending sampling is disabled by default. Enable it with
WithPendingInterval.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/velmie/outbox"
"github.com/velmie/outbox/mysql"
)
type relayMetrics struct{}
func (relayMetrics) ObserveBatchDuration(time.Duration) {}
func (relayMetrics) AddProcessed(int) {}
func (relayMetrics) AddErrors(int) {}
func (relayMetrics) AddRetries(int) {}
func (relayMetrics) AddDead(int) {}
func (relayMetrics) SetPending(int) {}
type stdLogger struct{}
func (stdLogger) Debug(msg string, args ...any) { log.Printf("DEBUG %s %v", msg, args) }
func (stdLogger) Info(msg string, args ...any) { log.Printf("INFO %s %v", msg, args) }
func (stdLogger) Warn(msg string, args ...any) { log.Printf("WARN %s %v", msg, args) }
func (stdLogger) Error(msg string, args ...any) { log.Printf("ERROR %s %v", msg, args) }
func main() {
dsn := os.Getenv("OUTBOX_DSN")
if dsn == "" {
log.Fatal("OUTBOX_DSN is required")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
store, err := mysql.NewStore(db)
if err != nil {
log.Fatal(err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Fatal(err)
}
if _, err := store.Enqueue(ctx, tx, outbox.Entry{
AggregateType: "order",
AggregateID: "123",
EventType: "order.created",
Payload: json.RawMessage(`{"id":"123"}`),
}); err != nil {
_ = tx.Rollback()
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}
handler := outbox.HandlerFunc(func(ctx context.Context, record outbox.Record) error {
return nil
})
relay := outbox.NewRelay(store, handler,
outbox.WithLogger(stdLogger{}),
outbox.WithMetrics(relayMetrics{}),
outbox.WithPendingInterval(5*time.Second),
)
if err := relay.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
}If the consumer implements PendingCounter (the MySQL store does), pending counts are sampled and reported via
Metrics.SetPending.
Single-run results on the developer laptop (not production hardware). Payload 512B JSON, 200k records/run. MySQL 8.0.36 with a 1GB buffer pool. Performance depends heavily on MySQL and IO; the gap between profiles below is mostly durability and storage cost.
| Profile | Durability / storage | Consume-only peak | Mixed peak (p99) | Enqueue (tx) |
|---|---|---|---|---|
| Prod-like | fsync + binlog sync | ~32k msg/s (16x200) | ~207 msg/s (~64 ms) | ~203 msg/s |
| Fast | tmpfs, relaxed durability | ~51k msg/s (8x100) | ~18.7k msg/s (~10 ms) | ~36k msg/s |
Raw insert baseline (use_tx=false) is ~211 msg/s (prod-like) and ~53k msg/s (fast).
Historical reports: docs/benchmarks/results/20251225T225119Z/report.md and
docs/benchmarks/results/20251228T165819Z/report.md.
They describe the repository revision that produced them; use the active benchmark documentation for current behavior.
Reproduce: docs/benchmarks.md.
See docs/guide.md for architecture, tuning, failure handling, cleanup, and extension notes. See docs/benchmarks.md for the research harness and plotting workflow. See docs/migration-v0.3.0.md to upgrade from v0.2.0 and enable durable retry delay. See docs/release-v0.3.0.md for the v0.3.0 release notes. See docs/migration-v0.2.0.md before upgrading from v0.1.1. See docs/release-v0.2.0.md for the v0.2.0 release notes.
for module in . mysql cmd; do
(cd "$module" && go test -race ./... && go vet ./...)
done
(cd mysql && go test -count=1 -tags=integration -timeout 12m ./...)
(cd cmd && go test -count=1 -tags=integration -timeout 12m ./...)The committed go.work makes the nested modules use the current checkout. Integration tests use Testcontainers and
require a working Docker daemon; container startup failures fail the suite.
CLI integration tests in cmd/outbox-cleanup and cmd/outbox-partitions build the binaries and run them in a container against a real MySQL container.
Run only the CLI integration tests:
cd cmd
go test -tags=integration -timeout 5m ./outbox-cleanup ./outbox-partitionsgo install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
repo_root="$(pwd)"
for module in . mysql cmd; do
(cd "$module" && golangci-lint run --config "$repo_root/.golangci.yml" ./...)
doneThe complete gate requires Go 1.26.7, Git, tar, zip, Docker, golangci-lint 2.12.2, govulncheck 1.7.0, and Trivy 0.74.0.
Run ./scripts/verify.sh for the root, mysql, and cmd release gate. It verifies the v0.3.0 candidate module graph
without go.work, then runs integration tests, govulncheck, and Trivy.
MIT - see LICENSE.