Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design decisions

Why pg2osync is built the way it is. Code that contradicts a decision here is a bug: change this document first, then the code.

Change capture

Read the replication log, not triggers or timestamps. Logical replication (PostgreSQL) and the binlog (MySQL) see every change including deletes, add no write overhead to the source, and are available on managed databases. Triggers tax every transaction; timestamp polling cannot see deletes at all — which is why poll mode exists only as a documented fallback.

pgoutput, not a plugin. PostgreSQL's built-in output plugin needs nothing installed server-side, unlike wal2json or decoderbufs.

Protocol code is ours

Transport is a dependency, decoding is not. PostgreSQL replication uses the pgwire-replication crate purely as a transport that hands us raw XLogData frames; tokio-postgres does not support replication. For MySQL the whole wire protocol is implemented in-house, because the only Rust binlog client is unmaintained, blocking, and cannot handle split packets.

Everything above the transport — decoding, slot and publication management, transaction buffering, checkpointing — is ours, behind our own boundary. No CDC framework, and the transport stays swappable.

Boundaries

core depends on nothing. It holds ChangeEvent, the Sink trait, checkpoint types and the error taxonomy. Everything else depends on it and not on each other, so the compiler enforces the architecture instead of discipline.

The Sink trait lives in core, not in the sink crate. The engine must never import a sink implementation; the contract has to sit next to the shared types for that to hold.

The engine is source-agnostic. It knows ChangeEvent and an opaque u64 position token. Whether that token is a WAL LSN or a packed binlog coordinate is the source's business, and the binary injects a closure that renders it back to text for the checkpoint. This is what made adding MySQL a small change rather than a fork.

New targets implement Sink. No match sink_kind anywhere in the engine.

Correctness

At-least-once with idempotent writes. Exactly-once across two systems without a shared transaction is a fiction. Every document's _id is its row's primary key, so a replay overwrites to the same value. Duplicates are therefore invisible, which is what makes the snapshot-then-stream overlap safe.

The id is configurable; the default is still the primary key. (#62.) [sync.x] id = "tenant-{tenant_id}-{id}" renders an id from literals and placeholders; a table that configures nothing is filed under its key byte-for-byte as before, so no existing index needs a rebuild. Three rules make that safe, and they are the reason the feature was slow to arrive: identity renders from the row's raw values — before projections and before transforms, because identity is a property of the row, not of the projected document; exactly one place mints ids (materialize and completion_key in the engine), so the stream, the load, the re-snapshot and poll can never disagree; and a NULL in any column an id names halts the pipeline rather than inventing a name. An id that references columns outside the key additionally needs the row's before-image to delete and move its documents, which is why run refuses such a table unless PostgreSQL reports REPLICA IDENTITY FULL — on MySQL binlog_row_image = FULL already guarantees it.

One row can fan out into many documents. (fan_out, #62.) A JSON-array column can be indexed as one document per element — each the parent-minus-array document merged with the element — so a search can match a single tag without the whole parent. The documents of one row then share a key, and identity has to say something about that too, which is why the element id is a second template rendered from the merged document. Deletes and update-diffs are computed from the row's before-image and issued as ordinary per-document versioned writes: delete_by_query was rejected because it cannot carry an external version and would have to act as a barrier, breaking write_concurrency. The Sink trait is unchanged. Reconcile and re-snapshot refuse fanned tables for now — both page by key, and one row is no longer one document.

Parent-child can be a join field, not only an embedded array. (join, #60.) An embedded array is one document and one write, and the right answer nearly always; a join field is for children that are many, change far more often than the parent, or must be searched in their own right — re-fetching a 50,000-row collection because one child changed is the cost it avoids. What it costs: the two tables share an index and a shard, so routing rides on every operation that touches a child — bulk actions, the _mget behind TOAST completion, reconcile's deletes, a quarantined document's replay — and a parent delete cascades through a search, refreshed first, rather than an id list the engine could have built, because the engine does not know which children the target holds. A join child needs REPLICA IDENTITY FULL unless its parent column is part of its key: routing comes from the same place identity does. Exactly one parent, one shared field, no fan-out on a join table, and a parent id naming anything outside its key is refused at config load — the child holds one column and computes the parent's id from it alone. Ids must be unique across the shared index, which config cannot see, and TRUNCATE on either table clears its relation only — the join field is what tells the halves apart. A table that is both an embedded child and a section of its own is warned about at startup rather than refused — a load-once index is a legitimate thing to want — because the runner reads its rows only as a re-fetch of the owner, so its own index receives the initial load and no streamed change.

A column can route a document; the rule is the id rule. (routing, #109.) Shard co-location arrived as a by-product of join, where a child has to live on its parent's shard. It is worth having on its own: hundreds of small tenants in one index, each query reading one shard. An index per tenant is the alternative, and it is the wrong one at that shape — every index costs shards, and shards cost memory whether they hold ten documents or ten million. A bare column name, not a template: a routing value has no grammar to check, nothing downstream parses it, and a composite routing key is a use case nobody has. The value is read from the raw row, before projection and transforms, because a projection must not be able to move a document to another shard — and NULL, missing or empty halts, since the target rejects an empty routing and a silent fallback to the default shard would hide the document from every routed query. A routing column can change, which makes it identity's twin: the document is written under the new routing and deleted under the old, through the same comparison a changed id or a changed index template goes through, and a non-key routing column therefore needs REPLICA IDENTITY FULL for the same reason a non-key id does. Refused together with join, which already owns the child's shard; refused on Meilisearch, which ignores routing. reconcile is not refused: it never derives a routing, it reads each hit's _routing, so the only thing it cannot see is a duplicate left under a stale routing — and that document's row is still there, which is precisely what reconcile does not collect.

Several tables can feed one index once each declares its identity. (#61.) An index built before pg2osync is usually a union of several tables, and what the old refusal protected against was never the union: it was _id inherited from each table's own key, where two tables with a row 1 become one document by accident. An explicit id on every section sharing the index is the declaration that removes the accident — user-{id} on both sections still collides, but now it is something the operator wrote down, and nothing checks the values because nothing can see them. Two things cannot be recovered: reconcile pages an index by one table's key column, so every other table's document would look like an orphan, and it refuses; and TRUNCATE clears an index, which would wipe tables the source never truncated. It is not halted on — a halt would replay the same event from the slot at every restart with nothing the operator could change — but skipped, logged and counted, and the truncated table's documents stay until cleared by hand. A join pair escapes that: its relation name is exactly the set of documents to clear.

A row can choose its index; the rule is the id rule. (#69.) index = "events-{tenant}" renders the index from a column, and a name derived from a column is the same problem as an id derived from one: the column can change, and the document is then in the old index. So it is the same template, rendered from the same raw row, through the same ladder — the row, else the before-image, else the bare key, else halt — and (old index, old id) != (new index, new id) is the move id already handles. The index is created on demand, at the first document that needs it, rather than ahead of time: pre-creating means enumerating a column's values, which nothing can do without querying the source for a search concern, and recording the glob and creating on first use keeps the mapping the operator configured. reconcile is refused because it pages one index by its key column, and a templated table's documents are spread over every index the template renders — there is no single index to page. And a template must carry a literal, because TRUNCATE clears what the template claims, and a claim of * is a claim on the cluster.

Vectors are the target's to compute. (pipeline, #68.) Semantic search needs an embedding per document, and the obvious design — an embedding client inside pg2osync — puts a network hop and a rate limit inside every batch, adds a second failure mode that backpressure would have to respect on top of the target's, and makes a model choice that is not this project's to make. An ingest pipeline gets the same result for one config field: the section names the pipeline, every document it writes carries "pipeline": "<name>" on its bulk action, and the target — which already owns the model, the plugin and the knn_vector field — computes the vector on the way in. The document still travels the one write path, so a quarantined document replayed later goes through the pipeline again instead of landing without its vector. It is per section, not per index, because the pipeline rides on the operation rather than on the index: two tables feeding one index can embed different columns. A delete carries none, since an ingest pipeline runs on index actions only. Meilisearch has no ingest pipelines, and refuses the option at config load rather than ignoring it.

A table without a key syncs as insert-only, under a content hash. (append_only, #70.) The key requirement was right for a mutable row — an update or a delete has to find the document the row already owns, and only a key says which — and wrong for an event log or an audit trail, which never updates or deletes and was refused for a case that never arises. Declared append_only, a table files each row under a sha256 of its raw values as canonical JSON. Not the source position: the initial load has no per-row position, and the same row has to hash the same on every path — COPY, WAL, poll, MySQL load and binlog — which key-sorted JSON of the raw row gives and a position never could. Two identical rows are therefore one document, and that is the at-least-once guarantee restated: a replayed row is the same document, and a duplicate the source itself cannot tell apart is not one the index should invent a difference for. An UPDATE or DELETE that arrives anyway halts the pipeline, naming the table, rather than being missed: nothing can say which document it addresses. init writes the flag for a keyless table instead of refusing it, so the smallest config still runs and the declaration sits where the operator will read it.

A column can be renamed in the target; the rename is the last step. (fields, #66.) The source name is the one namespace the operator already knows, and the one every other check — projection, transform, id, primary_key, soft_delete, poll_column — is written against, so it stays the name those options use. Renaming last, after identity, projection and transforms, means none of them has to know a rename exists. The one place the target name leaks back in is TOAST completion, which reads the stored document and so translates the column through the same map. A rename onto a name another surviving column already has is refused wherever it can be seen — at config load and by validate against the live catalogue; at write time the renamed value wins.

A document can carry fields that come from no column. (constants, #67.) The alternative is a generated column: DDL on someone else's production table for a search concern, the same objection this project raises against event triggers and signal tables. Constants only, no expressions: the README promises no transformation language, and a language is a parser, an evaluator and a null semantics to own forever, where an entity tag or a {schema}.{table} origin marker is the whole of what was asked. The two placeholders render once at startup, so the engine inserts literal JSON and stays source-agnostic. Constants are added last because columns would otherwise strip a field that is not a column; written last, a constant wins a collision — so every collision the configuration can see is refused at load, and the one only the catalogue can see is refused by validate.

Transforms are fixed, named reshapes, not a language. (#63.) Six ops — hash, redact, json, split, number, date — and the README's promise holds: a closed set, one literal parameter at most, no chaining, nothing evaluated at run time, for the same reason constants carry no expressions. A value an op cannot convert is indexed as it arrived and counted, neither halted on nor nulled: halting turns a data-quality problem into an availability problem, and a NULL is silent loss. The target's mapping is the arbiter of what a field holds, quarantine already catches what it refuses, and pg2osync_transform_unconverted_total plus one warning per (table, column) keeps the rest visible. A value already in the target shape is not a failure, so every op is idempotent under at-least-once replay. split cannot feed fan_out, because fan-out reads the raw row — identity is a property of the row, as the id paragraph says. chrono, already in the build through the OpenSearch client, parses the dates: a strptime calendar is not protocol code.

A row filter is SQL the database also runs, evaluated once more in the engine. (where, #64.) A subset, because a stream has no query to push a predicate into: the engine evaluates it on every WAL, binlog and polled row, and everything it accepts is valid on both sources, so the load pushes it down unchanged. Three-valued logic is SQL's — NULL is unknown, only TRUE matches — so the two evaluations agree. Strings compare byte-wise, exact for equality and for ASCII and ISO 8601 order, so created_at >= '2024-01-01' holds against a textual timestamp; a number against a string holding one compares numerically, because numeric/DECIMAL arrive as strings on purpose. A row that leaves the filter is deleted the way a moved id is, the before-image naming a fanned row's element documents. An insert that never matched still costs one idempotent not-found delete; the alternative is remembering what was written. Poll mode does not push the predicate down: a row that left the filter has to keep arriving to become the delete it now is. It travels the ordinary op path, so the guard against a load resurrecting a removed document holds. A filter selects rows and computes no values: no transformation language.

Never acknowledge a position before it is durable. The value reported to the source is clamped to the persisted checkpoint. Acknowledging further lets the database recycle history for rows that are not indexed yet — the classic way a CDC pipeline loses data on crash-restart.

Buffer until commit. Rows are held until their commit boundary so a partial transaction is never presented as complete. A transaction that exceeds the batch limits is split, which is a deliberate, documented exception: an unbounded buffer is a worse failure than briefly observable partial state.

Writes and truncates share one ordered channel. Executing a truncate directly would let writes still queued ahead of it land afterwards and resurrect rows the source has already dropped. The target is also refreshed before the truncate, because delete_by_query only removes documents a search can see.

Stop on permanent rejection, unless told to quarantine. A document the target will never accept halts the pipeline by default. Skipping it would be silent data loss, and every later batch would widen the divergence. Halting means making no progress, not exiting: the attempt fails and is retried, so the position never passes the document and a mapping fix is picked up without a restart.

The cost of that default is that one malformed row stops replication for every table until someone edits a mapping, so on_permanent_rejection = "quarantine" records the refused document — with its position and the operation itself, in a hidden .pg2osync_rejects index — and carries on. What it must never become is Airbyte's Elasticsearch destination, which dead-letters a document while the offset advances anyway: the rule is that a position may be acknowledged only once the document behind it was written or durably recorded as refused, which is why quarantining happens before the acknowledgement and a failure to quarantine halts.

Quarantining a document is a partial transaction, and that is the trade. "No partial transactions" is otherwise an invariant here; skipping one row while its siblings land breaks it for that transaction. It is why the option is off by default and why it is named after what it does rather than after being resilient.

Quarantine is bounded. max_rejects (default 100) counts what the store actually holds, read at startup rather than kept in memory, so a crash loop cannot hand the budget back. One bad row is worth carrying on past; a mapping that refuses a whole table is not, and the pipeline halts naming the limit. Nothing is lost either way: the batch that reaches the limit has its refusals recorded first, and a batch arriving once the limit is already spent is left unacknowledged, so the source sends it again when the mapping is fixed.

A rejected document is replayed through the ordinary write path. pg2osync rejects --replay submits it again with its original position as its version, so a row the source has since changed loses to the newer value by the same rule that orders everything else, and the record is cleared only once the target has taken it.

A checkpoint is bound to its stream. It records the source kind, the slot or server_id, and the publication. A checkpoint from another stream is rejected rather than used to resume into an unrelated position space.

Initial load

No exported snapshot; short transactions and replay instead. The obvious design exports one transaction snapshot and reads every chunk from it, which keeps a read view open for the whole load: VACUUM cannot then clean anything that died after it started, and on MySQL a long read view makes purge block rather than lag. What makes our load safe is not snapshot consistency but that streaming resumes from a position that predates every chunk: on PostgreSQL because the slot exists before the first range and nothing advances it during the load, on MySQL because the binlog coordinate is read before the first chunk. Anything a chunk missed or read stale is still in the log and replays onto an idempotent write. Two conditions that argument rests on: writes are whole-document upserts keyed by the row's primary key, and an update whose unchanged TOASTed columns arrive as markers is completed from the stored document — without that, a replayed update would erase a value a range read correctly. A completed value is copied as it is stored, already transformed, and is not put through the transforms again: a hash of a hash would drift from what a fresh write of the same row produces.

The table is cut the way the storage engine reads it. PostgreSQL's heap order says nothing about the key, so ranges are sampled in advance and read unordered: ORDER BY key LIMIT n forbids a bitmap heap scan, and index order costs random heap access on any key that is not physically correlated. InnoDB's clustered index is the table, so MySQL does the opposite — WHERE key > cursor ORDER BY key LIMIT n walks the rows themselves, nothing is sampled, and each chunk's last key is the next chunk's cursor. That also makes the MySQL resume point exact, where PostgreSQL has to store its boundaries because a second sample would cut the table elsewhere.

The cursor comparison is never a row constructor. (a, b) > (x, y) says exactly what the expanded (a > x) OR (a = x AND b > y) says, and MySQL plans it as type: index with no usable key while the expansion plans as type: range. Measured on a composite key, for 1000 rows returned: 1000 rows read expanded, 2000 read as a row constructor, restarting at the head of the index every chunk — so the multiplier grows with how far the cursor has travelled. MySQL bug #111952, closed as not-a-bug with a worklog in its place. No IS NOT NULL guard accompanies the comparison even though MySQL sorts NULLs first: a PRIMARY KEY column is NOT NULL whether it was declared so or not.

Every document carries the position it became visible at, as a target document version. Streamed rows carry their commit position, copied rows the position read before their range. A copied row that is already stale therefore loses to the streamed change at the target, whichever order the two arrive in, and a version conflict is success rather than a rejection. It is deliberately separate from the checkpoint token: a copied row needs a version and must never advance a position. Poll mode, which has no position at all, writes no version and relies on ordering alone.

MySQL versions by its binlog coordinate, not by a GTID. (file index << 32) | offset is monotonic across rotation and was already the ordering token, and a transaction's events are written to the binlog as one group at commit — so no position inside a group can predate a coordinate a reader saw earlier, which is what makes an event's own offset a sound version. A GTID could not be one: it is source_uuid:N with N restarting at 1 for each UUID, so a GTID set has no order as an integer. MariaDB is the exception that proves the rule — its sequence number is one monotonic 64-bit counter per replication domain — and one version scheme for both servers is worth more than exploiting that.

The version carries a generation, so the coordinate space can change. The version is base + ((file index << 32) | offset), with base persisted beside the checkpoint. That space is per server and per binlog history, and a failover moves to a different one: the new coordinate may be lower than what the target already holds, and external_gte would then refuse every write and leave the index quietly stale. So when the source is behind the checkpoint and there is a GTID position to resume the stream from, a new generation opens at stored token + 2^40 instead, and every later version outranks everything written under the old numbering.

The margin has to exceed the highest version written but not yet acknowledged. That gap is bounded by how much binlog one unacknowledged transaction can span — a few file rotations, so a few multiples of 2^32. 2^40 is a thousand rotations of headroom and still leaves room for millions of generations in a u64.

Without a GTID position the refusal stands: a coordinate behind the checkpoint then means we can neither continue the stream nor trust the numbering, and reloading into silence is the one outcome worth refusing.

GTID is the resume position, never the version. Binlog file names and offsets are per server, which is why MySQL's own GTID_ONLY exists to stop persisting them; a checkpoint holding only a coordinate cannot resume anywhere but the server it came from. So the checkpoint carries a GTID position as well, inside the source's own position text — core says that text is the source's business and nothing else parses it.

The set is accumulated from the stream, one GTID per commit, and never read from @@GLOBAL.gtid_executed: that describes what the server holds, including transactions we have not consumed, so resuming from it would skip data.

The two servers share no mechanism for asking. MySQL has COM_BINLOG_DUMP_GTID carrying the set in binary; MariaDB has no such command at all and switches into GTID mode on the presence of @slave_connect_state alone. Both are implemented rather than one being emulated, because the difference is in the server and neither is a dialect of the other.

Anything that would leave the set incomplete refuses to use it rather than checkpointing a lie: a tagged GTID event, which MySQL 8.4 gives a type of its own, and an anonymous transaction under gtid_mode = ON_PERMISSIVE, which has no GTID to record at all.

A write the stream has already removed is dropped, not offered. Versioning alone does not make the overlap safe, and this is the one place it does not. A versioned delete leaves a tombstone carrying the delete's version, the target keeps that tombstone only for index.gc_deletes — 60s by default — and once it is gone external_gte accepts any version, including one below the delete's. So a copied row starved behind a busy stream for longer than that would put the document back. Measured against a real target at gc_deletes = 1s: the same write is refused with a 409 immediately and accepted two seconds later. TRUNCATE has the same shape, since it clears an index with versioned deletes.

The engine therefore remembers what the stream removed and drops a copied row that is older than the removal, rather than asking the target for a comparison it cannot make. This is the move DBLog makes for the same problem — it buffers a chunk and removes every key the log touched between two watermarks — except that watermarks exist there to substitute for versions, and versions already order everything else here, so only the case they cannot express needs it.

What bounds the state is the load's own protocol: a chunk's rows, then a mark, then a wait for that mark to be written. When a mark arrives, every row of its chunk has been handed over and none of the next chunk can exist yet, so the window closes and the memory is one chunk's worth of deletes rather than the load's. A loader that sent the next chunk before its mark was confirmed would reopen the hole silently, which is why the ordering is stated in both places.

Raising index.gc_deletes for the duration of the load was the alternative and is rejected: it moves the window instead of closing it, costs target heap for every tombstone it holds, and an interrupted load would leave the setting raised the way one already leaves refresh_interval at -1.

The load runs beside the stream, not before it. Loading first means nothing acknowledges a position for the load's whole duration, so retained WAL grows monotonically and a large enough table invalidates the slot — which forces the full reload the load was trying to finish. Alternating copy and catch-up phases does not fix it either: on PostgreSQL a paused consumer freezes restart_lsn whether it detaches or merely stops reading, so the only thing that releases WAL is continuing to consume it. Document versioning is what makes the overlap safe; change events take strict priority over copy rows, and the copy pauses between ranges while the slot's wal_status is anything but reserved.

On MySQL the load overlaps the stream but never pauses for it. The hazard runs the other way there: a slot retains WAL until it is consumed, so a slow consumer is what invalidates it, while MySQL purges binlogs on its own time and space policy and ignores consumers entirely. Nothing accumulates because of us, and what can go wrong — the file we still need being purged — is made likelier by holding the load back, not less. So there is no wal_status analogue to watch and deliberately no pause.

Load progress is recorded per range, in the target, behind a durability barrier. The order is strict: rows, then a mark the sink reports once they are written, then the progress document. A crash can therefore lose forward progress and redo a range, which idempotent writes make free, but can never claim a range that was not written. What the progress says depends on how the table was cut: PostgreSQL stores its sampled boundaries alongside a count of finished ranges, because recomputing them would cut the table elsewhere and the count would name a different span of rows; MySQL stores the last key written, which needs nothing else to be exact. A checkpoint alone is not proof the load finished — the two are separate facts, and conflating them is what silently skips a load.

The load reads in waves, and only for the tables where reading is the cost. Parallel readers were the obvious answer to a slow load and are worth almost nothing on an ordinary table: measured, four readers buy 8% on a narrow table and 5% on a wide one, because the target is what the pipeline waits for. On a table with a nested collection they buy 53% — there the COPY runs an aggregate subquery per parent row, so the server is doing per-row work and more backends do it in parallel. That is the whole justification, and [source] load_workers stays at 1 because outside that case it multiplies the read load on someone's production database for single digits.

Waves rather than a free-running pool, and that is not a matter of taste. The engine forgets its record of stream-removed keys on every load mark, which is only safe while nothing from before that mark is still in flight; with a pool, one worker's confirmed mark says nothing about the others. And progress is a count of leading ranges written, which out-of-order completion cannot advance. A wave satisfies both by construction: it is contiguous, and it is finished before its mark is sent. The cost is the skew inside a wave, which sampled ranges of equal row counts keep small.

The tombstone window is therefore bounded by a wave instead of a chunk — the same argument, load_workers times wider.

The load is made faster on the write side, because that is where the limit is. The obvious move is parallel readers, and it would have bought nothing. Measured on an 8-core laptop against the dev stack, 2M rows: one COPY hands over rows at ~1,050,000 a second, while the whole pipeline ran at 43,000 and spent 63% of its wall clock idle. The target was the reason — one bulk request open at a time tops out near 52,000 documents a second, and its size makes no difference at all (50,500 at 500 documents a request, 52,100 at 20,000), while four requests open at once reach 114,000. Refresh and replicas are already suspended for the load, so concurrency was the only variable left.

Opening more requests is therefore the whole change, and it delivers: 43,000 rows a second at one, 67,000 at two, 87,000 at four, 96,000 at eight. The process's CPU share over the same runs went from 37% of wall clock to 102%, which says plainly what happened — it stopped waiting and started working. At 10M rows the same shape holds and the numbers barely move — 42,700 at one, 90,100 at four — so this is not an artefact of a table that fits in cache.

Wide rows do not change the answer. A TOAST-heavy table reads at 11,200 rows a second through a client and gets slower with parallel readers, because what saturates is transporting the data, not the backend producing it; server-side the same read scales to 141,000 with four readers, so PostgreSQL is not the problem and neither is our connection count.

Write requests are open concurrently and completed in order. Concurrency that reordered completions would break three things at once, so it does not: a position is acknowledged only after every batch sent before it is durable, a refused document is filed before the position covering it passes, and a failure acknowledges nothing behind it. Load marks, truncates and bare positions are barriers that wait for the open writes to finish — for a load mark that is required rather than tidy, because the engine forgets its record of stream-removed keys on one, and that is only safe while the mark still means every copy row before it is durable.

It stays at one request by default. Raising it multiplies the load placed on someone's production target, which is not a default anyone should inherit unmeasured, and it needs a target that decides between two writes by their version: Meilisearch keeps whichever landed last, so it refuses the setting outright rather than reordering writes quietly.

A re-snapshot is a subcommand, not a signal table. Debezium triggers an ad-hoc snapshot by writing to a table in the user's database. pg2osync will not write to the source, and the CLI is already where operator actions live, so pg2osync resnapshot --table reads one table again into the index it is mapped to. It is the initial load's chunked reader with a scope, going through the whole ordinary write path — mapping, projections, transforms, children, id derivation — because a document it writes has to be indistinguishable from one the load wrote, and a second write path would drift immediately.

It cannot move the checkpoint by construction rather than by care: its rows carry position 0, so nothing acknowledges a position and the checkpoint task has nothing to persist. That is what makes it safe beside a running pipeline, together with the versioning that already orders a copied row against a streamed change.

It records no progress. An interruption means running it again; the alternative is bookkeeping under the key the initial load uses, which the next pipeline start would read as an unfinished load — the silent skip the load's own progress documents exist to prevent. It also leaves refresh_interval alone, unlike the initial load: it repairs an index that is in use, so hiding new writes for its duration would be the wrong trade.

It adds and updates but never deletes. reconcile is the other half, and keeping them apart is what keeps each one explainable.

A rebuild is a fresh index and an alias flip, not an in-place rewrite. (#107.) A mapping cannot be changed on a live index for anything that matters, so rebuilding one means writing a new one; rewriting in place would leave an index that is half old and half new searchable throughout, which is the outage the exercise exists to avoid. pg2osync reindex --table T --alias A therefore creates <index>-<unix seconds> with the section's mapping, loads the table into it, checks the count against the source, and moves the alias onto it in one atomic request.

It refuses to run while the stream is live, which is the one place it differs from a re-snapshot. A re-snapshot is safe beside the stream because a copied row and a streamed change meet at the target and the higher position wins — a comparison between two documents in one index. A fresh index the stream is not writing to has no second operand: a row updated during the load would be permanently wrong there and the count would still match. So a rebuild closes the window the way the initial load does — its rows carry position 0, the checkpoint does not move, and restarting the pipeline against the new index replays everything committed since. The refusal is positive evidence rather than a flag: an active replication slot on PostgreSQL, and on either source a checkpoint that moves while it is watched. There is no --force.

The alias is flipped before the restart because it costs nothing to: the old index is exactly as stale as the new one at that moment, and both catch up from the same replay. A live cutover with no freshness gap at all is still two instances, as operations.md describes — dual-writing from one process is not on the table.

refresh_interval is suspended for the duration here, unlike a re-snapshot's: nothing searches an index no alias points at yet, so there is no visibility to trade away. The old index is kept unless --drop-old says otherwise, because it is the rollback — one alias flip back — and a --keep-old that defaults to on would be an option that does nothing. The count proves the number of rows, not their contents; what proves the contents is the replay the restart runs.

An alias is a contract, not an API call. (#108.) Sink::switch_alias names an outcome — after it returns, the name readers use resolves to the documents the rebuild wrote, and it never resolved to nothing in between — and leaves the mechanism to the target. OpenSearch and Elasticsearch have an alias namespace and move a pointer inside it. Meilisearch has none: the name readers use is an index uid, and its atomic operation is POST /swap-indexes, which exchanges the contents of two uids in one task. Modelling that as "this target has no aliases" was the easy reading and the wrong one; the contract holds there, so the sink implements it.

Two things follow from the swap, and the command says both out loud. --alias on Meilisearch has to be the index the section already writes to, because that is the only name a reader is using; any other value is refused rather than quietly creating an index nobody reads. And the exchange runs both ways, so once it is done the timestamped <index>-<unix seconds> holds the previous documents, not the new ones — it is the rollback, --drop-old deletes it, and the kept-index message says which name it is. No config edit follows a rebuild here, only the restart, because the section's index never changed. The checkpoint is a file in state_dir rather than a document in the target, so it sits outside the uid namespace entirely and no swap can touch it.

A rebuild on this target is therefore never about a mapping — Meilisearch has no field types to declare, and ensure_ready refuses a spec that carries a mapping. What it is for is an index settings change that only applies to documents indexed after it, or a decoding bug whose wrong values are already written: both need the documents built again, and both want the live name to keep answering until they are.

Children resolve in the source, once per transaction. The engine is source-agnostic and runs no SQL against the source, so the only place that can group child lookups is the source's own decode loop — which already knows where a transaction begins and ends. Rows of tables with no children go straight out; the rest are held, and at the commit the distinct parents they affect are read in one query per collection. A child row holds nothing but the parent key it names, so a transaction touching a thousand children of one parent holds one key rather than a thousand rows, and writes one document rather than a thousand identical ones.

Measured on 2,000 child rows across 20 parents in one transaction: 2,000 parent re-reads and 2,001 child fetches became 1 and 2, documents written fell from 2,000 to 20, and throughput went from 845 to 2,829 rows/s. Every competitor breaks on this cost model — PGSync asks the index which documents a child row affects and was measured at 108s per batch; asking the source, once per batch, is the whole difference.

A MySQL child array is aggregated in Rust, not by JSON_ARRAYAGG. The obvious tool is the wrong one, measured on both servers: JSON_OBJECT renders a varbinary as "base64:type15:AP8Q" on MySQL and as raw escaped bytes on MariaDB where the pipeline says "AP8Q", a set as "a,b" where the pipeline says ["a","b"], a decimal as a JSON number where the pipeline keeps the string so the precision survives, and a bit as base64 on MySQL and as invalid JSON on MariaDB — its own JSON_VALID returns 0 for it.

Casting each column (TO_BASE64, CAST(… AS CHAR), CAST(… AS UNSIGNED)) gets closer and still fails: TO_BASE64 wraps at 76 characters, so any value over 57 bytes disagrees with the pipeline's base64, and a set cannot become an array without JSON_TABLE per row. And where it does work it means writing the type mapping a second time, in SQL, for the two to agree.

So child rows come back as ordinary rows and go through the same build_document that builds a parent. A value inside an array is then identical to the same value as a document because it is the same code, and the cost stays one query per collection per batch — the server still does the ordering, the cap and the count.

The child aggregation is built in one place. The initial load's COPY and the streaming re-fetch use the same subquery, so the array's contents, order and cap cannot drift between them. Two builders would disagree the moment either changed, and the disagreement is invisible until someone re-snapshots.

The array is ordered by the child's primary key. Without an order it is a set in arbitrary order, so the two paths could embed the same children differently and a re-snapshot would rewrite documents for no reason. With a cap it decides which children are kept, so the same subset has to come back every time.

No cap on an embedded collection by default, and truncation says so. A cap loses data, and the bound that matters is already the target's: past index.mapping.nested_objects.limit (10,000) OpenSearch refuses a nested document outright, which is reported and quarantined rather than lost. So the default embeds everything and logs an array past that limit, naming the parent. Where max_rows is set, the document carries <field>_truncated and <field>_total — a consumer cannot otherwise tell a short array from a complete one, and handing over part of a collection as if it were all of it is worse than either extreme. Data Prepper's equivalent defaults to 1000 and documents no overflow behaviour at all, which is the version of this not worth copying.

A one-to-one child is an object, and a second row is a warning, not a choice. single = true unwraps the collection after the aggregation, in core, rather than reading it with a LIMIT 1 of its own: each source keeps exactly one aggregation builder, so the initial load and the per-transaction re-fetch cannot embed different shapes, and the ordering, counting and capping machinery is untouched. A second matching row does not fail the run — a duplicate that exists for the length of a migration must not halt an index — and it is not silently resolved either: the batch logs one line naming the collection, how many parents matched twice and the worst of them. The row that stands is the lowest-keyed one, not the newest: primary-key order is what both the load and the re-fetch already promise, so a re-snapshot embeds the same row rather than rewriting the document. No metric counts it: neither source crate holds a Metrics handle, and a warning already names what to fix.

Checkpoints

State lives in the target. A hidden .pg2osync_meta index holds one document per stream; per-document atomicity gives the crash safety for free, with no compare-and-swap. Per stream rather than one shared document, because two pipelines writing to the same target otherwise overwrite each other's position — which is what a zero-downtime re-index runs, and what splitting tables across instances means. A local file breaks on ephemeral containers, and a table in the source database pollutes the user's schema and risks replicating itself. Meilisearch has nowhere to put an arbitrary document, so it uses a write-then-rename state file — the documented exception.

One position format for every source. The document stores an ordering token plus the source's own textual position. Documents written by earlier versions, which stored only an LSN, are still readable: refusing them would force a full re-index on upgrade.

Types

numeric and decimal become JSON strings. A float round-trip loses precision, and these columns are usually money. MySQL decimals keep their declared scale so a streamed value matches what the initial load read. transform = "number" is the operator's explicit opt-out — an index that sorts or range-queries on money asks for it, and accepts the double.

Unknown types become strings. Domains, ranges and composites are passed through as text rather than guessed at.

bytea, blobs, binary and geometry become base64. Binary cannot go into JSON any other way.

On MySQL both readers decide from the declared type, not from the wire. Neither format is self-describing where it matters. A binlog row image gives a string column no charset, so char and binary share a type code and so do text and blob; it gives an enum an ordinal and a set a bitmask with the labels nowhere. The text protocol has the opposite gap: every value is bytes and only the declared type says whether they are characters. So the shape is resolved from information_schema once — column_type alongside data_type, because that is where the labels live — and both decoders consult it. Deciding per format instead is what made text arrive as base64 from the stream and as a string from the load, and varbinary arrive as mangled text from both.

A MySQL enum is its label, a set is an array of its labels, and a bit is a number. The alternatives are what the wire happens to carry — an ordinal, a bitmask, a byte string — and none of them is searchable, which is the only reason the document exists. A set is an array rather than a joined string so each label matches on its own. bit fits a number because MySQL caps it at 64 bits.

Operating limits

Retention is reported, never capped by us. A slot nothing is reading pins WAL until the disk fills, and max_slot_wal_keep_size is the one setting that turns that into a recoverable incident. pg2osync still will not set it: it is a server-wide setting, and writing to the source's configuration is the same refusal as not running DDL and not writing a signal table. What is owed instead is that the number cannot be missed — pg2osync_slot_retained_bytes for every slot on the server, the server's own wal_status beside it, and a startup warning naming what an idle slot already holds.

Measured, so the risk is a number rather than a caution: a 110-byte row retains 238 bytes of WAL, which is ~820 MB an hour at a thousand writes a second.

The check has to work while the pipeline is down. That is the case that takes a database out — a process stopped on Friday, nobody reading logs for something that is not running, metrics unscraped because nothing is serving them. So pg2osync status --max-retained-mb exits non-zero over a limit, which makes it something a cron job can own, and it looks at every slot rather than the configured one: an orphan from a former slot_name fills the same disk.

No Amazon OpenSearch Serverless. It looks like one more OpenSearch endpoint and is a different target: SigV4 is the only authentication a collection accepts, a custom document id works only on a search collection, and the service owns refresh and index settings. The first rules out talking to it at all without a signing implementation, the second would make the _id-is-the- primary-key rule fail on two of the three collection types, and the third removes the load's refresh suspension and /synced.

A serverless = true flag existed from the first commit and was never run against the service. That is a support claim nobody could stand behind, so it is gone and the url is refused instead. Nothing in the competitive set advertises Serverless either — the tools that do are log shippers and AWS's own ingestion pipeline, not database-to-index replication — so this closes no gap.

Scope

One-way replication only. No bidirectional sync, no conflict resolution.

Schema drift is reported, never applied. pg2osync will not run DDL on the target's behalf. A publication that does not match the configuration is an error, not something to silently fix. A table whose columns change under a running pipeline is logged, naming what was added, removed or retyped — the index and the database now disagree about what a row looks like, and only a rebuild closes that. Which is why the index name is configuration.

It is also counted, as pg2osync_schema_drift_total{table}. A log line is not alertable: an operator who does not read logs never learns the index and the table stopped agreeing, and "reported" that nobody can be paged on is barely reported at all. The report reaches the counter through the change-event channel, as a positionless SchemaDrift event the engine counts and drops — the same path rows and truncates already take, so both sources report drift the same way, neither of them holds a Metrics handle, and nothing PostgreSQL- or MySQL-specific reaches the engine. Carrying no position is what keeps it inert: a drift event can never flush a batch, acknowledge a position or move a checkpoint. On MySQL the comparison is between the catalog's answer before a DDL and its answer after, since the binlog says a statement ran but not what it did to a column layout.

A binlog shape the catalog cannot match is skipped, not fatal. MySQL's TABLE_MAP describes the table as it was when the row was written, and information_schema only ever answers for now. A crash-restart resumes from the last durable checkpoint, so any DDL that committed after that checkpoint is replayed: the rows before it carry a column count the catalog no longer has, and re-reading the catalog cannot bring the old layout back. Refusing to continue there looks safe and is not — the reconnect resumes from the same checkpoint, reaches the same event and fails again, so the pipeline stops replicating everything rather than the handful of rows it cannot decode. Those rows are therefore counted as drift, named in the log and left undecoded, which is the same bargain the rest of this section makes: the index and the table disagree about a shape that changed, and only a rebuild closes that.

No event trigger in the user's database. The attractive version of DDL detection puts a CREATE EVENT TRIGGER in the source, which writes each schema change into the WAL as a logical message so it arrives inline and correctly ordered ahead of the data that depends on it. pgstream does this, and the machinery is already here — /synced emits logical messages and the decoder already advances on them.

It is still refused, for two reasons that were measured rather than assumed.

The first is that pgoutput already does the ordering. PostgreSQL re-sends a RELATION message whenever a replicated table's shape changes, before the first row event that depends on it, and column_drift reports exactly what changed. Verified against a live database: an ALTER TABLE ... ADD COLUMN between two row events logs added later_col and the next document carries the new column, in order, with no trigger involved. The problem the trigger exists to solve is not one we have.

The second is the cost. CREATE EVENT TRIGGER needs superuser — PostgreSQL's own documentation says so plainly — which many managed providers do not grant, and it would put an object of ours inside the user's database. That is the same refusal as not running DDL on the source and not writing a signal table into it, and the refusal is itself something people choose this tool for.

What the trigger would add over what pgoutput gives us is the DDL text, earlier notice on a table nobody is writing to, and DDL that does not touch a replicated table's shape at all. None of those change what a document looks like, which is the only thing the index can disagree with the database about.

Worth revisiting only if pg2osync ever applies schema changes to the target — a different product than this one, and the point at which knowing the statement rather than the resulting shape starts to matter.

No relational sources beyond PostgreSQL and MySQL/MariaDB, and no non-relational sources. The value is depth on these, not breadth.

Nested children stay one level deep. Anything deeper is the application's to shape before it reaches the database, and there is no view route around that: only base tables are eligible (relkind = 'r' on PostgreSQL, table_type = 'BASE TABLE' on MySQL) because the WAL and the binlog carry base-table rows, and a view has none to stream.

Implementation choices

Hand-rolled metrics endpoint. Six counters and one summary do not justify a Prometheus client plus an HTTP framework in a binary that advertises having no dependencies.

Batch reads with COPY … (FORMAT text). Text parsing measured fast enough (~21k docs/s end to end) that binary format's complexity is not yet justified.

Secrets from the environment. Every secret has an *_env form. Inline values still work but warn, because config files end up in version control.

Errors: thiserror in libraries, anyhow only in the binary. Callers get matchable variants; the CLI gets readable messages.

YAGNI on configuration. An option that does nothing is worse than a missing one, because it implies a guarantee. Options that had no effect were removed rather than documented.

Advisories are reviewed, not muted. cargo audit runs in CI when the dependencies move, and every entry in .cargo/audit.toml carries the argument for why the advisory does not reach this binary — for the rsa sidechannel, that the process holds no private key to leak. An advisory with no such argument is a bug to fix, not a line to add there.