High-fidelity initial_load modes for per-table opt-in
(future/runtime_config_from_pg.md
§Per-table opt-in): source pre-opt-in rows from a base backup instead of a
live COPY (SELECT …). Reuses greenfield bootstrap plumbing
(bootstrap.md) — BackupSource impls, PageWalkSink,
pipeline::bootstrap::drain — with a per-rel filter over the tables being
added. Orchestrated by src/backup_backfill.rs; dispatch + coalescing live
on the backfiller (src/copy_backfill.rs); visibility gate in
src/visibility.rs.
config_table.initial_load (text) selects the mode; TOML
[table.*] initial_load is the equivalent surface for pinned mappings
(applies at boot with S = the WAL resume LSN):
| value | source | WAL leg |
|---|---|---|
'none' | NULL |
none | live stream from S |
'copy' |
live COPY (SELECT …) at _lsn = S (src/copy_backfill.rs) |
live stream from S |
'base_backup' |
fresh BASE_BACKUP over a second replication conn (DirectSource) |
live stream from S |
'object_store' |
latest wal-g full backup from bucket (ObjectStoreSource) |
archive-WAL gap replay B_redo → S, then live stream |
- One decoder, not two. The page walk feeds on-disk tuples through the
same heap decoder the WAL hot path uses (
decode_on_page_tuple→decode_block_data) — zero codec drift by construction. COPY carries a paralleltypsend-wire codec plus::textcasts for out-of-matrix types; bootstrap.md §Why not 2C rejected exactly that shape for greenfield. Backup modes bring opt-in backfill back onto the 2A path - Source load. COPY holds a statement snapshot and full-scans the table
through the query path of the node walshadow streams from.
BASE_BACKUPis bulk-file I/O, boundable via the protocol'sMAX_RATE;object_storecosts source PG nothing — bucket bandwidth only - Node coupling. COPY must run against the streamed node (the
P ≥ Ssnapshot argument).object_storeneeds no source SQL for the data leg - Dead tuples. COPY sees an MVCC snapshot. The page walk sees raw
pages, so backup modes add a visibility gate (below) where greenfield's
walk emits every
LP_NORMALtuple
- Filter set = the tables being added. Backup-mode opt-ins queue on the
backfiller and wait a fixed coalesce window (
BACKUP_COALESCE_WINDOW, 1s) for siblings, so an opt-in burst — several rows in one xact, or a boot seed — coalesces into one cluster-sized pass per mode. Page-walkCatalogMapseeds only those descriptors plus theirpg_toast_<oid>rels (ShadowCatalog::toast_descriptor_for).PageWalkSinkTaps main-fork filenodes in the set (_fsm/_vmforks skip;.Nsegment suffixes route viaparse_base_path); every other file Skips - Nothing lands on disk. No
DiskLanderSink, shadow untouched — backup bytes stream through the walker and drop. Exceptions:pg_xact/andpg_multixact/{offsets,members}Tap into memory (256 KiB segments,PgXactAccum/PgMultiXactAccum) for the visibility gate, and object_store's gap segments land under{spill_dir}/backup_backfill/ - Visibility gate (
src/visibility.rs). Emit a tuple only when backup-erapg_xactsaysxmincommitted andxmaxabsent/aborted; infomask hint bits (HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID, PGsrc/include/access/htup_details.h) short-circuit most lookups. Unhinted tuples defer until walk EOF through aDeferredSpool(in-memory prefix, disk past it — xact.md Spill backend) — same posture as the drain's deferred-TOAST spool. Deferrals resolve only after a successful walk (pg_xactcomplete only then): a failed source drops the sink too, and partialpg_xactreads committed deleters as in-progress, emitting dead tuples reruns can't remove. Skipped in-flight tuples are re-delivered by the mode's WAL leg; skipped aborted tuples were never real. Dead-but-unvacuumed tuples stop resurrecting — the gate is what makes these modes higher-fidelity than greenfield's walk, not just cheaper than COPY.HEAP_XMAX_IS_MULTIresolves through backuppg_multixact(PgMultiXactAccum::updater): the update/delete member's xid runs through the same pg_xact view, so a pre-coverage committed updater (UPDATE/DELETE alongside lockers, dead tuple still on-page) gates instead of resurrecting. Bytes the backup never copied prove the multi postdates the copy — copies happen past redo, so the WAL leg re-delivers that update and the old version emits safely (counted,multixact_emitted). A multi the snapshot can't bound (truncated below collected range, garbage reads) aborts the pass, error naming the remedies — emitting risks resurrection, skipping risks losing a live row whose updater aborted - PgXact patch. For object_store, commit/abort records harvested from the
gap pre-scan (
PgXactPatch, top xid + subxids) overlay backuppg_xactbefore deferred tuples resolve. This covers xacts in flight acrossB_redowhose commits land inside the gap: their pre-B_redorows exist only in backup pages that backup-erapg_xactcalls in-progress, and the gap replay re-delivers only records≥ B_redo— without the patch those rows would be lost - Same tail as COPY.
BackfillTuple→ gate →pipeline::bootstrap::drain→ dedicated insert tail (own CH connection); the live pipeline never blocks on a backfill. Rows route via a per-pass snapshot of the mapping pointed at staging tables — the destination is untouched until publish (§Staging swap). The pass resolver shares the pipeline's memory budget (PassContext, emitter.md Memory budget). Regime A (config.md §Failure containment) holds: a failed pass leaves every entry pending in the ledger, never poisons the pump - Ledger.
backfills.tomlentries carrymode(absent ⇒copy) and the staging-swap phase (swapped+ staging uuid, §Staging swap); boot re-runs the recorded mode at the recordedS— or resumes the swap tail — the config row's current mode applies only to a fresh entry. Dedup keeps re-runs idempotent.note_opt_out: entry drops, a queued request withdraws before its pass fires; an in-flight walk keeps loading its staging table (the pass routes via its own snapshot) and the publish discards an unmapped rel's staging instead of swapping
Walked rows must lose to every WAL-delivered mutation the backup state does
not already reflect. Rule: tag walked rows with the LSN where continuous
WAL coverage of the rel begins, never later. Tags are per-rel
(PageWalkSink::with_lsn_overrides); a rel's toast rows tag with their
parent.
'base_backup': backup starts atB ≥ S, live WAL covers(S, ∞)→ tag_lsn = S. A delete committed in(S, B)may leave a dead tuple that the visibility gate misses only ifpg_xacthasn't caught up in the tar; its_is_deletedrow atcommit_lsn > Smust outrank the walked copy. TaggingBwould resurrect it'object_store': the backup normally predates the opt-in; the gap(B_redo, S]is covered by archive replay, not the live stream → tag_lsn = min(B_redo, S)(B_redo= the backup's start LSN, from the sentinel). Replayed rows carry real commit LSNs> B_redoand win; taggingSwould let stale walked rows beat fresher gap-replay rows. A backup newer than a rel's opt-in needs no replay for it and its rows tagS
Issue BASE_BACKUP on a second replication connection once the opt-in
applies (wal: false — WAL rides the live stream). The protocol is
cluster-scoped (PG src/backend/backup/basebackup.c walks the whole datadir;
no per-rel filter exists), so the tar streams the entire cluster past the
daemon and the filter discards all but the target filenodes — bandwidth is
cluster-sized even for one table. Coalescing concurrent opt-ins amortizes the
pass.
No replay leg: any xact the tar catches mid-write commits after S, so the
live stream re-delivers it at its real commit LSN (pre-S heap records were
buffered inclusion-agnostically by the pump). Pre-S commits are durable in
pg_xact before BASE_BACKUP starts, so the gate never reads ahead of copied
pg_xact for pre-S xids. Torn pages lose only tuples whose writers are
post-S — strictly safer than greenfield's no-FPI-replay caveat, where
nothing replays [start_lsn, end_lsn].
-
Resolve backup.
resolve_name(LATEST)+fetch_sentinelon the wal-g bucket ([backup]TOML, same source as bootstrap) →B_redo/B_endLSNs, timeline. Delta chains reject (increment_fromset) with the same operator-actionable error as bootstrap: the streaming walk has no disk-resident base to overlay -
Gap fetch + pre-scan. Fetch archive WAL
B_redo → max(S)(wal-ruspg::wal::fetch) into scratch and sweep records-only: harvest the pg_xact patch, and abort on catalog skew touching filtered rels — apg_attributewrite on a filtered rel, apg_classwrite changing a filtered rel's filenode, a catalog write whose row oid is undecodable (OidInPrefix, so possibly a filtered rel), anRM_RELMAPupdate (mapped-catalog rewrite), or anXLOG_HEAP_TRUNCATEnaming a filtered oid. Any hit aborts before a row is emitted: the walk would decode with the wrong shape, and a rewrite in the gap means the backup's filenode isn't the current rfn at all. Error names the remedies — fresher backup, or'copy'. A timeline switch or archive gap surfaces as a fetch failure with the same remedies.Skew is a per-database question: every database repeats the catalog OIDs, mapped-catalog filenodes, and relation OIDs a check compares against. One pass covers one database (
BackupRequest.desc.rfn.db_node, verified identical and nonzero across the request set), so each check first proves the record's own database — block 0db_nodeforpg_class/pg_attribute,xl_heap_truncate.dbId,xl_relmap_update.dbid(0= shared map, no per-database catalog) — and ignores foreign records. A scope-bearing record that will not parse fails closed as skew. The pg_xact harvest stays cluster-wide: XIDs are, and backup pages hold tuples written by every database -
Filtered walk. Tar parts stream through
PageWalkSinkwith the filter set; rows tag_lsn = min(B_redo, S)per rel; the gate resolves deferred tuples against backup pg_xact + patch at successful walk EOF -
Gap replay. The fetched segments drive the shared decode path (
BufferingDecoderSink+ReplaySinkoverdrain_committed+into_walk, so subxacts, TOAST reassembly and update/delete decode match the hot path) over records whose block-0 rfn is in the filter set; committed rows ship at real commit LSNs through the same insert tail, continuing the walk's seq space. Commits≤ B_redodrop (the walked copy carries them); commits> Sdrop per rel — the live stream owns them (dedup absorbs overlap regardless)
Convergence: walk EOF + gap replay reaching S. As with COPY, completion is
observability, not correctness — nothing gates on it. No gap-length guard: a
months-old backup makes B_redo → S replay dominate cost, where 'copy' is
strictly cheaper — the pass runs regardless and the segment count is logged.
Backup-mode passes never insert into the destination
(src/backfill_staging.rs). Rows land in a per-rel staging table
(<table>__wsstg, same database, CREATE TABLE .. AS structure clone,
rebuilt from scratch per attempt); a successful pass publishes per rel:
- Gate. Destination and staging schemas must still fingerprint equal
(
system.columnsname+type by position) — mid-pass DDL means the loaded copy has the pre-DDL shape; discard staging, entry stays pending, next boot re-loads. A rel unmapped at publish (opt-out mid-pass) discards the same way - Exchange. Persist
swapped+ the staging table's uuid in the ledger, thenEXCHANGE TABLES. Order is load-bearing: post-swap the staging name holds the only copy of the live-window rows, and a pending-looking entry would re-run the pass and rebuild staging over them. EXCHANGE never blindly resends — an ambiguous timeout may have applied and a resend would swap back; the recorded uuid disambiguates at recovery - Copy-back. Live rows delivered during the pass resolved the
destination name pre-swap, so they sit in the swapped-out storage:
INSERT INTO real (cols) SELECT cols FROM staging WHERE _lsn > S. The filter is what keeps a re-opt-in purge effective — only live-window rows carry_lsn > S; prior-life rows stay purged. The column list is the two tables' intersection (destination order) so DDL applied after the swap can't wedge it. Runs oneinsert_timeoutafter the exchange: an INSERT that resolved the name pre-swap finishes into the old storage within one attempt cap, later attempts re-resolve to the swapped-in table - Drop + done. Staging (now the pre-swap storage) drops; ledger marks done
Why: a failed pass flushes nothing visible, so no retry has to tombstone what
an earlier attempt leaked. Without staging, a failed 'object_store' attempt
retried against a newer LATEST leaves phantoms: a row deleted between the
two backups is on neither the retry's pages nor inside its replay window, so
its stale copy survives every rerun. Staging also lets a re-opt-in purge
stale rows wholesale (mutations during the opted-out window were never
delivered) and gives readers an atomic cutover instead of a growing table.
Boot recovery for a swapped entry resumes the tail instead of re-loading;
the staging name's uuid tells the phase apart — unchanged ⇒ exchange never
applied (re-gate schemas, exchange); changed ⇒ exchange applied (copy-back +
drop); missing ⇒ only the done mark is owed. A failed pass joins its whole
tail before surfacing, so no detached inserter can final-flush into a staging
table a retry has already rebuilt.
Caveats: EXCHANGE TABLES needs an Atomic/Replicated database engine (the
modern default); CREATE TABLE .. AS clones engine args, so a Replicated*
destination with a hard-coded ZooKeeper path (no {uuid} macro) collides;
materialized views on the destination never fire for backfill rows (staging
inserts don't trigger them) and fire twice for copy-back rows.
config_backfills_pendinggauge keeps counting all modes; per-mode labelled series (mode="copy|base_backup|object_store") split it- Per-pass INFO summary: rows walked / gated / deferred, multixact emits,
gap segments + records replayed, pg_xact segments + patch size,
B_redo
- No shadow involvement. Backup bytes never land in a data dir; the modes add no second shadow or scratch cluster
- Bucket/creds stay out of the overlay. Object-store settings come from
the
[backup]TOML section bootstrap uses, never the source-PG config tables — credentials in a source-PG table is the wrong trust direction - No server-side filtering. Do not fork the BASE_BACKUP protocol;
cluster-sized bandwidth is the documented cost of
'base_backup', and'object_store'is the answer when that cost bites
- base_backup opt-in. Non-empty
app.orders; insertconfig_table (replicate=true, initial_load='base_backup'). Daemon streams WAL fromS, second replication conn pulls the backup, onlyapp.ordersfilenodes walk, rows land at_lsn = S. Mutations committed mid-backup reflect the WAL copy, not the walked copy - Dead tuple stays dead. Delete a row, opt in via
'base_backup'before autovacuum prunes. Walked page still carries the tuple; visibility gate drops it; CH never resurrects - object_store opt-in. wal-g bucket with a full backup + continuous
archive. Opt in with
'object_store'; zero source SQL/replication load beyond the live slot; walked rows at_lsn = B_redo, gap replay bridges toS, post-Sinserts stream live. Row mutated in the gap ends at its gap commit LSN value - Catalog skew aborts.
ALTER TABLE app.orders ADD COLUMNbetween backup and opt-in. Pre-scan detects thepg_attributewrite, backfill aborts with the remedy error, ledger entry stays pending, live stream unaffected - Coalesce. Two
'base_backup'opt-ins in one xact → one backup pass, both rels' filenodes walk in it - Unknown mode.
initial_load='snapshot'warns (unknown initial_load mode), scope change still applies, streaming fromSonly - Failed pass leaves destination untouched. Kill the source mid-walk;
destination carries only live rows, the partial load sits in
<table>__wsstg, ledger stays pending. Retry rebuilds staging and publishes exactly once - Swap crash recovery. Crash between
EXCHANGEand copy-back; boot resumes fromswapped: uuid mismatch ⇒ exchange already applied, copy-back delivers the live-window rows (_lsn > S), staging drops, entry marks done