lsm_tree/memtable/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5pub(crate) mod arena;
6pub(crate) mod interval_tree;
7pub(crate) mod own_line;
8use own_line::OwnLine;
9pub(crate) mod skiplist;
10pub(crate) mod value_store;
11
12use crate::comparator::SharedComparator;
13use crate::key::InternalKey;
14use crate::range_tombstone::RangeTombstone;
15use crate::{
16 UserKey,
17 value::{InternalValue, SeqNo},
18};
19#[cfg(not(feature = "std"))]
20use alloc::vec::Vec;
21use core::ops::RangeBounds;
22use core::sync::atomic::AtomicBool;
23use portable_atomic::AtomicU64;
24// `parking_lot::RwLock` (std: small, userspace fast-path, no poisoning) /
25// `spin::RwLock` (no_std). Neither poisons on a panicked holder, so the read/
26// write guards are taken without a `LockResult` unwrap.
27#[cfg(feature = "std")]
28use parking_lot::RwLock;
29#[cfg(not(feature = "std"))]
30use spin::RwLock;
31
32pub use crate::tree::inner::MemtableId;
33
34/// The distinct keys among `keys`, in the table's key order, and the distinct
35/// hashes a table filter built over them holds: one per key and, with
36/// `prefixes`, one per prefix, as a full filter deduplicates them before it
37/// builds. Versions of a key add none.
38///
39/// Counted over all of `keys` at once: a flush rotating into several tables
40/// hashes a prefix spanning two of them in each, so the count runs short by
41/// at most one prefix per rotation.
42pub(crate) fn filter_count(
43 keys: impl Iterator<Item = crate::UserKey>,
44 prefixes: Option<&dyn crate::PrefixExtractor>,
45) -> crate::filter_budget::FilterCount {
46 let mut distinct = 0u64;
47 let mut previous: Option<crate::UserKey> = None;
48 // Without an extractor a filter holds one hash a key, and needs no list.
49 let mut hashes: Vec<u64> = Vec::new();
50 let mut previous_prefixes: Vec<u64> = Vec::new();
51 for key in keys {
52 if previous
53 .as_ref()
54 .is_some_and(|previous| crate::comparator::same_user_key(previous, &key))
55 {
56 continue;
57 }
58 distinct += 1;
59 if let Some(extractor) = prefixes {
60 hashes.push(crate::hash::hash64(&key));
61 // A prefix the key before had at the same position is a repeat,
62 // dropped here to keep the list short, as the writer does.
63 for (position, prefix) in extractor.prefixes(&key).enumerate() {
64 let hash = crate::hash::hash64(prefix);
65 match previous_prefixes.get_mut(position) {
66 Some(previous) if *previous == hash => {}
67 Some(previous) => {
68 *previous = hash;
69 hashes.push(hash);
70 }
71 None => {
72 previous_prefixes.push(hash);
73 hashes.push(hash);
74 }
75 }
76 }
77 }
78 previous = Some(key);
79 }
80 let hashes = if prefixes.is_some() {
81 // Tokens equal to a key, or repeated out of position, the writer
82 // drops when it sorts its hashes.
83 hashes.sort_unstable();
84 hashes.dedup();
85 u64::try_from(hashes.len()).unwrap_or(u64::MAX)
86 } else {
87 distinct
88 };
89 crate::filter_budget::FilterCount {
90 keys: distinct,
91 hashes,
92 }
93}
94
95/// The memtable serves as an intermediary, ephemeral, sorted storage for new items
96///
97/// When the Memtable exceeds some size, it should be flushed to a table.
98pub struct Memtable {
99 #[doc(hidden)]
100 pub id: MemtableId,
101
102 /// The user key comparator used for ordering entries.
103 pub(crate) comparator: SharedComparator,
104
105 /// The actual content, stored in an arena-based skiplist with lock-free traversal.
106 ///
107 /// Nodes are allocated from a contiguous byte arena for cache locality
108 /// and O(1) bulk deallocation when the memtable is dropped. Traversal of
109 /// the skiplist index uses atomic loads and CAS for inserts.
110 pub(crate) items: skiplist::SkipMap,
111
112 /// Range tombstones stored in an interval tree.
113 ///
114 /// Protected by `RwLock` — read-heavy suppression queries (`query_suppression`,
115 /// `range_tombstones_sorted`) take a shared read lock, while `insert_range_tombstone`
116 /// takes an exclusive write lock. After a rotation has been requested via
117 /// `requested_rotation`, the interval tree is treated as read-only by convention,
118 /// and only readers are expected to access this field (the `RwLock` is still used
119 /// for synchronization, but there should be no further writes).
120 ///
121 /// `std::sync::RwLock` may be reader-biased on some platforms, but writer
122 /// starvation is not a concern here: range deletes are rare, the write-side
123 /// critical section is O(log n) with n typically small, and the memtable
124 /// rotates (becoming read-only) well before contention could accumulate.
125 pub(crate) range_tombstones: RwLock<interval_tree::IntervalTree>,
126
127 /// Approximate active memtable size.
128 ///
129 /// If this grows too large, a flush is triggered. Updated with `Relaxed`
130 /// RMWs: it is a rotation heuristic, and nothing derives the visibility
131 /// of other memory from it (entry visibility comes from the skiplist's
132 /// release/acquire tower stores; `highest_seqno` keeps `AcqRel` so a
133 /// reader that observes a seqno also observes its entry).
134 ///
135 /// Each counter every insert writes sits on a line of its own, apart from
136 /// the fields reads and inserts only load.
137 pub(crate) approximate_size: OwnLine<AtomicU64>,
138
139 /// Highest encountered sequence number.
140 ///
141 /// This is used so that `get_highest_seqno` has O(1) complexity.
142 pub(crate) highest_seqno: OwnLine<AtomicU64>,
143
144 pub(crate) requested_rotation: AtomicBool,
145
146 /// Whether any insert-time per-KV digest (`KvChecksumComputePoint::AtInsert`)
147 /// has been stored in this memtable. Set on the first digest-bearing insert
148 /// and read once at flush by [`Self::verify_kv_residence`] to skip walking
149 /// the nodes entirely when there is nothing to verify (the default `Off` /
150 /// `AtBlockCompile` path). The per-node digest carries its own algorithm, so
151 /// no memtable-wide algorithm is tracked here.
152 has_at_insert_digests: AtomicBool,
153
154 /// Blob files the cell rows written here reference. Until this memtable
155 /// is flushed, no table links them for these rows, so a blob file listed
156 /// here is neither removed nor punched.
157 blob_refs: RwLock<crate::HashSet<crate::vlog::BlobFileId>>,
158}
159
160impl Memtable {
161 /// Returns the memtable ID.
162 pub fn id(&self) -> MemtableId {
163 self.id
164 }
165
166 /// Returns `true` if the memtable was already flagged for rotation.
167 pub fn is_flagged_for_rotation(&self) -> bool {
168 self.requested_rotation
169 .load(core::sync::atomic::Ordering::Relaxed)
170 }
171
172 /// Flags the memtable as requested for rotation.
173 pub fn flag_rotated(&self) {
174 self.requested_rotation
175 .store(true, core::sync::atomic::Ordering::Relaxed);
176 }
177
178 // `pub` + `#[doc(hidden)]`: used by the host crate (fjall) to construct
179 // ephemeral memtables. Not part of the semver-stable API.
180 // Keep the comparator by-value for hidden-public API compatibility while
181 // still requiring callers to pass the tree comparator explicitly.
182 #[doc(hidden)]
183 #[expect(
184 clippy::needless_pass_by_value,
185 reason = "hidden-public constructor keeps the preexisting by-value signature for compatibility"
186 )]
187 #[must_use]
188 pub fn new(id: MemtableId, comparator: SharedComparator) -> Self {
189 Self {
190 id,
191 items: skiplist::SkipMap::new(comparator.clone()),
192 comparator: comparator.clone(),
193 range_tombstones: RwLock::new(interval_tree::IntervalTree::new_with_comparator(
194 comparator.clone(),
195 )),
196 approximate_size: OwnLine(AtomicU64::default()),
197 highest_seqno: OwnLine(AtomicU64::default()),
198 requested_rotation: AtomicBool::default(),
199 has_at_insert_digests: AtomicBool::default(),
200 blob_refs: RwLock::new(crate::HashSet::default()),
201 }
202 }
203
204 /// Records the blob files `item` references when it is a cell row.
205 ///
206 /// Every insert path calls this, so a row replayed from an external log
207 /// registers its files like a row written through
208 /// [`BlobTree::insert_cells`](crate::BlobTree::insert_cells). A row that
209 /// does not decode registers nothing: it fails at flush or read, which
210 /// decode it again.
211 fn note_blob_refs(&self, item: &InternalValue) {
212 if !item.key.value_type.is_cell_row() {
213 return;
214 }
215 let Ok(refs) = crate::blob_tree::field_row::row_refs(&item.value) else {
216 return;
217 };
218 if refs.is_empty() {
219 return;
220 }
221 let mut set = self.blob_refs.write();
222 for (indirection, _) in refs {
223 set.insert(indirection.vhandle.blob_file_id);
224 }
225 }
226
227 /// Whether a cell row in this memtable references `blob_file_id`.
228 pub(crate) fn references_blob_file(&self, blob_file_id: crate::vlog::BlobFileId) -> bool {
229 self.blob_refs.read().contains(&blob_file_id)
230 }
231
232 /// Adds the blob files cell rows in this memtable reference to `files`.
233 pub(crate) fn collect_blob_files(&self, files: &mut crate::HashSet<crate::vlog::BlobFileId>) {
234 files.extend(self.blob_refs.read().iter().copied());
235 }
236
237 /// Creates an iterator over all items.
238 pub fn iter(&self) -> impl DoubleEndedIterator<Item = InternalValue> + '_ {
239 self.items.iter().map(|entry| InternalValue {
240 key: entry.key(),
241 value: entry.value(),
242 })
243 }
244
245 /// Creates an iterator over a range of items.
246 ///
247 /// Accepts `InternalKey`-based bounds.
248 pub(crate) fn range_internal<'a, R: RangeBounds<InternalKey> + 'a>(
249 &'a self,
250 range: R,
251 ) -> impl DoubleEndedIterator<Item = InternalValue> + 'a {
252 self.items.range(range).map(|entry| InternalValue {
253 key: entry.key(),
254 value: entry.value(),
255 })
256 }
257
258 /// Returns the item by key if it exists.
259 ///
260 /// Returns the version with the highest seqno that is strictly less than
261 /// the given `seqno`. Pass [`MAX_SEQNO`](crate::MAX_SEQNO) to retrieve the latest version.
262 #[doc(hidden)]
263 pub fn get(&self, key: &[u8], seqno: SeqNo) -> Option<InternalValue> {
264 if seqno == 0 {
265 return None;
266 }
267
268 // NOTE: This range start deserves some explanation...
269 // InternalKeys are multi-sorted by 2 categories: user_key and Reverse(seqno). (tombstone doesn't really matter)
270 // We search for the lowest entry that is greater or equal the user's prefix key
271 // and has the seqno (or lower) we want (because the seqno is stored in reverse order)
272 //
273 // Example: We search for "abc"
274 //
275 // key -> seqno
276 //
277 // a -> 7
278 // abc -> 5 <<< This is the lowest key (highest seqno) that matches the key with seqno=MAX
279 // abc -> 4
280 // abc -> 3 <<< If searching for abc and seqno=4, we would get this
281 // abcdef -> 6
282 // abcdef -> 5
283 //
284 // Borrowed-parts point lookup: no `InternalKey` bound is built, so the
285 // probe key is never copied (the old `range(lower_bound..)` path
286 // heap-copied `key` into a fresh bound per probed memtable).
287 self.items.point_get(key, seqno - 1)
288 }
289
290 /// Gets approximate size of memtable in bytes.
291 pub fn size(&self) -> u64 {
292 self.approximate_size
293 .load(core::sync::atomic::Ordering::Acquire)
294 }
295
296 /// Counts the number of items in the memtable.
297 pub fn len(&self) -> usize {
298 self.items.len()
299 }
300
301 /// How often an insert had to retry linking an item because a concurrent
302 /// insert changed its neighbour first: the contention writers meet in
303 /// this memtable, counted once per retried level of the skiplist.
304 #[cfg(feature = "metrics")]
305 #[must_use]
306 pub fn insert_cas_retries(&self) -> usize {
307 self.items.cas_retries()
308 }
309
310 /// Returns `true` if the memtable has no KV items and no range tombstones.
311 #[must_use]
312 pub fn is_empty(&self) -> bool {
313 self.items.is_empty() && self.range_tombstone_count() == 0
314 }
315
316 /// Inserts multiple items into the memtable in bulk.
317 ///
318 /// More efficient than calling [`Memtable::insert`] in a loop because it
319 /// performs a single `fetch_add` for the total size and a single
320 /// `fetch_max` for the highest seqno.
321 ///
322 /// Returns `(total_bytes_added, new_memtable_size)`.
323 #[doc(hidden)]
324 pub fn insert_batch(&self, items: Vec<InternalValue>) -> (u64, u64) {
325 self.insert_batch_with_kv_algo(items, None)
326 }
327
328 /// Bulk insert, optionally computing an insert-time per-KV digest per item
329 /// under `kv_algo` (`KvChecksumComputePoint::AtInsert`).
330 ///
331 /// `kv_algo` is `Some(algo)` (a 4-byte algorithm) to fix each entry's
332 /// digest at insert for the flush-time residence check, or `None` for the
333 /// plain bulk path. Same single-`fetch_add` / single-`fetch_max` accounting
334 /// as [`Self::insert_batch`].
335 #[doc(hidden)]
336 pub fn insert_batch_with_kv_algo(
337 &self,
338 items: Vec<InternalValue>,
339 kv_algo: Option<crate::runtime_config::ChecksumAlgorithm>,
340 ) -> (u64, u64) {
341 if items.is_empty() {
342 let size = self
343 .approximate_size
344 .load(core::sync::atomic::Ordering::Acquire);
345 return (0, size);
346 }
347
348 let mut total_size: u64 = 0;
349 let mut max_seqno: u64 = 0;
350
351 let overhead =
352 core::mem::size_of::<InternalValue>() + core::mem::size_of::<SharedComparator>();
353
354 for item in &items {
355 #[expect(
356 clippy::expect_used,
357 reason = "keys are limited to 16-bit length + values are limited to 32-bit length"
358 )]
359 let item_size: u64 = (item.key.user_key.len() + item.value.len() + overhead)
360 .try_into()
361 .expect("should fit into u64");
362
363 // Running memtable byte total, bounded by the in-memory data size;
364 // a plain add cannot overflow u64.
365 total_size += item_size;
366
367 if item.key.seqno > max_seqno {
368 max_seqno = item.key.seqno;
369 }
370 }
371
372 let size_before = self
373 .approximate_size
374 .fetch_add(total_size, core::sync::atomic::Ordering::Relaxed);
375
376 if kv_algo.is_some() {
377 // Flag that this memtable carries residence digests for the
378 // flush-time verify. The algorithm lives per node.
379 self.has_at_insert_digests
380 .store(true, core::sync::atomic::Ordering::Relaxed);
381 }
382
383 for item in items {
384 self.note_blob_refs(&item);
385 let digest = kv_algo.and_then(|algo| {
386 crate::table::block::kv_checksum::kv_digest(&item, algo).map(|d| {
387 #[expect(
388 clippy::cast_possible_truncation,
389 reason = "AtInsert is config-validated to a 4-byte algorithm; the digest fits u32"
390 )]
391 let lo = d as u32;
392 (lo, algo)
393 })
394 });
395 self.items
396 .insert_with_kv_digest(&item.key, item.value, digest);
397 }
398
399 self.highest_seqno
400 .fetch_max(max_seqno, core::sync::atomic::Ordering::AcqRel);
401
402 // fetch_add returns value BEFORE the add, so size_before + total_size
403 // = value AFTER add = new memtable size. Same pattern as Memtable::insert().
404 (total_size, size_before + total_size)
405 }
406
407 /// Inserts an item into the memtable
408 #[doc(hidden)]
409 pub fn insert(&self, item: InternalValue) -> (u64, u64) {
410 #[expect(
411 clippy::expect_used,
412 reason = "keys are limited to 16-bit length + values are limited to 32-bit length"
413 )]
414 // Account for MemtableKey overhead (InternalKey + Arc<dyn UserComparator>)
415 let item_size = (item.key.user_key.len()
416 + item.value.len()
417 + core::mem::size_of::<InternalValue>()
418 + core::mem::size_of::<SharedComparator>())
419 .try_into()
420 .expect("should fit into u64");
421
422 let size_before = self
423 .approximate_size
424 .fetch_add(item_size, core::sync::atomic::Ordering::Relaxed);
425
426 self.note_blob_refs(&item);
427
428 // `item.key` is borrowed and `item.value` moved: the key bytes are
429 // copied into the arena, the value handle is stored as-is.
430 self.items.insert(&item.key, item.value);
431
432 self.highest_seqno
433 .fetch_max(item.key.seqno, core::sync::atomic::Ordering::AcqRel);
434
435 (item_size, size_before + item_size)
436 }
437
438 /// Inserts an item, optionally carrying a precomputed insert-time per-KV
439 /// digest (`KvChecksumComputePoint::AtInsert`).
440 ///
441 /// `kv_digest` is `Some((digest, algo))` when the caller computed the
442 /// entry's 4-byte logical-content digest at insert (under `AtInsert` with a
443 /// 4-byte algorithm), or `None` for the plain path. When present, the digest
444 /// and its algorithm are stored in the skiplist node (per node, so a later
445 /// config change cannot misverify it) and the memtable flags that it carries
446 /// at least one digest so [`Self::verify_kv_residence`] knows to walk at
447 /// flush. Mixed inserts (some with, some without a digest) are supported for
448 /// the `Off` -> `AtInsert` live toggle.
449 #[doc(hidden)]
450 pub fn insert_with_kv_digest(
451 &self,
452 item: InternalValue,
453 kv_digest: Option<(u32, crate::runtime_config::ChecksumAlgorithm)>,
454 ) -> (u64, u64) {
455 #[expect(
456 clippy::expect_used,
457 reason = "keys are limited to 16-bit length + values are limited to 32-bit length"
458 )]
459 let item_size = (item.key.user_key.len()
460 + item.value.len()
461 + core::mem::size_of::<InternalValue>()
462 + core::mem::size_of::<SharedComparator>())
463 .try_into()
464 .expect("should fit into u64");
465
466 let size_before = self
467 .approximate_size
468 .fetch_add(item_size, core::sync::atomic::Ordering::Relaxed);
469
470 if kv_digest.is_some() {
471 // Flag that this memtable carries at least one residence digest so
472 // the flush-time verify walks the nodes. The algorithm lives per
473 // node, not here.
474 self.has_at_insert_digests
475 .store(true, core::sync::atomic::Ordering::Relaxed);
476 }
477
478 self.note_blob_refs(&item);
479
480 self.items
481 .insert_with_kv_digest(&item.key, item.value, kv_digest);
482
483 self.highest_seqno
484 .fetch_max(item.key.seqno, core::sync::atomic::Ordering::AcqRel);
485
486 (item_size, size_before + item_size)
487 }
488
489 /// Verifies every insert-time per-KV digest in this memtable against a
490 /// recompute over the entry's current bytes (the
491 /// [`KvChecksumComputePoint::AtInsert`](crate::runtime_config::KvChecksumComputePoint::AtInsert)
492 /// residence check), called once at flush.
493 ///
494 /// Returns `Ok` immediately when no `AtInsert` digest was ever inserted, so
495 /// the default path pays nothing.
496 ///
497 /// # Errors
498 ///
499 /// - [`crate::Error::MemtableKvChecksumMismatch`] when an entry's stored
500 /// digest diverges from the recompute (a RAM bit-flip during residence).
501 /// - [`crate::Error::FeatureUnsupported`] when a node's algorithm is not
502 /// compiled into this build.
503 pub fn verify_kv_residence(&self) -> crate::Result<()> {
504 if !self
505 .has_at_insert_digests
506 .load(core::sync::atomic::Ordering::Relaxed)
507 {
508 return Ok(());
509 }
510 self.items.verify_kv_digests()
511 }
512
513 /// Inserts a range tombstone covering `[start, end)` at the given seqno.
514 ///
515 /// Returns the approximate size added to the memtable.
516 ///
517 /// Returns 0 if `start >= end` or if either bound exceeds `u16::MAX` bytes.
518 ///
519 /// # Panics
520 ///
521 /// Panics if the internal `RwLock` is poisoned.
522 #[must_use]
523 pub fn insert_range_tombstone(&self, start: UserKey, end: UserKey, seqno: SeqNo) -> u64 {
524 // flag_rotated() (which sets requested_rotation) is called by the host
525 // crate (fjall) before rotation; this crate never sets it directly.
526 // The assert catches misuse by callers
527 // in debug builds — intentionally debug-only because post-rotation writes
528 // are structurally prevented by the host (sealed memtables are behind Arc
529 // with no write path exposed), and an atomic load here would add overhead
530 // on the hot insert path in release builds for no practical benefit.
531 debug_assert!(
532 !self.is_flagged_for_rotation(),
533 "insert_range_tombstone called after memtable was flagged for rotation"
534 );
535
536 // Reject invalid intervals in release builds (debug_assert is not enough)
537 if self.comparator.compare(&start, &end) != core::cmp::Ordering::Less {
538 return 0;
539 }
540
541 // On-disk RT format writes key lengths as u16, enforce at insertion time.
542 // Emit a warning when rejecting an oversized bound so this failure is diagnosable.
543 if u16::try_from(start.len()).is_err() || u16::try_from(end.len()).is_err() {
544 log::warn!(
545 "insert_range_tombstone: rejecting oversized range tombstone \
546 bounds (start_len = {}, end_len = {}, max = {})",
547 start.len(),
548 end.len(),
549 u16::MAX,
550 );
551 return 0;
552 }
553
554 let size = (start.len() + end.len() + core::mem::size_of::<RangeTombstone>()) as u64;
555
556 self.range_tombstones
557 .write()
558 .insert(RangeTombstone::new(start, end, seqno));
559
560 self.approximate_size
561 .fetch_add(size, core::sync::atomic::Ordering::AcqRel);
562
563 self.highest_seqno
564 .fetch_max(seqno, core::sync::atomic::Ordering::AcqRel);
565
566 size
567 }
568
569 /// Returns `true` if the key at `key_seqno` is suppressed by a range tombstone
570 /// visible at `read_seqno`.
571 pub(crate) fn is_key_suppressed_by_range_tombstone(
572 &self,
573 key: &[u8],
574 key_seqno: SeqNo,
575 read_seqno: SeqNo,
576 ) -> bool {
577 self.range_tombstones
578 .read()
579 .query_suppression(key, key_seqno, read_seqno)
580 }
581
582 /// Hands `visit` the range tombstones holding `key`.
583 pub(crate) fn for_each_range_tombstone_containing(
584 &self,
585 key: &[u8],
586 visit: impl FnMut(&RangeTombstone),
587 ) {
588 self.range_tombstones.read().for_each_containing(key, visit);
589 }
590
591 /// Returns all range tombstones in sorted order (for flush).
592 pub(crate) fn range_tombstones_sorted(&self) -> Vec<RangeTombstone> {
593 self.range_tombstones.read().iter_sorted()
594 }
595
596 /// Returns the number of range tombstones.
597 #[must_use]
598 pub fn range_tombstone_count(&self) -> usize {
599 self.range_tombstones.read().len()
600 }
601
602 /// Returns the highest sequence number in the memtable.
603 pub fn get_highest_seqno(&self) -> Option<SeqNo> {
604 if self.is_empty() {
605 None
606 } else {
607 Some(
608 self.highest_seqno
609 .load(core::sync::atomic::Ordering::Acquire),
610 )
611 }
612 }
613}
614
615#[cfg(test)]
616mod tests;