Skip to content
Merged
43 changes: 24 additions & 19 deletions tests/prop_btreemap_oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,12 @@ impl Oracle {
let start = (key.to_vec(), Reverse(read_seqno - 1));
let end_inclusive = (key.to_vec(), Reverse(0));

for ((k, _), val) in self.data.range(start..=end_inclusive) {
if k != key {
break;
}
return val.clone();
}
None
self.data
.range(start..=end_inclusive)
.take_while(|((k, _), _)| k == key)
.map(|(_, val)| val.clone())
.next()
.flatten()
}

/// Full scan: return all visible (key, value) pairs at read_seqno, sorted by key.
Expand Down Expand Up @@ -137,45 +136,51 @@ fn ops_strategy() -> impl Strategy<Value = Vec<Op>> {

fn run_oracle_test(ops: Vec<Op>) -> Result<(), TestCaseError> {
let tmpdir = lsm_tree::get_tmp_folder();
let tree = Config::new(
&tmpdir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()
.map_err(|e| TestCaseError::fail(format!("failed to open tree: {e}")))?;
let seqno_counter = SequenceNumberCounter::default();
let visible_seqno = SequenceNumberCounter::default();
let tree = Config::new(&tmpdir, seqno_counter.clone(), visible_seqno.clone())
.open()
.map_err(|e| TestCaseError::fail(format!("failed to open tree: {e}")))?;

let mut oracle = Oracle::new();
let mut seqno: u64 = 1;

// Apply all ops.
// Data seqnos come from the shared counter (as required by the API).
// Internal operations (flush, compact) may also advance this counter via
// upgrade_version when they do work, keeping SV seqnos and data seqnos
// interleaved in those cases.
for op in &ops {
match op {
Op::Insert { key_idx, value } => {
let key = key_from_idx(*key_idx);
let seqno = seqno_counter.next();
oracle.insert(key.clone(), value.clone(), seqno);
tree.insert(key, value.clone(), seqno);
seqno += 1;
visible_seqno.fetch_max(seqno + 1);
}
Op::Remove { key_idx } => {
let key = key_from_idx(*key_idx);
let seqno = seqno_counter.next();
oracle.remove(key.clone(), seqno);
tree.remove(key, seqno);
seqno += 1;
visible_seqno.fetch_max(seqno + 1);
}
Op::Flush => {
tree.flush_active_memtable(0)
.map_err(|e| TestCaseError::fail(format!("flush failed: {e}")))?;
}
Op::Compact => {
tree.major_compact(common::COMPACTION_TARGET, seqno)
let gc_watermark = seqno_counter.get();
tree.major_compact(common::COMPACTION_TARGET, gc_watermark)
.map_err(|e| TestCaseError::fail(format!("compact failed: {e}")))?;
}
}
}

// Verify point reads.
let read_seqno = seqno;
// Use visible_seqno — it tracks the visibility watermark and won't
// drift ahead of what the tree considers readable.
let read_seqno = visible_seqno.get();
for idx in 0..KEY_SPACE {
let key = key_from_idx(idx);
let expected = oracle.get(&key, read_seqno);
Expand Down
82 changes: 82 additions & 0 deletions tests/regression_point_read_seqno.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
mod common;
use lsm_tree::{AbstractTree, Config, SequenceNumberCounter};

/// Regression test derived from proptest seed cc 90710f96...
///
/// The original proptest used an independent seqno counter (`let mut seqno = 1`)
Comment thread
polaz marked this conversation as resolved.
/// that did not advance on flush/compact, violating the API contract which
/// requires data seqnos to come from the shared `SequenceNumberCounter` passed
/// to `Config::new`. With independent counters, the tree's internal SuperVersion
/// seqno advances faster than the data seqno, causing `get_version_for_snapshot`
/// to return a stale SuperVersion whose memtable misses recent inserts.
///
/// This test uses the shared counter (correct API usage) and verifies the
/// same operation pattern works correctly.
#[test]
fn point_read_after_compact_flush_returns_latest_value() -> lsm_tree::Result<()> {
let tmpdir = lsm_tree::get_tmp_folder();
let seqno = SequenceNumberCounter::default();
let visible_seqno = SequenceNumberCounter::default();
let tree = Config::new(&tmpdir, seqno.clone(), visible_seqno.clone()).open()?;
let k = vec![0u8];
let v0 = vec![0u8; 8];
let v1 = vec![1u8; 8];

// No-op compact on empty tree
let gc = seqno.get();
tree.major_compact(common::COMPACTION_TARGET, gc)?;
// No-op flush on empty memtable
tree.flush_active_memtable(0)?;

let s = seqno.next();
tree.insert(&k, &v0, s);
visible_seqno.fetch_max(s + 1);
tree.flush_active_memtable(0)?;

let s = seqno.next();
tree.insert(&k, &v0, s);
visible_seqno.fetch_max(s + 1);

// Triple compact (first one moves L0→L6, next two re-compact L6)
let gc = seqno.get();
tree.major_compact(common::COMPACTION_TARGET, gc)?;
tree.major_compact(common::COMPACTION_TARGET, gc)?;
tree.major_compact(common::COMPACTION_TARGET, gc)?;
// Flush the pending memtable entry
tree.flush_active_memtable(0)?;

// Insert+flush cycle
for _ in 0..3 {
let s = seqno.next();
tree.insert(&k, &v0, s);
visible_seqno.fetch_max(s + 1);
tree.flush_active_memtable(0)?;
}

// Second major compact
let gc = seqno.get();
tree.major_compact(common::COMPACTION_TARGET, gc)?;

// Insert + flush after compact (creates L0 table)
let s = seqno.next();
tree.insert(&k, &v0, s);
visible_seqno.fetch_max(s + 1);
tree.flush_active_memtable(0)?;

// Two memtable inserts — last one has v1
let s = seqno.next();
tree.insert(&k, &v0, s);
visible_seqno.fetch_max(s + 1);

let s = seqno.next();
tree.insert(&k, &v1, s);
visible_seqno.fetch_max(s + 1);

let read_seqno = visible_seqno.get();
assert_eq!(
tree.get(&k, read_seqno)?.as_ref().map(|v| v.to_vec()),
Some(v1),
"Point read at seqno={read_seqno} should return v1 (the latest insert)"
);
Ok(())
}
Loading