lsm_tree/config/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
4
5mod block_size;
6mod compression;
7mod delete_strategy;
8mod filter;
9mod hash_ratio;
10mod locator;
11mod pinning;
12mod restart_interval;
13
14pub use block_size::BlockSizePolicy;
15pub use compression::CompressionPolicy;
16pub use delete_strategy::{DeleteStrategy, DeleteStrategyPolicy};
17pub use filter::{BloomConstructionPolicy, FilterPolicy, FilterPolicyEntry};
18pub use hash_ratio::HashRatioPolicy;
19pub use locator::{LocatorPolicy, LocatorPolicyEntry, LocatorPrecision};
20pub use pinning::PinningPolicy;
21pub use restart_interval::RestartIntervalPolicy;
22
23/// Partitioning policy for indexes and filters
24pub type PartitioningPolicy = PinningPolicy;
25
26#[cfg(feature = "std")]
27use crate::fs::StdFs;
28use crate::path::PathBuf;
29use crate::{
30 AnyTree, BlobTree, Cache, CompressionType, DescriptorTable, SharedSequenceNumberGenerator,
31 Tree,
32 compaction::filter::Factory,
33 comparator::SharedComparator,
34 encryption::EncryptionProvider,
35 file::TABLES_FOLDER,
36 fs::{Fs, SyncMode},
37 merge_operator::MergeOperator,
38 path::absolute_path,
39 prefix::PrefixExtractor,
40};
41// std-only: used solely by the std-gated `Config::default` / `Config::new`
42// constructors (the no_std path builds `Config` field-by-field).
43#[cfg(feature = "std")]
44use crate::{SequenceNumberCounter, comparator, path::Path, version::DEFAULT_LEVEL_COUNT};
45use alloc::sync::Arc;
46#[cfg(not(feature = "std"))]
47use alloc::vec::Vec;
48use core::ops::Range;
49
50/// Per-level filesystem routing entry for tiered storage.
51///
52/// Maps a range of LSM levels to a base directory and filesystem backend.
53/// Tables at these levels are stored under `path/tables/`.
54///
55/// # Example
56///
57/// ```
58/// use lsm_tree::config::LevelRoute;
59/// use lsm_tree::fs::StdFs;
60/// use std::sync::Arc;
61///
62/// // Hot tier: L0-L1 on NVMe
63/// let hot = LevelRoute {
64/// levels: 0..2,
65/// path: "/mnt/nvme/db".into(),
66/// fs: Arc::new(StdFs),
67/// };
68///
69/// // Cold tier: L4-L6 on HDD
70/// let cold = LevelRoute {
71/// levels: 4..7,
72/// path: "/mnt/hdd/db".into(),
73/// fs: Arc::new(StdFs),
74/// };
75/// ```
76#[derive(Clone)]
77pub struct LevelRoute {
78 /// LSM levels this route covers (e.g., `0..2` for L0–L1).
79 pub levels: Range<u8>,
80
81 /// Base data directory for tables at these levels.
82 pub path: PathBuf,
83
84 /// Filesystem backend for I/O at these levels.
85 pub fs: Arc<dyn Fs>,
86}
87
88impl core::fmt::Debug for LevelRoute {
89 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90 f.debug_struct("LevelRoute")
91 .field("levels", &self.levels)
92 .field("path", &self.path)
93 .finish_non_exhaustive()
94 }
95}
96
97/// Policy governing what `Tree::open` does when the on-disk MANIFEST
98/// contains corrupt records.
99///
100/// Mirrors `RocksDB`'s `WALRecoveryMode` semantics, but applied to the
101/// manifest layer (`src/version/recovery.rs`) — lsm-tree itself has no
102/// WAL (durability lives one layer up in the parent fjall/keyspace
103/// crate's `Journal`). The MANIFEST is the equivalent surface where
104/// "loss-tolerance vs strict-consistency" matters at open time.
105///
106/// The default is [`AbsoluteConsistency`](Self::AbsoluteConsistency) —
107/// any corrupt record fails the open. Switching to a more permissive
108/// mode is an explicit, informed operator decision: you are trading
109/// "the tree might silently come up with missing tables / blob files"
110/// for "the tree comes up at all". When a non-default mode drops
111/// records, the recovery path emits a `warn!` summary with the
112/// AGGREGATE dropped count per section (`tables` / `blob_files`) —
113/// individual table IDs / blob-file IDs are NOT enumerated, because
114/// they were never decoded in the first place. Operators wanting a
115/// per-record audit trail should pair tail-tolerant recovery with an
116/// out-of-band integrity scan ([`verify_integrity`](crate::verify::verify_integrity))
117/// of the recovered tree.
118#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
119pub enum ManifestRecoveryMode {
120 /// Production-safe default. Any per-record decode mismatch (bad
121 /// XXH3, invalid tag, truncated TOC entry, declared-count overrun)
122 /// aborts the open with the original error. Surfaces every byte
123 /// of corruption; never silently drops data.
124 #[default]
125 AbsoluteConsistency,
126
127 /// Power-loss-at-write-tail salvage. If the per-section iteration
128 /// over the `tables` / `blob_files` records runs out of bytes
129 /// before the declared count is reached (truncated tail), keep
130 /// everything that decoded cleanly before the cut and emit a
131 /// `warn!` listing the dropped record counts.
132 ///
133 /// A declared count that exceeds the section's payload capacity
134 /// (e.g. `table_count` claims more entries than the section has
135 /// bytes for) is treated as the same "writer committed a count
136 /// header then truncated the entries" shape — the recovery
137 /// downgrades the original hard fail to a `warn!` and lets the
138 /// per-entry decode loop walk bytes-actually-present until the
139 /// first `UnexpectedEof`.
140 ///
141 /// Any decode error that is NOT a clean tail truncation (bad
142 /// `checksum_type` tag, etc.) still aborts the open — this mode
143 /// is specifically for "the writer never finished" scenarios,
144 /// not for arbitrary bit-rot in already-committed bytes.
145 TolerateCorruptedTailRecords,
146
147 /// Recover the largest consistent prefix and discard the rest.
148 /// Adapts `RocksDB`'s `kPointInTimeRecovery` accept-the-prefix
149 /// rule to the level/run/table nesting: on the first
150 /// record-decode mismatch inside the `tables` section, the
151 /// recovery keeps the records that decoded cleanly *before*
152 /// the corrupt one in the current run, plus every complete
153 /// earlier run in the same level, plus every complete earlier
154 /// level. "Record-decode mismatch" covers ALL three failure
155 /// shapes the per-record loop can surface:
156 ///
157 /// 1. Framing-layer XXH3 mismatch (the 8-byte digest in the
158 /// record header doesn't match `xxh3_64(payload)`).
159 /// 2. Framing-header structural failure (`len > MAX_FRAME_PAYLOAD`),
160 /// surfaced as `BadHeader`. Note: `LenMismatch` (decoded `len`
161 /// disagrees with a fixed-length pin) is a SEPARATE hard-abort
162 /// case in every recovery mode, not a record-decode mismatch
163 /// for the purpose of this mode.
164 /// 3. Payload decode failure AFTER a clean framing pass —
165 /// e.g. `Error::InvalidTag` from a corrupt `checksum_type`
166 /// byte inside an otherwise-framed-OK record. The framing
167 /// XXH3 happens to cover the corrupt byte too (it's a
168 /// digest of the whole payload), so the bytes decode
169 /// cleanly at the framing layer; the corruption only
170 /// surfaces inside the per-entry decode helper.
171 ///
172 /// PIT drops the corrupt record itself, the remaining records
173 /// of that run, and every level not yet read. The same rule
174 /// applies to the `blob_files` section. Clean tail-truncation
175 /// is still tolerated, same as
176 /// [`TolerateCorruptedTailRecords`](Self::TolerateCorruptedTailRecords).
177 PointInTimeRecovery,
178
179 /// Skip each corrupt record individually, keep all others.
180 /// Maximum-availability, lossy. On any per-record decode
181 /// mismatch — framing-layer XXH3 mismatch, payload-decode
182 /// failure inside an otherwise-framed-OK record (e.g.
183 /// `Error::InvalidTag` on a corrupt `checksum_type` byte), or
184 /// a framing-header `BadHeader` — the reader logs the skip
185 /// and advances exactly past the bad record using the
186 /// framing-supplied length field. If the length field itself
187 /// is unusable (the recorded length is outside the legal
188 /// range, so the next-record boundary is unknown), the rest
189 /// of that section is dropped. Intended companion to the
190 /// `repair_db` tooling tracked as `#303`: this mode recovers
191 /// what it can in-place; `repair_db` rebuilds the manifest
192 /// from the SST files
193 /// themselves when even this mode can't reach a usable state.
194 SkipAnyCorruptedRecords,
195}
196
197/// LSM-tree type
198#[derive(Copy, Clone, Debug, PartialEq, Eq)]
199pub enum TreeType {
200 /// Standard LSM-tree, see [`Tree`]
201 Standard,
202
203 /// Key-value separated LSM-tree, see [`BlobTree`]
204 Blob,
205}
206
207impl From<TreeType> for u8 {
208 fn from(val: TreeType) -> Self {
209 match val {
210 TreeType::Standard => 0,
211 TreeType::Blob => 1,
212 }
213 }
214}
215
216impl TryFrom<u8> for TreeType {
217 type Error = ();
218
219 fn try_from(value: u8) -> Result<Self, Self::Error> {
220 match value {
221 0 => Ok(Self::Standard),
222 1 => Ok(Self::Blob),
223 _ => Err(()),
224 }
225 }
226}
227
228#[cfg_attr(
229 not(feature = "std"),
230 allow(
231 dead_code,
232 reason = "default data-folder path used only on the std-gated default-config path"
233 )
234)]
235const DEFAULT_FILE_FOLDER: &str = ".lsm.data";
236
237/// Options for key-value separation
238#[derive(Clone, Debug, PartialEq)]
239pub struct KvSeparationOptions {
240 /// What type of compression is used for blobs
241 #[doc(hidden)]
242 pub compression: CompressionType,
243
244 /// Blob file target size in bytes
245 #[doc(hidden)]
246 pub file_target_size: u64,
247
248 /// Key-value separation threshold in bytes
249 #[doc(hidden)]
250 pub separation_threshold: u32,
251
252 #[doc(hidden)]
253 pub staleness_threshold: f32,
254
255 #[doc(hidden)]
256 pub age_cutoff: f32,
257
258 /// How many upcoming values a scan reads ahead in one coalesced batch.
259 ///
260 /// `0` disables read-ahead. See
261 /// [`scan_prefetch`](KvSeparationOptions::scan_prefetch).
262 #[doc(hidden)]
263 pub scan_prefetch: u16,
264
265 /// Pre-trained zstd dictionary for blob-file dictionary compression.
266 ///
267 /// Required when `compression` is [`CompressionType::ZstdDict`].
268 /// The `dict_id` in the compression type must match [`ZstdDictionary::id`](crate::ZstdDictionary::id).
269 #[cfg(zstd_any)]
270 #[doc(hidden)]
271 pub zstd_dictionary: Option<alloc::sync::Arc<crate::compression::ZstdDictionary>>,
272}
273
274impl Default for KvSeparationOptions {
275 fn default() -> Self {
276 Self {
277 #[cfg(feature="lz4")]
278 compression: CompressionType::Lz4,
279
280 #[cfg(not(feature="lz4"))]
281 compression: CompressionType::None,
282
283 file_target_size: /* 64 MiB */ 64 * 1_024 * 1_024,
284 separation_threshold: /* 1 KiB */ 1_024,
285
286 staleness_threshold: 0.25,
287 age_cutoff: 0.25,
288
289 scan_prefetch: 64,
290
291 #[cfg(zstd_any)]
292 zstd_dictionary: None,
293 }
294 }
295}
296
297impl KvSeparationOptions {
298 /// Sets the blob compression method.
299 #[must_use]
300 pub fn compression(mut self, compression: CompressionType) -> Self {
301 self.compression = compression;
302 self
303 }
304
305 /// Sets the target size of blob files.
306 ///
307 /// Smaller blob files allow more granular garbage collection
308 /// which allows lower space amp for lower write I/O cost.
309 ///
310 /// Larger blob files decrease the number of files on disk and maintenance
311 /// overhead.
312 ///
313 /// Defaults to 64 MiB.
314 #[must_use]
315 pub fn file_target_size(mut self, bytes: u64) -> Self {
316 self.file_target_size = bytes;
317 self
318 }
319
320 /// Sets the key-value separation threshold in bytes.
321 ///
322 /// Smaller value will reduce compaction overhead and thus write amplification,
323 /// at the cost of lower read performance.
324 ///
325 /// Defaults to 1 KiB.
326 #[must_use]
327 pub fn separation_threshold(mut self, bytes: u32) -> Self {
328 self.separation_threshold = bytes;
329 self
330 }
331
332 /// Sets how many upcoming values a scan reads ahead in one batch.
333 ///
334 /// A scan resolves separated values one at a time, and each is its own read
335 /// of a few hundred bytes. Values sit in the blob file in the order the
336 /// flush wrote them, which is key order, so a scan's next values are
337 /// usually its immediate on-disk neighbours: reading a window of them at
338 /// once collapses that stream of small reads into a few large ones.
339 ///
340 /// Read-ahead starts only once the scan's caller resolves a value
341 /// unconditionally, so a scan that reads keys alone never pays for it, and
342 /// neither does one that decides per key whether the value is worth
343 /// reading. Larger windows amortize better on long scans; smaller ones
344 /// waste less on a scan that stops early. `0` disables it.
345 ///
346 /// Applies to the sequential scans: `iter`, `range`, `prefix` and
347 /// `batch_range_scan`. NOT to the seekable iterator, where a seek would
348 /// throw away whatever was read ahead, and which exists for jumping around
349 /// rather than walking a run.
350 ///
351 /// Defaults to 64.
352 #[must_use]
353 pub fn scan_prefetch(mut self, items: u16) -> Self {
354 self.scan_prefetch = items;
355 self
356 }
357
358 /// Sets the staleness threshold percentage.
359 ///
360 /// The staleness percentage determines how much a blob file needs to be fragmented to be
361 /// picked up by the garbage collection.
362 ///
363 /// Defaults to 33%.
364 #[must_use]
365 pub fn staleness_threshold(mut self, ratio: f32) -> Self {
366 self.staleness_threshold = ratio;
367 self
368 }
369
370 /// Sets the age cutoff threshold.
371 ///
372 /// Defaults to 20%.
373 #[must_use]
374 pub fn age_cutoff(mut self, ratio: f32) -> Self {
375 self.age_cutoff = ratio;
376 self
377 }
378
379 /// Sets the zstd dictionary for blob-file dictionary compression.
380 ///
381 /// Required when [`compression`](Self::compression) is set to
382 /// [`CompressionType::ZstdDict`]. The `dict_id` encoded in the
383 /// compression type must equal [`ZstdDictionary::id()`](crate::ZstdDictionary::id) of the
384 /// supplied dictionary; [`Config::open`] will return
385 /// [`Error::ZstdDictMismatch`](crate::Error::ZstdDictMismatch) if
386 /// they disagree.
387 #[cfg(zstd_any)]
388 #[must_use]
389 pub fn dict(
390 mut self,
391 dictionary: alloc::sync::Arc<crate::compression::ZstdDictionary>,
392 ) -> Self {
393 self.zstd_dictionary = Some(dictionary);
394 self
395 }
396}
397
398/// Tree configuration builder
399// Clone: every shared handle is `Arc`-backed, so a clone is a cheap second
400// reference to the same backends — which is what lets `open_or_repair` retry
401// an `open(self)` after a repair.
402#[derive(Clone)]
403pub struct Config {
404 /// Folder path
405 #[doc(hidden)]
406 pub path: PathBuf,
407
408 /// Default filesystem backend for levels without an explicit route.
409 ///
410 /// Defaults to [`StdFs`]. Use [`Config::with_fs`] to plug in an
411 /// alternative backend such as [`MemFs`](crate::fs::MemFs).
412 ///
413 /// Both fresh tree creation and reopening (recovery) are supported
414 /// for any backend that implements [`Fs`].
415 #[doc(hidden)]
416 pub fs: Arc<dyn Fs>,
417
418 /// Per-level filesystem routing for tiered storage.
419 ///
420 /// When set, tables at different LSM levels can be stored on different
421 /// storage devices (e.g., NVMe for L0–L1, SSD for L2–L4, HDD for L5–L6).
422 /// Each entry maps a range of levels to a base directory and filesystem
423 /// backend. Uncovered levels fall back to the primary `path` and `fs`.
424 ///
425 /// Zero additional overhead when `None` — only a single branch check;
426 /// path construction allocations are unchanged.
427 #[doc(hidden)]
428 pub level_routes: Option<Vec<LevelRoute>>,
429
430 /// Block cache to use
431 #[doc(hidden)]
432 pub cache: Arc<Cache>,
433
434 /// Descriptor table to use
435 #[doc(hidden)]
436 pub descriptor_table: Option<Arc<DescriptorTable>>,
437
438 /// Number of levels of the LSM tree (depth of tree)
439 ///
440 /// Once set, the level count is fixed (in the "manifest" file)
441 pub level_count: u8,
442
443 /// What type of compression is used for data blocks
444 pub data_block_compression_policy: CompressionPolicy,
445
446 /// What type of compression is used for index blocks
447 pub index_block_compression_policy: CompressionPolicy,
448
449 /// Restart interval inside data blocks
450 pub data_block_restart_interval_policy: RestartIntervalPolicy,
451
452 /// Restart interval inside index blocks
453 pub index_block_restart_interval_policy: RestartIntervalPolicy,
454
455 /// Block size of data blocks
456 pub data_block_size_policy: BlockSizePolicy,
457
458 /// Whether to pin index blocks
459 pub index_block_pinning_policy: PinningPolicy,
460
461 /// Whether to pin filter blocks
462 pub filter_block_pinning_policy: PinningPolicy,
463
464 /// Whether to pin top level index of partitioned index
465 pub top_level_index_block_pinning_policy: PinningPolicy,
466
467 /// Whether to pin top level index of partitioned filter
468 pub top_level_filter_block_pinning_policy: PinningPolicy,
469
470 /// Data block hash ratio
471 pub data_block_hash_ratio_policy: HashRatioPolicy,
472
473 /// Whether to partition index blocks
474 pub index_block_partitioning_policy: PartitioningPolicy,
475
476 /// Whether to partition filter blocks
477 pub filter_block_partitioning_policy: PartitioningPolicy,
478
479 /// Partition size when using partitioned indexes
480 pub index_block_partition_size_policy: BlockSizePolicy,
481
482 /// Partition size when using partitioned filters
483 pub filter_block_partition_size_policy: BlockSizePolicy,
484
485 /// If `true`, the last level will not build filters, reducing the filter size of a database
486 /// by ~90% typically
487 pub(crate) expect_point_read_hits: bool,
488
489 /// Per-block Page ECC. When `true`, every block on disk carries a parity
490 /// trailer; on read, if the block's XXH3 disagrees with the on-disk bytes,
491 /// the reader attempts recovery from the trailer before surfacing the
492 /// corruption. The correction scheme is selected at runtime
493 /// (`update_runtime_config`): per-word SEC-DED (the default), single XOR
494 /// parity, or Reed-Solomon. Requires the `page_ecc` cargo feature — opening a
495 /// tree with `page_ecc = true` on a build without the feature returns
496 /// [`crate::Error::PageEccUnsupported`].
497 ///
498 /// Off by default. `RocksDB` ships per-block ECC as an operator-
499 /// chosen knob (typically off on RAID-protected media, on on
500 /// single-drive) and the cost is non-trivial on the write path,
501 /// so the default keeps the existing behaviour.
502 pub(crate) page_ecc: bool,
503
504 /// Initial [`crate::runtime_config::RuntimeConfig`] snapshot
505 /// the tree starts with. Seeds both the first
506 /// `persist_version` call and the Tree's
507 /// `RuntimeConfigHandle`, so a non-default value supplied via
508 /// [`Config::with_runtime_config`] is honoured from byte zero
509 /// of the manifest. Defaults to `RuntimeConfig::default()` —
510 /// matches the pre-existing implicit behaviour.
511 #[expect(
512 clippy::struct_field_names,
513 reason = "name mirrors the type for grep-ability across the persist + Tree handle init wiring"
514 )]
515 pub(crate) initial_runtime_config: crate::runtime_config::RuntimeConfig,
516
517 /// Filter construction policy
518 pub filter_policy: FilterPolicy,
519
520 /// Retrieval-ribbon locator policy (per level). Defaults to
521 /// [`LocatorPolicy::block_level`]: written SSTs carry an optional `locator`
522 /// section mapping each key to its data block for O(1) point reads (skipping
523 /// the index-block binary search). Set [`LocatorPolicy::disabled`] to opt
524 /// out — disabled levels produce byte-identical SSTs (no section).
525 pub locator_policy: LocatorPolicy,
526
527 /// Compaction filter factory
528 pub compaction_filter_factory: Option<Arc<dyn Factory>>,
529
530 /// Prefix extractor for prefix bloom filters.
531 ///
532 /// When set, the bloom filter indexes extracted prefixes in addition to
533 /// full keys, allowing prefix scans to skip segments that contain no
534 /// matching prefixes.
535 pub prefix_extractor: Option<Arc<dyn PrefixExtractor>>,
536
537 /// Merge operator for commutative operations
538 ///
539 /// When set, enables `merge()` operations that store partial updates
540 /// which are lazily combined during reads and compaction.
541 pub merge_operator: Option<Arc<dyn MergeOperator>>,
542
543 #[doc(hidden)]
544 pub kv_separation_opts: Option<KvSeparationOptions>,
545
546 /// Custom user key comparator.
547 ///
548 /// When set, all key comparisons use this comparator instead of the
549 /// default lexicographic byte ordering. Once a tree is opened with a
550 /// comparator, it must always be re-opened with the same comparator.
551 // Not `pub` — use `Config::comparator()` builder method as the public API.
552 #[doc(hidden)]
553 pub(crate) comparator: SharedComparator,
554
555 /// Block-level encryption provider for encryption at rest.
556 ///
557 /// When set, all blocks (data, index, filter, meta) are encrypted
558 /// using this provider after compression and before checksumming.
559 pub(crate) encryption: Option<Arc<dyn EncryptionProvider>>,
560
561 /// Policy governing what `Tree::open` does when the on-disk
562 /// MANIFEST contains corrupt records. Defaults to
563 /// [`ManifestRecoveryMode::AbsoluteConsistency`], the only
564 /// production-safe choice — any corruption aborts the open. Other
565 /// modes trade strict correctness for partial-availability after a
566 /// disaster; see the enum doc for the operational scenarios that
567 /// motivate each mode.
568 pub(crate) manifest_recovery_mode: ManifestRecoveryMode,
569
570 /// Durability level for every fsync the tree issues (SST writes,
571 /// manifest, version persist, directory syncs).
572 ///
573 /// Defaults to [`SyncMode::Normal`] (plain `fsync`), matching the
574 /// out-of-the-box durability of `RocksDB` and `SQLite`. Only observable on
575 /// macOS, where [`SyncMode::Full`] opts into the much slower
576 /// `F_FULLFSYNC` barrier; on other platforms both modes are plain
577 /// `fsync`. Set via [`Config::sync_mode`].
578 pub(crate) sync_mode: SyncMode,
579
580 /// When `true` (the default), [`Config::open`] and [`Config::repair`]
581 /// acquire an exclusive cross-process lock on a `LOCK` file in the tree
582 /// directory (an advisory OS file lock) and hold it for the lifetime of the
583 /// [`Tree`] (open) or the duration of the call (repair). A
584 /// second process attempting to open / repair the same directory fails fast
585 /// with [`Error::Locked`](crate::Error::Locked) instead of racing on the
586 /// manifest. Set `false` via [`Config::with_directory_lock`] only when the
587 /// embedder already enforces exclusive directory ownership at a higher layer
588 /// (e.g. a keyspace / journal manager). Best-effort per `Fs` backend: real
589 /// on-disk backends enforce it, in-memory backends are single-process and
590 /// satisfy it vacuously.
591 pub(crate) directory_lock: bool,
592
593 /// Shared live-progress counters the repair / salvage paths tick while
594 /// they run, or `None` (the default) to skip publishing. Set via
595 /// [`Config::with_recovery_progress`]; observed by polling
596 /// [`RecoveryProgress::snapshot`](crate::RecoveryProgress::snapshot) from
597 /// another thread.
598 #[cfg(feature = "std")]
599 pub(crate) recovery_progress: Option<Arc<crate::RecoveryProgress>>,
600
601 /// Edit-log size (bytes) past which the next manifest persist rotates: it
602 /// writes a fresh full snapshot and starts an empty log instead of appending
603 /// another [`VersionEdit`](crate::version::edit::VersionEdit). Bounds both
604 /// recovery replay time (edits to re-apply) and log disk use, while keeping
605 /// the common per-flush path a tiny `O(changed-levels)` append rather than an
606 /// `O(all-SSTs)` full manifest rewrite.
607 ///
608 /// Defaults to 1 MiB (≈ tens of thousands of edits). Set via
609 /// [`Config::manifest_log_rotate_bytes`]. A smaller value rotates more
610 /// often (shorter recovery, more frequent full-snapshot writes); `0` rotates
611 /// on every upgrade, degenerating to the full-rewrite-per-version behaviour.
612 pub(crate) manifest_log_rotate_bytes: u64,
613
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
624 /// Compaction I/O rate limit in bytes per second.
625 ///
626 /// Caps the rate at which the compaction worker is allowed to issue
627 /// I/O, so background compaction cannot saturate the device and starve
628 /// user point reads / range scans (P99 stability). `0` (the default)
629 /// means unlimited — no throttling, no behaviour change. Flush and
630 /// user reads are never throttled, only compaction. Set via
631 /// [`Config::compaction_rate_limit`].
632 pub(crate) compaction_rate_limit: u64,
633
634 /// Worker-thread count for compaction parallelism (`std` only), used two
635 /// ways: it sizes the per-tree block-compression pool built at open when
636 /// [`Self::compaction_pool`] is `None`, and it caps how many range-parallel
637 /// sub-compactions a single compaction is split into. Default
638 /// `max(1, available_parallelism / 2)` — leaves half the cores for
639 /// application work. `1` forces the serial path for both. Without the
640 /// `parallel` feature there is no built-in pool, so block compression and
641 /// sub-compaction ranges run serially even for a value > 1. Set via
642 /// [`Config::compaction_threads`].
643 #[cfg(feature = "std")] // no-std: parallel compaction unavailable (no threads)
644 pub(crate) compaction_threads: usize,
645
646 /// Optional shared compaction thread pool. `None` (default) = a per-tree
647 /// pool is built at [`crate::Tree::open`] sized by [`Self::compaction_threads`]
648 /// (predictable, matches the per-DB pattern). `Some` = caller-supplied
649 /// executor shared across every tree holding this `Arc`, bounding total
650 /// threads regardless of tree count. Set via [`Config::compaction_pool`].
651 #[cfg(feature = "std")]
652 pub(crate) compaction_pool: Option<Arc<dyn crate::table::writer::CompactionSpawner>>,
653
654 /// Minimum total input size (bytes) for a compaction to be split into
655 /// parallel sub-compactions. Below it the compaction stays single-threaded
656 /// (per-thread setup + extra output tables outweigh the parallelism on small
657 /// compactions). Default
658 /// [`SUBCOMPACTION_MIN_INPUT_BYTES`](crate::compaction::worker::SUBCOMPACTION_MIN_INPUT_BYTES)
659 /// (8 MiB). Set via [`Config::subcompaction_min_bytes`].
660 #[cfg(feature = "std")]
661 pub(crate) subcompaction_min_bytes: u64,
662
663 /// Test-only failpoint: when armed, the first parallel sub-compaction range
664 /// that observes it returns an error and disarms it, so the crash-safety
665 /// rollback paths (sibling output rollback, input restore) can be exercised
666 /// deterministically. Behind `cfg(test)`, never compiled into release builds.
667 #[cfg(all(test, feature = "std"))]
668 pub(crate) fail_one_subcompaction: Arc<core::sync::atomic::AtomicBool>,
669
670 /// Test-only failpoint: when armed, a tight-space compaction returns an error
671 /// immediately after durably installing (and punching) its FIRST slice, so
672 /// the crash-mid-loop recovery path (reopen a tree whose manifest carries a
673 /// persisted input restriction) can be exercised deterministically. Behind
674 /// `cfg(test)`, never compiled into release builds.
675 #[cfg(all(test, feature = "std"))]
676 pub(crate) fail_tight_after_first_slice: Arc<core::sync::atomic::AtomicBool>,
677
678 /// Test-only failpoint: when armed, a tight-space relocation fails at the
679 /// restricted-blob reopen step of its current slice — after the slice's
680 /// outputs were finalized but before the install — so the pre-install
681 /// rollback (retract the finalized-but-unreferenced outputs) can be
682 /// exercised deterministically. Behind `cfg(test)`, never compiled into
683 /// release builds.
684 #[cfg(all(test, feature = "std"))]
685 pub(crate) fail_tight_blob_reopen: Arc<core::sync::atomic::AtomicBool>,
686
687 /// Pre-trained zstd dictionary for dictionary compression.
688 ///
689 /// When set together with a [`CompressionType::ZstdDict`] compression
690 /// policy, data blocks are compressed using this dictionary. The
691 /// dictionary must remain the same for the lifetime of the tree —
692 /// opening a tree with a different dictionary will produce
693 /// [`Error::ZstdDictMismatch`](crate::Error::ZstdDictMismatch) errors.
694 #[cfg(zstd_any)]
695 pub(crate) zstd_dictionary: Option<Arc<crate::compression::ZstdDictionary>>,
696
697 /// The global sequence number generator.
698 ///
699 /// Should be shared between multiple trees of a database.
700 pub(crate) seqno: SharedSequenceNumberGenerator,
701
702 /// Sequence number watermark that is visible to readers.
703 ///
704 /// Used for MVCC snapshots and to control which updates are
705 /// observable in a given view of the database.
706 pub(crate) visible_seqno: SharedSequenceNumberGenerator,
707}
708
709// TODO: remove default?
710// std-only: the default backend is `StdFs` and the default path is resolved
711// via std::path::absolute. no_std callers construct `Config` explicitly with a
712// caller-provided `Fs`.
713#[cfg(feature = "std")]
714impl Default for Config {
715 fn default() -> Self {
716 Self {
717 path: absolute_path(Path::new(DEFAULT_FILE_FOLDER)),
718 fs: Arc::new(StdFs),
719 level_routes: None,
720 descriptor_table: Some(Arc::new(DescriptorTable::new(256))),
721 seqno: SharedSequenceNumberGenerator::from(SequenceNumberCounter::default()),
722 visible_seqno: SharedSequenceNumberGenerator::from(SequenceNumberCounter::default()),
723
724 cache: Arc::new(Cache::with_capacity_bytes(
725 /* 16 MiB */ 16 * 1_024 * 1_024,
726 )),
727
728 data_block_restart_interval_policy: RestartIntervalPolicy::all(16),
729 index_block_restart_interval_policy: RestartIntervalPolicy::all(1),
730
731 level_count: DEFAULT_LEVEL_COUNT,
732
733 data_block_size_policy: BlockSizePolicy::all(4_096),
734
735 index_block_pinning_policy: PinningPolicy::new([true, true, false]),
736 filter_block_pinning_policy: PinningPolicy::new([true, false]),
737
738 top_level_index_block_pinning_policy: PinningPolicy::all(true), // TODO: implement
739 top_level_filter_block_pinning_policy: PinningPolicy::all(true), // TODO: implement
740
741 // Partitioned at every level so a bit-flip inside one
742 // sub-index block only takes out the keys covered by that
743 // partition, not the entire SST. A full-index SST has no
744 // within-block redundancy: one corrupt byte in the single
745 // index block makes every data block in the table
746 // unreachable. See tests/partitioned_index_blast_radius.rs
747 // for the isolation property this default relies on.
748 index_block_partitioning_policy: PinningPolicy::all(true),
749 // Filter-block default intentionally left at the pre-#329
750 // shape (L3+ only). A corrupt filter block can produce a
751 // false negative (filter says "not present" → read short-
752 // circuits → caller misses an existing key), which is a
753 // correctness hazard distinct from index corruption (where
754 // the read fails loudly). Flipping this default is tracked
755 // as a separate decision pending a filter blast-radius /
756 // false-negative analysis; symmetry with index is not
757 // sufficient justification on its own.
758 filter_block_partitioning_policy: PinningPolicy::new([false, false, false, true]),
759
760 index_block_partition_size_policy: BlockSizePolicy::all(4_096), // TODO: implement
761 filter_block_partition_size_policy: BlockSizePolicy::all(4_096), // TODO: implement
762
763 data_block_compression_policy: ({
764 #[cfg(feature = "lz4")]
765 let c = CompressionPolicy::new([CompressionType::None, CompressionType::Lz4]);
766
767 #[cfg(not(feature = "lz4"))]
768 let c = CompressionPolicy::new([CompressionType::None]);
769
770 c
771 }),
772 index_block_compression_policy: CompressionPolicy::all(CompressionType::None),
773
774 data_block_hash_ratio_policy: HashRatioPolicy::all(0.0),
775
776 locator_policy: LocatorPolicy::block_level(),
777 filter_policy: FilterPolicy::all(FilterPolicyEntry::Bloom(
778 BloomConstructionPolicy::BitsPerKey(10.0),
779 )),
780
781 compaction_filter_factory: None,
782 merge_operator: None,
783
784 prefix_extractor: None,
785
786 expect_point_read_hits: false,
787
788 page_ecc: false,
789
790 initial_runtime_config: crate::runtime_config::RuntimeConfig::default(),
791
792 kv_separation_opts: None,
793
794 #[cfg(zstd_any)]
795 zstd_dictionary: None,
796
797 comparator: comparator::default_comparator(),
798 encryption: None,
799 manifest_recovery_mode: ManifestRecoveryMode::AbsoluteConsistency,
800 sync_mode: SyncMode::Normal,
801 directory_lock: true,
802 #[cfg(feature = "std")]
803 recovery_progress: None,
804 manifest_log_rotate_bytes: 1024 * 1024,
805 repair_retention_floor: 0,
806 compaction_rate_limit: 0,
807
808 #[cfg(feature = "std")]
809 compaction_threads: std::thread::available_parallelism()
810 .map_or(1, |n| (n.get() / 2).max(1)),
811 #[cfg(feature = "std")]
812 compaction_pool: None,
813 #[cfg(feature = "std")]
814 subcompaction_min_bytes: crate::compaction::worker::SUBCOMPACTION_MIN_INPUT_BYTES,
815 #[cfg(all(test, feature = "std"))]
816 fail_one_subcompaction: Arc::new(core::sync::atomic::AtomicBool::new(false)),
817 #[cfg(all(test, feature = "std"))]
818 fail_tight_after_first_slice: Arc::new(core::sync::atomic::AtomicBool::new(false)),
819 #[cfg(all(test, feature = "std"))]
820 fail_tight_blob_reopen: Arc::new(core::sync::atomic::AtomicBool::new(false)),
821 }
822 }
823}
824
825/// Name of the lock file created in a tree directory for the cross-process
826/// exclusive directory lock.
827#[cfg_attr(
828 not(feature = "std"),
829 allow(
830 dead_code,
831 reason = "directory-lock filename used only by the std-gated lock-acquisition path"
832 )
833)]
834pub(crate) const DIRECTORY_LOCK_FILE: &str = "LOCK";
835
836/// Acquires the cross-process exclusive directory lock when `enabled`.
837///
838/// Opens (creating if absent) a `LOCK` file under `dir` and takes a
839/// non-blocking exclusive advisory lock on it through the `Fs` backend. Returns
840/// the locked handle to hold for as long as exclusivity is required; dropping it
841/// releases the lock (the OS frees an advisory lock when the fd / handle
842/// closes). `Ok(None)` when `enabled` is false. Fails with
843/// [`Error::Locked`](crate::Error::Locked) when another live instance holds the
844/// lock. The directory must already exist (the caller creates it for a fresh
845/// tree before acquiring).
846#[cfg(feature = "std")]
847pub(crate) fn acquire_directory_lock(
848 fs: &dyn Fs,
849 dir: &Path,
850 enabled: bool,
851) -> crate::Result<Option<Box<dyn crate::fs::FsFile>>> {
852 if !enabled {
853 return Ok(None);
854 }
855 let lock_path = dir.join(DIRECTORY_LOCK_FILE);
856 let file = fs.open(
857 &lock_path,
858 &crate::fs::FsOpenOptions::new()
859 .read(true)
860 .write(true)
861 .create(true),
862 )?;
863 if file.try_lock_exclusive()? {
864 Ok(Some(file))
865 } else {
866 Err(crate::Error::Locked(dir.display().to_string()))
867 }
868}
869
870impl Config {
871 /// Initializes a new config
872 // std-only: seeds the remaining fields from `Config::default`, whose
873 // default `Fs` is `StdFs`. no_std callers build `Config` field-by-field
874 // with a caller-provided `Fs`.
875 #[cfg(feature = "std")]
876 pub fn new<P: AsRef<Path>>(
877 path: P,
878 seqno: SequenceNumberCounter,
879 visible_seqno: SequenceNumberCounter,
880 ) -> Self {
881 Self {
882 path: absolute_path(path.as_ref()),
883 seqno: Arc::new(seqno),
884 visible_seqno: Arc::new(visible_seqno),
885 ..Default::default()
886 }
887 }
888
889 /// Sets the default filesystem backend used for levels without an explicit route.
890 ///
891 /// Defaults to [`StdFs`]. Use [`MemFs`](crate::fs::MemFs) for
892 /// in-memory trees (testing, ephemeral indexes).
893 ///
894 /// # Example
895 ///
896 /// ```
897 /// # fn main() -> lsm_tree::Result<()> {
898 /// use lsm_tree::{Config, SequenceNumberCounter};
899 /// use lsm_tree::fs::MemFs;
900 ///
901 /// let tree = Config::new(
902 /// "/virtual/tree",
903 /// SequenceNumberCounter::default(),
904 /// SequenceNumberCounter::default(),
905 /// )
906 /// .with_fs(MemFs::new())
907 /// .open()?;
908 /// # Ok(())
909 /// # }
910 /// ```
911 #[must_use]
912 pub fn with_fs<F: Fs>(mut self, fs: F) -> Self {
913 self.fs = Arc::new(fs);
914 self
915 }
916
917 /// Sets the default filesystem backend from an existing shared handle.
918 ///
919 /// Useful when multiple configs should reuse the same backend
920 /// instance, including trait objects and backends that are not `Clone`.
921 ///
922 #[must_use]
923 pub fn with_shared_fs(mut self, fs: Arc<dyn Fs>) -> Self {
924 self.fs = fs;
925 self
926 }
927
928 /// Opens a tree using the config.
929 ///
930 /// # Errors
931 ///
932 /// Will return `Err` if an IO error occurs.
933 /// Returns [`Error::ZstdDictMismatch`](crate::Error::ZstdDictMismatch) if
934 /// the compression policy references a `dict_id` that doesn't match the
935 /// configured dictionary.
936 pub fn open(self) -> crate::Result<AnyTree> {
937 #[cfg(zstd_any)]
938 self.validate_zstd_dictionary()?;
939
940 // On a zstd build the live block path seals encrypted blocks through
941 // the AAD-bound envelope, so the configured provider MUST implement it.
942 // Reject an opaque-only provider here, at open time, instead of letting
943 // it fail on the first encrypted read/write.
944 #[cfg(zstd_any)]
945 if self
946 .encryption
947 .as_ref()
948 .is_some_and(|enc| !enc.supports_aad_block_path())
949 {
950 return Err(crate::Error::Encrypt(
951 "encryption provider does not implement the AAD-bound block path \
952 (encrypt_block_aad / decrypt_block_aad) required for encrypted \
953 blocks on a zstd build",
954 ));
955 }
956
957 Ok(if self.kv_separation_opts.is_some() {
958 AnyTree::Blob(BlobTree::open(self)?)
959 } else {
960 AnyTree::Standard(Tree::open(self)?)
961 })
962 }
963
964 /// Validates that every `ZstdDict` entry in compression policies references
965 /// a `dict_id` that matches the configured dictionary. Catches mismatches
966 /// at open time rather than at first block write/read.
967 #[cfg(zstd_any)]
968 fn validate_zstd_dictionary(&self) -> crate::Result<()> {
969 let dict_id = self.zstd_dictionary.as_ref().map(|d| d.id());
970
971 // NOTE: Only data block policies are validated. Index blocks never
972 // carry a dictionary — Writer::use_index_block_compression() downgrades
973 // ZstdDict to plain Zstd. Validating index policies here would reject
974 // configs that use ZstdDict solely for index blocks even though the
975 // writer handles them correctly.
976 for ct in self.data_block_compression_policy.iter() {
977 if let &CompressionType::ZstdDict {
978 dict_id: required, ..
979 } = ct
980 {
981 match dict_id {
982 None => {
983 return Err(crate::Error::ZstdDictMismatch {
984 expected: required,
985 got: None,
986 });
987 }
988 Some(actual) if actual != required => {
989 return Err(crate::Error::ZstdDictMismatch {
990 expected: required,
991 got: Some(actual),
992 });
993 }
994 _ => {}
995 }
996 }
997 }
998
999 // Blob files with ZstdDict compression must have a matching dictionary.
1000 if let Some(ref kv_opts) = self.kv_separation_opts
1001 && let CompressionType::ZstdDict {
1002 dict_id: required, ..
1003 } = kv_opts.compression
1004 {
1005 match kv_opts.zstd_dictionary.as_ref().map(|d| d.id()) {
1006 None => {
1007 return Err(crate::Error::ZstdDictMismatch {
1008 expected: required,
1009 got: None,
1010 });
1011 }
1012 Some(actual) if actual != required => {
1013 return Err(crate::Error::ZstdDictMismatch {
1014 expected: required,
1015 got: Some(actual),
1016 });
1017 }
1018 _ => {}
1019 }
1020 }
1021
1022 Ok(())
1023 }
1024
1025 /// Like [`Config::new`], but accepts pre-built shared generators.
1026 ///
1027 /// This is useful when the caller already has
1028 /// [`SharedSequenceNumberGenerator`] instances (e.g., from a higher-level
1029 /// database that shares generators across multiple trees).
1030 // std-only: see [`Config::new`] — seeds via `Config::default` (`StdFs`).
1031 #[cfg(feature = "std")]
1032 pub fn new_with_generators<P: AsRef<Path>>(
1033 path: P,
1034 seqno: SharedSequenceNumberGenerator,
1035 visible_seqno: SharedSequenceNumberGenerator,
1036 ) -> Self {
1037 Self {
1038 path: absolute_path(path.as_ref()),
1039 seqno,
1040 visible_seqno,
1041 ..Default::default()
1042 }
1043 }
1044}
1045
1046#[cfg(all(test, zstd_any))]
1047mod tests;
1048
1049impl Config {
1050 /// Returns the tables folder path and [`Fs`] backend for the given level.
1051 ///
1052 /// If [`level_routes`](Self::level_routes) has an entry covering this
1053 /// level, uses that entry's path and `Fs`. Otherwise falls back to the
1054 /// primary [`path`](Self::path) and [`fs`](Self::fs).
1055 #[must_use]
1056 pub fn tables_folder_for_level(&self, level: u8) -> (PathBuf, Arc<dyn Fs>) {
1057 if let Some(routes) = &self.level_routes {
1058 for route in routes {
1059 if route.levels.contains(&level) {
1060 return (route.path.join(TABLES_FOLDER), route.fs.clone());
1061 }
1062 }
1063 }
1064 (self.path.join(TABLES_FOLDER), self.fs.clone())
1065 }
1066
1067 /// Best-effort minimum free space (bytes) across every filesystem this tree
1068 /// writes to: the primary [`path`](Self::path) plus each
1069 /// [`level_routes`](Self::level_routes) volume.
1070 ///
1071 /// The tightest volume bounds storage admission and compaction space gating,
1072 /// since a full routed (cold-tier) volume fails a flush / compaction
1073 /// targeting it even while the primary still has room. A backend that cannot
1074 /// report free space (or an I/O hiccup) contributes `u64::MAX`, so a probe
1075 /// failure never fabricates disk pressure.
1076 #[must_use]
1077 pub(crate) fn min_available_space(&self) -> u64 {
1078 let mut free = self.fs.available_space(&self.path).unwrap_or(u64::MAX);
1079 if let Some(routes) = &self.level_routes {
1080 for route in routes {
1081 free = free.min(route.fs.available_space(&route.path).unwrap_or(u64::MAX));
1082 }
1083 }
1084 free
1085 }
1086
1087 /// Returns all unique tables folders that need to be scanned during
1088 /// recovery: the primary folder plus every [`LevelRoute`] folder.
1089 #[must_use]
1090 pub fn all_tables_folders(&self) -> Vec<(PathBuf, Arc<dyn Fs>)> {
1091 let primary_fs: Arc<dyn Fs> = self.fs.clone();
1092 let mut folders: Vec<(PathBuf, Arc<dyn Fs>)> =
1093 vec![(self.path.join(TABLES_FOLDER), primary_fs)];
1094
1095 if let Some(routes) = &self.level_routes {
1096 for route in routes {
1097 let folder = route.path.join(TABLES_FOLDER);
1098 // Dedup by path: scanning the same directory twice would cause
1099 // already-recovered tables to be classified as orphans and
1100 // deleted. Routing the same path through different Fs backends
1101 // is a configuration error (level_routes validation in
1102 // Config::level_routes rejects overlapping ranges).
1103 if !folders.iter().any(|(p, _)| *p == folder) {
1104 folders.push((folder, route.fs.clone()));
1105 }
1106 }
1107 }
1108
1109 folders
1110 }
1111
1112 /// Configures per-level filesystem routing for tiered storage.
1113 ///
1114 /// Each [`LevelRoute`] maps a range of LSM levels to a base directory
1115 /// and filesystem backend. Levels not covered by any route fall back to
1116 /// the primary `path` and `fs`.
1117 ///
1118 /// # Reopen contract
1119 ///
1120 /// The route configuration is **not persisted** in the manifest.
1121 /// On reopen, the [`Config`] must specify `level_routes` such that
1122 /// [`all_tables_folders`](Self::all_tables_folders) includes every
1123 /// directory and filesystem pair that may contain existing SST files
1124 /// for this tree.
1125 ///
1126 /// Changing the mapping from levels to paths is allowed as long as
1127 /// the previously used folders remain covered. If old folders are
1128 /// omitted, recovery may fail with
1129 /// [`RouteMismatch`](crate::Error::RouteMismatch) (when all missing
1130 /// tables are on uncovered levels) or
1131 /// [`Unrecoverable`](crate::Error::Unrecoverable) (when some missing
1132 /// tables are on levels that are still covered).
1133 ///
1134 /// # Panics
1135 ///
1136 /// Panics if any route has an empty range or if any two routes have
1137 /// overlapping level ranges.
1138 #[must_use]
1139 pub fn level_routes(mut self, routes: Vec<LevelRoute>) -> Self {
1140 // Validate no empty/inverted ranges
1141 for route in &routes {
1142 assert!(
1143 route.levels.start < route.levels.end,
1144 "empty or inverted level route range: {:?}",
1145 route.levels,
1146 );
1147 }
1148
1149 // Validate no overlapping ranges
1150 for (i, a) in routes.iter().enumerate() {
1151 for b in routes.iter().skip(i + 1) {
1152 assert!(
1153 a.levels.end <= b.levels.start || b.levels.end <= a.levels.start,
1154 "overlapping level routes: {:?} and {:?}",
1155 a.levels,
1156 b.levels,
1157 );
1158 }
1159 }
1160 self.level_routes = if routes.is_empty() {
1161 None
1162 } else {
1163 // Normalize paths the same way Config::new normalizes self.path
1164 Some(
1165 routes
1166 .into_iter()
1167 .map(|mut r| {
1168 r.path = absolute_path(&r.path);
1169 r
1170 })
1171 .collect(),
1172 )
1173 };
1174 self
1175 }
1176
1177 /// Overrides the sequence number generator.
1178 ///
1179 /// By default, [`SequenceNumberCounter`] is used. This allows plugging in
1180 /// a custom generator (e.g., HLC for distributed databases).
1181 #[must_use]
1182 pub fn seqno_generator(mut self, generator: SharedSequenceNumberGenerator) -> Self {
1183 self.seqno = generator;
1184 self
1185 }
1186
1187 /// Overrides the visible sequence number generator.
1188 #[must_use]
1189 pub fn visible_seqno_generator(mut self, generator: SharedSequenceNumberGenerator) -> Self {
1190 self.visible_seqno = generator;
1191 self
1192 }
1193
1194 /// Sets the global cache.
1195 ///
1196 /// You can create a global [`Cache`] and share it between multiple
1197 /// trees to cap global cache memory usage.
1198 ///
1199 /// Defaults to a cache with 16 MiB of capacity *per tree*.
1200 #[must_use]
1201 pub fn use_cache(mut self, cache: Arc<Cache>) -> Self {
1202 self.cache = cache;
1203 self
1204 }
1205
1206 /// Sets the file descriptor cache.
1207 ///
1208 /// Can be shared across trees.
1209 #[must_use]
1210 pub fn use_descriptor_table(mut self, descriptor_table: Option<Arc<DescriptorTable>>) -> Self {
1211 self.descriptor_table = descriptor_table;
1212 self
1213 }
1214
1215 /// If `true`, the last level will not build filters, reducing the filter size of a database
1216 /// by ~90% typically.
1217 ///
1218 /// **Enable this only if you know that point reads generally are expected to find a key-value pair.**
1219 #[must_use]
1220 pub fn expect_point_read_hits(mut self, b: bool) -> Self {
1221 self.expect_point_read_hits = b;
1222 self
1223 }
1224
1225 /// Enables per-block Page ECC.
1226 ///
1227 /// When enabled, every block written by this tree carries a parity
1228 /// trailer; on read, if the block's XXH3 disagrees with the on-disk
1229 /// bytes, the reader attempts recovery from the trailer before surfacing
1230 /// the corruption. The correction scheme defaults to per-word SEC-DED and
1231 /// is selectable at runtime (`update_runtime_config`): per-word SEC-DED,
1232 /// single XOR parity, or Reed-Solomon.
1233 ///
1234 /// Opening a tree with `page_ecc = true` on a build that does not
1235 /// have the `page_ecc` cargo feature enabled returns
1236 /// [`crate::Error::PageEccUnsupported`] at `Tree::open` — the
1237 /// reader has no way to honour the parity trailer without the
1238 /// codec, so silently downgrading integrity is not an option.
1239 ///
1240 /// Wired into the on-disk write path via `MultiWriter::use_page_ecc`
1241 /// at every `Tree::open` / `Tree::ingestion` / compaction-worker
1242 /// `MultiWriter` construction site. With this flag set, every
1243 /// `Block::write_into` call those writers make upgrades its
1244 /// `BlockTransform` to the matching `*Ecc` variant — emitting the
1245 /// configured scheme's parity trailer and setting the `ECC_PARITY` flag
1246 /// in each block header (the trailer length is derived from
1247 /// `data_length`, not stored).
1248 #[must_use]
1249 pub fn page_ecc(mut self, enabled: bool) -> Self {
1250 self.page_ecc = enabled;
1251 self
1252 }
1253
1254 /// Enables or disables the cross-process directory lock (default: enabled).
1255 ///
1256 /// When enabled, [`Config::open`] and [`Config::repair`] acquire an
1257 /// exclusive advisory lock on a `LOCK` file in the tree directory, so a
1258 /// second process opening / repairing the same directory fails fast with
1259 /// [`Error::Locked`](crate::Error::Locked) rather than corrupting the shared
1260 /// manifest. Disable ONLY when exclusive directory ownership is already
1261 /// guaranteed at a higher layer (e.g. an embedding keyspace / journal
1262 /// manager that opens each directory at most once per host).
1263 #[must_use]
1264 pub fn with_directory_lock(mut self, enabled: bool) -> Self {
1265 self.directory_lock = enabled;
1266 self
1267 }
1268
1269 /// Wires shared live-progress counters into the repair / salvage paths.
1270 ///
1271 /// A repair over a large store streams every SST and blob file and can run
1272 /// for a long time; the handle set here is ticked as files are discovered
1273 /// and blocks / rows are recovered, so another thread can poll
1274 /// [`RecoveryProgress::snapshot`](crate::RecoveryProgress::snapshot) while
1275 /// [`Config::repair`] (or a salvage it triggers) runs. Without it, repair
1276 /// publishes no progress (zero overhead).
1277 ///
1278 /// # Examples
1279 ///
1280 /// ```no_run
1281 /// use lsm_tree::{Config, RecoveryProgress, SequenceNumberCounter};
1282 /// use std::sync::Arc;
1283 ///
1284 /// let progress = Arc::new(RecoveryProgress::default());
1285 /// let config = Config::new(
1286 /// "my-tree",
1287 /// SequenceNumberCounter::default(),
1288 /// SequenceNumberCounter::default(),
1289 /// )
1290 /// .with_recovery_progress(progress.clone());
1291 /// // spawn the repair, then poll `progress.snapshot()` elsewhere
1292 /// let report = config.repair()?;
1293 /// # Ok::<_, lsm_tree::Error>(())
1294 /// ```
1295 #[cfg(feature = "std")]
1296 #[must_use]
1297 pub fn with_recovery_progress(mut self, progress: Arc<crate::RecoveryProgress>) -> Self {
1298 self.recovery_progress = Some(progress);
1299 self
1300 }
1301
1302 /// Sets the Page ECC scheme used when [`Self::page_ecc`] is enabled.
1303 ///
1304 /// ECC is off until `page_ecc(true)`. When on, this picks the
1305 /// algorithm:
1306 /// [`EccScheme::Secded`](crate::runtime_config::EccScheme::Secded)
1307 /// (per-word single-bit correct / double-bit detect, the default, supported
1308 /// at Block granularity),
1309 /// [`EccScheme::Xor`](crate::runtime_config::EccScheme::Xor) (RAID-5
1310 /// single-parity), or
1311 /// [`EccScheme::ReedSolomon`](crate::runtime_config::EccScheme::ReedSolomon).
1312 /// There is no implicit RS(4,2) default.
1313 #[must_use]
1314 pub fn ecc_scheme(mut self, scheme: crate::runtime_config::EccScheme) -> Self {
1315 self.initial_runtime_config.ecc_scheme = scheme;
1316 self
1317 }
1318
1319 /// Sets whether the writer clears per-file copy-on-write on newly created
1320 /// SST / blob files when the backing filesystem is copy-on-write (Btrfs).
1321 ///
1322 /// Default `true`: write-once SSTs gain no benefit from `CoW` but suffer a
1323 /// fragmentation penalty (~20% write throughput on Btrfs), so clearing it
1324 /// recovers the ext4-equivalent baseline. A no-op on non-`CoW` filesystems.
1325 /// Set `false` to preserve `CoW` (e.g. Btrfs subvolume snapshots that depend
1326 /// on it). See [`crate::runtime_config::RuntimeConfig::disable_cow_on_sst_files`].
1327 #[must_use]
1328 pub fn disable_cow_on_sst_files(mut self, enabled: bool) -> Self {
1329 self.initial_runtime_config.disable_cow_on_sst_files = enabled;
1330 self
1331 }
1332
1333 /// Sets whether [`crate::AbstractTree::create_checkpoint`] clones files via
1334 /// reflink (`FICLONE` / `clonefile`) when the filesystem supports it,
1335 /// falling back to a hard link otherwise.
1336 ///
1337 /// Default `true`: a reflinked checkpoint has an independent inode (no
1338 /// max-links constraint, modifications never touch the original) at O(1)
1339 /// cost via copy-on-write block sharing. A no-op (hard-link path) on
1340 /// filesystems without reflink. See
1341 /// [`crate::runtime_config::RuntimeConfig::use_reflink_for_checkpoint`].
1342 #[must_use]
1343 pub fn use_reflink_for_checkpoint(mut self, enabled: bool) -> Self {
1344 self.initial_runtime_config.use_reflink_for_checkpoint = enabled;
1345 self
1346 }
1347
1348 /// Sets the initial [`crate::runtime_config::RuntimeConfig`]
1349 /// snapshot the tree will start with.
1350 ///
1351 /// Seeds both the first manifest write and the live
1352 /// `RuntimeConfigHandle` exposed via
1353 /// [`crate::Tree::runtime_config`].
1354 ///
1355 /// **Manifest-hardening toggles** in the supplied snapshot
1356 /// that are currently wired through the writer
1357 /// (`manifest_footer_mirror`, `page_ecc` *as consumed by
1358 /// `manifest_blocks::writer` when picking the `BlockTransform`
1359 /// variant*) take effect from byte zero of the on-disk
1360 /// manifest rather than waiting for a post-open
1361 /// [`crate::Tree::update_runtime_config`] call. Subsequent
1362 /// updates still flow through the live handle and apply to
1363 /// the next manifest write.
1364 ///
1365 /// `manifest_kv_checksums` is plumbed in the snapshot but the
1366 /// writer does NOT yet consult or persist it (per-entry
1367 /// framing + footer-flag slot land in a follow-up). Setting
1368 /// it here today has no on-disk effect; it is exposed for
1369 /// forward-compat with no behaviour break.
1370 ///
1371 /// **Note on data-block ECC:** `RuntimeConfig::page_ecc`
1372 /// currently affects manifest Blocks only — data-block ECC is
1373 /// still gated by [`Config::page_ecc`] at tree-open time. The
1374 /// SST writer path consumes the tree-static config, not the
1375 /// runtime handle. Wiring through SST emission is a follow-up.
1376 #[must_use]
1377 pub fn with_runtime_config(mut self, runtime: crate::runtime_config::RuntimeConfig) -> Self {
1378 self.initial_runtime_config = runtime;
1379 self
1380 }
1381
1382 /// Sets the partitioning policy for filter blocks.
1383 #[must_use]
1384 pub fn filter_block_partitioning_policy(mut self, policy: PinningPolicy) -> Self {
1385 self.filter_block_partitioning_policy = policy;
1386 self
1387 }
1388
1389 /// Sets the partitioning policy for index blocks.
1390 #[must_use]
1391 pub fn index_block_partitioning_policy(mut self, policy: PinningPolicy) -> Self {
1392 self.index_block_partitioning_policy = policy;
1393 self
1394 }
1395
1396 /// Sets the pinning policy for filter blocks.
1397 #[must_use]
1398 pub fn filter_block_pinning_policy(mut self, policy: PinningPolicy) -> Self {
1399 self.filter_block_pinning_policy = policy;
1400 self
1401 }
1402
1403 /// Sets the pinning policy for index blocks.
1404 #[must_use]
1405 pub fn index_block_pinning_policy(mut self, policy: PinningPolicy) -> Self {
1406 self.index_block_pinning_policy = policy;
1407 self
1408 }
1409
1410 /// Sets the restart interval inside data blocks.
1411 ///
1412 /// A higher restart interval saves space while increasing lookup times
1413 /// inside data blocks.
1414 ///
1415 /// Default = 16
1416 ///
1417 /// # Panics
1418 ///
1419 /// Panics if any restart interval in `policy` is zero.
1420 #[must_use]
1421 pub fn data_block_restart_interval_policy(mut self, policy: RestartIntervalPolicy) -> Self {
1422 assert!(
1423 policy.iter().all(|interval| *interval > 0),
1424 "data block restart interval must be greater than zero",
1425 );
1426 self.data_block_restart_interval_policy = policy;
1427 self
1428 }
1429
1430 /// Sets the restart interval inside index blocks.
1431 ///
1432 /// A higher restart interval saves space while increasing lookup times
1433 /// inside index blocks.
1434 ///
1435 /// Default = 1
1436 ///
1437 /// # Panics
1438 ///
1439 /// Panics if any restart interval in `policy` is zero.
1440 #[must_use]
1441 pub fn index_block_restart_interval_policy(mut self, policy: RestartIntervalPolicy) -> Self {
1442 assert!(
1443 policy.iter().all(|interval| *interval > 0),
1444 "index block restart interval must be greater than zero",
1445 );
1446 self.index_block_restart_interval_policy = policy;
1447 self
1448 }
1449
1450 /// Sets the filter construction policy.
1451 #[must_use]
1452 pub fn filter_policy(mut self, policy: FilterPolicy) -> Self {
1453 self.filter_policy = policy;
1454 self
1455 }
1456
1457 /// Sets the retrieval-ribbon locator policy.
1458 ///
1459 /// On by default at [`LocatorPrecision::Block`] (see
1460 /// [`LocatorPolicy::block_level`]). When enabled for a level, written SSTs on
1461 /// that level carry an optional `locator` section mapping each key to its
1462 /// data block (and, at finer precisions, its slot), letting point reads skip
1463 /// the index-block binary search. Set [`LocatorPolicy::disabled`] to opt out;
1464 /// disabled levels emit byte-identical SSTs.
1465 #[must_use]
1466 pub fn locator_policy(mut self, policy: LocatorPolicy) -> Self {
1467 self.locator_policy = policy;
1468 self
1469 }
1470
1471 /// Sets the compression method for data blocks.
1472 #[must_use]
1473 pub fn data_block_compression_policy(mut self, policy: CompressionPolicy) -> Self {
1474 self.data_block_compression_policy = policy;
1475 self
1476 }
1477
1478 /// Sets the compression method for index blocks.
1479 #[must_use]
1480 pub fn index_block_compression_policy(mut self, policy: CompressionPolicy) -> Self {
1481 self.index_block_compression_policy = policy;
1482 self
1483 }
1484
1485 // TODO: level count is fixed to 7 right now
1486 // /// Sets the number of levels of the LSM tree (depth of tree).
1487 // ///
1488 // /// Defaults to 7, like `LevelDB` and `RocksDB`.
1489 // ///
1490 // /// Cannot be changed once set.
1491 // ///
1492 // /// # Panics
1493 // ///
1494 // /// Panics if `n` is 0.
1495 // #[must_use]
1496 // pub fn level_count(mut self, n: u8) -> Self {
1497 // assert!(n > 0);
1498
1499 // self.level_count = n;
1500 // self
1501 // }
1502
1503 /// Sets the data block size policy.
1504 #[must_use]
1505 pub fn data_block_size_policy(mut self, policy: BlockSizePolicy) -> Self {
1506 self.data_block_size_policy = policy;
1507 self
1508 }
1509
1510 /// Sets the hash ratio policy for data blocks.
1511 ///
1512 /// If greater than 0.0, a hash index is embedded into data blocks that can speed up reads
1513 /// inside the data block.
1514 #[must_use]
1515 pub fn data_block_hash_ratio_policy(mut self, policy: HashRatioPolicy) -> Self {
1516 self.data_block_hash_ratio_policy = policy;
1517 self
1518 }
1519
1520 /// Toggles key-value separation.
1521 #[must_use]
1522 pub fn with_kv_separation(mut self, opts: Option<KvSeparationOptions>) -> Self {
1523 self.kv_separation_opts = opts;
1524 self
1525 }
1526
1527 /// Installs a custom compaction filter.
1528 #[must_use]
1529 pub fn with_compaction_filter_factory(mut self, factory: Option<Arc<dyn Factory>>) -> Self {
1530 self.compaction_filter_factory = factory;
1531 self
1532 }
1533
1534 /// Sets the prefix extractor for prefix bloom filters.
1535 ///
1536 /// When configured, bloom filters will index key prefixes returned by
1537 /// the extractor. Prefix scans can then skip segments whose bloom
1538 /// filter reports no match for the scan prefix.
1539 #[must_use]
1540 pub fn prefix_extractor(mut self, extractor: Arc<dyn PrefixExtractor>) -> Self {
1541 self.prefix_extractor = Some(extractor);
1542 self
1543 }
1544
1545 /// Installs a merge operator for commutative operations.
1546 ///
1547 /// When set, enables [`crate::AbstractTree::merge`] which stores partial updates
1548 /// (operands) that are lazily combined during reads and compaction.
1549 #[must_use]
1550 pub fn with_merge_operator(mut self, op: Option<Arc<dyn MergeOperator>>) -> Self {
1551 self.merge_operator = op;
1552 self
1553 }
1554
1555 /// Sets a custom user key comparator.
1556 ///
1557 /// When configured, all key ordering (memtable, block index, merge,
1558 /// range scans) uses this comparator instead of the default lexicographic
1559 /// byte ordering.
1560 ///
1561 /// # Important
1562 ///
1563 /// The comparator's [`crate::UserComparator::name`] is persisted when a tree is
1564 /// first created. On subsequent opens the stored name is compared against
1565 /// the supplied comparator's name — a mismatch causes the open to fail
1566 /// with [`Error::ComparatorMismatch`](crate::Error::ComparatorMismatch).
1567 #[must_use]
1568 pub fn comparator(mut self, comparator: SharedComparator) -> Self {
1569 self.comparator = comparator;
1570 self
1571 }
1572
1573 /// Sets the block-level encryption provider for encryption at rest.
1574 ///
1575 /// When set, all blocks written to SST files are encrypted after
1576 /// compression and before checksumming, using the provided
1577 /// [`EncryptionProvider`].
1578 ///
1579 /// The caller is responsible for key management and rotation.
1580 /// See `crate::Aes256GcmProvider` (behind the `encryption` feature)
1581 /// for a ready-to-use AES-256-GCM implementation.
1582 ///
1583 /// **Important constraints:**
1584 /// - Encryption state is NOT recorded in SST metadata. Opening an
1585 /// encrypted tree without the correct provider (or vice versa) will
1586 /// cause block validation errors, not silent corruption.
1587 /// - Blob files (KV-separated large values) are NOT covered by
1588 /// block-level encryption. Large values stored via KV separation
1589 /// remain in plaintext on disk.
1590 #[must_use]
1591 pub fn with_encryption(mut self, encryption: Option<Arc<dyn EncryptionProvider>>) -> Self {
1592 self.encryption = encryption;
1593 self
1594 }
1595
1596 /// Sets the MANIFEST recovery policy for `Tree::open`.
1597 ///
1598 /// The default ([`ManifestRecoveryMode::AbsoluteConsistency`]) is the
1599 /// only choice that's safe for live production: any corrupt record
1600 /// in the on-disk manifest aborts the open. Switching to a more
1601 /// permissive mode trades strict correctness for partial
1602 /// availability after a disaster. The recovery path emits a
1603 /// `warn!` summary per affected section (aggregate counts: total
1604 /// table records dropped, total blob-file records dropped,
1605 /// header truncations) rather than one log line per dropped
1606 /// record — the dropped records were never decoded in the first
1607 /// place, so no per-record IDs are available. Always pair the
1608 /// non-default modes with an out-of-band integrity scan
1609 /// ([`verify_integrity`](crate::verify::verify_integrity) for
1610 /// whole-file XXH3 over every SST + blob file, or
1611 /// [`verify_block_checksums`](crate::verify::verify_block_checksums)
1612 /// for per-block granularity) before trusting the recovered tree
1613 /// for writes.
1614 ///
1615 /// See the [`ManifestRecoveryMode`] doc for per-variant semantics.
1616 #[must_use]
1617 pub fn manifest_recovery_mode(mut self, mode: ManifestRecoveryMode) -> Self {
1618 self.manifest_recovery_mode = mode;
1619 self
1620 }
1621
1622 /// Sets the durability level for every fsync the tree issues.
1623 ///
1624 /// Defaults to [`SyncMode::Normal`] (plain `fsync`, matching `RocksDB` /
1625 /// `SQLite` defaults). Pass [`SyncMode::Full`] to force `F_FULLFSYNC` on
1626 /// macOS for power-loss durability without an external journal — at a
1627 /// large per-flush cost. On non-macOS platforms both modes are
1628 /// identical (plain `fsync`).
1629 #[must_use]
1630 pub fn sync_mode(mut self, mode: SyncMode) -> Self {
1631 self.sync_mode = mode;
1632 self
1633 }
1634
1635 /// Sets the edit-log rotation threshold in bytes (default 1 MiB).
1636 ///
1637 /// Once the manifest edit log exceeds this size, the next version upgrade
1638 /// writes a fresh full snapshot and starts an empty log instead of appending
1639 /// another edit. Lower it to shorten recovery replay and cap log size at the
1640 /// cost of more frequent full-snapshot writes; `0` rotates on every upgrade.
1641 #[must_use]
1642 pub fn manifest_log_rotate_bytes(mut self, bytes: u64) -> Self {
1643 self.manifest_log_rotate_bytes = bytes;
1644 self
1645 }
1646
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 every
1655 /// retention-advancing install established (a GC compaction, `clear`, a
1656 /// table drop, a filtering compaction; see
1657 /// [`AbstractTree::retention_floor`](crate::AbstractTree::retention_floor)).
1658 /// A repair rebuilds the manifest from the tables, which do not record it
1659 /// (a GC compaction zeroes the seqnos of the rows it settles), so only the
1660 /// deployment knows it: record
1661 /// [`retention_floor()`](crate::AbstractTree::retention_floor) in your own
1662 /// durable state (it already folds in every operation that raised the
1663 /// boundary, so no per-operation bookkeeping is needed) and pass the last
1664 /// recorded value here. Left at `0`, a repaired tree serves every
1665 /// snapshot, which is correct only if history was never collected. Has no
1666 /// effect on an open that finds a manifest.
1667 #[must_use]
1668 pub fn repair_retention_floor(mut self, floor: crate::SeqNo) -> Self {
1669 self.repair_retention_floor = floor;
1670 self
1671 }
1672
1673 /// Sets the compaction I/O rate limit in bytes per second.
1674 ///
1675 /// Caps how fast the compaction worker may issue I/O so background
1676 /// compaction does not saturate the device and spike user read P99.
1677 /// `0` (the default) disables throttling. Only compaction is limited;
1678 /// flush and user reads always pass through.
1679 #[must_use]
1680 pub fn compaction_rate_limit(mut self, bytes_per_sec: u64) -> Self {
1681 self.compaction_rate_limit = bytes_per_sec;
1682 self
1683 }
1684
1685 /// Sets the compaction worker-thread count.
1686 ///
1687 /// Under `std` this both sizes the per-tree block-compression pool built at
1688 /// open when no shared pool is supplied (see [`Self::compaction_pool`]) and
1689 /// caps how many range-parallel sub-compactions a compaction splits into.
1690 /// `1` keeps compaction serial. Default is `max(1, available_parallelism /
1691 /// 2)`. Without the `parallel` feature there is no built-in pool, so the
1692 /// work runs serially even for a value > 1.
1693 #[cfg(feature = "std")]
1694 #[must_use]
1695 pub fn compaction_threads(mut self, threads: usize) -> Self {
1696 // Clamp to >= 1: the documented semantics treat `1` as "serial", and a
1697 // 0-thread pool would be an invalid state.
1698 self.compaction_threads = threads.max(1);
1699 self
1700 }
1701
1702 /// Sets the minimum total input size (bytes) for a compaction to be split
1703 /// into parallel sub-compactions. Default 8 MiB. `0` splits every eligible
1704 /// compaction; a large value effectively disables sub-compaction (block
1705 /// compression still parallelizes via [`Self::compaction_threads`]).
1706 #[cfg(feature = "std")]
1707 #[must_use]
1708 pub fn subcompaction_min_bytes(mut self, bytes: u64) -> Self {
1709 self.subcompaction_min_bytes = bytes;
1710 self
1711 }
1712
1713 /// Supplies a shared compaction thread pool, used in place of the per-tree
1714 /// default. Pass one [`crate::table::writer::CompactionSpawner`] (e.g. a
1715 /// `RayonSpawner` wrapping a shared rayon thread pool) to several trees so
1716 /// the total worker-thread count stays bounded by the pool size rather than
1717 /// the number of open trees.
1718 #[cfg(feature = "std")]
1719 #[must_use]
1720 pub fn compaction_pool(
1721 mut self,
1722 pool: Option<Arc<dyn crate::table::writer::CompactionSpawner>>,
1723 ) -> Self {
1724 self.compaction_pool = pool;
1725 self
1726 }
1727
1728 /// Sets the pre-trained zstd dictionary for dictionary compression.
1729 ///
1730 /// When set, data blocks using [`CompressionType::ZstdDict`] will be
1731 /// compressed and decompressed with this dictionary. The dictionary
1732 /// should be trained on representative data samples for best results.
1733 ///
1734 /// Create a dictionary with [`ZstdDictionary::new`](crate::ZstdDictionary::new),
1735 /// then use [`CompressionType::zstd_dict`] to create a matching
1736 /// compression type:
1737 ///
1738 /// ```ignore
1739 /// use lsm_tree::{CompressionType, ZstdDictionary};
1740 ///
1741 /// let dict = ZstdDictionary::new(&training_data);
1742 /// let compression = CompressionType::zstd_dict(3, dict.id()).unwrap();
1743 ///
1744 /// config
1745 /// .zstd_dictionary(Some(Arc::new(dict)))
1746 /// .data_block_compression_policy(CompressionPolicy::all(compression));
1747 /// ```
1748 #[cfg(zstd_any)]
1749 #[must_use]
1750 pub fn zstd_dictionary(
1751 mut self,
1752 dictionary: Option<Arc<crate::compression::ZstdDictionary>>,
1753 ) -> Self {
1754 self.zstd_dictionary = dictionary;
1755 self
1756 }
1757}
1758
1759#[cfg(test)]
1760mod builder_tests;