Skip to content

Commit a91be3d

Browse files
authored
fix(version): refuse a snapshot read below the retained history (#617)
## Summary `SuperVersions::get_version_for_snapshot` panicked with `should always find a SuperVersion` when a read asked for a snapshot at or below the oldest retained version's seqno. Compaction maintenance (with the caller's GC watermark) and `clear` prune the version history past such snapshots, so every read API (`get`, `multi_get`, range / prefix iterators, seekable and batch scans, columnar scan, range estimates) could take the tree down on a valid argument. The read is now refused with a typed error instead of served from a newer version (which would silently return data the snapshot never saw), and the boundary is durable: - `Error::SnapshotBelowRetention { requested, oldest_retained }`: point reads return it directly; iterators yield it as their first and only item (also via `peek_key` on the seekable iterator, whose seeks become no-ops). Standard and KV-separated trees alike, including an empty `multi_get` batch. - `AbstractTree::oldest_retained_seqno()`: the read boundary, so a caller can validate a long-lived snapshot before reading. A snapshot is servable iff it is `0` or strictly above that seqno. - Snapshot `0` stays served from the oldest retained version: nothing is visible at `0` from any version, so the choice is immaterial and probing an empty tree keeps working after pruning. - **The boundary survives a reopen.** `Version::retention_floor` (persisted as a manifest section plus an appended edit-log field, both optional so older manifests recover as `0`) records the highest snapshot an install made unservable: a GC compaction with watermark `w` sets it to `w - 1`, capped at the compaction's own install seqno (so a `SeqNo::MAX` watermark cannot refuse every later snapshot); a `clear` / `drop_range` / FIFO eviction / a compaction whose user filter removed or rewrote rows sets it to its own install seqno; additive installs (flush, ingest, trivial move) and an empty drop leave it alone. The install passes a `RetentionEffect` to `upgrade_version`, so the floor rides in the same version edit as the data loss it records. A reopened history is seeded at the floor, version seqnos are clamped non-decreasing, and the front is checked explicitly, so a counter reset below the floor cannot slip a version under it. - `AbstractTree::retention_floor()`: the persisted boundary (what a reopen enforces), distinct from the live `oldest_retained_seqno()`, and the value a deployment records for a later repair. - `Config::repair_retention_floor` (default `0`): a rebuilt manifest cannot derive the floor (a GC compaction zeroes the settled rows' seqnos) and must not guess it (the external-WAL reconciliation reads intermediate snapshots back after a repair), so the deployment supplies the `retention_floor()` it last recorded. - `Tree::create_{iter,range,prefix,seekable_range_bounds}` (doc-hidden) return `Result` and raise the error before any I/O; the happy path gains no per-item branch, and the lock-free `snapshot_for_read` fast path is unchanged. - Docs: `INVARIANTS.md` (retention boundary, its durability, and the storage cost of a retention window), `manifest-recovery.md` (retention floor after a repair), `external-wal.md` (repair floor), rustdoc on the new API. ## Testing - `tests/snapshot_below_retention.rs` (31 tests): every read surface on both tree types, the `clear` path, snapshot `0`, the exact boundary (`oldest` fails, `oldest + 1` succeeds), the unpruned case; the boundary after a reopen following a GC compaction, `clear`, `drop_range`, FIFO eviction, a leveled merge and a filtering compaction at watermark 0, a `SeqNo::MAX` watermark capped at the install, an empty `drop_range` leaving it alone, additive installs leaving it at `0`, manifest rotation (floor read from the snapshot section), a reopen chain with a rising floor, a reset counter, the persisted-vs-live boundary, checkpoints, and repair with and without the configured floor. - Unit tests: edit codec round-trips (floor with / without blob frontiers, pre-floor payloads), `diff` emits the floor only when changed, recovery applies it, the history seeds at the floor and checks the front before searching. - Gates: `cargo fmt --check`, `cargo clippy --workspace --all-targets` (default and `--all-features`) with `-D warnings`, `cargo doc --no-deps` (default and `--all-features`) 0 warnings, no-std check 0 errors, `cargo nextest run --workspace --all-features` 3288/3288, `cargo test --doc --all-features` 80/80. Closes #616
1 parent b5c834a commit a91be3d

31 files changed

Lines changed: 2462 additions & 156 deletions

docs/INVARIANTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,45 @@ matching entry (and add one for a new subsystem).
132132
visible at read seqno `s + 1`. Enforced in the read path (`src/tree`, `src/mvcc_stream.rs`) and the
133133
seqno ordering (`src/seqno.rs`, `src/value.rs`).
134134

135+
- **A snapshot is served from a retained version, or refused; never clamped.**
136+
A read at snapshot `R` resolves to the newest `SuperVersion` installed below
137+
`R`. Compaction maintenance keeps only the newest version below the caller's
138+
GC watermark (`major_compact`'s `seqno_threshold`) and releases the older
139+
ones, and `clear` drains the history to the new empty version; a read at
140+
`0 < R <= oldest_retained_seqno()` then has no version to be served from and
141+
fails with `Error::SnapshotBelowRetention` (point reads directly, iterators as
142+
their first and only item) rather than being served from a newer version,
143+
which would return data the snapshot never saw. Snapshot `0` sees nothing
144+
from any version and is always served empty. Enforced in
145+
`src/version/super_version.rs` (`get_version_for_snapshot`) and surfaced by
146+
every read path that resolves a snapshot (`src/tree`, `src/blob_tree`).
147+
148+
- **The retention boundary is durable.** An install that discards what older
149+
snapshots saw raises the version's *retention floor* in the same version
150+
edit (`Version::retention_floor`, the `retention_floor` manifest section and
151+
the appended edit-log field): a GC compaction with watermark `w` sets it to
152+
`w - 1` (capped at its own install seqno), a `clear`, a table drop or a
153+
compaction whose filter removed or rewrote rows to its own install seqno;
154+
a flush, ingest, move, relocation or an empty drop leaves it alone.
155+
`AbstractTree::retention_floor` exposes the persisted value. A reopened history is seeded at
156+
the floor, so the snapshots the live tree refused stay refused after a
157+
restart instead of being answered from the surviving version. Version seqnos
158+
are non-decreasing along the history (`upgrade_version_with_seqno` clamps),
159+
so a counter reset below the floor cannot slip a version under it. A
160+
manifest rebuilt by `Config::repair` seeds the floor from
161+
`Config::repair_retention_floor` (default `0`): the tables cannot record it
162+
and the engine must not guess it (see
163+
[manifest-recovery.md](manifest-recovery.md#retention-floor)).
164+
165+
- **Retention is versioned, so its storage cost scales with write + compaction
166+
volume.** History is served from whole retained `SuperVersion`s: every table a
167+
compaction consumed stays on disk until the GC watermark passes that
168+
compaction's install seqno, whether or not the snapshot window still needs any
169+
key version inside it. A wide window therefore costs the disk of every flush
170+
and compaction output produced while it was open, not just the disk of the
171+
superseded key versions; keep `seqno_threshold` as close to the oldest live
172+
snapshot as the caller can prove.
173+
135174
- **Re-applying a put / delete at its original seqno is idempotent; a merge
136175
operand is NOT.** For a put or delete, the same (key, value, seqno) reproduces
137176
the same MVCC version (an overwrite), which is what makes external-WAL replay

docs/external-wal.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,20 @@ up to `b` for `LostUpTo(b)`, unbounded for `FullHistory`.
240240
Run the replay BEFORE publishing your visible watermark, so readers never
241241
observe the repaired-but-not-yet-reconciled state.
242242

243+
**Snapshots below the retention boundary stay refused.** A live tree refuses
244+
a snapshot read below the history it no longer holds
245+
(`Error::SnapshotBelowRetention`): a compaction's GC watermark, a `clear`, a
246+
table drop (`drop_range`, FIFO eviction) or a filtering compaction each raise
247+
that boundary, and the manifest carries it across a normal reopen. A repair
248+
rebuilds the manifest from the tables, which do not record it, so record
249+
`retention_floor()` in your own durable state whenever you record the trim
250+
watermark `W` (it already folds in every operation that raised the boundary,
251+
not only the GC watermark) and pass the last recorded value as
252+
`Config::repair_retention_floor` on the repairing `Config`: the repaired tree
253+
then refuses exactly the snapshots the source refused, and the reconciled
254+
replay above the floor stays readable. Left at `0`, the repaired tree serves
255+
every snapshot, including ones a past compaction or drop collected.
256+
243257
## Executable companion
244258

245259
This recipe is not only specified here; it is executed and self-verified in the

docs/manifest-recovery.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,34 @@ claiming success would describe a tree that cannot open. Quarantine is not
312312
a fallback here — it preserves damaged *data*, and this file holds none. A
313313
retry after the filesystem is fixed completes the drop.
314314

315+
## Retention floor
316+
317+
A live tree refuses a snapshot read below the oldest version its history
318+
retains (`Error::SnapshotBelowRetention`), and the manifest records that
319+
boundary as the version's *retention floor* so the refusal holds across a
320+
reopen: a GC compaction raises it to its watermark minus one (capped at the
321+
compaction's own install seqno), a `clear`, a table drop or a compaction
322+
whose filter transformed rows to the install's own seqno. The lost manifest
323+
was the floor's only durable copy, and the tables cannot stand in for it: a
324+
GC compaction zeroes the seqnos of the rows it settles, so a table's highest
325+
seqno reads as `0` precisely on the trees where history was collected.
326+
327+
Nor may the rebuilt manifest guess the floor high (say, at the highest
328+
persisted seqno): the external-WAL reconciliation that follows a repair
329+
([external-wal.md](external-wal.md), section 4) restores intermediate
330+
snapshots and reads them back, so a guessed floor would refuse history the
331+
caller has just made whole. The floor is therefore **supplied by the caller**:
332+
`Config::repair_retention_floor(floor)` seeds the rebuilt manifest with it,
333+
and the reopened tree refuses every snapshot at or below `floor` exactly as it
334+
did before the manifest was lost. Record `AbstractTree::retention_floor()` in
335+
the deployment's own durable state (it is the maximum over every operation
336+
that raised the boundary: GC compactions, `clear`, table drops, filtering
337+
compactions) and pass the last recorded value; deriving it from GC watermarks
338+
alone would miss the drops. Left at the default `0`, a repaired tree serves
339+
every snapshot, which is correct only if history was never collected. No
340+
resurrection question arises: the floor discards nothing, it only declines to
341+
answer what cannot be answered honestly.
342+
315343
## Blob frame validation and salvage
316344

317345
Before a blob file's digest is recorded, its live frame range is walked frame

src/abstract_tree.rs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,11 @@ pub trait AbstractTree: sealed::Sealed {
565565
/// Returns an iterator that scans through the entire tree.
566566
///
567567
/// Avoid using this function, or limit it as otherwise it may scan a lot of items.
568+
///
569+
/// A snapshot the history no longer retains (see
570+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)) yields
571+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
572+
/// as the iterator's first and only item.
568573
fn iter(
569574
&self,
570575
seqno: SeqNo,
@@ -576,6 +581,11 @@ pub trait AbstractTree: sealed::Sealed {
576581
/// Returns an iterator over a prefixed set of items.
577582
///
578583
/// Avoid using an empty prefix as it may scan a lot of items (unless limited).
584+
///
585+
/// A snapshot the history no longer retains (see
586+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)) yields
587+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
588+
/// as the iterator's first and only item.
579589
fn prefix<K: AsRef<[u8]>>(
580590
&self,
581591
prefix: K,
@@ -586,6 +596,11 @@ pub trait AbstractTree: sealed::Sealed {
586596
/// Returns an iterator over a range of items.
587597
///
588598
/// Avoid using full or unbounded ranges as they may scan a lot of items (unless limited).
599+
///
600+
/// A snapshot the history no longer retains (see
601+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)) yields
602+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
603+
/// as the iterator's first and only item.
589604
fn range<K: AsRef<[u8]>, R: RangeBounds<K>>(
590605
&self,
591606
range: R,
@@ -601,6 +616,12 @@ pub trait AbstractTree: sealed::Sealed {
601616
/// so a consumer can jump a live iterator to any key (`RocksDB` `Seek` /
602617
/// `SeekForPrev`) — enabling data-dependent scans (joins, skip-scan) without
603618
/// reopening per-SST readers per jump.
619+
///
620+
/// A snapshot the history no longer retains (see
621+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)) yields
622+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
623+
/// as the iterator's first and only item (also through `peek_key`); seeks
624+
/// on such an iterator are no-ops.
604625
fn range_seekable<K: AsRef<[u8]>, R: RangeBounds<K>>(
605626
&self,
606627
range: R,
@@ -619,6 +640,11 @@ pub trait AbstractTree: sealed::Sealed {
619640
///
620641
/// The interval source is pulled lazily, so intervals may be produced on
621642
/// demand (e.g. computed from rows already returned).
643+
///
644+
/// A snapshot the history no longer retains (see
645+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)) yields
646+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
647+
/// as the iterator's first and only item.
622648
fn batch_range_scan<K: AsRef<[u8]>, R: RangeBounds<K> + 'static, I: IntoIterator<Item = R>>(
623649
&self,
624650
intervals: I,
@@ -686,6 +712,16 @@ pub trait AbstractTree: sealed::Sealed {
686712
/// or GC happens** — `major_compact(target, 0)` only restructures tables and
687713
/// leaves a merge-only key's full operand chain intact.
688714
///
715+
/// The same watermark prunes the version history: every version older
716+
/// than the newest one installed below `seqno_threshold` is released
717+
/// (and the tables only those versions referenced become deletable).
718+
/// Afterwards a snapshot at or below the oldest retained version's seqno
719+
/// (see [`oldest_retained_seqno`](Self::oldest_retained_seqno)) can no
720+
/// longer be read and fails with
721+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention),
722+
/// which is why the watermark must not exceed the oldest snapshot still in
723+
/// use.
724+
///
689725
/// # Errors
690726
///
691727
/// Will return `Err` if an IO error occurs.
@@ -946,6 +982,81 @@ pub trait AbstractTree: sealed::Sealed {
946982
/// Returns the highest sequence number that is flushed to disk.
947983
fn get_highest_persisted_seqno(&self) -> Option<SeqNo>;
948984

985+
/// Returns the seqno of the oldest version the history still retains:
986+
/// the lower bound of the readable snapshot window.
987+
///
988+
/// A read at snapshot `seqno` is served from the newest retained version
989+
/// installed below it, so a snapshot is servable iff it is `0` (sees
990+
/// nothing from any version) or strictly above this seqno; a read at
991+
/// `0 < seqno <= oldest_retained_seqno()` fails with
992+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention).
993+
/// The boundary advances when [`major_compact`](Self::major_compact)
994+
/// prunes the history up to its `seqno_threshold` and when
995+
/// [`clear`](Self::clear) drains it, so a caller holding a long-lived
996+
/// snapshot (a lagging consumer, a point-in-time query) can validate it
997+
/// here before reading and report "history collected" instead of an
998+
/// unexpected error mid-scan. A fresh tree reports `0`.
999+
///
1000+
/// The boundary survives a reopen. The install that discards what older
1001+
/// snapshots saw records it in the same version edit: after a GC
1002+
/// compaction with watermark `w` the reopened boundary is `w - 1`, capped
1003+
/// at the compaction's own install seqno (the retained pre-compaction
1004+
/// version that served reads between the live front and `w` does not
1005+
/// survive a restart), after a `clear`, a table drop or a compaction
1006+
/// whose filter transformed rows it is that install's seqno. A manifest rebuilt by
1007+
/// [`Config::repair`](crate::Config::repair) cannot know what the lost
1008+
/// manifest recorded and seeds the boundary from
1009+
/// [`Config::repair_retention_floor`](crate::Config::repair_retention_floor)
1010+
/// (default `0`: every snapshot served).
1011+
///
1012+
/// # Examples
1013+
///
1014+
/// ```
1015+
/// # let folder = tempfile::tempdir()?;
1016+
/// use lsm_tree::{AbstractTree, Config, Error, SequenceNumberCounter};
1017+
///
1018+
/// let seqno = SequenceNumberCounter::default();
1019+
/// let tree = Config::new(folder, seqno.clone(), Default::default()).open()?;
1020+
/// assert_eq!(tree.oldest_retained_seqno(), 0);
1021+
///
1022+
/// tree.insert("a", "v1", seqno.next());
1023+
/// tree.flush_active_memtable(0)?;
1024+
/// let stale = seqno.get();
1025+
/// tree.insert("a", "v2", seqno.next());
1026+
/// tree.flush_active_memtable(0)?;
1027+
///
1028+
/// // A watermark above every live snapshot lets compaction prune the
1029+
/// // history; the boundary moves past the stale snapshot.
1030+
/// tree.major_compact(u64::MAX, seqno.get())?;
1031+
/// let oldest = tree.oldest_retained_seqno();
1032+
/// assert!(stale <= oldest);
1033+
/// assert!(matches!(
1034+
/// tree.get("a", stale),
1035+
/// Err(Error::SnapshotBelowRetention { .. })
1036+
/// ));
1037+
/// assert_eq!(tree.get("a", oldest + 1)?.as_deref(), Some(b"v2".as_slice()));
1038+
/// #
1039+
/// # Ok::<(), lsm_tree::Error>(())
1040+
/// ```
1041+
fn oldest_retained_seqno(&self) -> SeqNo;
1042+
1043+
/// Returns the PERSISTED retention boundary: the highest snapshot seqno
1044+
/// the tree will refuse after a reopen.
1045+
///
1046+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno) is the LIVE
1047+
/// boundary, which the in-memory version history may hold below this
1048+
/// one: a table drop with no GC watermark keeps its pre-drop version
1049+
/// retained (and serving) until the restart, while the manifest already
1050+
/// records the drop's install seqno here. The two agree after a reopen.
1051+
///
1052+
/// This is the value a deployment records for a later manifest repair
1053+
/// ([`Config::repair_retention_floor`](crate::Config::repair_retention_floor)):
1054+
/// it already folds in every operation that advanced the boundary (GC
1055+
/// compactions, `clear`, table drops, filtering compactions), so no
1056+
/// caller-side bookkeeping of watermarks is needed. `0` until the first
1057+
/// such install.
1058+
fn retention_floor(&self) -> SeqNo;
1059+
9491060
/// Scans the entire tree, returning the number of items.
9501061
///
9511062
/// ###### Caution
@@ -1127,7 +1238,17 @@ pub trait AbstractTree: sealed::Sealed {
11271238
///
11281239
/// # Errors
11291240
///
1130-
/// Will return `Err` if an IO error occurs.
1241+
/// Will return `Err` if an IO error occurs, or
1242+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention)
1243+
/// when the history no longer retains a version for `seqno` (see
1244+
/// [`oldest_retained_seqno`](Self::oldest_retained_seqno)). The same applies
1245+
/// to every read that resolves a snapshot: [`get_pinned`](Self::get_pinned),
1246+
/// [`contains_key`](Self::contains_key), [`size_of`](Self::size_of),
1247+
/// [`multi_get`](Self::multi_get), [`len`](Self::len),
1248+
/// [`is_empty`](Self::is_empty), [`first_key_value`](Self::first_key_value),
1249+
/// [`last_key_value`](Self::last_key_value),
1250+
/// [`approximate_range_stats`](Self::approximate_range_stats) and
1251+
/// [`approximate_range_cardinality`](Self::approximate_range_cardinality).
11311252
fn get<K: AsRef<[u8]>>(&self, key: K, seqno: SeqNo) -> crate::Result<Option<UserValue>>;
11321253

11331254
/// Retrieves an item from the tree as a [`PinnableSlice`](crate::PinnableSlice).

src/blob_tree/ingest.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ impl<'a> BlobIngestion<'a> {
299299
&*self.tree.index.config.fs,
300300
self.tree.index.0.runtime_config.load_full(),
301301
self.tree.index.0.config.encryption.clone(),
302+
// Ingestion only adds tables and blob files: older snapshots keep
303+
// everything.
304+
crate::version::RetentionEffect::Keep,
302305
)?;
303306

304307
// Perform maintenance on the version history (e.g., clean up old versions).

0 commit comments

Comments
 (0)