A lightweight Go CDC handler that reads PostgreSQL logical replication changes and publishes normalized events to NATS JetStream. Comes with Docker-based local stack (Postgres + NATS JetStream) for quick end-to-end testing.
- PostgreSQL logical replication via
pgoutputorwal2json(wal2jsonis the config default;pgoutputis the recommended production plugin). - Deterministic event IDs, before/after images, commit timestamps.
- NATS JetStream publisher with automatic stream creation and publish retries.
- High throughput pipeline with configurable buffered channels and batch async publishing.
- Checkpoint recovery from PostgreSQL replication slot (
confirmed_flush_lsn). - Table allowlisting and publication support.
- Simple health endpoint and lightweight metrics logger.
-
Start infra (Postgres with logical replication, NATS):
docker compose up -d
-
Run the CDC app:
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres export CDC_SLOT_NAME=better_cdc_slot export CDC_PLUGIN=wal2json export CDC_PUBLICATIONS=better_cdc_pub go run ./cmd/cdc-handler
-
Generate changes:
psql -h localhost -U postgres -d postgres \ -c "insert into public.orders(account_id,total_cents,status) values (1,2999,'pending');" -
Inspect NATS (JetStream) messages on the
CDCstream (subjectscdc.>). For example usingnatsCLI:nats --server localhost:4222 sub 'cdc.>' -
Debug logging: set
DEBUG=trueto enable verbose zap logging of WAL events, publishes, and checkpoints.
Environment variables (defaults in internal/config):
Database & Replication:
DATABASE_URL(defaultpostgres://postgres:postgres@localhost:5432/postgres)CDC_DATABASE_NAME(optional explicit subject/source database name override; otherwise derived fromDATABASE_URL)CDC_SLOT_NAME(defaultbetter_cdc_slot)CDC_PLUGIN(pgoutput|wal2json)CDC_PUBLICATIONS(comma-separated; defaultbetter_cdc_pub)TABLE_FILTERS(comma-separatedschema.tableallowlist)
Batching & Throughput:
BATCH_SIZE(default500) - events per batch before flush; must be>= 0BATCH_TIMEOUT(default100ms) - max time before flushPUBLISH_ASYNC_MAX_PENDING(defaultmax(256, BATCH_SIZE)) - JetStream async publishes allowed in flight beforePublishAsyncstalls/errors; set this to at least your effective batch size for safer defaults under higher RTTRAW_MESSAGE_BUFFER_SIZE(default5000) - buffer between WAL reader and parserPARSED_EVENT_BUFFER_SIZE(default5000) - buffer between parser and engine
Checkpoint:
CHECKPOINT_INTERVAL(default1s) - how often to advance replication slot feedback
NATS:
NATS_URL(comma-separated; defaultnats://localhost:4222)NATS_USERNAME,NATS_PASSWORDNATS_TIMEOUT(default5s)ALLOW_NOOP_PUBLISHER(defaultfalse) - allows startup without NATS and drops publishes intentionally; use only for local testing, and note that/readywill stay unhealthy while it is enabled
JetStream Stream:
STREAM_NAME(defaultCDC)STREAM_SUBJECTS(comma-separated; defaultcdc.>)STREAM_STORAGE(fileormemory; defaultfile)STREAM_REPLICAS(default1)STREAM_MAX_AGE(default72h) - max retention age for messagesDUPLICATE_WINDOW(default2m) - JetStream de-duplication window; publishes with the sameevent_idwithin this window are idempotent
Other:
HEALTH_ADDR(default:8080)DEBUG(settruefor verbose logging)ENABLE_PPROF(defaultfalse) - exposes/debug/pprof/*endpoints on the health server
PostgreSQL WAL ──► [buffer] ──► Parser ──► [buffer] ──► Engine ──► JetStream
│
▼
Checkpoint
- WAL Reader (
internal/wal): replication connection,pgoutput/wal2json, emits begin/commit markers and row changes; configurable output buffer for backpressure handling. - Parser (
internal/parser): decodes WAL messages into structured events; buffered output channel. - Transformer (
internal/transformer): builds normalized CDC events with deterministic IDs. - Publisher (
internal/publisher): connects to NATS JetStream, ensures stream exists (defaults: nameCDC, subjectscdc.>). Supports batch async publishing for high throughput. - Engine (
internal/engine): orchestrates the pipeline; batches events by size/timeout/commit boundaries; uses async batch publishing when available. - Checkpoint Manager (
internal/checkpoint): on startup, readsconfirmed_flush_lsnfrom the PostgreSQL replication slot. During operation, theStandbyStatusUpdateheartbeat advances the slot position in Postgres; the checkpointSaveis a no-op since Postgres already tracks the position durably. - Health/Metrics:
/healthfor liveness,/readyfor dependency readiness,/metricsfor Prometheus.
- The engine checkpoints only on commit boundaries after publish acks succeed. PostgreSQL replication feedback is advanced only to that durable checkpoint, so the base guarantee is at-least-once.
- On a graceful shutdown, the engine flushes any pending durable checkpoint and sends a final
StandbyStatusUpdatebefore closing the replication connection. If the slot was already current,confirmed_flush_lsnmay not visibly move during shutdown; either way, restarting the same slot resumes from the last flushed checkpoint so already-acknowledged commits should not be replayed on a clean stop/start cycle. - Unclean exits can still replay already-published commits: process crashes, host loss, network failures before feedback reaches PostgreSQL, and partial batch failures all fall back to at-least-once behavior.
- JetStream de-duplication is enabled by default: each message is published with
Nats-Msg-Idset to the deterministicevent_id. Duplicate publishes within the configuredDUPLICATE_WINDOW(default 2 minutes) are silently discarded by JetStream, providing effectively-once delivery only within that window. - Consumers that need exactly-once processing beyond the de-dup window must de-duplicate using the deterministic
event_id(or an equivalent idempotency key).
- Schema evolution is not tracked as first-class CDC.
ALTER TABLEand most other DDL are not emitted as structured events, so consumers must tolerate shape changes during deploys. TRUNCATEis supported by both plugins and is emitted ascdc.ddlwith emptybefore/afterimages. Other DDL is still unsupported.- Large
pgoutputtransactions are buffered until commit. IfMAX_TX_BUFFER_SIZEis exceeded, the parser switches to streaming mode to avoid unbounded memory growth; row events from that overflowed transaction can be published before commit metadata is available. - Duplicate delivery remains possible after crashes, after publish retries, or after recovery outside the JetStream de-dup window.
The pipeline uses buffered channels and batch async publishing to achieve high throughput (target: 1-2k TPS).
Buffer sizes control backpressure between pipeline stages:
RAW_MESSAGE_BUFFER_SIZE=5000 # Increase for bursty workloads
PARSED_EVENT_BUFFER_SIZE=5000 # Increase if parser is faster than publisherBatch settings control how events are grouped before publishing:
BATCH_SIZE=500 # Larger = higher throughput, more latency
BATCH_TIMEOUT=100ms # Lower = less latency, more flushes
PUBLISH_ASYNC_MAX_PENDING=500 # Keep >= effective batch size for async JetStream publishesSet buffer sizes to 0 to revert to unbuffered (sequential) behavior for debugging.
- Postgres in compose is configured with
wal_level=logical,max_wal_senders=10,max_replication_slots=10and initializes schema, publication, and replication slot viadocker/postgres/init/001_init.sql. - The local bootstrap provisions
better_cdc_slotwithwal2json, so the local quickstart should keepCDC_PLUGIN=wal2jsonunless you recreate the slot withpgoutput. - If you change the publication, slot, or plugin, update both the init SQL and env vars.
- JetStream stream can be customized via
JetStreamOptions(stream name, subjects); defaults targetcdc.*topics. - wal2json path now emits begin/commit markers so checkpoints persist;
pgoutputremains the recommended plugin for RDS-like environments.
End-to-end tests validate the full CDC pipeline (Postgres WAL -> Parser -> Engine -> JetStream -> Checkpoint) using testcontainers-go. Requires Docker running.
go test -tags=integration -v -timeout=3m ./tests/integration/Tests cover:
- Basic CDC (
TestBasicCDC): INSERT/UPDATE/DELETE capture with bothwal2jsonandpgoutputparsers, plus cross-table validation. - Checkpoint Recovery (
TestCheckpointRecovery): Graceful shutdown preserves the flushed checkpoint, and restart on the same slot resumes with only post-shutdown work. - At-Least-Once Recovery (
TestRecoveryAtLeastOnce): Hard-kill mid-stream, verify all events are captured across two engine runs. - Truncate Parity (
TestTruncateCDC):TRUNCATEis emitted ascdc.ddlfor bothwal2jsonandpgoutput. - JetStream Dedup (
TestJetStreamDedup): Duplicate messages with the sameevent_idare rejected by JetStream.
- Requires Go 1.23+
- Run
go test ./...for unit tests. - Run
go test -tags=integration -v -timeout=3m ./tests/integration/for integration tests (requires Docker). - Code lives under
internal/; entrypointcmd/cdc-handler/main.go.