PGSync is a standalone Rust process that replicates a selected set of tables from a source
PostgreSQL (16, 17, 18) to a target PostgreSQL using native logical replication (pgoutput),
with a transactionally consistent initial snapshot, atomic per-transaction apply, and
checkpoints stored in the target database in the same transaction as the data.
This document records what is reused, what is implemented independently, why each library
was selected, the consistency design, known limitations, and how the beta evolves without a
rewrite. Research inputs: research/pgstream-audit.md,
research/rust-libraries.md,
protocol-reference.md.
| Concern | Decision |
|---|---|
Ordinary SQL connections (catalog queries, COPY, target apply, checkpoints) |
Reused: tokio-postgres 0.7.18 + tokio-postgres-rustls 0.14 (crates.io, pinned). |
| Wire-level message encoding, SCRAM-SHA-256 / MD5 authentication, backend message parsing | Reused: postgres-protocol 0.6.12. |
| TLS | Reused: rustls 0.23 (ring backend), tokio-rustls, rustls-native-certs. |
Replication connection (replication=database startup, IDENTIFY_SYSTEM, CREATE_REPLICATION_SLOT, START_REPLICATION, CopyBoth stream, keepalive/feedback) |
Implemented in pgsync-postgres::replication (~700 lines). The published tokio-postgres cannot open a replication connection; see library assessment. |
pgoutput decoder (proto v1 message set) |
Implemented in pgsync-postgres::pgoutput, unit-tested from byte fixtures. |
Snapshot export / import, streaming COPY |
Implemented on top of tokio-postgres copy_out / copy_in. |
| Schema bootstrap | Reused tool: pg_dump --schema-only for the selected tables; PGSync executes the SQL. Optional schema.mode: existing skips it. |
| Transaction model, checkpoint contract, budgets, backpressure, retry, lifecycle | Implemented in pgsync-core. |
| CLI, config loading, logging | Implemented in pgsync (crates/pgsync-cli) with clap, serde_yaml_ng, tracing. |
No code is copied from pgstream (Go) or any Rust fork. Attribution is therefore not required; the Apache-2.0 licence of this repository applies.
tokio-postgresis the mature async PostgreSQL client for Rust, pinned to a crates.io release. Its lack of replication support is worked around by a small adapter instead of a git fork, so the whole client stack stays on published versions.postgres-protocolalready implements SCRAM and the message grammar; PGSync adds the one missing backend tag (CopyBothResponse) and thereplication=databasestartup parameter.rustlswithringavoids a C toolchain dependency (aws-lc-rsneeds cmake) and gives certificate + hostname verification by default.serde_yaml_ngis the maintained successor of the archivedserde_yaml.- No message broker, no
sqlx, no ORM.
crates/
pgsync-core lifecycle, config + validation, Lsn, errors, event model,
checkpoint contract, trait boundaries, budgets, retry policy
pgsync-postgres replication connection, pgoutput decoder, source preflight,
snapshot reader, PostgreSQL sink + checkpoint store, WAL monitor
pgsync-cli `pgsync` binary: check / bootstrap / run / status / cleanup
Boundaries (traits in pgsync-core::traits):
ReplicationSource— yields orderedSourceEvents (Begin,Op,Commit,Keepalive).SnapshotSource— exports a snapshot, lists tables, streams a table asCOPYbytes.ReplicationSink— opens a target transaction per source transaction, applies ops, commits together with the checkpoint, exposesCheckpointStore.CheckpointStore— load/saveCheckpoint.ReplicationEngine— orchestrates bootstrap, catch-up, streaming, reconnects.
Traits exist only where a second implementation is plausible (PGX / PGXStore sinks, test doubles). Everything else is concrete.
┌───────────────── source PostgreSQL ─────────────────┐
│ replication conn (pgoutput) N copy conns │
└───────────┬─────────────────────────────┬───────────┘
│ CopyData 'w'/'k' │ COPY ... TO STDOUT
▼ ▼
ReplicationReader task SnapshotCopier (bounded parallelism)
(decode, byte-accounted, │ COPY ... FROM STDIN
bounded mpsc, feedback timer) │ per table, one target txn
│ ▼
▼ SourceEvent target PostgreSQL
Applier task ─── BEGIN / DML batches / UPDATE pgsync.checkpoint / COMMIT ──▶
│
▼ watch<AckPosition>
feedback 'r' (flushed = durable position)
Two long-lived tasks communicate over one bounded channel. The reader never buffers a
transaction: each decoded message becomes one SourceEvent and is forwarded immediately.
The applier opens a target transaction on Begin, executes DML in bounded batches, and on
Commit updates the checkpoint row and commits. Memory therefore depends on batch and
channel limits, never on transaction or database size.
- The channel is bounded in items (
resources.channel_capacity) and bytes (resources.max_inflight_bytes, a semaphore the reader must acquire per event). - When the applier is slow the reader blocks on the channel, which stops reading the socket, which stops the walsender (TCP backpressure).
- While blocked the reader still services a feedback timer so
wal_sender_timeoutis never tripped; the reported position is always the last durable one.
- Open the replication connection;
IDENTIFY_SYSTEM(recordsystemid, timeline). CREATE_REPLICATION_SLOT <slot> LOGICAL pgoutput EXPORT_SNAPSHOT→consistent_point,snapshot_name. The connection then stays idle; the exported snapshot is valid until the next command on that connection.- Persist
pgsync.jobin the target with stateSNAPSHOTTING, the system id, slot, publication, andconsistent_point. - Bootstrap the schema (
pg_dump --schema-only) unlessschema.mode: existing. - For each table (bounded parallelism, FK-aware ordering): on a copy connection
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; SET TRANSACTION SNAPSHOT '<name>', thenCOPY (SELECT cols FROM t) TO STDOUTstreamed intoCOPY t (cols) FROM STDINinside a target transaction that also records the table as copied. Both sides stream inbuffer_size_kbchunks. - When all tables are copied, set state
CATCHING_UP,committed_lsn = consistent_point. START_REPLICATION SLOT <slot> LOGICAL <consistent_point> (proto_version '1', publication_names '<pub>')on the same connection.
Per the PostgreSQL documentation, everything visible in the exported snapshot is excluded
from the stream starting at consistent_point, and everything committed later is included.
There is no overlap and no gap, so the copy needs no idempotency tricks.
If the snapshot fails, the job stays in SNAPSHOTTING. pgsync bootstrap --restart-snapshot
drops the slot PGSync created, truncates the copied tables, and starts over. There is no
partial resume in Beta 1.
pgsync.job (one row per job) holds committed_lsn (the end_lsn of the last applied
transaction), commit_lsn, acked_lsn (the highest position ever reported as flushed),
last_xid, last_commit_ts, state, and source identity.
- On
Begin(final_lsn): iffinal_lsn <= job.commit_lsnthe transaction was already applied (redelivery after crash/reconnect); it is consumed and skipped without touching the target. - Otherwise
BEGINon the target, apply ops, and onCommit(commit_lsn, end_lsn):UPDATE pgsync.job SET committed_lsn = end_lsn, commit_lsn = commit_lsn, acked_lsn = end_lsn ...in the same transaction, thenCOMMIT. - Only after the commit returns does the applier publish
end_lsnto the feedback channel. Feedback reportsflushed = applied = acked_lsn. WAL is never acknowledged before the data is durable in the target. Keepalive(wal_end)events travel through the same ordered channel. The applier handles them only between transactions: it recordsacked_lsn = max(acked_lsn, wal_end)in the target (autocommit) and then publishes it. This lets the slot advance while the publication is idle (source writes to unpublished tables, other databases) without retaining WAL, and keeps the invariantslot.confirmed_flush_lsn <= job.acked_lsn.- On start,
runchecks: slot exists, plugin ispgoutput,wal_statusnotlost,systemidmatches, andconfirmed_flush_lsn <= job.acked_lsn. Any violation →RESYNC_REQUIRED(orPREFLIGHT), never a silent skip. PGSync requests streaming fromjob.committed_lsn; the server starts atmax(requested, confirmed_flush_lsn).
Truncate is applied as TRUNCATE ... [CASCADE] [RESTART IDENTITY] (tables in the
publication only). Message events are ignored only if non-transactional and are logged;
transactional Message payloads are not data changes. Any other message tag (streaming,
two-phase, unknown) stops the job with unsupported and the transaction is not acknowledged.
An UPDATE/DELETE without a usable key, a relation that does not exist on the target, a column
mismatch, or a server error during apply stops the job (ERROR state) with the transaction
rolled back. A relation outside replication.tables (publication changed after bootstrap)
stops the job with RESYNC_REQUIRED before anything is applied. Parameters are cast to the
target column's base type ($1::character varying, not ::character varying(80)): the
column's assignment rules then raise for values that do not fit instead of truncating or
rounding them as an explicit modified-type cast would. No automatic skipping exists anywhere.
Configurable and validated (resources section):
| Setting | Default | Effect |
|---|---|---|
memory_budget_mb |
128 | Soft cap; max_inflight_bytes + batch bytes + buffers must fit. |
buffer_size_kb |
256 | COPY chunk size and socket read buffer. |
max_inflight_bytes |
16 MiB | Byte semaphore on the reader→applier channel. |
channel_capacity |
4096 | Item bound on the channel. |
max_batch_rows / max_batch_bytes |
500 / 4 MiB | Statements pipelined per flush inside a target transaction. |
max_message_bytes |
derived (≤ 32 MiB) | Largest single replication message; enforced before buffering, larger ones stop the job (resource, nothing acknowledged). |
max_parallel_copies |
2 | Concurrent table copies during the snapshot. |
connection_timeout |
10s | TCP + startup timeout. |
retry_backoff |
1s → 30s, ×2 | Reconnect policy for transient errors. |
snapshot_timeout |
6h | Abort the snapshot if it runs longer. |
max_replication_lag_bytes |
1 GiB | Alarm threshold on wal_end - applied. |
wal_retention_alarm_bytes |
1 GiB | Alarm threshold on pg_current_wal_lsn - restart_lsn. |
Measured values (RSS, CPU, throughput, lag) live in docs/benchmarks.md once recorded.
runnever creates publications or slots.bootstrapcreates the slot named in the config (an explicit, operator-approved action) and records that PGSync owns it — the record is written before the slot exists, and the slot is dropped again if that bootstrap's snapshot does not complete. Publication creation requiressafety.auto_create_publication: trueand is refused for hosts that are not loopback unlesssafety.allow_remote_publication_create: true.- No DDL on the source, no event triggers, no extensions, no DML. The replication role needs
REPLICATION,LOGIN, andSELECTon the published tables only. - TLS with certificate verification is required for non-loopback hosts
(
safety.require_tls: true).safety.insecure_skip_tls_verifyis accepted only for loopback hosts. - Passwords are read from the URL in an environment variable and never logged; row values are never logged.
- The running process writes a heartbeat row (
pgsync.job_runtime) to the target everysafety.heartbeat_interval;statusreads it (seedocs/operations.md). checkand the runtime monitor report slot state, lag, retained WAL,wal_status, andinvalidation_reason. Exceedingwal_retention_alarm_bytesraises an alarm and, withsafety.wal_retention_action: stop, stops the job. Stopping never advances the slot.- Stopping a consumer does not release WAL.
pgsync cleanupdrops the slot PGSync owns after explicit confirmation (--yes), and the job must then be re-bootstrapped.
- pgoutput protocol version 1 only: no in-progress transaction streaming, no two-phase
commit, no
originfiltering. Large transactions are decoded by the server at commit (the server spills to disk underlogical_decoding_work_mem); PGSync streams them straight into one target transaction, so PGSync memory stays bounded but the target holds a long transaction. - DDL is not replicated. Any schema change on a published table that alters the column set
causes a mismatch error at apply time (
ERROR, nothing skipped). Operator applies the DDL to the target and resumes. - Sequences are not replicated (identity values are, via
OVERRIDING SYSTEM VALUE). - Partitioned tables, foreign tables, views, large objects, row filters, and column lists are rejected in preflight.
- Tables need a primary key or a unique replica identity index;
REPLICA IDENTITY FULLis accepted;NOTHINGis rejected. - Snapshot restart is all-or-nothing; the exported-snapshot transaction holds the source
xmin for the duration of the copy (documented operational risk;
snapshot_timeoutbounds it). - Single node, single job per process. Run several processes for several jobs.
- Text-format transfer only (
binaryoption unused). Every value is cast server-side with the target column's declared type.
- Beta 2 masking: insert a
Transformstage between decoder and sink operating on theOpmodel; the snapshot copier switches from rawCOPYpassthrough to the same row model when a policy applies to a table. - Large transactions / spill: the applier's batch buffer can be backed by a bounded disk spool without touching the reader or the protocol layer.
- Streaming protocol v2+: the decoder gains
Stream*variants; the applier gains a per-xid staging area. The channel and checkpoint contracts stay the same. - Parallel copy by ctid ranges:
SnapshotSource::copy_tabletakes a range; the copier fans out ranges under the same semaphore. - DDL (strict mode): compare the
Relationmessage to the cached target columns and pause withRESYNC_REQUIRED/PAUSEDinstead of failing at apply. - PGX / PGXStore sinks: implement
ReplicationSink+CheckpointStore. - Metrics: the
Metricsstruct already counts bytes/rows/lag; a Prometheus exporter is an additive feature.