Externally-toasted column values are reconstructable in every path, including
values toasted before the replication window. Chunks land in a store of
record (ToastResolver / ChunkStore, src/toast.rs), selected by
[toast] mode, so reassembly does not depend on a value's chunks coinciding
with the referring tuple in WAL. In-xact WAL reassembly is the fast path — see
xact.md.
Each pg_toast relation is a replicated table whose ClickHouse mirror is
keyed by heap TID. A chunk INSERT is a row birth at its TID, a chunk DELETE a
tombstone at its TID: the table replicates line-pointer occupancy exactly —
one live tuple per TID at a time, PG's own invariant. Generation identity is
structural (a rewritten heap has different TIDs, a reused line pointer
supersedes its tombstone at a higher version); nothing resolves anything.
Reclamation is ReplacingMergeTree merge behavior, not walshadow logic.
- Modes.
disabled(default; NULL/default-fill on miss, countedtoast_values_filled_default, never an error) andclickhouse(ClickHouseChunkStore, one CH table per toast rel).MemChunkStoreis the in-memory test double whosefetchis the literal as-of algorithm the SQL encodes. - Schema.
pg_toast_<relid>(blkno,offnum,chunk_id,chunk_seq,chunk_data,_lsn,_is_deleted),ReplacingMergeTree(_lsn, _is_deleted)ORDER BY (blkno, offnum), bloom-filter index onchunk_id._lsnis the record LSN (not commit LSN): total order per TID, distinguishes same-commit birth+death at one TID, and re-emits produce byte-identical rows so_lsndedup stays pure dedup under walshadow's eventual-consistency contract.chunk_idis payload; key order belongs to the hot op (delete application via insert), fetch is the cold path (pre-window re-emits, bootstrap deferred resolution). - Tombstone.
(blkno, offnum, 0, 0, '', delete_record_lsn, 1). Merge keeps the latest version per TID: a tombstone supersedes its data row and reclaims the ~2KBchunk_data; residual is one ~20B tombstone per dead never-reused TID, and a reused line pointer replaces even that. Keys are TIDs and TIDs churn, so parts merge under normal background pressure — no dead-forever key ranges.OPTIMIZE … FINAL CLEANUP(gated onallow_experimental_replacing_merge_with_cleanup) is the operator lever for the residual. - Data flow. Chunk INSERTs and toast DELETEs buffer through the xact
spill (
SpillEntry::Chunk/ToastDelete, both carrying TID + record LSN), so aborts discard them and the drain merge preserves WAL order. The drain collects them asToastRowRefs (gated onstores_chunks(), body behindBody, sealed per slice inDrainedBatch.new_rows); reorder materializes bodies just in time (put_row_refs, batches sealed atCHUNK_PUT_BATCHrows /CHUNK_PUT_BYTESunder a leaf permit) before the commit's publishing ack marker, so a later referrer's fetch finds them durable — barrier slices interleave puts with mirror wipes via merge cursors sealed with the slice (see Lifecycle). Gap replay puts rows through the same walk steps for the same reason. No fsync, no journal, no ack coupling: toast rows ride the main-table durability story (CH insert ack → emitter ack). - Fetch.
fetch(relid, value_id, max_lsn)is an as-of query: candidate TIDs that ever held the value at or before the bound (bloom-pruned), then per-TIDargMax(…, _lsn)under the primary key, live rows of the value, newest per seq. Per-TID latest-≤-LSN is PG visibility at the referring record: lagging decode cannot see a future generation, a reusedchunk_id's dead generation is invisible past its tombstones, rewrite-duplicated live copies resolve last-wins per seq (byte-identical anyway). The candidate-TID pass makes correctness unconditional — a TID whose latest visible row is a tombstone or another value's birth drops out without assuming a tombstone interleaves every occupancy change. - Miss policy. As-of correctness depends on history surviving until no
fetch wants it, but merges collapse history on their own schedule. A
collapsed generation is only unreachable when a superseding version of
every referring row already reached CH (tombstone and superseding main-row
version commit together; replay re-covers through that commit), so a
store-side miss NULL-fills and counts
toast_values_filled_superseded— end state unaffected under_lsndedup. A store-side dense-but-short run fills the same way, countedtoast_values_filled_mismatch: a tombstone part can merge with a subset of a value's birth parts, so partial collapse carries the same supersession proof as a full miss (the distinct counter keeps generation mixing observable). A key present in the xact's own chunk maps but gapped or short is a decode bug: hard error, countedtoast_fetch_miss. Bootstrap deferred misses stay hard errors (the walk put those chunks moments earlier; a miss is a walk/store bug). - Mirror-absence guard. Absence is evidence only while the mirror
exists:
fetchagainst a missing table/database isChunkStoreError::MissingMirror, fail-fast past the retry loop — a dropped mirror or never-run bootstrap is an operator/infra anomaly, and a silent fill there would corrupt current head values with no superseding version to come.MemChunkStoremirrors the semantic (a relid exists once a put created it). - Chimera guard.
try_reassemblevalidates concatenated length against the pointer's stored size, as PG'stoast_fetch_datumdoes (src/backend/access/common/detoast.c) — a full-length value that fails decompression is a loud error, never a silent chimera; length mismatches route by the miss policy above. - WAL path. Same-xact values reassemble inline from the buffered chunk
map (
src/xact_buffer.rs), the fast path. A chunk decoded without a TID (unexpected shape;toast_save_datumnever multi-inserts) still serves same-xact resolution but cannot be keyed in the mirror: warned + countedtoast_chunks_malformed, a later referrer superseded-fills. - Bootstrap. Page walk decodes
pg_toast_*tuples into rows at their on-page TID + walk LSN; the drain defers any main-table tuple carrying a mappedExternalToastinto a deferred spool (memory prefix, disk past it — bootstrap.md),puts all rows durable, then replays the spool throughresolve_or_fill_toast(src/pipeline/bootstrap.rs) against the mapping frozen at defer time. Walk-side rows cover dead-at-walk referrers too: a replayed WAL delete supersedes them with a higher-LSN tombstone. TID identity makes the walk fork/segment-aware:_fsm/_vmfiles are skipped, and a.Nsegment's pages number fromsegno * RELSEG_BLOCKS(relation block numbers are global; per-file numbering would collide segment TIDs at equal walk LSN, and WAL tombstones at global blkno would miss walk births past 1 GiB). - Mirror-only seed. A bootstrap run with
[toast] mode = "clickhouse"and no[table.*]blocks seeds mirrors without shipping main-table rows: chunk persistence precedes the mapping lookup in the drain (src/pipeline/bootstrap.rs), unmapped rels drop atlookup_mapping(countedunsupported_relations), and with nothing mapped nothing defers, so the deferred hard-error path cannot fire. Bootstrap consults the static[table.*]map only, never[namespace.*]. Filtering is at the drain, not the walk: catalog seeding is unconditional, main heap pages still decode and drop; onlypg_toast_*decode is config-gated (store_toast). Seeds every toast rel in the catalog — no per-table filter. A re-seed of a live deployment runs as a one-off against a scratch--bootstrap-shadow-data-dirand separate--out-dirso the live manifest stays untouched. - Decode shape (R2). Value reassembled before the main-table INSERT,
stored inline
Bytea/Text;encode_value(src/ch_emitter.rs) needs no toast-specific handling. Tier 3 detoast routing:detoasted_valueruns reassembled bytes back throughvarlena_to_value(src/heap_decoder.rs), so a detoasted jsonb/array/numeric resolves like an inline one (PgPending→ oracle). - Compression.
chunk_dataholds PG's compressed bytes; the reassembler decompresses at ingest from the pointer it already holds, via the shareddecompress_varlena(src/heap_decoder.rs, pglz/lz4).
Physical WAL carries lifecycle operations tuple-level decode cannot see
(relfilenode swaps, no per-tuple deletes; xl_heap_truncate lists only
logically-logged rels, never toast):
- Owner TRUNCATE wipes the mirror (
TRUNCATE TABLE IF EXISTS) inside the same reorder barrier that truncates the CH dest, resolved viaShadowCatalog::toast_descriptor_for(TRUNCATE keeps the toast rel's oid, so the mirror name is stable). Countedtoast_mirror_truncates. Barrier slices putDrainedBatch.new_rowsinterleaved with applies via merge cursors the drain seals per event/truncate (OrderedEvent::row_idx,truncate_rows): pre-truncate births land before the wipe (dead — wiped with it), later births land past it. Same-xact post-truncate chunks ride the toast rel's new relfilenode, MVCC-invisible in shadow until the truncating xact commits — they stash and decode at commit (see Rewrite generations), their births ordering past the wipe by record LSN. - DROP (owner DROP, or a rewrite retiring its old toast rel) surfaces
the toast rel's
Droppedvia descriptor capture (absent from the boundary's fetch with a Present predecessor); reorder queues the retire and executes it — emptied, table kept, countedtoast_mirror_retires— only once the persisted resolved floor passes the dropping commit. Deferral is what makes the wipe replay-safe: durability of earlier referrer versions is not enough, since a crash before the commit's publishing marker replays it, and a pre-drop referrer re-emit fetching an emptied mirror would fill NULL at the same_lsnas its durable original — equal-version rows with different bodies, dedup can't arbitrate (a retained dest underdrop_table_strategy=retainkeeps whichever merges last). Once the floor's segment passes, no restart re-reads any pre-drop referrer, so the wipe is unobservable. The queue is durable (toast_retires.tomlbesidemanifest.toml,toast_retire::RetireLedger): the entry fsyncs inside the dropping xact's barrier apply, strictly before its commit can publish to the ack collector, so any persisted manifest whose floor passed the drop already holds it. Flushes run at each commit boundary, at idle advance, and at pipeline standup — the standup flush is the only route to the wipe for an entry whose floor passed before a stop, since resume never replays its drop; a replayed drop re-pushes an identical entry (deduped), and a crash between wipe and ledger removal re-runs an idempotentTRUNCATEon the emptied mirror. Never a CH DROP — decode detoasts before the mapping lookup, so a crash-replay re-emit of a pre-drop referrer still fetches this mirror; against a dropped table that'sMissingMirror(fatal by design, a permanent replay wedge). Residual emptypg_toast_*tables are the operator-drop lever once the slot passes the dropping commit. Owner TRUNCATE's wipe needs no deferral: a replayed pre-truncate referrer may fill, but the destination TRUNCATE re-applies after it in the same replayed barrier order. Capture covers toast rels alongside their owners (the boot seed and every boundary's inval set include them), so a pinned owner's DROP retires the mirror with no post-restart chunk decode. - Rewrite generations (VACUUM FULL / CLUSTER / rewriting ALTER, and
the same-xact CREATE/TRUNCATE + INSERT siblings): the new toast heap
fills through ordinary
XLOG_HEAP_INSERTs on a filenode whose pg_class row is MVCC-invisible until the xact commits (only the main heap is FPI-logged, PGsrc/backend/access/heap/rewriteheap.c). The decoder stashes those records raw in the xact spill (SpillEntry::Raw) — admission gated on having seen the filenode'sXLOG_SMGR_CREATEmarker, which doubles as completeness proof (records cannot precede creation). At commit,resolve_stashlooks each filenode up in the descriptor log at the commit'snext_lsn: content swap resolves to the original toast oid with value ids preserved (rd_toastoid), link swap to the surviving transient toast oid with fresh ids and the old mirror retiring via the DROP path above. Resolved toast records decode in the drain merge exactly like live chunks (births + TID tombstones, in-xact map included); an insert whose tuple rides only its FPI (HEAP_INSERT_NO_LOGICAL+ mid-rewrite checkpoint) decodes from the restored image. Each marker-proven generation closes with a store-side residual barrier (rewrite_barrier, countedtoast_rewrite_barriers): TIDs live as of the marker with no row past it tombstone at commit LSN —O - B— after the generation's births are put; replay re-runs insert nothing. Never a mirror truncate: pre-rewrite as-of windows stay fetchable. Filenodes unresolvable post-commit (created-and-dropped in one xact, or rotated by a later replayed commit) discard their records (toast_stash_discarded) — access-exclusive supersession makes that end-state-neutral. Records resolving to ordinary heaps decode at drain under their commit-resolution descriptor (xact.md Commit-time stash). A toast resolution without its marker (observation began mid-xact) fails closed — fresh snapshot — rather than emitting an unauditable partial generation. Physical copies (SET TABLESPACE / SET LOGGED) FPI the toast heap verbatim with TIDs, bytes, and value ids preserved: the mirror already matches, so those records are ignored.
- R1 query-time-JOIN mode. Out of scope. Would be per-table opt-in: store
the
ToastPointerin the main column and reassemble via a CH JOIN onchunk_id = va_valueidinstead of inline at ingest. Wins dedup + defers reassembly cost off ingest, costs a CH-side concat + PGLZ path (materialized view / UDF / client-side) and a pointer column carryingva_extinfo+va_rawsize. R2 inline is the default. - Bounded-memory streaming reassembly. A multi-MB value is thousands of
chunks.
fetchstreams the SELECT block-by-block (max_block_size-capped, seq order validated across blocks) and assembles into one exact-capacity buffer sized from the pointer'sva_extsize— but the assembled value is still fully materialised in memory, R2-inherent, same as inline reassembly. A value whoseva_rawsize/va_extsizeexceedsinline_value_maxrejects typed and non-retryable (ValueTooLarge) before any assembly or decompress allocation; resolution runs under a leaf permit sized bycheck_value_caps(emitter.md Memory budget). Streaming reassembly of values past the cap is out of scope. - Drain payload residency. A commit drain shares each chunk body once:
resolution map and mirror row hold one
Bytesallocation, charged to the chunk ownership share (drain_chunk_resident_bytes; rows carry ref metadata only,drain_row_resident_bytes). Pasttoast_body_mem_maxcumulative body bytes the drain appends bodies once to a per-xacttoastbody-*spool and both consumers holdBodyRefranges (xact.md Spill backend), so resident payload stops scaling with the xact's total TOAST bytes. Ref metadata still scales with chunk count, capped bytoast_index_mem_max(typedToastIndexOverflow, non-retryable — replay hits the same cardinality). Gauges count ownership: transfer into a sealed generation or row batch is not release; each generation carries its resident share and admission permit until its last holder drops.
- TID death tracking + GC sweep. A persistent TID→
chunk_idbridge (journal, map, compaction) resolving deletes into value deaths, applied by an ack-gated sweep against a value-keyed store. Spends durable state and a per-commit fsync to collect an incomplete subset (rewrites, TRUNCATE/DROP, pre-journal chunks all leak anyway), must prove physical generation identity to avoid false deletes, and gating on the live ack rather than the persisted replay floor makes crash replay a deterministic wedge. Keying the store by TID makes deletes native data and deletes the whole apparatus. - Lightweight
DELETEmutations per toast delete. Mutation storm on churn; tombstone rows are the CH-native equivalent at insert cost. - Value-keyed store with TID columns. Tombstone insert then needs the value id, recreating the TID→value bridge and the tracker.
- Source anti-join sweep. Per relid, anti-join store valueids against the
live source toast rel under WAL-horizon + snapshot-visibility barriers.
Requires a sidecar SQL session with
pg_toastschema read (superuser orpg_read_all_data— replication privileges don't grant it) and cannot collect a reused id's dead generations. Worth revisiting only as an explicit repair mode for the rewrite/truncate leak classes. - Inline reassembly only. Correct for same-xact WAL, wrong for bootstrap
(errors at the emitter) and pre-window values (
MissingToastChunk). This is behavior with no[toast]store configured. - NULL / raw-marker fallback as the resolution. Lossy: the WAL re-emit of
the referring tuple does not carry the chunks (PG reuses the old
va_valueid), so the value never resolves. Kept only as the explicit, surfaceddisabled-mode fill, never silent loss. - pg_toast in the shadow PG catalog. Would promote the catalog shadow to a full data replica, reintroducing the cross-segment missing-page PANIC class filter rewrite exists to avoid, and coupling every detoast to a replay-LSN wait + the catalog mutex. The CH mirror is append-only, walshadow-owned, lifecycle-independent.