Skip to content

Commit 0d78cfe

Browse files
committed
feat(snapshot): wire multi-page journal into apply_incremental and open recovery
apply_incremental now writes the journal record to the sidecar via the Pager path, carries the journal_id in the A/B header, installs the producer's target B+ tree roots (active_root and catalog_root) from the manifest, then removes the sidecar once the cleared header is durable. open_existing decodes the pending journal_id from the header fields, replays any in-flight sidecar idempotently, rewrites the header to clear the pointer, removes the drained sidecar, and sweeps any orphaned applyjournal/ debris left by a crash between the clear commit and sidecar removal. SnapshotManifest gains target_active_root_page_id and target_catalog_root_page_id so incremental applies advance the follower's trees to the producer's roots rather than leaving them at the base snapshot. ReadTxn exposes root_page_id() to let callers extract the current root for manifest construction. Add snapshot_basic integration tests covering the produce-and-apply round-trip, crash-replay idempotency, and tree-root advancement.
1 parent a83067b commit 0d78cfe

5 files changed

Lines changed: 237 additions & 46 deletions

File tree

src/snapshot/export.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ pub struct SnapshotManifest {
4747
/// reserved section of the manifest so `restore_from` can reopen with the
4848
/// correct AAD.
4949
pub realm_id: [u8; 16],
50+
/// Data B+ tree root page id at `target_commit`. The receiver installs this
51+
/// as its `active_root` — without it an incremental apply cannot advance
52+
/// the tree past the base snapshot's root.
53+
pub target_active_root_page_id: u64,
54+
/// Catalog B+ tree root page id at `target_commit`. The receiver installs
55+
/// this so incrementally-applied segments are reachable from the catalog.
56+
pub target_catalog_root_page_id: u64,
5057
}
5158

5259
/// Encode and HK-MAC a manifest into the 240-byte on-disk format.
@@ -79,7 +86,11 @@ pub fn encode_manifest(m: &SnapshotManifest, hk_key: &[u8; 32]) -> [u8; MANIFEST
7986
buf[82..86].copy_from_slice(&m.segments_count.to_le_bytes());
8087
// realm_id [16] [86..102]
8188
buf[86..102].copy_from_slice(&m.realm_id);
82-
// reserved zeros [102..224]
89+
// target_active_root_page_id u64 LE [102..110]
90+
buf[102..110].copy_from_slice(&m.target_active_root_page_id.to_le_bytes());
91+
// target_catalog_root_page_id u64 LE [110..118]
92+
buf[110..118].copy_from_slice(&m.target_catalog_root_page_id.to_le_bytes());
93+
// reserved zeros [118..224]
8394
// HK-MAC[16] [224..240]
8495
let mac = compute_manifest_mac(&buf[..224], hk_key);
8596
buf[224..240].copy_from_slice(&mac);
@@ -118,6 +129,9 @@ pub fn decode_manifest(
118129
let segments_count = u32::from_le_bytes(buf[82..86].try_into().unwrap_or([0; 4]));
119130
let mut realm_id = [0u8; 16];
120131
realm_id.copy_from_slice(&buf[86..102]);
132+
let target_active_root_page_id = u64::from_le_bytes(buf[102..110].try_into().unwrap_or([0; 8]));
133+
let target_catalog_root_page_id =
134+
u64::from_le_bytes(buf[110..118].try_into().unwrap_or([0; 8]));
121135
Ok(SnapshotManifest {
122136
version,
123137
kind,
@@ -131,6 +145,8 @@ pub fn decode_manifest(
131145
next_page_id_at_target,
132146
segments_count,
133147
realm_id,
148+
target_active_root_page_id,
149+
target_catalog_root_page_id,
134150
})
135151
}
136152

src/txn/db/open.rs

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ impl<V: Vfs + Clone> Db<V> {
338338
};
339339

340340
let latest_commit = fields.commit_id.0;
341-
let writer = WriterState {
341+
let mut writer = WriterState {
342342
root_page_id: fields.active_root_page_id,
343343
next_page_id: fields.next_page_id,
344344
active_slot,
@@ -355,17 +355,46 @@ impl<V: Vfs + Clone> Db<V> {
355355

356356
let pager_arc = Arc::new(pager);
357357

358-
// Replay any pending apply journal before catalog reconciliation.
359-
// Per architecture §6: the journal must run first so that catalog
360-
// reconciliation sees the post-replay disk state. In this slice no
361-
// producer sets a non-zero page id, so this is a no-op for all
362-
// existing code paths.
363-
crate::recovery::journal::replay_apply_journal(
364-
&*vfs_arc,
358+
// Replay any pending apply journal before catalog reconciliation, so
359+
// reconciliation sees the post-replay disk state. A non-zero
360+
// `apply_journal_root` means a crash interrupted an `apply_incremental`
361+
// after the header swap but before the renames completed; replay
362+
// re-executes them idempotently, then we clear the header pointer and
363+
// remove the now-drained sidecar.
364+
let pending_journal_id = crate::recovery::journal::decode_journal_id(
365365
fields.apply_journal_root_page_id,
366366
fields.apply_journal_root_version,
367-
)
368-
.await?;
367+
);
368+
if crate::recovery::journal::replay_apply_journal(&pager_arc, realm, pending_journal_id)
369+
.await?
370+
.is_some()
371+
{
372+
let mut cleared = fields.clone();
373+
cleared.seq = fields.seq + 1;
374+
cleared.apply_journal_root_page_id = 0;
375+
cleared.apply_journal_root_version = 0;
376+
let new_slot = crate::pager::header::commit_header(
377+
&*vfs_arc,
378+
&main_db_path,
379+
&hk,
380+
&cleared,
381+
active_slot,
382+
page_size,
383+
)
384+
.await?;
385+
pager_arc.remove_journal(pending_journal_id).await?;
386+
writer.active_slot = new_slot;
387+
writer.seq = cleared.seq;
388+
}
389+
390+
// Sweep orphaned journal sidecars. The header now references no pending
391+
// journal, so any file left under `applyjournal/` is debris from a crash
392+
// between the journal-clear commit and sidecar removal; reclaim it.
393+
if let Ok(entries) = vfs_arc.list_dir("applyjournal").await {
394+
for name in entries {
395+
let _ = vfs_arc.remove(&format!("applyjournal/{name}")).await;
396+
}
397+
}
369398

370399
// Walk the catalog and reconcile each segment file against its expected path.
371400
crate::recovery::reconcile_catalog(

src/txn/db/snapshot.rs

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ impl<V: Vfs + Clone> Db<V> {
3737
};
3838

3939
let (target_commit, next_page_id) = { (txn.commit_id().0, txn.next_page_id()) };
40+
let target_active_root_page_id = txn.root_page_id();
41+
let target_catalog_root_page_id = txn.catalog_root_page_id();
4042

4143
// Collect live segment ids from the pinned catalog snapshot.
4244
let segments = txn.list_segments("").await?;
@@ -62,6 +64,8 @@ impl<V: Vfs + Clone> Db<V> {
6264
next_page_id_at_target: next_page_id,
6365
segments_count,
6466
realm_id: self.realm_id.0,
67+
target_active_root_page_id,
68+
target_catalog_root_page_id,
6569
};
6670

6771
let src_root = get_vfs_root(&*self.vfs)?;
@@ -168,6 +172,8 @@ impl<V: Vfs + Clone> Db<V> {
168172

169173
let target_commit = txn.commit_id().0;
170174
let target_next_page_id = txn.next_page_id();
175+
let target_active_root_page_id = txn.root_page_id();
176+
let target_catalog_root_page_id = txn.catalog_root_page_id();
171177

172178
// Get base snapshot's next_page_id by reading the commit history.
173179
let base_txn_result = self.begin_read_at(base_commit).await;
@@ -225,6 +231,8 @@ impl<V: Vfs + Clone> Db<V> {
225231
next_page_id_at_target: target_next_page_id,
226232
segments_count,
227233
realm_id: self.realm_id.0,
234+
target_active_root_page_id,
235+
target_catalog_root_page_id,
228236
};
229237

230238
let src_root = get_vfs_root(&*self.vfs)?;
@@ -253,11 +261,10 @@ impl<V: Vfs + Clone> Db<V> {
253261
&self,
254262
src_path: &std::path::Path,
255263
) -> crate::Result<crate::snapshot::ApplyStats> {
256-
use crate::pager::format::data_page::ENVELOPE_OVERHEAD;
257-
use crate::pager::format::page_kind::PageKind;
258264
use crate::pager::header::commit_header;
259265
use crate::recovery::journal::{
260-
ApplyJournalRecord, JournalAction, encode_apply_journal, execute_journal_actions,
266+
ApplyJournalRecord, JournalAction, encode_journal_id, encode_journal_pages,
267+
execute_journal_actions,
261268
};
262269
use crate::snapshot::apply::{apply_delta_pages, stage_snapshot_segments};
263270
use crate::txn::mode::DbMode;
@@ -315,31 +322,32 @@ impl<V: Vfs + Clone> Db<V> {
315322

316323
let mut state = self.writer.lock().await;
317324

318-
// Select the journal slot by parity of the current version counter.
319-
// Even version → slot page 2; odd version → slot page 3.
320-
let journal_version = state.seq;
321-
let journal_page_id: u64 = if journal_version % 2 == 0 { 2 } else { 3 };
322-
323-
// Write the journal record to the selected slot page via the Pager's
324-
// AEAD path. This ensures the journal is authenticated under the same
325-
// keys as all other pages.
326-
if !actions.is_empty() {
327-
let body_len = page_size - ENVELOPE_OVERHEAD;
325+
// Write the journal record to a fresh apply-journal sidecar via the
326+
// Pager AEAD path. A fresh, never-reused `journal_id` guarantees the
327+
// sidecar's nonce space never collides with another file's under one
328+
// key. The sidecar may span any number of pages, so the promotion set
329+
// is unbounded — no single-page ceiling. The 16-byte id is carried in
330+
// the header's `apply_journal_root` fields after the swap.
331+
let journal_id = if actions.is_empty() {
332+
[0u8; 16]
333+
} else {
334+
let id = self.next_segment_id();
328335
let record = ApplyJournalRecord {
329336
target_commit_id: new_commit_id,
330337
actions: actions.clone(),
331338
};
332-
let body = encode_apply_journal(&record, body_len)?;
333-
self.pager
334-
.write_main_page(
335-
journal_page_id,
336-
self.realm_id,
337-
PageKind::ApplyJournal,
338-
&body,
339-
)
340-
.await?;
341-
self.pager.flush_main(self.realm_id).await?;
342-
}
339+
let pages = encode_journal_pages(&record, page_size)?;
340+
self.vfs.mkdir_all("applyjournal").await?;
341+
for (page_id, body) in pages.iter().enumerate() {
342+
self.pager
343+
.stage_journal_page(id, page_id as u64, self.realm_id, body)
344+
.await?;
345+
}
346+
self.pager.flush_journal(id, self.realm_id).await?;
347+
self.vfs.sync_dir("applyjournal").await.ok();
348+
id
349+
};
350+
let (journal_root_page_id, journal_root_version) = encode_journal_id(&journal_id);
343351

344352
// Commit the A/B header with the journal root pointing at the slot we
345353
// just wrote. After this commit, a crash-recovery replay can re-execute
@@ -348,16 +356,18 @@ impl<V: Vfs + Clone> Db<V> {
348356
let new_seq = state.seq + 1;
349357
let counter_anchor = self.pager.pending_anchor();
350358

359+
// Install the target trees the producer shipped in the manifest. The
360+
// delta pages just written to main.db contain these root pages; pointing
361+
// the header at them is what advances the data and catalog trees past the
362+
// base snapshot (without this, incrementally-applied rows and segments
363+
// are unreachable from the follower's catalog).
364+
let new_root_page_id = manifest.target_active_root_page_id;
365+
let new_catalog_root_page_id = manifest.target_catalog_root_page_id;
366+
351367
let mut catalog_root_bytes = [0u8; 16];
352-
catalog_root_bytes[..8].copy_from_slice(&state.catalog_root_page_id.to_le_bytes());
368+
catalog_root_bytes[..8].copy_from_slice(&new_catalog_root_page_id.to_le_bytes());
353369
catalog_root_bytes[8..].copy_from_slice(&new_commit_id.to_le_bytes());
354370

355-
let journal_root_page_id_for_header = if actions.is_empty() {
356-
0
357-
} else {
358-
journal_page_id
359-
};
360-
361371
let fields_with_journal = MainDbHeaderFields {
362372
format_version: 1,
363373
cipher_id: self.cipher_id.as_byte(),
@@ -367,14 +377,14 @@ impl<V: Vfs + Clone> Db<V> {
367377
kek_salt: self.kek_salt,
368378
mk_epoch: self.mk_epoch.load(std::sync::atomic::Ordering::SeqCst),
369379
seq: new_seq,
370-
active_root_page_id: manifest.next_page_id_at_target,
380+
active_root_page_id: new_root_page_id,
371381
active_root_txn_id: new_commit_id,
372382
counter_anchor,
373383
commit_id: crate::CommitId(new_commit_id),
374384
free_list_root: [0; 16],
375385
catalog_root: catalog_root_bytes,
376-
apply_journal_root_page_id: journal_root_page_id_for_header,
377-
apply_journal_root_version: journal_version,
386+
apply_journal_root_page_id: journal_root_page_id,
387+
apply_journal_root_version: journal_root_version,
378388
commit_history_root_page_id: 0,
379389
commit_history_root_version: 0,
380390
restore_mode: 0,
@@ -417,7 +427,7 @@ impl<V: Vfs + Clone> Db<V> {
417427
kek_salt: self.kek_salt,
418428
mk_epoch: self.mk_epoch.load(std::sync::atomic::Ordering::SeqCst),
419429
seq: new_seq2,
420-
active_root_page_id: new_next_page_id,
430+
active_root_page_id: new_root_page_id,
421431
active_root_txn_id: new_commit_id,
422432
counter_anchor: counter_anchor2,
423433
commit_id: crate::CommitId(new_commit_id),
@@ -444,10 +454,17 @@ impl<V: Vfs + Clone> Db<V> {
444454
self.pager.commit_anchor(counter_anchor2)?;
445455
state.active_slot = new_slot2;
446456
state.seq = new_seq2;
457+
458+
// The journal root is cleared and durable; the sidecar is no longer
459+
// needed. Remove it (a crash before this point leaves the sidecar,
460+
// which the next open's replay re-runs idempotently then removes).
461+
self.pager.remove_journal(journal_id).await?;
447462
}
448463

449464
state.latest_commit_id = new_commit_id;
450465
state.next_page_id = new_next_page_id;
466+
state.root_page_id = new_root_page_id;
467+
state.catalog_root_page_id = new_catalog_root_page_id;
451468
self.latest_commit
452469
.store(new_commit_id, std::sync::atomic::Ordering::SeqCst);
453470
self.publish_snapshot(&state);

src/txn/read.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ impl<'db, V: Vfs + Clone> ReadTxn<'db, V> {
112112
self.catalog_root_page_id
113113
}
114114

115+
/// The data B+ tree root page id at this snapshot.
116+
#[must_use]
117+
pub fn root_page_id(&self) -> u64 {
118+
self.root_page_id
119+
}
120+
115121
fn tree(&self) -> BTree<V> {
116122
BTree::open(
117123
self.db.pager.clone(),

0 commit comments

Comments
 (0)