Skip to main content

sample_app/
sample_app.rs

1//! Sample app: a tour of `subms-lsm-tree`, base API first, then each opt-in
2//! feature. Run the base with `cargo run --example sample_app`; add
3//! `--all-features` (or a subset like `--features wal`) to light up the
4//! feature sections.
5//!
6//! The framing throughout is an embedded order journal: a per-symbol store of
7//! fills keyed by order id, the kind of write-heavy append-shaped state an LSM
8//! tree is built for.
9//!
10//! * base                    - the order journal: put/get, a bloom miss, a cancel-tombstone, a range scan
11//! * wal                     - durable append-before-ack; replay recovers an un-flushed memtable
12//! * tiered-compaction       - size-tiered merge for a write-heavy ingest tier
13//! * leveled-compaction      - leveled merge for a read-latency-SLA serving tier
14//! * snapshot                - a point-in-time read view for an end-of-day report
15//! * lz4                     - fast block compression for the hot tier
16//! * zstd                    - higher-ratio block compression for the cold tier
17//! * block-cache-integration - a read-side LRU in front of block IO
18
19use std::env;
20use std::io;
21use std::path::PathBuf;
22
23use subms_lsm_tree::LsmTree;
24
25fn main() -> io::Result<()> {
26    base_order_journal()?;
27
28    #[cfg(feature = "wal")]
29    wal_durable_log()?;
30
31    #[cfg(feature = "tiered-compaction")]
32    tiered_ingest_tier();
33
34    #[cfg(feature = "leveled-compaction")]
35    leveled_serving_tier();
36
37    #[cfg(feature = "snapshot")]
38    snapshot_end_of_day_report();
39
40    #[cfg(feature = "lz4")]
41    lz4_hot_tier();
42
43    #[cfg(feature = "zstd")]
44    zstd_cold_tier();
45
46    #[cfg(feature = "block-cache-integration")]
47    block_cache_read_path();
48
49    Ok(())
50}
51
52/// A fresh, process-unique data dir under the temp root so repeated runs never
53/// read a previous run's SSTables.
54fn scratch_dir(label: &str) -> PathBuf {
55    let dir = env::temp_dir().join(format!("lsm-sample-{}-{}", label, std::process::id()));
56    let _ = std::fs::remove_dir_all(&dir);
57    dir
58}
59
60/// Base API: a journal of order fills. A small flush threshold rolls a few
61/// SSTables so the read path actually walks more than the memtable. Shows the
62/// four moves that define the store - a hit, a bloom-accelerated miss on an id
63/// that was never written, a cancel that lands as a tombstone, and a sorted
64/// range scan over the live book.
65fn base_order_journal() -> io::Result<()> {
66    println!("== base: embedded order journal ==");
67    let dir = scratch_dir("base");
68    // 256-byte threshold so a handful of fills spill across a couple of SSTables.
69    let mut journal = LsmTree::open(&dir, 256)?;
70
71    journal.put("ORD-0001", b"AAPL,100@150.10")?;
72    journal.put("ORD-0002", b"MSFT,50@320.55")?;
73    journal.put("ORD-0003", b"GOOG,25@140.20")?;
74    journal.flush()?; // roll SSTable_0
75
76    journal.put("ORD-0001", b"AAPL,100@150.42")?; // amended fill shadows the old one
77    journal.put("ORD-0004", b"NVDA,10@900.00")?;
78    journal.delete("ORD-0002")?; // cancel: a tombstone
79    journal.flush()?; // roll SSTable_1
80
81    let filled = journal.get("ORD-0001")?.expect("ORD-0001 is live");
82    println!("  ORD-0001 -> {}", String::from_utf8_lossy(&filled));
83    assert_eq!(filled, b"AAPL,100@150.42", "newest write wins");
84
85    // A cancelled order reads as absent - the tombstone shadows the older fill.
86    assert!(
87        journal.get("ORD-0002")?.is_none(),
88        "cancelled order is absent"
89    );
90
91    // An id that was never written: the per-SSTable bloom answers "no" in a few
92    // hash probes, so the miss never scans a single record.
93    assert!(
94        journal.get("ORD-9999")?.is_none(),
95        "unknown id: bloom-accelerated miss"
96    );
97
98    let book = journal.range(Some("ORD-0001"), Some("ORD-0005"))?;
99    let live_ids: Vec<&str> = book.iter().map(|(k, _)| k.as_str()).collect();
100    println!(
101        "  live book {live_ids:?} across {} sstables",
102        journal.sstable_count()
103    );
104    assert_eq!(
105        live_ids,
106        ["ORD-0001", "ORD-0003", "ORD-0004"],
107        "sorted, tombstone dropped"
108    );
109
110    let _ = std::fs::remove_dir_all(&dir);
111    Ok(())
112}
113
114/// `wal` feature: the base tree loses an un-flushed memtable on a crash. The
115/// write-ahead log appends every mutation before the write is acked, so a
116/// replay rebuilds the surviving records into a fresh memtable on restart. A
117/// torn or bad-CRC tail is dropped without poisoning the recovered prefix.
118#[cfg(feature = "wal")]
119fn wal_durable_log() -> io::Result<()> {
120    use subms_lsm_tree::WriteAheadLog;
121    println!("\n== wal: durable append-before-ack ==");
122    let dir = scratch_dir("wal");
123    std::fs::create_dir_all(&dir)?;
124    let path = dir.join("journal.wal");
125
126    {
127        let mut wal = WriteAheadLog::open(&path)?;
128        wal.log_put("ORD-0100", b"AAPL,100@150.10")?;
129        wal.log_put("ORD-0101", b"MSFT,50@320.55")?;
130        wal.log_delete("ORD-0100")?; // cancel, logged too
131        wal.sync()?; // force durability, then "crash" (drop the handle)
132    }
133
134    let recovered = WriteAheadLog::replay(&path)?;
135    println!("  replayed {} records after crash", recovered.len());
136    assert_eq!(recovered.len(), 3, "every acked write survives");
137    assert!(
138        recovered[2].value.is_none(),
139        "the cancel replays as a tombstone"
140    );
141
142    let _ = std::fs::remove_dir_all(&dir);
143    Ok(())
144}
145
146/// `tiered-compaction` feature: a write-heavy ingest tier keeps flushing
147/// similar-sized runs. Size-tiered compaction merges N runs at a level into
148/// one larger run at the next level, keeping write amplification low at the
149/// cost of read/space amplification. The planner is pure logic; the caller
150/// owns the actual rewrite.
151#[cfg(feature = "tiered-compaction")]
152fn tiered_ingest_tier() {
153    use subms_lsm_tree::{TieredCompactionPlanner, TieredManifest, TieredRun};
154    println!("\n== tiered-compaction: write-heavy ingest tier ==");
155    let mut manifest = TieredManifest::new();
156    for i in 0..4 {
157        let entries = vec![(format!("ORD-{i:04}"), Some(b"fill".to_vec()))];
158        manifest.push(0, TieredRun::new(i, entries)); // newest last
159    }
160
161    let planner = TieredCompactionPlanner::new(4);
162    let level = planner
163        .pick_level(&manifest)
164        .expect("level 0 is full at 4 runs");
165    planner.merge(&mut manifest, level, 100);
166    println!(
167        "  merged 4 L0 runs -> {} run at L1",
168        manifest.level_run_count(1)
169    );
170    assert_eq!(manifest.level_run_count(0), 0, "L0 drained");
171    assert_eq!(manifest.level_run_count(1), 1, "one merged run promoted");
172    assert!(
173        planner.pick_level(&manifest).is_none(),
174        "a single run does not re-trigger"
175    );
176}
177
178/// `leveled-compaction` feature: a serving tier whose contract is a stable read
179/// p99. Leveled compaction keeps each level beyond L0 key-disjoint, so a point
180/// read probes at most one run per level - read amplification is bounded. The
181/// price is higher write amplification.
182#[cfg(feature = "leveled-compaction")]
183fn leveled_serving_tier() {
184    use subms_lsm_tree::features::leveled_compaction::level_is_non_overlapping;
185    use subms_lsm_tree::{LeveledCompactionPlanner, LeveledManifest, LeveledRun};
186    println!("\n== leveled-compaction: read-latency-SLA serving tier ==");
187    let mut manifest = LeveledManifest::new();
188    // Two overlapping L0 runs plus an older L1 run they overlap.
189    manifest.push(
190        0,
191        LeveledRun::new(
192            1,
193            vec![
194                ("AAPL".to_string(), Some(b"150.10".to_vec())),
195                ("MSFT".to_string(), Some(b"320.55".to_vec())),
196            ],
197        ),
198    );
199    manifest.push(
200        0,
201        LeveledRun::new(2, vec![("GOOG".to_string(), Some(b"140.20".to_vec()))]),
202    );
203    manifest.push(
204        1,
205        LeveledRun::new(
206            3,
207            vec![
208                ("AAPL".to_string(), Some(b"149.00".to_vec())), // stale, will be shadowed
209                ("NVDA".to_string(), Some(b"900.00".to_vec())),
210            ],
211        ),
212    );
213
214    let planner = LeveledCompactionPlanner::new(1_000_000, 10, 2);
215    let from = planner
216        .pick_level(&manifest)
217        .expect("L0 over its 2-run limit");
218    planner.compact(&mut manifest, from, 100);
219    println!(
220        "  compacted L0 -> L1: {} run(s) at L1",
221        manifest.level_run_count(1)
222    );
223    assert_eq!(manifest.level_run_count(0), 0, "L0 drained into L1");
224    assert!(
225        level_is_non_overlapping(&manifest, 1),
226        "L1 is key-disjoint after compaction"
227    );
228}
229
230/// `snapshot` feature: an end-of-day report scans a consistent view while the
231/// ingest thread keeps flushing. `snapshot()` pins the manifest behind an
232/// `Arc`; publishing a new manifest afterwards does not perturb the held view.
233#[cfg(feature = "snapshot")]
234fn snapshot_end_of_day_report() {
235    use subms_lsm_tree::{SnapshotManager, SnapshotManifest};
236    println!("\n== snapshot: point-in-time end-of-day report ==");
237    let manager = SnapshotManager::new();
238    manager.publish(SnapshotManifest::new(vec![1, 2, 3]));
239
240    let report_view = manager.snapshot(); // the report starts scanning here
241    manager.publish(SnapshotManifest::new(vec![1, 2, 3, 4, 5])); // ingest keeps flushing
242
243    println!(
244        "  report sees {:?}, live set is now {:?}",
245        report_view.sstable_ids(),
246        manager.current_ids()
247    );
248    assert_eq!(
249        report_view.sstable_ids(),
250        &[1, 2, 3],
251        "held view is isolated from later flushes"
252    );
253    assert_eq!(
254        manager.current_ids(),
255        vec![1, 2, 3, 4, 5],
256        "the live manifest moved on"
257    );
258}
259
260/// `lz4` feature: the hot tier reads constantly, so decompression sits on the
261/// read hot path. LZ4 is the fast codec - a lower ratio for a cheaper decode.
262/// Incompressible blocks fall back to a stored encoding so they never inflate.
263#[cfg(feature = "lz4")]
264fn lz4_hot_tier() {
265    use subms_lsm_tree::Lz4BlockCompressor;
266    println!("\n== lz4: fast compression for the hot tier ==");
267    let codec = Lz4BlockCompressor::new();
268    // A block of repeated fills, like a run of same-symbol ticks.
269    let block = "AAPL,100@150.10;".repeat(256).into_bytes();
270    let encoded = codec.compress(&block);
271    println!("  {} bytes -> {} compressed", block.len(), encoded.len());
272    assert!(encoded.len() < block.len(), "repetitive block shrinks");
273    assert_eq!(
274        codec.decompress(&encoded).unwrap(),
275        block,
276        "lossless round trip"
277    );
278}
279
280/// `zstd` feature: the cold tier is written once and read rarely, so bytes on
281/// disk dominate. Zstd trades CPU for a better ratio than LZ4 - the right deal
282/// for archival runs.
283#[cfg(feature = "zstd")]
284fn zstd_cold_tier() {
285    use subms_lsm_tree::ZstdBlockCompressor;
286    println!("\n== zstd: higher-ratio compression for the cold tier ==");
287    let codec = ZstdBlockCompressor::new();
288    let block = "MSFT,50@320.55;".repeat(256).into_bytes();
289    let encoded = codec.compress(&block).unwrap();
290    println!(
291        "  {} bytes -> {} compressed (level {})",
292        block.len(),
293        encoded.len(),
294        codec.level()
295    );
296    assert!(encoded.len() < block.len(), "cold block shrinks");
297    assert_eq!(
298        codec.decompress(&encoded).unwrap(),
299        block,
300        "lossless round trip"
301    );
302}
303
304/// `block-cache-integration` feature: a read-side LRU keyed on
305/// `(sstable_id, block_offset)`. The read path consults it before touching
306/// disk, so a hit skips the IO. Cached blocks are `Arc<[u8]>`, shared across
307/// readers without a copy.
308#[cfg(feature = "block-cache-integration")]
309fn block_cache_read_path() {
310    use subms_lsm_tree::{Block, BlockCache, BlockKey, LruBlockCache};
311    println!("\n== block-cache-integration: read-side block cache ==");
312    let cache = LruBlockCache::new(2);
313    let hot = BlockKey::new(1, 0);
314
315    assert!(cache.get(&hot).is_none(), "cold: a miss");
316    cache.put(hot, Block::from(b"AAPL block".as_slice()));
317    let served = cache.get(&hot).expect("warm: a hit");
318    println!(
319        "  {} hit / {} miss after one warm read",
320        cache.hits(),
321        cache.misses()
322    );
323    assert_eq!(&*served, b"AAPL block", "the cached payload is served");
324
325    // A third distinct block evicts the least-recently-used entry (cap 2).
326    cache.put(BlockKey::new(2, 0), Block::from(b"MSFT block".as_slice()));
327    cache.put(BlockKey::new(3, 0), Block::from(b"GOOG block".as_slice()));
328    assert!(
329        cache.get(&hot).is_none(),
330        "coldest block evicted at capacity"
331    );
332}