Skip to content

Commit b54fbe2

Browse files
committed
fix(snapshot): stage incremental apply into a scratch image copy
Writing the target state directly into the live main.db meant a follower's own free-list chain or commit-history tree could collide with pages the incoming delta claims, so any such collision had to be refused outright. Building the target in a scratch copy alongside main.db and swapping it in with a rename removes that constraint: colliding structures are relocated onto fresh ids instead, so the apply only refuses a delta when the collision is genuinely unrelocatable base-live state. Splits snapshot::apply into a module directory (delta streaming, image staging, segment staging) and adds a read-only Pager view plus an EpochKeyring snapshot so the target image's trees can be authenticated before it goes live.
1 parent a21d38e commit b54fbe2

18 files changed

Lines changed: 1289 additions & 509 deletions

CHANGELOG.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,6 @@ version, since none exists yet.
5353
- Single-writer per database; multi-writer cross-process is not supported.
5454
- Writes carry per-page AEAD and copy-on-write overhead; for throughput-bound
5555
plaintext KV workloads a generic store may be faster.
56-
- An incremental snapshot cannot always be applied: if the producer recycled any
57-
page the follower's base state still holds, apply reports
58-
`PagedbError::SnapshotBasePageReused` and the follower needs a full snapshot or
59-
a nearer base commit. Copy-on-write recycles page ids continuously, so a
60-
follower should expect this within a few deltas under ordinary write churn and
61-
must be able to fall back to a full snapshot. The refusal is checked before any
62-
page is written, so it leaves the follower unchanged.
56+
- Applying an incremental snapshot stages a full copy of `main.db` alongside the
57+
original and commits it with a rename, so a follower needs room for two copies
58+
of the database during an apply.

src/errors.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,23 @@ pub enum PagedbError {
5656
#[error("incremental snapshot is incompatible: {field}")]
5757
SnapshotIncompatible { field: &'static str },
5858

59-
/// The target state reuses a page the base commit still hosts, so the delta
60-
/// cannot be expressed without overwriting state a follower on that base
61-
/// still depends on.
59+
/// A delta record names a page the base commit's readers can still reach.
6260
///
63-
/// Not a corruption: both states are internally sound. They simply cannot be
64-
/// related by a page delta, and the correct remedy is a full snapshot or a
65-
/// nearer base commit — which is why this is distinct from
66-
/// [`CorruptionDetail::SnapshotArtifactInvalid`].
61+
/// A delta is defined as target-reachable minus base-reader-visible, so no
62+
/// well-formed export produces such a record: the pages both sides of the
63+
/// protocol can name are exactly the ones it must not carry. Raised before
64+
/// anything is staged.
65+
///
66+
/// Recycling ids the *follower's own* free-list chain or commit-history tree
67+
/// occupies is not this condition and never raises it. The producer cannot
68+
/// see those pages, so it cannot avoid them; the apply relocates both
69+
/// structures out of the incoming page space instead of refusing an
70+
/// otherwise-healthy delta.
71+
///
72+
/// Not a corruption of this store: both states are internally sound, they
73+
/// simply cannot be related by this delta — which is why this is distinct
74+
/// from [`CorruptionDetail::SnapshotArtifactInvalid`]. The remedy is a full
75+
/// snapshot or a nearer base commit.
6776
#[error("incremental snapshot would overwrite base-live page {page_id}")]
6877
SnapshotBasePageReused { page_id: u64 },
6978

src/pager/core.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,59 @@ impl KindBinding {
224224
}
225225
}
226226

227+
impl<V: Vfs + Clone> Pager<V> {
228+
/// A read-only Pager over an alternate main.db image at `image_path`.
229+
///
230+
/// An incremental apply builds the target image in a scratch file and has to
231+
/// authenticate the producer's trees *inside* that image before the rename
232+
/// that makes it live. The live Pager cannot serve those reads: it is still
233+
/// answering base-image reads for concurrent Follower readers, and
234+
/// repointing it would hand them target bytes for a state no durable header
235+
/// names yet.
236+
///
237+
/// The view carries a snapshot of the epoch keyring, so a page sealed under
238+
/// any epoch the live handle can decrypt opens here too. It is read-only, so
239+
/// it can never issue a nonce or write to the image — the single nonce
240+
/// counter stays with the live Pager, and no page is ever sealed twice.
241+
///
242+
/// The view's buffer pool is sized by the same `OpenOptions` budget as the
243+
/// live one. Callers drop cached main pages before opening a view and drop
244+
/// the view before the swap, so the two pools are not resident at full size
245+
/// at the same time.
246+
// Only the incremental-apply staging path opens a view, and that path is
247+
// native-only.
248+
#[cfg(not(target_arch = "wasm32"))]
249+
pub(crate) fn open_main_view(&self, image_path: String) -> Self {
250+
let mut cfg = self.cfg.clone();
251+
cfg.main_db_path = image_path;
252+
let inner = Arc::new(PagerInner {
253+
buffer_pool: parking_lot::Mutex::new(PageCache::with_capacity(cfg.buffer_pool_pages)),
254+
// A view exists to walk main-db trees; no caller routes a segment or
255+
// journal page through it, so its second cache class is given the
256+
// smallest legal budget rather than a copy of the configured one.
257+
segment_cache: parking_lot::Mutex::new(PageCache::with_capacity(1)),
258+
buffer_pool_hits: AtomicU64::new(0),
259+
buffer_pool_misses: AtomicU64::new(0),
260+
metrics_enabled: false,
261+
});
262+
let main_nonce = MainDbNonceGen::new(&cfg.main_db_file_id, cfg.anchor_budget);
263+
Self {
264+
dek_lru: parking_lot::Mutex::new(DekLru::with_capacity(cfg.dek_lru_capacity)),
265+
main_nonce: parking_lot::Mutex::new(main_nonce),
266+
segment_nonces: parking_lot::Mutex::new(BTreeMap::new()),
267+
journal_nonces: parking_lot::Mutex::new(BTreeMap::new()),
268+
files: AsyncMutex::new(BTreeMap::new()),
269+
inner,
270+
active_epoch: AtomicU64::new(self.active_epoch.load(AtomOrd::SeqCst)),
271+
read_only: AtomicBool::new(true),
272+
keyring: self.keyring.duplicate(),
273+
observer_retry_count: self.observer_retry_count,
274+
vfs: self.vfs.clone(),
275+
cfg,
276+
}
277+
}
278+
}
279+
227280
impl<V: Vfs> Pager<V> {
228281
pub(crate) fn page_size(&self) -> usize {
229282
self.cfg.page_size

src/snapshot/apply.rs

Lines changed: 0 additions & 226 deletions
This file was deleted.

0 commit comments

Comments
 (0)