Skip to content

Commit 1c5a7a2

Browse files
committed
fix(version): persist the retention boundary across reopen
The boundary a GC compaction or `clear` established lived only in the in-memory version history: `SuperVersions::new` seeded the recovered version at seqno 0, so after a restart every historical snapshot was served from the surviving version, silently answering with data the snapshot never saw (the exact outcome `SnapshotBelowRetention` exists to refuse). - `Version::retention_floor`: the highest snapshot seqno the version can no longer serve, monotone across versions; persisted as the manifest `retention_floor` section and an appended edit-log field (both optional, so older manifests recover as floor 0) - `RetentionEffect` names what an install does to older snapshots and is passed to `upgrade_version`: a GC compaction (`GcBelow(w)`) raises the floor to `w - 1`, a `clear` / table drop (`DropsData`) to its own install seqno, additive installs (`Keep`) leave it alone; 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 (a counter reset below the floor cannot slip a version under it) and `get_version_for_snapshot` checks the front explicitly - `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), so the deployment that ran the compactions supplies it - `BlobTree::multi_get` validates the snapshot before the empty-batch return, matching `Tree::multi_get` - the retention fixture pins `first + 1 < oldest_retained_seqno()` so the strict-below probes stay strict - docs: INVARIANTS (durable boundary), manifest-recovery (retention floor), external-wal (repair floor) Regression tests cover reopen after GC compaction / clear / drop_range / FIFO eviction / leveled merge, additive installs, manifest rotation, a reopen chain, a reset counter, checkpoints and repair, on both tree types; edit / diff / recovery / history unit tests cover the codec and the boundary check. Part of #616
1 parent d97ae74 commit 1c5a7a2

26 files changed

Lines changed: 1199 additions & 56 deletions

docs/INVARIANTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,21 @@ matching entry (and add one for a new subsystem).
145145
`src/version/super_version.rs` (`get_version_for_snapshot`) and surfaced by
146146
every read path that resolves a snapshot (`src/tree`, `src/blob_tree`).
147147

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`, a `clear` or a table drop to its own install seqno; a flush,
153+
ingest, move or relocation leaves it alone. A reopened history is seeded at
154+
the floor, so the snapshots the live tree refused stay refused after a
155+
restart instead of being answered from the surviving version. Version seqnos
156+
are non-decreasing along the history (`upgrade_version_with_seqno` clamps),
157+
so a counter reset below the floor cannot slip a version under it. A
158+
manifest rebuilt by `Config::repair` seeds the floor from
159+
`Config::repair_retention_floor` (default `0`): the tables cannot record it
160+
and the engine must not guess it (see
161+
[manifest-recovery.md](manifest-recovery.md#retention-floor)).
162+
148163
- **Retention is versioned, so its storage cost scales with write + compaction
149164
volume.** History is served from whole retained `SuperVersion`s: every table a
150165
compaction consumed stays on disk until the GC watermark passes that

docs/external-wal.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ 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 your GC watermark stay refused.** A live tree refuses a
244+
snapshot read below the history a compaction's GC watermark collected
245+
(`Error::SnapshotBelowRetention`), and the manifest carries that boundary
246+
across a normal reopen. A repair rebuilds the manifest from the tables, which
247+
do not record it, so pass the highest `seqno_threshold` you ever applied,
248+
minus one, as `Config::repair_retention_floor` on the repairing `Config`: the
249+
repaired tree then refuses exactly the snapshots the source refused, and the
250+
reconciled replay above the floor stays readable. Left at `0`, the repaired
251+
tree serves every snapshot, including ones a past compaction collected.
252+
243253
## Executable companion
244254

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

docs/manifest-recovery.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,31 @@ 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, a `clear` or a
321+
table drop to the install's own seqno. The lost manifest was the floor's only
322+
durable copy, and the tables cannot stand in for it: a GC compaction zeroes
323+
the seqnos of the rows it settles, so a table's highest seqno reads as `0`
324+
precisely on the trees where history was collected.
325+
326+
Nor may the rebuilt manifest guess the floor high (say, at the highest
327+
persisted seqno): the external-WAL reconciliation that follows a repair
328+
([external-wal.md](external-wal.md), section 4) restores intermediate
329+
snapshots and reads them back, so a guessed floor would refuse history the
330+
caller has just made whole. The floor is therefore **supplied by the caller**,
331+
the only party that knows the GC watermarks it passed:
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. Pass the highest compaction watermark ever
335+
applied, minus one. Left at the default `0`, a repaired tree serves every
336+
snapshot, which is correct only if history was never collected. No
337+
resurrection question arises: the floor discards nothing, it only declines to
338+
answer what cannot be answered honestly.
339+
315340
## Blob frame validation and salvage
316341

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

src/abstract_tree.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,17 @@ pub trait AbstractTree: sealed::Sealed {
997997
/// here before reading and report "history collected" instead of an
998998
/// unexpected error mid-scan. A fresh tree reports `0`.
999999
///
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` (the
1003+
/// retained pre-compaction version that served reads between the live
1004+
/// front and `w` does not survive a restart), after a `clear` or a table
1005+
/// drop it is that install's seqno. A manifest rebuilt by
1006+
/// [`Config::repair`](crate::Config::repair) cannot know what the lost
1007+
/// manifest recorded and seeds the boundary from
1008+
/// [`Config::repair_retention_floor`](crate::Config::repair_retention_floor)
1009+
/// (default `0`: every snapshot served).
1010+
///
10001011
/// # Examples
10011012
///
10021013
/// ```

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).

src/blob_tree/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,9 @@ impl AbstractTree for BlobTree {
951951
&*config.fs,
952952
self.index.0.runtime_config.load_full(),
953953
self.index.0.config.encryption.clone(),
954+
// Every table and blob file goes: no snapshot up to this install
955+
// is servable after a reopen.
956+
crate::version::RetentionEffect::DropsData,
954957
)?;
955958

956959
// Same MVCC-safe reclaim as the standard tree, plus the blob files:
@@ -1422,13 +1425,17 @@ impl AbstractTree for BlobTree {
14221425
keys: impl IntoIterator<Item = K>,
14231426
seqno: SeqNo,
14241427
) -> crate::Result<Vec<Option<crate::UserValue>>> {
1428+
// The snapshot is validated BEFORE the empty-batch return: the
1429+
// retention contract is per read, not per key, and the standard tree
1430+
// refuses an empty batch below retention the same way.
1431+
let super_version = self.index.snapshot_for_read(seqno)?;
1432+
14251433
let keys: Vec<_> = keys.into_iter().collect();
14261434
let n = keys.len();
14271435
if n == 0 {
14281436
return Ok(Vec::new());
14291437
}
14301438

1431-
let super_version = self.index.snapshot_for_read(seqno)?;
14321439
let comparator = self.index.config.comparator.as_ref();
14331440

14341441
// For small batches, use the simple per-key path

src/compaction/flavour.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,9 @@ pub(super) fn install_merge(
478478
&*opts.config.fs,
479479
opts.runtime_config.load_full(),
480480
opts.encryption.clone(),
481+
// The merge stream GC'd below the watermark: versions a snapshot
482+
// below it depended on are gone from the outputs.
483+
crate::version::RetentionEffect::GcBelow(opts.mvcc_gc_watermark),
481484
)
482485
.inspect_err(|_| {
483486
for table in &created_tables {

src/compaction/worker.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,9 @@ fn run_tight_space_compaction(
13301330
&*opts.config.fs,
13311331
opts.runtime_config.load_full(),
13321332
opts.encryption.clone(),
1333+
// A slice is a merge with GC below the watermark, plus the
1334+
// punched input prefix: older snapshots lose both.
1335+
crate::version::RetentionEffect::GcBelow(opts.mvcc_gc_watermark),
13331336
);
13341337
if let Err(e) = install {
13351338
// The install did not commit, so no sidecar was written (the mark
@@ -1849,6 +1852,8 @@ fn move_tables(
18491852
&*opts.config.fs,
18501853
opts.runtime_config.load_full(),
18511854
opts.encryption.clone(),
1855+
// A trivial move rewrites nothing: every version survives as-is.
1856+
crate::version::RetentionEffect::Keep,
18521857
)?;
18531858

18541859
if let Err(e) = version_history_lock.maintenance(
@@ -2848,6 +2853,10 @@ fn drop_tables(
28482853
&*opts.config.fs,
28492854
opts.runtime_config.load_full(),
28502855
opts.encryption.clone(),
2856+
// Whole tables go (drop-range, FIFO / TTL eviction): the rows every
2857+
// older snapshot saw in them are gone, so no snapshot up to this
2858+
// install is servable after a reopen.
2859+
crate::version::RetentionEffect::DropsData,
28512860
)?;
28522861

28532862
if let Err(e) = version_history_lock.maintenance(

src/config/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,16 @@ pub struct Config {
611611
/// on every upgrade, degenerating to the full-rewrite-per-version behaviour.
612612
pub(crate) manifest_log_rotate_bytes: u64,
613613

614+
/// Retention floor a manifest REPAIR seeds the rebuilt version with: the
615+
/// highest snapshot seqno the repaired tree refuses to serve. The lost
616+
/// manifest was the only record of the floor a past GC compaction or
617+
/// `clear` established, and the tables cannot stand in for it, so the
618+
/// deployment that ran those compactions (and knows the watermark it
619+
/// passed) supplies it here. Defaults to `0`: a repaired tree serves
620+
/// every snapshot, which is right only if history was never collected.
621+
/// Set via [`Config::repair_retention_floor`].
622+
pub(crate) repair_retention_floor: crate::SeqNo,
623+
614624
/// Compaction I/O rate limit in bytes per second.
615625
///
616626
/// Caps the rate at which the compaction worker is allowed to issue
@@ -792,6 +802,7 @@ impl Default for Config {
792802
#[cfg(feature = "std")]
793803
recovery_progress: None,
794804
manifest_log_rotate_bytes: 1024 * 1024,
805+
repair_retention_floor: 0,
795806
compaction_rate_limit: 0,
796807

797808
#[cfg(feature = "std")]
@@ -1633,6 +1644,28 @@ impl Config {
16331644
self
16341645
}
16351646

1647+
/// Sets the retention floor a manifest repair seeds the rebuilt tree with
1648+
/// (default `0`): after [`repair`](Self::repair) /
1649+
/// [`open_or_repair`](Self::open_or_repair) every snapshot at or below
1650+
/// `floor` is refused with
1651+
/// [`Error::SnapshotBelowRetention`](crate::Error::SnapshotBelowRetention),
1652+
/// exactly as the tree refused it before the manifest was lost.
1653+
///
1654+
/// A normal open needs no help: the manifest carries the floor a GC
1655+
/// compaction or `clear` established (see
1656+
/// [`AbstractTree::oldest_retained_seqno`](crate::AbstractTree::oldest_retained_seqno)).
1657+
/// A repair rebuilds the manifest from the tables, which do not record it
1658+
/// (a GC compaction zeroes the seqnos of the rows it settles), so only the
1659+
/// deployment that ran those compactions knows it: pass the highest GC
1660+
/// watermark (`seqno_threshold`) ever applied, minus one. Left at `0`, a
1661+
/// repaired tree serves every snapshot, which is correct only if history
1662+
/// was never collected. Has no effect on an open that finds a manifest.
1663+
#[must_use]
1664+
pub fn repair_retention_floor(mut self, floor: crate::SeqNo) -> Self {
1665+
self.repair_retention_floor = floor;
1666+
self
1667+
}
1668+
16361669
/// Sets the compaction I/O rate limit in bytes per second.
16371670
///
16381671
/// Caps how fast the compaction worker may issue I/O so background

src/error.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,8 @@ pub enum Error {
463463
/// from. Serving it from the oldest retained version instead would
464464
/// silently answer with data the snapshot never saw, so the read is
465465
/// refused. Snapshot `0` is the exception: it sees no entry from any
466-
/// version and is always served (empty).
466+
/// version and is always served (empty). The boundary is persisted with
467+
/// the manifest, so the refusal holds across a reopen as well.
467468
///
468469
/// Point reads return it directly; iterators yield it as their first and
469470
/// only item. [`AbstractTree::oldest_retained_seqno`](crate::AbstractTree::oldest_retained_seqno)

0 commit comments

Comments
 (0)