Skip to content

slapec93/pg-outboxer

Repository files navigation

pg-outboxer

CI Go Report Card License: MIT Go Version

A lightweight, language-agnostic sidecar for implementing the Transactional Outbox Pattern with PostgreSQL.

No Kafka required. No JVM required. Just a single binary.

What is the Transactional Outbox Pattern?

The outbox pattern solves the dual-write problem: when your application needs to update the database AND publish an event, there's no atomic transaction spanning both systems. If the process crashes between the two operations, you get either:

  • A committed database change with no event published (data loss)
  • An event published for a transaction that rolled back (phantom events)

Solution: Write the event to an "outbox" table in the same transaction as your business data. A separate relay process (pg-outboxer) reads from the outbox and delivers events reliably.

Features

  • At-least-once delivery with retry logic and dead letter queue
  • Ordered delivery per aggregate - events for the same entity are delivered in order
  • Multiple publishers - webhook (HTTP), Redis Streams, Kafka
  • CDC via logical replication - sub-millisecond latency (optional)
  • Polling mode - simple, zero-config fallback
  • Language-agnostic - works with any application (Rails, Django, Spring, Go, etc.)
  • Single binary - no JVM, no Kafka required
  • Prometheus metrics - built-in observability

Quick Start

1. Install

# From source
go install github.com/slapec93/pg-outboxer/cmd/pg-outboxer@latest

# Or download binary from releases
# Or use Docker
docker pull ghcr.io/slapec93/pg-outboxer:latest

2. Create config.yaml

source:
  type: polling                          # or "cdc"
  dsn: ${DATABASE_URL}
  table: outbox
  poll_interval: 500ms
  batch_size: 100

publishers:
  - name: primary-webhook
    type: webhook
    url: https://your-service.com/events
    timeout: 10s
    signing_secret: ${WEBHOOK_SECRET}    # Optional HMAC signing

delivery:
  workers: 4
  max_retries: 10
  dead_letter_table: outbox_dead_letter

observability:
  metrics_port: 9090
  log_level: info
  log_format: json

3. Setup database tables

pg-outboxer setup --config=config.yaml

This creates:

  • outbox table with indexes
  • outbox_dead_letter table
  • Optionally: CDC publication and replication slot (with --cdc flag)

4. Write events in your application

// Go example - same pattern works in any language
func CreateOrder(db *sql.DB, order Order) error {
    tx, _ := db.Begin()
    defer tx.Rollback()

    // Insert business data
    _, err := tx.Exec(`
        INSERT INTO orders (id, customer_id, total)
        VALUES ($1, $2, $3)
    `, order.ID, order.CustomerID, order.Total)

    // Insert outbox event (same transaction!)
    payload, _ := json.Marshal(map[string]interface{}{
        "order_id": order.ID,
        "customer_id": order.CustomerID,
        "total": order.Total,
    })

    _, err = tx.Exec(`
        INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
        VALUES ($1, $2, $3, $4)
    `, "order", order.ID, "order.created", payload)

    return tx.Commit() // Both succeed or both fail atomically
}

5. Start pg-outboxer

pg-outboxer run --config=config.yaml

Events from the outbox are now delivered to your webhook!

Architecture

┌─────────────────────────────────────────────────────┐
│                   pg-outboxer                       │
│                                                     │
│  ┌─────────────┐    ┌──────────────────────────┐   │
│  │   Source    │    │      Dispatcher          │   │
│  │             │    │                          │   │
│  │  polling /  │───▶│  partition-key router    │   │
│  │  cdc (WAL)  │    │  (worker per agg_id)     │   │
│  └─────────────┘    └──────────┬───────────────┘   │
│                                │                   │
│                    ┌───────────▼────────────┐      │
│                    │      Publisher         │      │
│                    │  webhook | redis | kafka│      │
│                    └───────────┬────────────┘      │
│                                │                   │
│                    ┌───────────▼────────────┐      │
│                    │   Retry Scheduler      │      │
│                    │   Dead Letter Queue    │      │
│                    └────────────────────────┘      │
│                                                     │
│  Prometheus metrics on :9090/metrics               │
└─────────────────────────────────────────────────────┘

Delivery Guarantees

✅ What pg-outboxer guarantees:

  • At-least-once delivery - events will be delivered, but might be delivered more than once
  • Ordered delivery per aggregate - all events for the same aggregate_id are delivered in order
  • Durable delivery - events survive crashes and restarts

❌ What pg-outboxer does NOT guarantee:

  • Exactly-once delivery - impossible without consumer-side coordination
  • Cross-aggregate ordering - events for different aggregates may be delivered out of order

Your consumers must be idempotent. Use the event_id (UUID) as an idempotency key.

Demo

Try the complete Docker Compose demo with Prometheus + Grafana:

# Start full stack (Postgres, pg-outboxer, webhook receiver, Prometheus, Grafana)
docker-compose up -d

# Setup database tables
docker-compose exec pg-outboxer ./pg-outboxer setup --config /app/config.yaml

# Insert test events
docker-compose exec postgres psql -U postgres -d outbox_demo -c "
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', 'order-123', 'order.created', '{\"amount\": 100}');"

# Watch webhook receiver logs
docker-compose logs -f webhook-receiver

# Open Grafana dashboard
open http://localhost:3000  # admin/admin

See DOCKER_DEMO.md for full documentation with test scenarios.

Commands

# Setup database
pg-outboxer setup --config=config.yaml
pg-outboxer setup --config=config.yaml --cdc  # Also setup CDC

# Run the relay
pg-outboxer run --config=config.yaml
pg-outboxer run --config=config.yaml --log-level=debug

# Validate config
pg-outboxer validate-config --config=config.yaml

# Version
pg-outboxer version

CDC Mode (Optional)

For sub-millisecond latency (1-10ms vs 100-500ms polling), use CDC with logical replication:

1. Enable logical replication in postgresql.conf:

wal_level = logical
max_replication_slots = 4

2. Restart PostgreSQL

3. Setup CDC:

pg-outboxer setup --config=config.yaml --cdc

4. Update config.yaml:

source:
  type: cdc
  dsn: ${DATABASE_URL}
  table: outbox
  slot_name: pg_outboxer_slot
  publication: pg_outboxer_pub

📖 Full CDC documentation: docs/CDC.md

Latency comparison:

  • Polling: 250ms (p50), 500ms (p95)
  • CDC: 2ms (p50), 5ms (p95)

Monitoring

Prometheus metrics available at :9090/metrics:

pg_outboxer_events_processed_total{outcome}           # success, failed_retryable, failed_fatal
pg_outboxer_events_published_total{publisher, status} # per-publisher delivery status
pg_outboxer_publish_duration_seconds{publisher}       # publish latency histogram
pg_outboxer_event_retries_total{retry_count}          # retry attempts
pg_outboxer_dead_letter_events_total                  # events moved to DLQ
pg_outboxer_active_workers                            # number of worker goroutines
pg_outboxer_queue_depth{queue}                        # pending/processing queue depth
pg_outboxer_replication_lag_bytes                     # CDC mode only
pg_outboxer_wal_messages_total{type}                  # CDC mode only

Configuration Reference

See config.example.yaml for full configuration options.

Comparison

Feature pg-outboxer Debezium GoHarvest
Publisher targets Webhook, Redis, Kafka Kafka only Kafka only
CDC support
Polling support
Deployment Single binary Kafka + Connect + JVM Single binary
Language-agnostic
Webhook-first

Testing

Unit Tests

make test
# or
go test -v -race ./...

Integration Tests

Full end-to-end tests with real PostgreSQL and HTTP servers using testcontainers:

make test-integration
# or
go test -v -tags=integration ./test/integration/...

Tests cover:

  • Happy path event delivery
  • Batch processing (100 events)
  • Retry behavior on failures
  • Dead letter queue handling
  • Multi-publisher fan-out

See test/integration/README.md for details.

Contributing

Contributions welcome! See CONTRIBUTING.md for guidelines.

Quick Start:

git clone https://github.com/slapec93/pg-outboxer.git
cd pg-outboxer
make test

CI/CD: Automated testing and releases via GitHub Actions. See docs/CICD.md for details.

Development Status

Production-ready - Full implementation with comprehensive testing.

Implemented:

  • ✅ CLI framework with all commands
  • ✅ Configuration loading with validation
  • ✅ Polling source with batching
  • CDC source with logical replication (sub-ms latency)
  • ✅ Webhook publisher with HMAC signing
  • ✅ Multi-publisher support
  • ✅ Retry logic with exponential backoff
  • ✅ Dead letter queue
  • ✅ Prometheus metrics + Grafana dashboard
  • ✅ Docker Compose demo with full observability stack
  • ✅ Comprehensive unit tests (>90% coverage)
  • ✅ End-to-end integration tests
  • ✅ CI/CD with GitHub Actions

Not yet implemented:

  • ⏳ Redis Stream publisher - webhook works as alternative
  • ⏳ Kafka publisher - webhook works as alternative

Contributing

This is a portfolio project, but feedback and suggestions are welcome! Open an issue or PR.

License

MIT License - see LICENSE for details

Acknowledgments

Inspired by:

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages