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):
task up
-
Run the CDC app:
task run
task runuses the local defaults shown in the old manual command and still honors any existingDATABASE_URL,CDC_SLOT_NAME,CDC_PLUGIN, orCDC_PUBLICATIONSenvironment overrides. -
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.
This project uses Task as the local command runner. Task is expected to be installed locally.
task --list # show available tasks
task up # start the local Docker Compose stack
task run # run the CDC handler with local defaults
task test # run unit tests
task test:integration # run Docker-backed integration tests
task check # run formatting check, go vet, and unit testsOther useful tasks include task fmt, task tidy, task build, task bench, task logs, task ps, task down, and task load-test -- -n 10000 -b.
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 RTTUNSAFE_UNORDERED_ASYNC_PUBLISH(defaultfalse) - restores failed-item-only async batch retries for throughput, but can publish later CDC events before earlier failed events and break per-subject/per-key orderingRAW_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 async publish APIs. - Engine (
internal/engine): orchestrates the pipeline; batches events by size/timeout/commit boundaries; publishes batch items in acked order by default and offers an unsafe unordered async mode for throughput-focused deployments. - 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. - Publish retries preserve CDC order by default: the engine does not publish a later batch item until the current item is acknowledged. Setting
UNSAFE_UNORDERED_ASYNC_PUBLISH=truedisables this protection and can expose out-of-order events to consumers. - 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 spills raw messages to disk and replays them at commit to avoid unbounded memory growth while preserving commit metadata. - Duplicate delivery remains possible after crashes, after publish retries, or after recovery outside the JetStream de-dup window.
The pipeline uses buffered channels and batching for throughput. Publish retries are ordered by default for CDC correctness; UNSAFE_UNORDERED_ASYNC_PUBLISH=true restores the older failed-item-only async retry behavior if a deployment explicitly accepts out-of-order delivery risk.
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 # Async JetStream pending limit; most relevant with unsafe unordered modeSet 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.
task test:integrationTests 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.25.2+
- Run
task testfor unit tests. - Run
task test:integrationfor integration tests (requires Docker). - Run
task checkbefore opening a change. - Code lives under
internal/; entrypointcmd/cdc-handler/main.go.