Skip to content

Make catalog decoding consistent across DDL & restarts - #73

Merged
serprex merged 4 commits into
mainfrom
xact-r1
Jul 31, 2026
Merged

Make catalog decoding consistent across DDL & restarts#73
serprex merged 4 commits into
mainfrom
xact-r1

Conversation

@serprex

@serprex serprex commented Jul 22, 2026

Copy link
Copy Markdown
Member

Replace live catalog cache & invalidation tracking with durable relation descriptor log

Log checkpoints at commit boundaries. This allows looking up relation id at a given LSN


Track next_lsn as EndRecPtr. Synchronize on catalog updating commits

This will be basis for keeping some time series tracking of catalog updates,
in meantime this allows exact comparisons with pg_last_wal_replay_lsn

Also enforce shadow catalog is using streaming replication, avoids deadlocks


Plan transactions before emitting rows

Build and validate each committed transaction before applying schema, config, or ClickHouse side
effects. Freeze descriptors and routes in a checksummed plan, spill large plans to disk, then replay
only after planning succeeds

Defer records from catalog-dirty transactions until commit, decode them against committed
descriptors, and fail ambiguous layouts or unsupported WAL shapes instead of silently dropping rows

@serprex
serprex requested a review from harshil-goel July 22, 2026 23:00
serprex added 2 commits July 22, 2026 23:05
This will be basis for keeping some time series tracking of catalog updates,
in meantime this allows exact comparisons with pg_last_wal_replay_lsn

Also enforce shadow catalog is using streaming replication, avoids deadlocks
Replace live catalog cache & invalidation tracking with durable relation descriptor log

Log checkpoints at commit boundaries. This allows looking up relation id at a given LSN
@serprex
serprex force-pushed the xact-r1 branch 2 times, most recently from 41526a5 to 7e0938b Compare July 23, 2026 19:43
@serprex

serprex commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

PLAN_NO_SKIP_LOOP.md
PLAN_NO_SKIP.md
LOOP_JOURNAL.md

Last commit was built over the course of a 12 hour 35 iteration /loop

serprex added 2 commits July 24, 2026 18:50
Build and validate each committed transaction before applying schema, config, or ClickHouse side
effects. Freeze descriptors and routes in a checksummed plan, spill large plans to disk, then replay
only after planning succeeds

Defer records from catalog-dirty transactions until commit, decode them against committed
descriptors, and fail ambiguous layouts or unsupported WAL shapes instead of silently dropping rows
…dable

Capture marks span it can't prove decodable when a transaction changes a table in place.
Stash resolution asks log at the commit's next_lsn, past that span's end, so it always got a
clean Present back and rows written inside the span decoded under the final descriptor anyway

Only mark a span when bytes really change. typmod and attstorage moves are declared shape only.
Tuple walk reads attlen/attalign/attbyval, so keeps early valid_from and publish nothing.

Publish span per key verdict can name, filenode and oid, so decode and truncate see same fence

Close the neighbouring holes the same way. A filenode landing inside a span with no
XLOG_SMGR_CREATE marker fails closed instead of being tracked without payload and silently
dropped at commit. Draining stashed records with no verdict installed fails closed instead of
sending every one through the discard arm. An aborted tree's verdict is removed so the next
transaction reusing that xid can't fold rows under it

Stashed records drop block images on blocks that also carry data,
without this a dirty COPY spills its whole WAL volume

Run descriptor log compaction on its own task, fed each persisted floor over a watch channel.
Inline on the pump it rewrote the checkpoint while no keepalive was answered, long enough to hit
the source's wal_sender_timeout. Tail-bytes gauge reads a mirror so it no longer dips to zero for
the length of a compaction

Split plan-failure counter by cause so an unmodelled WAL op is distinguishable from spill error
Comment thread src/catalog/desc_log.rs
/// Load `desc_log.ckpt` + `desc_log.tail` under `dir`, repairing a torn
/// tail. Creates an empty tail (header only) when absent. Never creates
/// the ckpt — [`Self::seed`] and GC own it.
pub async fn open(dir: &Path, identity: DescLogIdentity) -> Result<Self> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on the restart, the log is already durable but clickhouse isn't updated yet. shadow pg is also already ahead. i have two concerns here:

  1. Clickhouse will not receive the relevant alter.
  2. What happens if before writng descriptor log we crash and shadow pg is alreayd updated?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on restart we go by manifest, not log. manifest only advances when CH durable. In 2nd scenario we shouldn't hit that since shadow shouldn't be written due to boundary_hold.rs, but catalog_capture.rs will raise "descriptor log lost coverage" if 2nd scenario did somehow happen

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can add some tests:

  • Crash after shadow reaches boundary, before descriptor append
  • Crash after descriptor fsync, before commit enters worker queue
  • Crash after commit queues, before ClickHouse DDL begins
  • Crash after first ClickHouse ALTER statement, before remaining statements
  • Crash after complete ClickHouse DDL, before emitter acknowledgment
  • Crash after in-memory acknowledgment, before manifest fsync
  • Crash after manifest fsync, before descriptor GC

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#36

self.stats.clone()
}

pub async fn capture_boundary(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For higher scale, we will need to think about making this hold per table or something? So that alter in one table doesn't affect other tables.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, https://github.com/ClickHouse/walshadow/blob/be-modular/plans/future/custom_rmgr.md seeks to improve overall efficiency, but we'd have to loosen our LSN ordering to be per table. I've been relying on these being slow path where we keep simpler design

Comment thread src/catalog/desc_log.rs
/// tail. Creates an empty tail (header only) when absent. Never creates
/// the ckpt — [`Self::seed`] and GC own it.
pub async fn open(dir: &Path, identity: DescLogIdentity) -> Result<Self> {
let mut index = Index::default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also what happens if this file gets corrupted during a restart? We will have to force resync?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have some recovery mechanisms: detecting corrupt tail replays beforehand, duplicate detection between checkpoint & tail (checkpoint written before tail, crash in between could cause this), but malformed checkpoint yeah resync

"decoder worker terminated during catalog boundary hold at {commit_lsn:#X}"
));
}
if start.elapsed() >= self.config.hold_timeout {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't walsender timeout first?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to avoid it by having hold_timeout below wal_sender_timeout (30s vs default 60s), but we could do better about handling primary keep alives in meantime. but if this fails then we just reconnect & pull wal from s3 maybe

Comment thread src/filter/engine.rs
if !is_commit {
return Ok(None);
}
// Local relcache invals: second oid source + capture-all trigger.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A missed alter can cause issues. (Due to restart or a new change by pg), should we also do an audit to make sure that the table the consistent?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That capability would be useful, maybe create issue or plans/future

@serprex
serprex merged commit d21bd3f into main Jul 31, 2026
5 checks passed
@serprex
serprex deleted the xact-r1 branch July 31, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants