Skip to content

Commit a812d42

Browse files
committed
fix(snapshot): stop rejecting deltas over follower-local pages
Incremental apply distinguished the base's reader-visible page set from its published set only for the write guard, then reused the wider published set to define which pages the delta must contain. That made a producer's legitimate reuse of a page still held by the follower's own free-list chain or commit-history tree look like a malformed artifact instead of the true base/target mismatch it is. Split the two page sets explicitly, report the real collision as a new SnapshotBasePageReused error, restrict segment restore to segment-id filenames, and record destination ownership so failed export/restore/ apply cleanup never deletes a directory the caller pre-created.
1 parent 71d1894 commit a812d42

5 files changed

Lines changed: 406 additions & 123 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9-
### Fixed
10-
11-
- Snapshot export, restore, and incremental apply now fail closed on malformed,
12-
incomplete, extra, or unreadable artifacts instead of silently skipping
13-
pages or segment files; exported files are synced before success.
14-
- Incremental apply authenticates its exact reachable page and segment sets,
15-
invalidates stale cached plaintext after raw page writes, preserves published
16-
base pages, rejects stale future-page dependencies, and journals both segment
17-
promotion and tombstoning before publishing the target header.
18-
19-
## [0.1.0] - 2026-06-07
20-
21-
Initial release.
9+
The first stable release. Pre-releases are published as `0.1.0-beta.N`; the
10+
entries below describe `0.1.0` as a whole rather than deltas against a shipped
11+
version, since none exists yet.
2212

2313
### Added
2414

@@ -40,7 +30,9 @@ Initial release.
4030
(Grand Central Dispatch), Android, WASM/OPFS, and WASI backends, plus a
4131
tokio thread-pool fallback and an in-memory backend, with format-bit identity
4232
across targets.
43-
- **Snapshots**`snapshot_to`, `restore_from`, and incremental apply.
33+
- **Snapshots**`snapshot_to`, `restore_from`, and incremental apply, each
34+
authenticated against the exact state its manifest describes. Destinations
35+
must be empty; malformed or incomplete artifacts fail closed.
4436
- **Recovery** — open-flow GC, apply-journal replay, deep-walk `fsck`, and the
4537
`pagedb-fsck` binary.
4638
- **Online rekey** — rekey the database under a new key with mixed-cipher and
@@ -53,5 +45,7 @@ Initial release.
5345
- Single-writer per database; multi-writer cross-process is not supported.
5446
- Writes carry per-page AEAD and copy-on-write overhead; for throughput-bound
5547
plaintext KV workloads a generic store may be faster.
56-
57-
[0.1.0]: https://github.com/nodedb-lab/pagedb/releases/tag/v0.1.0
48+
- An incremental snapshot cannot always be applied: if the producer recycled a
49+
page the follower's own free-list chain or commit-history tree still hosts,
50+
apply reports `PagedbError::SnapshotBasePageReused` and the follower needs a
51+
full snapshot or a nearer base commit.

src/errors.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ 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.
62+
///
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`].
67+
#[error("incremental snapshot would overwrite base-live page {page_id}")]
68+
SnapshotBasePageReused { page_id: u64 },
69+
5970
#[error("commit {commit:?} is durable but unpublished; reopen required")]
6071
DurablyCommittedButUnpublished { commit: CommitId },
6172

@@ -433,6 +444,12 @@ impl PagedbError {
433444
Self::SnapshotIncompatible { field }
434445
}
435446

447+
/// Canonical constructor for a target state that reuses a base-live page.
448+
#[must_use]
449+
pub const fn snapshot_base_page_reused(page_id: u64) -> Self {
450+
Self::SnapshotBasePageReused { page_id }
451+
}
452+
436453
/// Canonical constructor for a handle whose newest durable commit could
437454
/// not be reconciled into its reader-visible state.
438455
#[must_use]

src/snapshot/apply.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
1010

1111
use crate::Result;
1212
use crate::errors::PagedbError;
13+
use crate::pager::page_space::is_reserved;
1314

1415
/// Pages consumed from one `pages.delta` stream during an incremental apply.
1516
pub struct AppliedDeltaPages {
@@ -29,7 +30,6 @@ pub async fn apply_delta_pages(
2930
dst_main_db_path: &Path,
3031
page_size: usize,
3132
protected_page_ids: &BTreeSet<u64>,
32-
base_allocation_floor: u64,
3333
target_next_page_id: u64,
3434
) -> Result<AppliedDeltaPages> {
3535
let delta_path = src_path.join("pages.delta");
@@ -68,14 +68,25 @@ pub async fn apply_delta_pages(
6868
.await
6969
.map_err(PagedbError::Io)?;
7070
let page_id = u64::from_be_bytes(id_buf);
71-
if page_id < base_allocation_floor
72-
|| page_id >= target_next_page_id
73-
|| protected_page_ids.contains(&page_id)
74-
{
71+
// Reserved pages are the A/B headers and the apply-journal slots. A
72+
// delta record naming one would overwrite the very state that makes the
73+
// apply recoverable, so it is rejected by identity rather than by
74+
// happening to fall under some allocation bound.
75+
if is_reserved(page_id) || page_id >= target_next_page_id {
7576
return Err(PagedbError::snapshot_artifact_invalid(
7677
"pages.delta.page_id",
7778
));
7879
}
80+
// The follower keeps its own free-list chain and commit-history tree
81+
// across an apply, and those pages are invisible to the producer, which
82+
// can neither predict nor avoid them. A collision is therefore not a
83+
// malformed artifact — both states are internally sound, they simply
84+
// cannot be related by a page delta — so it reports as
85+
// `SnapshotBasePageReused` and the remedy is a full snapshot or a nearer
86+
// base. Caught here, before `main.db` is opened for writing.
87+
if protected_page_ids.contains(&page_id) {
88+
return Err(PagedbError::snapshot_base_page_reused(page_id));
89+
}
7990
if !page_ids.insert(page_id) {
8091
return Err(PagedbError::snapshot_artifact_invalid(
8192
"pages.delta.duplicate_page_id",

0 commit comments

Comments
 (0)