Getting started
Build your first rustcdc pipeline: provision a replication slot, stream changes from PostgreSQL, and acknowledge them durably.
On this page
By the end of this page you will have a Rust binary that streams row changes out of a PostgreSQL database, applies them to a sink, and records durable progress so a restart resumes where it left off rather than replaying from the beginning.
The example uses PostgreSQL because it needs the least server-side setup. MySQL, MariaDB and SQL Server differ only in the source config and the server prerequisites — see the configuration reference.
Prerequisites
- Rust 1.97 or newer
- A PostgreSQL 10+ server you can configure, with
wal_level = logical - A role with the
REPLICATIONattribute, and a direct connection to the server — the default WAL transport runsSTART_REPLICATIONand a pooler in transaction-pooling mode cannot carry it.ALTER ROLE <user> REPLICATION;grants it (rds_replicationon RDS). If your environment cannot provide either, seewal_transportfor the SQL-based fallback. - Docker, if you want to run the connector-backed test suites
A throwaway server for this walkthrough:
docker run --rm -d --name rustcdc-pg -p 5432:5432 \
-e POSTGRES_PASSWORD=postgres \
postgres:16 -c wal_level=logical
The postgres superuser already has REPLICATION. Note this container has TLS off, so the
walkthrough below sets TransportConfig::plaintext() explicitly — a TLS transport against a
server without TLS fails rather than downgrading.
1. Add the dependency
cargo add rustcdc --features postgres
cargo add tokio --features full2. Prepare the source database
CDC on PostgreSQL needs two server-side objects: a publication naming the tables to capture, and a replication slot holding the WAL position. Create both before the first run:
CREATE TABLE users (id bigserial PRIMARY KEY, email text NOT NULL, name text);
CREATE PUBLICATION rustcdc_publication FOR TABLE users;
SELECT pg_create_logical_replication_slot('rustcdc_slot', 'pgoutput');
rustcdc will not create the slot for you unless you ask it to. A slot that vanished
mid-life is a data-loss event, and silently recreating it restarts capture at the current
WAL position — everything written in between is gone with no error raised anywhere. Provision
it out of band, or set create_replication_slot_if_missing = true for first-time setup only.
A slot holds WAL until it is consumed. If your pipeline stops for long enough, the server accumulates WAL and eventually runs out of disk. Monitor slot lag from day one — see the runbook.
3. Configure the runtime
RuntimeConfig binds four things: which source to read, where durable progress is recorded,
where schema history lives, and the runtime options.
use rustcdc::{
checkpoint::FileCheckpoint, schema_history::FileSchemaHistory,
PostgresSourceConfig, RuntimeConfig, RuntimeSourceConfig,
};
# async fn build() -> rustcdc::Result<()> {
let source = PostgresSourceConfig {
host: "localhost".into(),
port: 5432,
user: "postgres".into(),
password: "postgres".into(),
database: "postgres".into(),
replication_slot_name: "rustcdc_slot".into(),
publication_name: "rustcdc_publication".into(),
// The throwaway container above has TLS off. Against a real server, drop this line and
// keep the default TLS transport — which means `sslmode=require`, so a server with
// `ssl = off` fails the connection rather than quietly sending everything in the clear.
transport: rustcdc::TransportConfig::plaintext(),
..PostgresSourceConfig::default()
};
let config = RuntimeConfig::new(
RuntimeSourceConfig::Postgres(source),
FileCheckpoint::new("/var/lib/rustcdc/checkpoints"),
FileSchemaHistory::new("/var/lib/rustcdc/schema-history.json").await?,
)
.with_max_buffer_size(1_000);
# let _ = config;
# Ok(())
# }
InMemoryCheckpoint and InMemorySchemaHistory exist and are convenient in tests, but they
lose everything on restart — which means a restart re-reads from the beginning, or from
nothing at all. Use the file-backed pair, or your own durable backend, for anything you care
about.
Create the checkpoint directory yourself.
FileCheckpointrefuses to run against a directory that does not exist rather than creating one. A directory it created would be an empty directory, and an empty checkpoint means "start from the log head" — which silently skips everything written before now. A typo in the path would look exactly like a first run.std::fs::create_dir_all(...)at startup makes that a deliberate choice.
Both file-backed stores take an exclusive owner lease on their path, so a second writable
instance against the same directory is refused instead of silently interleaving writes. Both
also run every filesystem call — including fsync — on a blocking worker, so a commit never
holds one of your executor threads.
4. Run the loop
The delivery contract is: poll a batch, apply it, then acknowledge it. The acknowledgement is a separate step on purpose — the durable position must not advance past what your sink has actually committed, or a crash in between silently skips those rows.
use rustcdc::{CdcRuntime, Event, RuntimeConfig};
# fn apply(_event: &Event) -> rustcdc::Result<()> { Ok(()) }
async fn run(config: RuntimeConfig) -> rustcdc::Result<()> {
let mut runtime = CdcRuntime::new(config)?;
runtime.start().await?;
loop {
let batch = runtime.poll_event_batch().await?;
for event in batch.events() {
apply(event)?; // your sink
}
// flush_sink().await?; // make the writes durable FIRST
runtime.commit_ack(batch.ack_mode()).await?;
}
}
batch.ack_mode() returns an AckToken that commit_ack consumes. There is no other way to
advance the checkpoint, so a pipeline that forgets to acknowledge stalls visibly instead of
losing data quietly. An empty batch yields AckMode::NotRequired and commit_ack is a no-op,
so the loop above is correct as written. Each token may be committed once — a second commit
of the same token is refused rather than advancing the checkpoint over events you never saw.
Order matters: flush the sink before acknowledging. Acknowledge first and a crash in the gap drops every event in the batch.
Or let the runtime drive it
If your sink implements SinkAdapter, that whole loop — in the right order — is one call:
use rustcdc::{CdcRuntime, RuntimeConfig, sink::StdoutSink};
use rustcdc::CancellationToken;
async fn run(config: RuntimeConfig, shutdown: CancellationToken) -> rustcdc::Result<()> {
let mut runtime = CdcRuntime::new(config)?;
runtime.register_sink(StdoutSink::new());
runtime.start().await?;
runtime.run_to_completion(shutdown).await?;
runtime.stop().await?; // closes the sink too
Ok(())
}
Write the loop yourself when the sink write has to be coordinated with something the runtime
cannot see — your own transaction, a two-phase commit, a fan-out with per-branch error
handling. Otherwise run_to_completion is the same thing with the ordering already right.
5. Apply events correctly
apply above is where the one genuinely subtle part of CDC lives. Not every event carries a
complete row, and writing a partial one as if it were complete overwrites untouched columns
with NULL. Match on row_write() rather than reaching for event.after:
use rustcdc::RowWrite;
# use rustcdc::Event;
# struct Sink;
# impl Sink {
# fn replace(&self, _key: Option<serde_json::Value>, _row: &serde_json::Value) {}
# fn update_only(&self, _key: serde_json::Value, _cols: &serde_json::Value) {}
# fn delete(&self, _key: serde_json::Value) {}
# fn truncate(&self) {}
# }
# fn example(event: &Event, sink: &Sink) {
match event.row_write() {
// The source supplied the whole row: replace it.
RowWrite::Replace { key, row } => sink.replace(key, row),
// Partial row: SET only these columns, leave the rest alone.
RowWrite::Merge { key, columns, .. } => sink.update_only(key, columns),
RowWrite::Delete { key } => sink.delete(key),
RowWrite::Truncate => sink.truncate(),
// DDL, or an event with no addressable row.
RowWrite::None { .. } => {}
_ => {}
}
# }
Full treatment, including which fields tell you why a payload was partial, is in Partial payloads.
6. Backfill existing rows
The steps so far capture changes made after the slot was created. To also load rows that already exist, run a snapshot. Prefer the incremental one:
use rustcdc::source::IncrementalSnapshotConfig;
# use rustcdc::{checkpoint::InMemoryCheckpoint, schema_history::InMemorySchemaHistory,
# RuntimeConfig, RuntimeSourceConfig};
# let config = RuntimeConfig::new(
# RuntimeSourceConfig::Disabled,
# InMemoryCheckpoint::default(),
# InMemorySchemaHistory::default(),
# );
let config = config.with_incremental_snapshot(
IncrementalSnapshotConfig::new(vec!["public.users".to_string()]),
);
# let _ = config;
This is the DBLog watermark algorithm: it interleaves keyset-paginated chunk reads with the live replication stream rather than holding one long transaction, so the stream never pauses and no transaction ID backlog accumulates. Chunk cursors are persisted inside the checkpoint offset — the same atomic, fsynced, checksummed write as the stream position — so a restart mid-snapshot resumes at the chunk boundary instead of starting the table over.
RuntimeConfig::with_snapshot_tables is the older blocking path, kept for the case where you
want one consistent read and do not care that the stream waits for it. Set one or the other,
never both.
7. Know whether it is running
RuntimeState cannot distinguish a connector streaming from a quiet database from one hung on
a dead socket — both report Running. Ask for the health verdict instead:
# use rustcdc::{CdcRuntime, HealthVerdict};
# async fn example(runtime: &CdcRuntime) {
let snapshot = runtime.admin_snapshot();
if snapshot.health.is_alertable() {
// `Stalled { reason }` — the reason names both the condition and the remedy.
eprintln!("cdc stalled: {:?}", snapshot.health);
}
# }
The same verdict is exported as rustcdc_runtime_health{verdict="stalled"} for Prometheus.
Alert on exactly that; see health verdict
for why Idle is not an alert condition.
Delivery guarantees
rustcdc is at-least-once. After a crash, a restart, or a partial ack window, you will see duplicates. Ordering is preserved within committed ack prefixes. Deduplicate sink-side on a stable key — source, table, primary key, source offset — and test that dedup before you rely on it.
There is no exactly-once mode, and no configuration that produces one.
Feature profiles
The default build is postgres + tls. Everything else is additive:
cargo build # postgres + tls
cargo build --features mysql # or mariadb, sqlserver
cargo build --features wasm # WASM transform sandbox (~15 MB overhead)
cargo build --no-default-features # foundation only, no connector
cargo build --all-features
Relational connector features enable tls transitively. Private-CA and mutual-TLS
deployments need no extra feature — configure TransportConfig::tls_with_ca_cert_path(...)
or TransportConfig::mtls(...) directly. TransportConfig::tls_insecure_skip_verify() exists
for local testing and air-gapped environments where CA distribution is impractical; it
disables certificate and hostname verification and does not belong in production.
Where to go next
- Architecture — the commit barrier and checkpoint model, and why the ack is a separate step
- API guide — transforms, codecs, schema registries, custom sinks
- Configuration reference — every option and the failure it prevents, including the MySQL and SQL Server server-side prerequisites
- Reliability testing — replay a captured stream inside your own test suite
- Runbook — what to alert on before this goes to production