Skip to main content

slatedb/
config.rs

1//! Configuration options for SlateDB.
2//!
3//! This module provides structures and functions to manage database configuration options,
4//! including reading from files and environment variables.
5//!
6//! # Examples
7//!
8//! Loading the default configuration for `Settings`:
9//!
10//! ```rust
11//! use slatedb::config::Settings;
12//! let config = Settings::default();
13//! ```
14//!
15//! Loading `Settings` from a specific file:
16//!
17//! ```rust
18//! use slatedb::config::Settings;
19//! let config = Settings::from_file("config.toml").expect("Failed to load options from file");
20//! ```
21//!
22//! Loading `Settings` from environment variables:
23//!
24//! ```rust
25//! use slatedb::config::Settings;
26//! let config = Settings::from_env("SLATEDB_").expect("Failed to load options from env");
27//! ```
28//!
29//! Loading `Settings` from predefined files, SlateDb.toml, SlateDb.json, SlateDb.yaml, or SlateDb.yml.
30//! This method also merges any environment variable that starts with `SLATEDB_` to the final `Settings` struct:
31//!
32//! ```rust
33//! use slatedb::config::Settings;
34//! let config = Settings::load().expect("Failed to load options");
35//! ```
36//!
37//! # Configuration formats
38//!
39//! SlateDB supports three configuration formats: TOML, JSON, and YAML.
40//! Duration options in the configuration are represented as human-friendly strings,
41//! allowing you to specify time intervals in a more intuitive way, such as "100ms" or "1s".
42//!
43//! Representing `Settings` with TOML:
44//!
45//! ```toml
46//! flush_interval = "100ms"
47//! wal_enabled = false
48//! manifest_poll_interval = "1s"
49//! manifest_update_timeout = "300s"
50//! min_filter_keys = 1000
51//! l0_sst_size_bytes = 67108864
52//! max_wal_flushes_before_l0_flush = 4096
53//! l0_max_ssts = 8
54//! l0_max_ssts_per_key = 8
55//! l0_flush_parallelism = 4
56//! max_unflushed_bytes = 536870912
57//! metric_level = "Info"
58//!
59//! [compactor_options]
60//! poll_interval = "5s"
61//! max_concurrent_compactions = 4
62//!
63//! [compactor_options.worker]
64//! max_sst_size = 1073741824
65//!
66//! [compactor_options.scheduler_options]
67//! min_compaction_sources = "4"
68//! max_compaction_sources = "8"
69//! include_size_threshold = "4.0"
70//!
71//! [object_store_cache_options]
72//! root_folder = "/tmp/slatedb-cache"
73//! max_cache_size_bytes = 17179869184
74//! part_size_bytes = 4194304
75//! scan_interval = "3600s"
76//!
77//! [garbage_collector_options.manifest_options]
78//! interval = "300s"
79//! min_age = "86400s"
80//!
81//! [garbage_collector_options.wal_options]
82//! interval = "60s"
83//! min_age = "60s"
84//!
85//! [garbage_collector_options.compacted_options]
86//! interval = "300s"
87//! min_age = "86400s"
88//!
89//! [garbage_collector_options.compactions_options]
90//! interval = "300s"
91//! min_age = "86400s"
92//! ```
93//!
94//! Representing `Settings` with JSON:
95//!
96//! ```json
97//!{
98//!  "flush_interval": "100ms",
99//!  "wal_enabled": false,
100//!  "manifest_poll_interval": "1s",
101//!  "manifest_update_timeout": "300s",
102//!  "min_filter_keys": 1000,
103//!  "l0_sst_size_bytes": 67108864,
104//!  "max_wal_flushes_before_l0_flush": 4096,
105//!  "l0_max_ssts": 8,
106//!  "l0_max_ssts_per_key": 8,
107//!  "l0_flush_parallelism": 4,
108//!  "max_unflushed_bytes": 536870912,
109//!  "metric_level": "Info",
110//!  "compactor_options": {
111//!    "poll_interval": "5s",
112//!    "max_concurrent_compactions": 4,
113//!    "worker": {
114//!      "max_sst_size": 1073741824
115//!    },
116//!    "scheduler_options": {
117//!      "min_compaction_sources": "4",
118//!      "max_compaction_sources": "8",
119//!      "include_size_threshold": "4.0"
120//!    }
121//!  },
122//!  "compression_codec": null,
123//!  "object_store_cache_options": {
124//!    "root_folder": "/tmp/slatedb-cache",
125//!    "max_cache_size_bytes": 17179869184,
126//!    "part_size_bytes": 4194304,
127//!    "scan_interval": "3600s"
128//!  },
129//!  "garbage_collector_options": {
130//!    "manifest_options": {
131//!      "interval": "300s",
132//!      "min_age": "86400s"
133//!    },
134//!    "wal_options": {
135//!      "interval": "60s",
136//!      "min_age": "60s"
137//!    },
138//!    "compacted_options": {
139//!      "interval": "300s",
140//!      "min_age": "86400s"
141//!    },
142//!    "compactions_options": {
143//!      "interval": "300s",
144//!      "min_age": "86400s"
145//!    }
146//!  }
147//!}
148//!```
149//!
150//! Representing `Settings` with YAML:
151//!
152//! ```yaml
153//! flush_interval: '100ms'
154//! wal_enabled: false
155//! manifest_poll_interval: '1s'
156//! manifest_update_timeout: '300s'
157//! min_filter_keys: 1000
158//! l0_sst_size_bytes: 67108864
159//! max_wal_flushes_before_l0_flush: 4096
160//! l0_max_ssts: 8
161//! l0_max_ssts_per_key: 8
162//! l0_flush_parallelism: 1
163//! max_unflushed_bytes: 536870912
164//! metric_level: Info
165//! compactor_options:
166//!   poll_interval: '5s'
167//!   max_concurrent_compactions: 4
168//!   worker:
169//!     max_sst_size: 1073741824
170//!   scheduler_options:
171//!     min_compaction_sources: "4"
172//!     max_compaction_sources: "8"
173//!     include_size_threshold: "4.0"
174//! compression_codec: null
175//! object_store_cache_options:
176//!   root_folder: /tmp/slatedb-cache
177//!   max_cache_size_bytes: 17179869184
178//!   part_size_bytes: 4194304
179//!   scan_interval: '3600s'
180//! garbage_collector_options:
181//!   manifest_options:
182//!     interval: '300s'
183//!     min_age: '86400s'
184//!   wal_options:
185//!     interval: '60s'
186//!     min_age: '60s'
187//!   compacted_options:
188//!     interval: '300s'
189//!     min_age: '86400s'
190//!   compactions_options:
191//!     interval: '300s'
192//!     min_age: '86400s'
193//! ```
194//!
195use crate::filter_policy::FilterContext;
196use crate::iter::IterationOrder;
197use duration_str::{deserialize_duration, deserialize_option_duration};
198use figment::providers::{Env, Format, Json, Toml, Yaml};
199use figment::{Figment, Metadata, Provider};
200use log::warn;
201use serde::{Deserialize, Serialize, Serializer};
202use std::collections::HashMap;
203use std::path::Path;
204use std::{str::FromStr, time::Duration};
205use uuid::Uuid;
206
207pub use slatedb_common::metrics::MetricLevel;
208
209use crate::error::SlateDBError;
210
211use crate::garbage_collector::{DEFAULT_INTERVAL, DEFAULT_MIN_AGE};
212
213/// Enum representing different levels of cache preloading on startup
214#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
215pub enum PreloadLevel {
216    /// Preload only L0 SSTs (most recently written files)
217    L0Sst,
218    /// Preload all SSTs (both L0 and compacted levels)
219    AllSst,
220}
221
222/// Enum representing valid SST block sizes
223#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Default)]
224pub enum SstBlockSize {
225    /// 1KiB blocks
226    Block1Kib,
227    /// 2KiB blocks
228    Block2Kib,
229    /// 4KiB blocks (default)
230    #[default]
231    Block4Kib,
232    /// 8KiB blocks
233    Block8Kib,
234    /// 16KiB blocks
235    Block16Kib,
236    /// 32KiB blocks
237    Block32Kib,
238    /// 64KiB blocks
239    Block64Kib,
240    /// Other block sizes
241    #[cfg(test)]
242    Other(usize),
243}
244
245impl SstBlockSize {
246    /// Get the block size in bytes
247    pub fn as_bytes(&self) -> usize {
248        match self {
249            SstBlockSize::Block1Kib => 1024,
250            SstBlockSize::Block2Kib => 2048,
251            SstBlockSize::Block4Kib => 4096,
252            SstBlockSize::Block8Kib => 8192,
253            SstBlockSize::Block16Kib => 16384,
254            SstBlockSize::Block32Kib => 32768,
255            SstBlockSize::Block64Kib => 65536,
256            #[cfg(test)]
257            SstBlockSize::Other(size) => *size,
258        }
259    }
260}
261
262/// Describes the durability of data based on the medium (e.g. in-memory, object storags)
263/// that the data is currently stored in. Currently this is used to define a
264/// durability filter for data served by a read.
265#[non_exhaustive]
266#[derive(Clone, Default, Debug, Copy, PartialEq)]
267pub enum DurabilityLevel {
268    /// Includes only data currently stored durably in object storage.
269    Remote,
270
271    /// Includes data with level Remote and data currently only stored in-memory awaiting flush
272    /// to object storage.
273    #[default]
274    Memory,
275}
276
277/// Configuration for client read operations. `ReadOptions` is supplied for each
278/// read call and controls the behavior of the read.
279#[derive(Clone, Debug)]
280pub struct ReadOptions {
281    /// Specifies the minimum durability level for data returned by this read. For example,
282    /// if set to Remote then slatedb returns the latest version of a row that has been durably
283    /// stored in object storage.
284    pub durability_filter: DurabilityLevel,
285    /// Whether to include dirty data in the scan. "dirty" means that the data is not considered
286    /// as "committed" yet, whose seq number is greater than the last committed seq number.
287    pub dirty: bool,
288    /// Whether fetched data blocks should be cached. SST indexes, filters,
289    /// and stats are cached independently of this setting.
290    pub cache_blocks: bool,
291    /// Optional context forwarded to custom filter policies; ignored by
292    /// built-in filters. See [`FilterContext`].
293    pub filter_context: Option<FilterContext>,
294}
295
296impl Default for ReadOptions {
297    fn default() -> Self {
298        Self {
299            durability_filter: DurabilityLevel::default(),
300            dirty: false,
301            cache_blocks: true,
302            filter_context: None,
303        }
304    }
305}
306
307impl ReadOptions {
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    pub fn with_dirty(self, dirty: bool) -> Self {
313        Self { dirty, ..self }
314    }
315
316    pub fn with_durability_filter(self, durability_filter: DurabilityLevel) -> Self {
317        Self {
318            durability_filter,
319            ..self
320        }
321    }
322
323    pub fn with_cache_blocks(self, cache_blocks: bool) -> Self {
324        Self {
325            cache_blocks,
326            ..self
327        }
328    }
329
330    pub fn with_filter_context(self, filter_context: Option<FilterContext>) -> Self {
331        Self {
332            filter_context,
333            ..self
334        }
335    }
336}
337#[derive(Clone, Debug)]
338pub struct ScanOptions {
339    /// Specifies the minimum durability level for data returned by this scan. For example,
340    /// if set to Remote then slatedb returns the latest version of a row that has been durably
341    /// stored in object storage.
342    pub durability_filter: DurabilityLevel,
343    /// Whether to include dirty data in the scan. "dirty" means that the data is not considered
344    /// as "committed" yet, whose seq number is greater than the last committed seq number.
345    pub dirty: bool,
346    /// The number of bytes to read ahead. The value is rounded up to the nearest
347    /// block size when fetching from object storage. The default is 1, which
348    /// rounds up to one block.
349    pub read_ahead_bytes: usize,
350    /// Whether or not fetched data blocks should be cached. SST indexes,
351    /// filters, and stats are cached independently of this setting.
352    pub cache_blocks: bool,
353    /// The maximum number of concurrent tasks for fetching blocks during scans.
354    /// Higher values can improve throughput but use more resources. The default is 1.
355    pub max_fetch_tasks: usize,
356    /// The iteration order for the scan. Defaults to [`IterationOrder::Ascending`].
357    pub order: IterationOrder,
358    /// Optional context forwarded to custom filter policies; ignored by
359    /// built-in filters. See [`FilterContext`].
360    ///
361    /// Only consulted for `scan_prefix` today. Plain range scans do not
362    /// evaluate SST filters, so this field has no effect on `scan`.
363    pub filter_context: Option<FilterContext>,
364}
365
366impl Default for ScanOptions {
367    /// Create a new ScanOptions with `read_level` set to [`DurabilityLevel::Memory`].
368    fn default() -> Self {
369        Self {
370            durability_filter: DurabilityLevel::default(),
371            dirty: false,
372            read_ahead_bytes: 1,
373            cache_blocks: false,
374            max_fetch_tasks: 1,
375            order: IterationOrder::Ascending,
376            filter_context: None,
377        }
378    }
379}
380
381impl ScanOptions {
382    pub fn new() -> Self {
383        Self::default()
384    }
385
386    pub fn with_dirty(self, dirty: bool) -> Self {
387        Self { dirty, ..self }
388    }
389
390    pub fn with_durability_filter(self, durability_filter: DurabilityLevel) -> Self {
391        Self {
392            durability_filter,
393            ..self
394        }
395    }
396
397    pub fn with_read_ahead_bytes(self, read_ahead_bytes: usize) -> Self {
398        Self {
399            read_ahead_bytes,
400            ..self
401        }
402    }
403
404    pub fn with_cache_blocks(self, cache_blocks: bool) -> Self {
405        Self {
406            cache_blocks,
407            ..self
408        }
409    }
410
411    pub fn with_max_fetch_tasks(self, max_fetch_tasks: usize) -> Self {
412        Self {
413            max_fetch_tasks,
414            ..self
415        }
416    }
417
418    pub fn with_order(self, order: IterationOrder) -> Self {
419        Self { order, ..self }
420    }
421
422    pub fn with_filter_context(self, filter_context: Option<FilterContext>) -> Self {
423        Self {
424            filter_context,
425            ..self
426        }
427    }
428}
429
430/// Enum representing the type of flush to perform.
431#[derive(Clone)]
432pub enum FlushType {
433    /// Freeze the active memtable [crate::mem_table::KVTable] and write
434    /// all immutable memtable entries (including the formerly active
435    /// memtable) to the object store.
436    MemTable,
437    /// Freeze the active WAL [crate::mem_table::KVTable] and write all
438    /// immutable WAL entries (including the formerly active WAL) to the
439    /// object store.
440    Wal,
441}
442
443#[derive(Clone)]
444pub struct FlushOptions {
445    /// The type of flush to perform.
446    pub flush_type: FlushType,
447}
448
449impl Default for FlushOptions {
450    fn default() -> Self {
451        Self {
452            flush_type: FlushType::Wal,
453        }
454    }
455}
456
457/// Configuration for client write operations. `WriteOptions` is supplied for each
458/// write call and controls the behavior of the write.
459#[derive(Clone, Debug)]
460pub struct WriteOptions {
461    /// Whether `put` calls should block until the write has been durably committed
462    /// to the DB.
463    pub await_durable: bool,
464    #[cfg(dst)]
465    /// Force the current timestamp for DST operations. See #719 for details.
466    pub now: i64,
467    /// An optional user-defined sequence number for this write. When non-zero, the
468    /// provided value is used instead of the internally generated sequence number.
469    /// The value must be strictly greater than the current maximum sequence number
470    /// or the write will fail with an `InvalidSequenceNumber` error.
471    pub seqnum: u64,
472}
473
474impl Default for WriteOptions {
475    /// Create a new `WriteOptions`` with `await_durable` set to `true`.
476    fn default() -> Self {
477        Self {
478            await_durable: true,
479            #[cfg(dst)]
480            now: 0,
481            seqnum: 0,
482        }
483    }
484}
485
486/// Configuration for client put operations. `PutOptions` is supplied for each
487/// row inserted. This differs from [`WriteOptions`] in that a write may encompass
488/// multiple puts (such as the case with batched writes)
489#[derive(Clone, Default, PartialEq, Debug)]
490pub struct PutOptions {
491    /// The time-to-live (ttl) for this insertion. If this insert overwrites an existing
492    /// database entry, the TTL for the most recent entry will be canonical.
493    ///
494    /// Default: the TTL configured in DbOptions when opening a SlateDB session
495    pub ttl: Ttl,
496}
497
498impl PutOptions {
499    pub(crate) fn expire_ts_from(&self, default: Option<u64>, now: i64) -> Option<i64> {
500        match self.ttl {
501            Ttl::Default => match default {
502                None => None,
503                Some(default_ttl) => Self::checked_expire_ts(now, default_ttl),
504            },
505            Ttl::NoExpiry => None,
506            Ttl::ExpireAfter(ttl) => Self::checked_expire_ts(now, ttl),
507            Ttl::ExpireAt(ts) => Some(ts),
508        }
509    }
510
511    fn checked_expire_ts(now: i64, ttl: u64) -> Option<i64> {
512        // for overflow, we will just assume no TTL
513        if ttl > i64::MAX as u64 {
514            return None;
515        };
516        let expire_ts = now + (ttl as i64);
517        if expire_ts < now {
518            return None;
519        };
520
521        Some(expire_ts)
522    }
523}
524
525/// Configuration for client merge operations. `MergeOptions` is supplied for each
526/// merge operand inserted into the database.
527#[derive(Clone, Default, PartialEq, Debug)]
528pub struct MergeOptions {
529    /// The time-to-live (ttl) for this merge operand. This behaves the same as
530    /// [`PutOptions::ttl`], where the latest non-expired value or merge operand
531    /// dictates the canonical TTL for a key.
532    pub ttl: Ttl,
533}
534
535impl MergeOptions {
536    // TODO(agavra): deduplicate this with PutOptions::expire_ts_from
537    pub(crate) fn expire_ts_from(&self, default: Option<u64>, now: i64) -> Option<i64> {
538        match self.ttl {
539            Ttl::Default => match default {
540                None => None,
541                Some(default_ttl) => Self::checked_expire_ts(now, default_ttl),
542            },
543            Ttl::NoExpiry => None,
544            Ttl::ExpireAfter(ttl) => Self::checked_expire_ts(now, ttl),
545            Ttl::ExpireAt(ts) => Some(ts),
546        }
547    }
548
549    fn checked_expire_ts(now: i64, ttl: u64) -> Option<i64> {
550        // for overflow, we will just assume no TTL
551        if ttl > i64::MAX as u64 {
552            return None;
553        };
554        let expire_ts = now + (ttl as i64);
555        if expire_ts < now {
556            return None;
557        };
558
559        Some(expire_ts)
560    }
561}
562
563#[non_exhaustive]
564#[derive(Clone, Default, PartialEq, Debug)]
565pub enum Ttl {
566    #[default]
567    Default,
568    NoExpiry,
569    ExpireAfter(u64),
570    ExpireAt(i64),
571}
572
573/// Defines the scope targeted by a given checkpoint. If set to All, then the checkpoint will
574/// include all writes that were issued at the time that create_checkpoint is called. SlateDB will
575/// flush WALs (if enabled) and do a best-effort flush of memtables to L0 SSTs in order to
576/// optimize for readers. The memtable flush will not await if blocked by backpressure. If set
577/// to Durable, then the checkpoint includes only writes that were durable at the time of the
578/// call. This will be faster, but may not include data from recent writes.
579#[non_exhaustive]
580#[derive(Debug, Copy, Clone)]
581pub enum CheckpointScope {
582    All,
583    Durable,
584}
585
586/// Specify options to provide when creating a checkpoint.
587#[derive(Debug, Clone, Default)]
588pub struct CheckpointOptions {
589    /// Optionally specifies the lifetime of the checkpoint to create. The expire time will be
590    /// set to the current wallclock time plus the specified lifetime. If lifetime is None, then
591    /// the checkpoint is created without an expiry time.
592    pub lifetime: Option<Duration>,
593
594    /// Optionally specifies an existing checkpoint to use as the source for this checkpoint. This
595    /// is useful for users to establish checkpoints from existing checkpoints, but with a different
596    /// lifecycle and/or metadata.
597    pub source: Option<Uuid>,
598
599    /// Optionally specifies a name for the checkpoint. Can be used to list the checkpoints.
600    pub name: Option<String>,
601}
602
603/// Settings represents the configuration options that a user can tweak to customize
604/// the database engine to their use case.
605///
606/// This is separate from components (like block_cache, clock, etc.) which are responsible
607/// for performing the work in the database.
608///
609/// Note: `compactor_options` is mutually exclusive with `DbBuilder::with_compactor_builder`.
610/// Setting both will result in an error.
611///
612/// For backward compatibility, DBOptions is a type alias for Settings.
613#[derive(Clone, Deserialize, Serialize)]
614pub struct Settings {
615    /// How frequently to flush the write-ahead log to object storage.
616    ///
617    /// When setting this configuration, users must consider:
618    ///
619    /// * **Latency**: The higher the flush interval, the longer it will take for
620    ///   writes to be committed to object storage. Writers blocking on `put` calls
621    ///   will wait longer for the write. Readers reading committed writes will also
622    ///   see data later.
623    /// * **API cost**: The lower the flush interval, the more frequently PUT calls
624    ///   will be made to object storage. This can increase your object storage costs.
625    ///
626    /// We recommend setting this value based on your cost and latency tolerance. A
627    /// 100ms flush interval should result in $130/month in PUT costs on S3 standard.
628    ///
629    /// Keep in mind that the flush interval does not include the network latency. A
630    /// 100ms flush interval will result in a 100ms + the time it takes to send the
631    /// bytes to object storage.
632    ///
633    /// If this value is None, automatic flushing will be disabled. The application
634    /// can flush by calling `Db::flush()` manually, and by closing the database.
635    #[serde(deserialize_with = "deserialize_option_duration")]
636    #[serde(serialize_with = "serialize_option_duration")]
637    pub flush_interval: Option<Duration>,
638
639    /// If set to false, SlateDB will disable the WAL and write directly into the memtable
640    #[cfg(feature = "wal_disable")]
641    pub wal_enabled: bool,
642
643    /// How frequently to poll for new manifest files. Refreshing the manifest file
644    /// allows the db to detect fencing operations and newly compacted data.
645    #[serde(deserialize_with = "deserialize_duration")]
646    #[serde(serialize_with = "serialize_duration")]
647    pub manifest_poll_interval: Duration,
648
649    /// The maximum amount of time to wait for a manifest update before giving up.
650    #[serde(deserialize_with = "deserialize_duration")]
651    #[serde(serialize_with = "serialize_duration")]
652    pub manifest_update_timeout: Duration,
653
654    /// Write SSTables with a bloom filter if the number of keys in the SSTable
655    /// is greater than or equal to this value. Reads on small SSTables might be
656    /// faster without a bloom filter.
657    pub min_filter_keys: u32,
658
659    /// The minimum size a memtable needs to be before it is frozen and flushed to
660    /// L0 object storage. Writes will still be flushed to the object storage WAL
661    /// (based on flush_interval) regardless of this value. Memtable sizes are checked
662    /// every `flush_interval`.
663    ///
664    /// When setting this configuration, users must consider:
665    ///
666    /// * **Recovery time**: The larger the L0 SSTable size threshold, the less
667    ///   frequently it will be written. As a result, the more recovery data there
668    ///   will be in the WAL if a process restarts.
669    /// * **Number of L0 SSTs/SRs**: The smaller the L0 SSTable size threshold, the
670    ///   more SSTs and Sorted Runs there will be. L0 SSTables are not range
671    ///   partitioned; each is its own sorted table. Similarly, each Sorted Run also
672    ///   stores the entire keyspace. As such, reads that don't hit the WAL or memtable
673    ///   may need to scan all L0 SSTables and Sorted Runs. The more there are, the
674    ///   slower the scan will be.
675    /// * **Memory usage**: The larger the L0 SSTable size threshold, the larger the
676    ///   unflushed in-memory memtable will grow. This shouldn't be a concern for most
677    ///   workloads, but it's worth considering for workloads with very high L0
678    ///   SSTable sizes.
679    /// * **API cost**: Smaller L0 SSTable sizes will result in more frequent writes
680    ///   to object storage. This can increase your object storage costs.
681    /// * **Secondary reader latency**: Secondary (non-writer) clients only see L0+
682    ///   writes; they don't see WAL writes. Thus, the higher the L0 SSTable size, the
683    ///   less frequently they will be written, and the longer it will take for
684    ///   secondary readers to see new data.
685    pub l0_sst_size_bytes: usize,
686
687    /// The maximum number of WAL flushes that can occur before the active memtable is
688    /// frozen and flushed to L0, regardless of memtable size.
689    ///
690    /// For databases with low write throughput, this can cause data to be available in
691    /// L0 SSTs sooner, making it accessible to readers.
692    ///
693    /// This also bounds the amount of WAL data that needs to be replayed on recovery: once
694    /// this many WAL flushes have occurred since the last memtable freeze, the active
695    /// memtable will be frozen even if it has not reached `l0_sst_size_bytes`.
696    pub max_wal_flushes_before_l0_flush: u64,
697
698    /// Defines the max total number of SSTs in L0 across the entire key space. Memtables
699    /// will not be flushed if the total L0 count (including in-flight uploads) would exceed
700    /// this value, until compaction can compact the ssts into compacted.
701    ///
702    /// This cap primarily bounds manifest size and global bookkeeping. Read amplification
703    /// and write backpressure are governed by [`Self::l0_max_ssts_per_key`], which enforces
704    /// a cap on L0 SSTs overlapping any single key. After a manifest union (rescaling),
705    /// the total L0 count can exceed a single source's `l0_max_ssts` while no individual
706    /// key is covered by more than `l0_max_ssts_per_key` SSTs; `l0_max_ssts` should be
707    /// set generously in that case (e.g. `l0_max_ssts_per_key * expected_max_shards`).
708    pub l0_max_ssts: usize,
709
710    /// Defines the max number of L0 SSTs whose effective ranges cover any single key.
711    /// Memtables will not be flushed if dispatching a new L0 upload would cause any point
712    /// in the key space to be covered by more L0 SSTs than this value.
713    ///
714    /// This is the per-key analogue of [`Self::l0_max_ssts`]: it bounds the number of L0
715    /// SSTs a point read may need to consult (read amplification) and drives write
716    /// backpressure. Because in-flight uploads have no known key range yet, each reserved
717    /// slot is treated conservatively as contributing to the peak at every point.
718    pub l0_max_ssts_per_key: usize,
719
720    /// Number of parallel workers for flushing immutable memtables to L0 SSTs.
721    /// Higher values increase L0 flush throughput at the cost of more concurrent
722    /// object store uploads. Increasing parallelism may require a higher `l0_max_ssts`
723    /// to avoid backpressure from compaction not keeping up with the higher steady-state
724    /// flush rate.
725    pub l0_flush_parallelism: usize,
726
727    /// Defines the max number of unflushed key/value pair bytes that should reside in memory
728    /// before applying backpressure to writers. This includes key/value pairs in both the
729    /// immutable WAL flush queue and the immutable memtable flush queue. Writes will be
730    /// paused if the total number of unflushed bytes exceeds this value until data is flushed
731    /// to object storage.
732    pub max_unflushed_bytes: usize,
733
734    /// Configuration options for the compactor. The embedded compaction worker
735    /// is configured via [`CompactorOptions::worker`].
736    pub compactor_options: Option<CompactorOptions>,
737
738    /// The compression algorithm to use for SSTables.
739    pub compression_codec: Option<CompressionCodec>,
740
741    /// The object store cache options.
742    pub object_store_cache_options: ObjectStoreCacheOptions,
743
744    /// Configuration options for the garbage collector.
745    pub garbage_collector_options: Option<GarbageCollectorOptions>,
746
747    /// Controls which metrics are active.
748    ///
749    /// Metrics below this threshold are replaced with no-op handles when they
750    /// are registered. Defaults to [`MetricLevel::Info`].
751    #[serde(default)]
752    pub metric_level: MetricLevel,
753
754    /// The default time-to-live (TTL) for insertions (note that re-inserting a key
755    /// with any value will update the TTL to use the default_ttl)
756    ///
757    /// Default: no TTL (insertions will remain until deleted)
758    pub default_ttl: Option<u64>,
759
760    /// The block format for SST files. This is only available in tests
761    /// to verify backward compatibility between V1 and V2 formats.
762    #[cfg(test)]
763    #[serde(skip)]
764    pub block_format: Option<crate::sst_builder::BlockFormat>,
765}
766
767// Implement Debug manually for DbOptions.
768// This is needed because DbOptions contains several boxed trait objects
769// which doesn't implement Debug.
770impl std::fmt::Debug for Settings {
771    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772        let mut data = f.debug_struct("DbOptions");
773        data.field("flush_interval", &self.flush_interval);
774        #[cfg(feature = "wal_disable")]
775        {
776            data.field("wal_enabled", &self.wal_enabled);
777        }
778        data.field("manifest_poll_interval", &self.manifest_poll_interval)
779            .field("manifest_update_timeout", &self.manifest_update_timeout)
780            .field("min_filter_keys", &self.min_filter_keys)
781            .field("max_unflushed_bytes", &self.max_unflushed_bytes)
782            .field("l0_sst_size_bytes", &self.l0_sst_size_bytes)
783            .field(
784                "max_wal_flushes_before_l0_flush",
785                &self.max_wal_flushes_before_l0_flush,
786            )
787            .field("l0_max_ssts", &self.l0_max_ssts)
788            .field("l0_max_ssts_per_key", &self.l0_max_ssts_per_key)
789            .field("l0_flush_parallelism", &self.l0_flush_parallelism)
790            .field("compactor_options", &self.compactor_options)
791            .field("compression_codec", &self.compression_codec)
792            .field(
793                "object_store_cache_options",
794                &self.object_store_cache_options,
795            )
796            .field("garbage_collector_options", &self.garbage_collector_options)
797            .field("metric_level", &self.metric_level)
798            .field("default_ttl", &self.default_ttl);
799        data.finish()
800    }
801}
802
803impl Settings {
804    /// Converts the Settings to a JSON string representation
805    pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
806        serde_json::to_string(self)
807    }
808
809    /// Loads Settings from a file.
810    ///
811    /// This function attempts to read and parse a configuration file to create a Settings instance.
812    /// The file format is determined by its extension:
813    /// - ".json" for JSON format
814    /// - ".toml" for TOML format
815    /// - ".yaml" or ".yml" for YAML format
816    ///
817    /// # Arguments
818    ///
819    /// * `path` - A path-like object pointing to the configuration file.
820    ///
821    /// # Returns
822    ///
823    /// * `Ok(Settings)` if the file was successfully read and parsed.
824    /// * `Err(Error)` if there was an error reading or parsing the file.
825    ///
826    /// # Errors
827    ///
828    /// This function will return an error if:
829    /// - The file extension is not recognized (not json, toml, yaml, or yml).
830    /// - The file cannot be read or parsed according to its presumed format.
831    ///
832    /// # Examples
833    ///
834    /// ```
835    /// use slatedb::config::Settings;
836    /// use std::path::Path;
837    ///
838    /// let config = Settings::from_file("config.toml").expect("Failed to load options from file");
839    /// ```
840    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Settings, crate::Error> {
841        let path = path.as_ref();
842        let Some(ext) = path.extension() else {
843            return Err(SlateDBError::UnknownConfigurationFormat(path.into()).into());
844        };
845
846        let mut builder = Figment::from(Settings::default());
847        match ext.to_str().unwrap_or_default() {
848            "json" => builder = builder.merge(Json::file(path)),
849            "toml" => builder = builder.merge(Toml::file(path)),
850            "yaml" | "yml" => builder = builder.merge(Yaml::file(path)),
851            _ => return Err(SlateDBError::UnknownConfigurationFormat(path.into()).into()),
852        }
853        builder
854            .extract()
855            .map_err(|e| SlateDBError::InvalidConfigurationFormat(Box::new(e)).into())
856    }
857
858    /// Loads Settings from environment variables with a specified prefix.
859    ///
860    /// This function attempts to create a Settings instance by reading environment variables
861    /// that start with the given prefix. Nested options are separated by a dot (.) in the environment variable names.
862    ///
863    /// For example, if the prefix is "SLATEDB_" and there's an environment variable named "SLATEDB_DB_FLUSH_INTERVAL",
864    /// it would correspond to the `flush_interval` field within the `Settings` struct.
865    /// If there is an environment variable named "SLATEDB_OBJECT_STORE_CACHE_OPTIONS.ROOT_FOLDER",
866    /// it would correspond to the `root_folder` field within the `ObjectStoreCacheOptions` within `Settings`".
867    ///
868    /// # Arguments
869    ///
870    /// * `prefix` - A string that specifies the prefix for the environment variables to be considered.
871    /// * `default` - The base `Settings` value to merge environment overrides into.
872    ///
873    /// # Returns
874    ///
875    /// * `Ok(Settings)` if the environment variables were successfully read and parsed.
876    /// * `Err(Error)` if there was an error reading or parsing the environment variables.
877    ///
878    /// # Examples
879    ///
880    /// ```
881    /// use slatedb::config::Settings;
882    ///
883    /// // Assuming environment variables like SLATEDB_FLUSH_INTERVAL, SLATEDB_WAL_ENABLED, etc. are set
884    /// let config = Settings::from_env_with_default("SLATEDB_", Settings::default()).expect("Failed to load options from env");
885    /// ```
886    pub fn from_env_with_default(
887        prefix: &str,
888        default: Settings,
889    ) -> Result<Settings, crate::Error> {
890        Figment::from(default)
891            .merge(Env::prefixed(prefix))
892            .extract()
893            .map_err(|e| SlateDBError::InvalidConfigurationFormat(Box::new(e)).into())
894    }
895
896    /// Loads Settings from environment variables with a specified prefix.
897    ///
898    /// This function attempts to create a Settings instance by reading environment variables
899    /// that start with the given prefix. Nested options are separated by a dot (.) in the environment variable names.
900    ///
901    /// For example, if the prefix is "SLATEDB_" and there's an environment variable named "SLATEDB_DB_FLUSH_INTERVAL",
902    /// it would correspond to the `flush_interval` field within the `Settings` struct.
903    /// If there is an environment variable named "SLATEDB_OBJECT_STORE_CACHE_OPTIONS.ROOT_FOLDER",
904    /// it would correspond to the `root_folder` field within the `ObjectStoreCacheOptions` within `Settings`".
905    ///
906    /// # Arguments
907    ///
908    /// * `prefix` - A string that specifies the prefix for the environment variables to be considered.
909    ///
910    /// # Returns
911    ///
912    /// * `Ok(Settings)` if the environment variables were successfully read and parsed.
913    /// * `Err(Error)` if there was an error reading or parsing the environment variables.
914    ///
915    /// # Examples
916    ///
917    /// ```
918    /// use slatedb::config::Settings;
919    ///
920    /// // Assuming environment variables like SLATEDB_FLUSH_INTERVAL, SLATEDB_WAL_ENABLED, etc. are set
921    /// let config = Settings::from_env("SLATEDB_").expect("Failed to load options from env");
922    /// ```
923    pub fn from_env(prefix: &str) -> Result<Settings, crate::Error> {
924        Settings::from_env_with_default(prefix, Settings::default())
925    }
926
927    /// Loads Settings from multiple configuration sources in a specific order.
928    ///
929    /// This function attempts to create a Settings instance by merging configurations
930    /// from various sources in the following order:
931    /// 1. Default options
932    /// 2. JSON file ("SlateDb.json")
933    /// 3. TOML file ("SlateDb.toml")
934    /// 4. YAML files ("SlateDb.yaml" and "SlateDb.yml")
935    /// 5. Environment variables prefixed with "SLATEDB_"
936    ///
937    /// Each subsequent source overrides the values from the previous sources if they exist.
938    ///
939    /// # Returns
940    ///
941    /// * `Ok(Settings)` if the configuration was successfully loaded and parsed.
942    /// * `Err(Error)` if there was an error reading or parsing the configuration.
943    ///
944    /// # Examples
945    ///
946    /// ```
947    /// use slatedb::config::Settings;
948    ///
949    /// let config = Settings::load().expect("Failed to load options");
950    /// ```
951    pub fn load() -> Result<Settings, crate::Error> {
952        Figment::from(Settings::default())
953            .merge(Json::file("SlateDb.json"))
954            .merge(Toml::file("SlateDb.toml"))
955            .merge(Yaml::file("SlateDb.yaml"))
956            .merge(Yaml::file("SlateDb.yml"))
957            .admerge(Env::prefixed("SLATEDB_"))
958            .extract()
959            .map_err(|e| SlateDBError::InvalidConfigurationFormat(Box::new(e)).into())
960    }
961}
962
963impl Provider for Settings {
964    fn metadata(&self) -> figment::Metadata {
965        Metadata::named("SlateDb configuration options")
966    }
967
968    fn data(
969        &self,
970    ) -> Result<figment::value::Map<figment::Profile, figment::value::Dict>, figment::Error> {
971        figment::providers::Serialized::defaults(self.clone()).data()
972    }
973}
974
975impl Default for Settings {
976    fn default() -> Self {
977        Self {
978            flush_interval: Some(Duration::from_millis(100)),
979            #[cfg(feature = "wal_disable")]
980            wal_enabled: true,
981            manifest_poll_interval: Duration::from_secs(1),
982            manifest_update_timeout: Duration::from_secs(300),
983            min_filter_keys: 1000,
984            max_unflushed_bytes: 1_073_741_824,
985            l0_sst_size_bytes: 64 * 1024 * 1024,
986            max_wal_flushes_before_l0_flush: 4096,
987            l0_max_ssts: 8,
988            l0_max_ssts_per_key: 8,
989            l0_flush_parallelism: 4,
990            compactor_options: Some(CompactorOptions::default()),
991            compression_codec: None,
992            object_store_cache_options: ObjectStoreCacheOptions::default(),
993            garbage_collector_options: Some(GarbageCollectorOptions::default()),
994            metric_level: MetricLevel::default(),
995            default_ttl: None,
996            #[cfg(test)]
997            block_format: None,
998        }
999    }
1000}
1001
1002#[derive(Clone, Debug, Deserialize, Serialize)]
1003pub struct DbReaderOptions {
1004    /// How frequently to poll for new manifest files and WAL data. Refreshing the manifest
1005    /// file allows readers to detect newly compacted data. The reader will also look for
1006    /// new writes to the WAL at this poll interval. If the reader is using an explicit checkpoint,
1007    /// then the manifest and WAL will not be polled.
1008    pub manifest_poll_interval: Duration,
1009
1010    /// For readers that do not provide an explicit checkpoint, the client will
1011    /// maintain its own checkpoint against the latest database state. The checkpoint's
1012    /// expire time will be set to the current time plus this value. This lifetime
1013    /// must always be greater than manifest_poll_interval x 2.
1014    pub checkpoint_lifetime: Duration,
1015
1016    /// The max size of a single in-memory table used to buffer WAL entries
1017    /// Defaults to 64MB
1018    pub max_memtable_bytes: u64,
1019
1020    /// Options for the local disk cache. If `root_folder` is set, the reader
1021    /// will wrap its object store in a `CachedObjectStore` backed by the
1022    /// local filesystem, mirroring the behaviour of `Db`.
1023    pub object_store_cache_options: ObjectStoreCacheOptions,
1024
1025    /// When true, skip WAL replay entirely. The reader will only see data that has been
1026    /// compacted into L0 or lower levels. This is useful for read-heavy workloads that
1027    /// don't need to see the most recent uncommitted writes and want to minimize the
1028    /// cost of opening many readers.
1029    ///
1030    /// WAL replay is also skipped when the reader is opened from a checkpoint.
1031    ///
1032    /// When combined with manifest polling (no explicit checkpoint), the reader will
1033    /// still see newly compacted data as manifests are updated.
1034    ///
1035    /// Defaults to false.
1036    pub skip_wal_replay: bool,
1037
1038    /// Optional metrics reporting level for standalone readers. Defaults to
1039    /// [`MetricLevel::default`] when unset.
1040    pub metric_level: Option<MetricLevel>,
1041}
1042
1043impl Default for DbReaderOptions {
1044    fn default() -> Self {
1045        Self {
1046            manifest_poll_interval: Duration::from_secs(10),
1047            checkpoint_lifetime: Duration::from_secs(10 * 60),
1048            max_memtable_bytes: 64 * 1024 * 1024,
1049            object_store_cache_options: ObjectStoreCacheOptions::default(),
1050            skip_wal_replay: false,
1051            metric_level: None,
1052        }
1053    }
1054}
1055
1056/// The compression algorithm to use for SSTables.
1057#[non_exhaustive]
1058#[derive(Clone, Copy, Deserialize, PartialEq, Debug, Serialize)]
1059pub enum CompressionCodec {
1060    #[cfg(feature = "snappy")]
1061    /// Snappy compression algorithm.
1062    Snappy,
1063    #[cfg(feature = "zlib")]
1064    /// Zlib compression algorithm.
1065    Zlib,
1066    #[cfg(feature = "lz4")]
1067    /// Lz4 compression algorithm.
1068    Lz4,
1069    #[cfg(feature = "zstd")]
1070    /// Zstd compression algorithm.
1071    Zstd,
1072}
1073
1074impl FromStr for CompressionCodec {
1075    type Err = crate::Error;
1076
1077    fn from_str(s: &str) -> Result<Self, Self::Err> {
1078        match s {
1079            #[cfg(feature = "snappy")]
1080            "snappy" => Ok(Self::Snappy),
1081            #[cfg(feature = "zlib")]
1082            "zlib" => Ok(Self::Zlib),
1083            #[cfg(feature = "lz4")]
1084            "lz4" => Ok(Self::Lz4),
1085            #[cfg(feature = "zstd")]
1086            "zstd" => Ok(Self::Zstd),
1087            _ => Err(SlateDBError::InvalidCompressionCodec.into()),
1088        }
1089    }
1090}
1091
1092/// Options for the compactor.
1093#[derive(Clone, Deserialize, Serialize)]
1094pub struct CompactorOptions {
1095    /// The interval at which the compactor checks for a new manifest and decides
1096    /// if a compaction must be scheduled
1097    #[serde(deserialize_with = "deserialize_duration")]
1098    #[serde(serialize_with = "serialize_duration")]
1099    pub poll_interval: Duration,
1100
1101    /// Timeout to limit how long manifest updates are retried before giving up.
1102    #[serde(deserialize_with = "deserialize_duration")]
1103    #[serde(serialize_with = "serialize_duration")]
1104    pub manifest_update_timeout: Duration,
1105
1106    /// The maximum number of concurrent compactions to execute at once
1107    pub max_concurrent_compactions: usize,
1108
1109    /// Scheduler-specific options expressed as string key/value pairs.
1110    #[serde(default)]
1111    pub scheduler_options: HashMap<String, String>,
1112
1113    /// Options for the in-process compaction worker spawned alongside the
1114    /// coordinator. When `Some` (the default), an embedded
1115    /// [`CompactionWorker`](crate::compaction_worker::CompactionWorker)
1116    /// executes compactions in the same process. Set to `None` when all workers
1117    /// run as separate processes.
1118    ///
1119    /// On deserialization an omitted `worker` key defaults to
1120    /// `Some(CompactionWorkerOptions::default())` so existing configs keep
1121    /// spawning the embedded worker. Set the key explicitly to `null` to
1122    /// disable it.
1123    #[serde(default = "default_compaction_worker_options")]
1124    pub worker: Option<CompactionWorkerOptions>,
1125
1126    /// Optional metrics reporting level for standalone compactors. When a
1127    /// compactor is owned by a [`Settings`] configured DB, unset means inherit
1128    /// [`Settings::metric_level`].
1129    pub metric_level: Option<MetricLevel>,
1130
1131    /// The interval at which the compaction coordinator commits compactions
1132    /// marked `Compacted` to the manifest
1133    #[serde(deserialize_with = "deserialize_duration")]
1134    #[serde(serialize_with = "serialize_duration")]
1135    pub commit_compacted_interval: Duration,
1136
1137    /// How long the coordinator will wait without a heartbeat before reclaiming
1138    /// a `Running` compaction from its worker and resetting it to `Submitted`.
1139    /// A worker that crashes or stalls will have its jobs reclaimed after this
1140    /// timeout. Default is 30 seconds.
1141    #[serde(deserialize_with = "deserialize_duration")]
1142    #[serde(serialize_with = "serialize_duration")]
1143    pub worker_heartbeat_timeout: Duration,
1144}
1145
1146/// Default options for the compactor. Currently, only a
1147/// `SizeTieredCompactionScheduler` compaction strategy is implemented.
1148impl Default for CompactorOptions {
1149    /// Returns a `CompactorOptions` with a 5 second poll interval and an embedded
1150    /// worker enabled with default [`CompactionWorkerOptions`].
1151    fn default() -> Self {
1152        Self {
1153            poll_interval: Duration::from_secs(5),
1154            manifest_update_timeout: Duration::from_secs(300),
1155            max_concurrent_compactions: 4,
1156            scheduler_options: HashMap::new(),
1157            worker: Some(CompactionWorkerOptions::default()),
1158            metric_level: None,
1159            commit_compacted_interval: Duration::from_secs(1),
1160            worker_heartbeat_timeout: Duration::from_secs(30),
1161        }
1162    }
1163}
1164
1165// Implement Debug manually for CompactorOptions.
1166impl std::fmt::Debug for CompactorOptions {
1167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168        f.debug_struct("CompactorOptions")
1169            .field("poll_interval", &self.poll_interval)
1170            .field("manifest_update_timeout", &self.manifest_update_timeout)
1171            .field(
1172                "max_concurrent_compactions",
1173                &self.max_concurrent_compactions,
1174            )
1175            .field("scheduler_options", &self.scheduler_options)
1176            .field("worker", &self.worker)
1177            .field("metric_level", &self.metric_level)
1178            .field("commit_compacted_interval", &self.commit_compacted_interval)
1179            .field("worker_heartbeat_timeout", &self.worker_heartbeat_timeout)
1180            .finish()
1181    }
1182}
1183
1184/// Options for the compaction worker.
1185#[derive(Clone, Debug, Deserialize, Serialize)]
1186pub struct CompactionWorkerOptions {
1187    /// How many jobs a single worker may hold simultaneously.
1188    pub max_concurrent_compactions: usize,
1189
1190    /// How often a worker checks `.compactions` for new jobs.
1191    #[serde(deserialize_with = "deserialize_duration")]
1192    #[serde(serialize_with = "serialize_duration")]
1193    pub compactions_poll_interval: Duration,
1194
1195    /// How many bytes a worker must process before emitting a heartbeat.
1196    pub heartbeat_bytes: u64,
1197
1198    /// Minimum wall-clock time between heartbeat writes.
1199    #[serde(deserialize_with = "deserialize_duration")]
1200    #[serde(serialize_with = "serialize_duration")]
1201    pub heartbeat_min_interval: Duration,
1202
1203    /// Maximum size of an output SST before a new one is rolled.
1204    pub max_sst_size: usize,
1205
1206    /// Maximum number of concurrent tasks for fetching SST blocks during
1207    /// compaction. Higher values can improve throughput but use more resources.
1208    pub max_fetch_tasks: usize,
1209
1210    /// Number of bytes to fetch in a single read-ahead request while iterating
1211    /// over input SSTs during compaction. The value is rounded up to the nearest
1212    /// block size when fetching from object storage. The default is 2MiB.
1213    ///
1214    /// This pairs with [`CompactionWorkerOptions::max_fetch_tasks`]:
1215    /// `bytes_to_fetch` is the size of each read-ahead request while
1216    /// `max_fetch_tasks` is how many run concurrently per input SST, so peak
1217    /// outstanding read-ahead per SST iterator is roughly
1218    /// `bytes_to_fetch * max_fetch_tasks`. With the defaults
1219    /// (`bytes_to_fetch = 2MiB`, `max_fetch_tasks = 4`) that is ~8MiB prefetched
1220    /// ahead of the cursor.
1221    pub bytes_to_fetch: usize,
1222
1223    /// The maximum number of subcompactions to split a single compaction into
1224    /// (RFC-0028). Each subcompaction covers a disjoint sub-range of the key
1225    /// space and executes concurrently with its siblings, so a single large
1226    /// compaction can use multiple cores. Any value `<= 1` disables
1227    /// subcompactions. The default is 4.
1228    ///
1229    /// The planner targets sub-ranges of
1230    /// `max(total_input_bytes / max_subcompactions, max_sst_size)`, so a
1231    /// compaction smaller than `max_subcompactions * max_sst_size` is split
1232    /// into fewer (or zero) ranges rather than fragmented into undersized
1233    /// SSTs. There is deliberately no separate minimum-size knob; the
1234    /// [`max_sst_size`](CompactionWorkerOptions::max_sst_size) floor subsumes
1235    /// it.
1236    pub max_subcompactions: usize,
1237
1238    /// Write SSTables with a bloom filter if the number of keys in the SSTable
1239    /// is greater than or equal to this value. Reads on small SSTables might be
1240    /// faster without a bloom filter.
1241    ///
1242    /// Must match the writer's [`Settings::min_filter_keys`] configuration so
1243    /// that SSTs rewritten by the worker carry filters consistent with those
1244    /// produced by the DB.
1245    pub min_filter_keys: u32,
1246
1247    /// The compression algorithm to use for SSTables the worker writes.
1248    ///
1249    /// Must match the writer's [`Settings::compression_codec`] configuration so
1250    /// that SSTs rewritten by the worker are encoded consistently with those
1251    /// produced by the DB.
1252    pub compression_codec: Option<CompressionCodec>,
1253
1254    /// Optional metrics reporting level for standalone compaction workers.
1255    /// Defaults to [`MetricLevel::default`] when unset.
1256    pub metric_level: Option<MetricLevel>,
1257}
1258
1259/// Default options for the compaction worker.
1260impl Default for CompactionWorkerOptions {
1261    /// Returns a `CompactionWorkerOptions` with a 5 second poll interval
1262    fn default() -> Self {
1263        Self {
1264            max_concurrent_compactions: 4,
1265            compactions_poll_interval: Duration::from_secs(5),
1266            heartbeat_bytes: 5_242_880,
1267            heartbeat_min_interval: Duration::from_secs(5),
1268            max_sst_size: 256 * 1024 * 1024,
1269            max_fetch_tasks: 4,
1270            bytes_to_fetch: 2 * 1024 * 1024,
1271            max_subcompactions: 4,
1272            min_filter_keys: 1000,
1273            compression_codec: None,
1274            metric_level: None,
1275        }
1276    }
1277}
1278
1279/// Default for [`CompactorOptions::worker`] when the key is omitted on
1280/// deserialization. Mirrors [`CompactorOptions::default`] so existing configs
1281/// without a `worker` key keep spawning the embedded worker. (A bare
1282/// `#[serde(default)]` would use `Option::default()` == `None`, silently
1283/// disabling it.)
1284fn default_compaction_worker_options() -> Option<CompactionWorkerOptions> {
1285    Some(CompactionWorkerOptions::default())
1286}
1287
1288/// Options for the Size-Tiered Compaction Scheduler
1289#[derive(Clone, Copy, Debug)]
1290pub struct SizeTieredCompactionSchedulerOptions {
1291    /// The minimum number of sources to include together in a single compaction step.
1292    pub min_compaction_sources: usize,
1293
1294    /// The maximum number of sources to include together in a single compaction step.
1295    pub max_compaction_sources: usize,
1296
1297    /// The size threshold that the scheduler will use to determine if a sorted run should
1298    /// be included in a given compaction. A sorted run S will be added to a compaction C if S's
1299    /// size is less than this value times the min size of the runs currently included in C.
1300    pub include_size_threshold: f32,
1301}
1302
1303impl Default for SizeTieredCompactionSchedulerOptions {
1304    fn default() -> Self {
1305        Self {
1306            min_compaction_sources: 4,
1307            max_compaction_sources: 8,
1308            include_size_threshold: 4.0,
1309        }
1310    }
1311}
1312
1313impl From<&HashMap<String, String>> for SizeTieredCompactionSchedulerOptions {
1314    fn from(map: &HashMap<String, String>) -> Self {
1315        let mut options = SizeTieredCompactionSchedulerOptions::default();
1316        for (key, value) in map {
1317            match key.as_str() {
1318                "min_compaction_sources" => match value.parse::<usize>() {
1319                    Ok(parsed) => options.min_compaction_sources = parsed,
1320                    Err(err) => {
1321                        warn!(
1322                            "invalid scheduler option value for min_compaction_sources: '{}': {}",
1323                            value, err
1324                        );
1325                    }
1326                },
1327                "max_compaction_sources" => match value.parse::<usize>() {
1328                    Ok(parsed) => options.max_compaction_sources = parsed,
1329                    Err(err) => {
1330                        warn!(
1331                            "invalid scheduler option value for max_compaction_sources: '{}': {}",
1332                            value, err
1333                        );
1334                    }
1335                },
1336                "include_size_threshold" => match value.parse::<f32>() {
1337                    Ok(parsed) => options.include_size_threshold = parsed,
1338                    Err(err) => {
1339                        warn!(
1340                            "invalid scheduler option value for include_size_threshold: '{}': {}",
1341                            value, err
1342                        );
1343                    }
1344                },
1345                _ => {
1346                    warn!("unknown scheduler option '{}'; ignoring", key);
1347                }
1348            }
1349        }
1350
1351        options
1352    }
1353}
1354
1355impl From<HashMap<String, String>> for SizeTieredCompactionSchedulerOptions {
1356    fn from(map: HashMap<String, String>) -> Self {
1357        Self::from(&map)
1358    }
1359}
1360
1361impl From<SizeTieredCompactionSchedulerOptions> for HashMap<String, String> {
1362    fn from(options: SizeTieredCompactionSchedulerOptions) -> Self {
1363        let mut map = HashMap::new();
1364        map.insert(
1365            "min_compaction_sources".to_string(),
1366            options.min_compaction_sources.to_string(),
1367        );
1368        map.insert(
1369            "max_compaction_sources".to_string(),
1370            options.max_compaction_sources.to_string(),
1371        );
1372        map.insert(
1373            "include_size_threshold".to_string(),
1374            options.include_size_threshold.to_string(),
1375        );
1376        map
1377    }
1378}
1379
1380/// Garbage collector options.
1381#[derive(Clone, Debug, Deserialize, Serialize)]
1382pub struct GarbageCollectorOptions {
1383    /// Garbage collection options for the manifest directory.
1384    ///
1385    /// None means garbage collection is disabled for the manifest directory.
1386    pub manifest_options: Option<GarbageCollectorDirectoryOptions>,
1387
1388    /// Garbage collection options for the WAL directory.
1389    ///
1390    /// None means garbage collection is disabled for the WAL directory.
1391    pub wal_options: Option<GarbageCollectorDirectoryOptions>,
1392
1393    /// Garbage collection options for zero-byte WAL fence objects.
1394    ///
1395    /// WARNING: Setting this to a non-None value can cause data loss if set
1396    /// too aggressively. It's possible for the following scenario to occur:
1397    ///
1398    /// - t0: A writer W1 calculates position 7 for its next WAL entry
1399    /// - t1: A writer W2 writes a fence at position 7
1400    /// - t2: `min_age` + 1 passes
1401    /// - t3: The garbage collector runs and deletes the fence at position 7
1402    /// - t4: W1 writes its WAL entry at position 7 and returns success
1403    ///
1404    /// Because the fence at position 7 was deleted, W1's write will succeed,
1405    /// but the data at position 7 is invalid. The only way to protect against
1406    /// this scenario is to ensure fence writes are never deleted. In practice,
1407    /// setting `min_age` to a very high number (longer than any writer is
1408    /// expected to run) should be sufficient to prevent this scenario.
1409    ///
1410    /// None means garbage collection is disabled for WAL fence objects.
1411    pub wal_fence_options: Option<GarbageCollectorDirectoryOptions>,
1412
1413    /// Garbage collection options for the compacted directory.
1414    ///
1415    /// None means garbage collection is disabled for the compacted directory.
1416    pub compacted_options: Option<GarbageCollectorDirectoryOptions>,
1417
1418    /// Garbage collection options for the compactions directory, which
1419    /// contains compactor job state `.compactions` files.
1420    ///
1421    /// None means garbage collection is disabled for the compactions directory.
1422    pub compactions_options: Option<GarbageCollectorDirectoryOptions>,
1423
1424    /// Garbage collection options for detaching a clone from its parent database(s).
1425    ///
1426    /// When a clone no longer references any of a parent's SSTs (in its current
1427    /// manifest or any live checkpoint), the detach pass removes the pinning
1428    /// checkpoint from the parent and drops the external DB entry from the clone.
1429    ///
1430    /// None means detach is disabled.
1431    pub detach_options: Option<GarbageCollectorScheduleOptions>,
1432
1433    /// Optional metrics reporting level for standalone garbage collectors. When
1434    /// a garbage collector is owned by a [`Settings`] configured DB, unset means
1435    /// inherit [`Settings::metric_level`].
1436    pub metric_level: Option<MetricLevel>,
1437}
1438
1439impl GarbageCollectorOptions {
1440    pub fn is_empty(&self) -> bool {
1441        self.manifest_options.is_none()
1442            && self.wal_options.is_none()
1443            && self.wal_fence_options.is_none()
1444            && self.compacted_options.is_none()
1445            && self.compactions_options.is_none()
1446            && self.detach_options.is_none()
1447    }
1448}
1449
1450/// Default options for the garbage collector for a directory.
1451///
1452/// By default, the garbage collector will run every minute and deletes files
1453/// that are at least 5 minutes old.
1454impl Default for GarbageCollectorDirectoryOptions {
1455    fn default() -> Self {
1456        Self {
1457            interval: Some(DEFAULT_INTERVAL),
1458            min_age: DEFAULT_MIN_AGE,
1459            dry_run: false,
1460        }
1461    }
1462}
1463
1464/// Garbage collector options for a directory.
1465#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1466pub struct GarbageCollectorDirectoryOptions {
1467    /// The interval at which the garbage collector will run in the background
1468    /// thread.
1469    ///
1470    /// If set to None while the parent directory options are enabled, recurring
1471    /// garbage collection uses the default interval. To disable garbage
1472    /// collection for a directory, set the parent `*_options` field to None.
1473    #[serde(deserialize_with = "deserialize_option_duration")]
1474    #[serde(serialize_with = "serialize_option_duration")]
1475    pub interval: Option<Duration>,
1476
1477    /// The minimum age of a file before it can be garbage collected.
1478    #[serde(deserialize_with = "deserialize_duration")]
1479    #[serde(serialize_with = "serialize_duration")]
1480    pub min_age: Duration,
1481
1482    /// If true, log files that would be deleted without deleting them.
1483    #[serde(default)]
1484    pub dry_run: bool,
1485}
1486
1487/// Schedule options for a GC task that has no file-age threshold.
1488#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
1489pub struct GarbageCollectorScheduleOptions {
1490    /// The interval at which the task will run in the background thread.
1491    ///
1492    /// If set to None while the parent task options are enabled, recurring
1493    /// execution uses the default interval. To disable the task, set the parent
1494    /// options field to None.
1495    #[serde(deserialize_with = "deserialize_option_duration")]
1496    #[serde(serialize_with = "serialize_option_duration")]
1497    pub interval: Option<Duration>,
1498}
1499
1500impl Default for GarbageCollectorScheduleOptions {
1501    fn default() -> Self {
1502        Self {
1503            interval: Some(DEFAULT_INTERVAL),
1504        }
1505    }
1506}
1507
1508/// Default options for the garbage collector.
1509///
1510/// By default, garbage collection is enabled for all managed directories
1511/// (manifest, WAL, WAL fence, compacted SSTs, and compactions) using
1512/// [`GarbageCollectorDirectoryOptions::default()`].
1513/// WAL fence garbage collection runs in dry-run mode by default.
1514///
1515/// WAL fence GC is visible by default but does not delete until users
1516/// explicitly disable `dry_run`. This is a very conservative setting.
1517/// Users can enable fence GC with a high `min_age` if they want to
1518/// clean up old fences. Alternatively, this log can be silenced entirely
1519/// by setting `wal_fence_options` to `None`. See
1520/// [`GarbageCollectorOptions::wal_fence_options`] for more details.
1521///
1522/// To disable garbage collection for a specific file type, set that
1523/// directory option to `None`.
1524impl Default for GarbageCollectorOptions {
1525    fn default() -> Self {
1526        Self {
1527            manifest_options: Some(GarbageCollectorDirectoryOptions::default()),
1528            wal_options: Some(GarbageCollectorDirectoryOptions::default()),
1529            wal_fence_options: Some(GarbageCollectorDirectoryOptions {
1530                dry_run: true,
1531                ..GarbageCollectorDirectoryOptions::default()
1532            }),
1533            compacted_options: Some(GarbageCollectorDirectoryOptions::default()),
1534            compactions_options: Some(GarbageCollectorDirectoryOptions::default()),
1535            detach_options: Some(GarbageCollectorScheduleOptions::default()),
1536            metric_level: None,
1537        }
1538    }
1539}
1540
1541/// Options for the object store cache. This cache is not enabled unless an explicit cache
1542/// root folder is set. The object store cache will split an object into align-sized parts
1543/// in the local, and save them into the local cache storage.
1544///
1545/// The local cache default uses file system as storage, it can also be extended to use other
1546/// like RocksDB, Redis, etc. in the future.
1547#[derive(Clone, Debug, Deserialize, Serialize)]
1548pub struct ObjectStoreCacheOptions {
1549    /// The root folder where the cache files are stored. If not set, the cache will be
1550    /// disabled.
1551    pub root_folder: Option<std::path::PathBuf>,
1552
1553    /// The limit of the cache size in bytes, the default value is 16gb on 64 bit systems and
1554    /// 4gb on 32 bit systems.
1555    pub max_cache_size_bytes: Option<usize>,
1556
1557    /// The size of each part file, the part size is expected to be aligned with 1kb,
1558    /// its default value is 4mb.
1559    pub part_size_bytes: usize,
1560
1561    /// Whether to cache PUT operations to disk. When enabled, data written via PUT operations
1562    /// will be cached locally for faster subsequent reads. Default is false.
1563    pub cache_puts: bool,
1564
1565    /// Whether to preload SST files into cache during database startup. When enabled,
1566    /// the database will load SST files into the cache up to the cache size limit
1567    /// to warm up the cache for faster access. Default is None (no preloading).
1568    pub preload_disk_cache_on_startup: Option<PreloadLevel>,
1569
1570    /// Interval to scan the cache directory to rebuild the in-memory map for evictor.
1571    /// The default value is 1 hour. If set to None, the cache directory will be only
1572    /// scanned once on start up.
1573    #[serde(deserialize_with = "deserialize_option_duration")]
1574    #[serde(
1575        serialize_with = "serialize_option_duration",
1576        skip_serializing_if = "Option::is_none"
1577    )]
1578    pub scan_interval: Option<Duration>,
1579
1580    /// The maximum number of file handles to keep open in the file handle cache.
1581    /// When the limit is reached, the least recently used handle is closed.
1582    /// Default is 1000.
1583    pub max_open_file_handles: usize,
1584}
1585
1586impl Default for ObjectStoreCacheOptions {
1587    fn default() -> Self {
1588        Self {
1589            root_folder: None,
1590            #[cfg(target_pointer_width = "32")]
1591            max_cache_size_bytes: Some(usize::MAX),
1592            #[cfg(not(target_pointer_width = "32"))]
1593            max_cache_size_bytes: Some(16 * 1024 * 1024 * 1024),
1594            part_size_bytes: 4 * 1024 * 1024,
1595            cache_puts: false,
1596            preload_disk_cache_on_startup: None,
1597            scan_interval: Some(Duration::from_secs(3600)),
1598            max_open_file_handles: 1000,
1599        }
1600    }
1601}
1602
1603// Custom serializer for Duration
1604fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
1605where
1606    S: Serializer,
1607{
1608    let secs = duration.as_secs();
1609    let millis = duration.subsec_millis();
1610    let duration_str = if secs > 0 && millis > 0 {
1611        format!("{secs}s+{millis:03}ms")
1612    } else if millis > 0 {
1613        format!("{millis:03}ms")
1614    } else {
1615        format!("{secs}s")
1616    };
1617    serializer.serialize_str(&duration_str)
1618}
1619
1620// Custom serializer for Option<Duration>
1621fn serialize_option_duration<S>(
1622    duration: &Option<Duration>,
1623    serializer: S,
1624) -> Result<S::Ok, S::Error>
1625where
1626    S: Serializer,
1627{
1628    match duration {
1629        Some(d) => serialize_duration(d, serializer),
1630        None => serializer.serialize_none(),
1631    }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use std::collections::HashMap;
1637    use std::path::PathBuf;
1638
1639    use super::*;
1640
1641    #[test]
1642    fn test_db_options_load_from_env() {
1643        figment::Jail::expect_with(|jail| {
1644            jail.set_env("SLATEDB_FLUSH_INTERVAL", "1s");
1645            jail.set_env(
1646                "SLATEDB_OBJECT_STORE_CACHE_OPTIONS.ROOT_FOLDER",
1647                "/tmp/slatedb-root",
1648            );
1649
1650            let options =
1651                Settings::from_env("SLATEDB_").expect("failed to load db options from environment");
1652            assert_eq!(Some(Duration::from_secs(1)), options.flush_interval);
1653            assert_eq!(
1654                Some(PathBuf::from("/tmp/slatedb-root")),
1655                options.object_store_cache_options.root_folder
1656            );
1657
1658            Ok(())
1659        });
1660    }
1661
1662    #[test]
1663    fn test_db_options_load_metric_level_from_env() {
1664        figment::Jail::expect_with(|jail| {
1665            jail.set_env("SLATEDB_METRIC_LEVEL", "Debug");
1666
1667            let options =
1668                Settings::from_env("SLATEDB_").expect("failed to load db options from environment");
1669            assert_eq!(MetricLevel::Debug, options.metric_level);
1670
1671            Ok(())
1672        });
1673    }
1674
1675    #[test]
1676    fn test_db_options_load_metric_level_case_insensitive() {
1677        for (value, expected) in [
1678            ("debug", MetricLevel::Debug),
1679            ("DEBUG", MetricLevel::Debug),
1680            ("Debug", MetricLevel::Debug),
1681        ] {
1682            figment::Jail::expect_with(|jail| {
1683                jail.set_env("SLATEDB_METRIC_LEVEL", value);
1684
1685                let options = Settings::from_env("SLATEDB_")
1686                    .expect("failed to load db options from environment");
1687                assert_eq!(expected, options.metric_level);
1688
1689                Ok(())
1690            });
1691        }
1692    }
1693
1694    #[test]
1695    fn test_db_options_default_metric_level() {
1696        let options = Settings::default();
1697        assert_eq!(MetricLevel::default(), options.metric_level);
1698    }
1699
1700    #[test]
1701    fn test_db_options_load_from_json_file() {
1702        figment::Jail::expect_with(|jail| {
1703            jail.create_file(
1704                "config.json",
1705                r#"
1706{
1707    "flush_interval": "1s",
1708    "metric_level": "Debug",
1709    "object_store_cache_options": {
1710        "root_folder": "/tmp/slatedb-root"
1711    }
1712}
1713"#,
1714            )
1715            .expect("failed to create db options config file");
1716
1717            let options = Settings::from_file("config.json")
1718                .expect("failed to load db options from environment");
1719            assert_eq!(Some(Duration::from_secs(1)), options.flush_interval);
1720            assert_eq!(MetricLevel::Debug, options.metric_level);
1721            assert_eq!(
1722                Some(PathBuf::from("/tmp/slatedb-root")),
1723                options.object_store_cache_options.root_folder
1724            );
1725            Ok(())
1726        });
1727    }
1728
1729    #[test]
1730    fn test_db_options_env_with_default_respects_overrides() {
1731        figment::Jail::expect_with(|_jail| {
1732            let options = Settings::from_env_with_default(
1733                "SLATEDB_",
1734                Settings {
1735                    flush_interval: Some(Duration::from_millis(40)),
1736                    ..Default::default()
1737                },
1738            )
1739            .expect("failed to load db options from environment");
1740
1741            assert_eq!(Some(Duration::from_millis(40)), options.flush_interval);
1742
1743            Ok(())
1744        });
1745    }
1746
1747    #[test]
1748    fn test_db_options_load_from_toml_file() {
1749        figment::Jail::expect_with(|jail| {
1750            jail.create_file(
1751                "config.toml",
1752                r#"
1753flush_interval = "1s"
1754metric_level = "Debug"
1755[object_store_cache_options]
1756root_folder = "/tmp/slatedb-root"
1757"#,
1758            )
1759            .expect("failed to create db options config file");
1760
1761            let options = Settings::from_file("config.toml")
1762                .expect("failed to load db options from environment");
1763            assert_eq!(Some(Duration::from_secs(1)), options.flush_interval);
1764            assert_eq!(MetricLevel::Debug, options.metric_level);
1765            assert_eq!(
1766                Some(PathBuf::from("/tmp/slatedb-root")),
1767                options.object_store_cache_options.root_folder
1768            );
1769            Ok(())
1770        });
1771    }
1772
1773    #[test]
1774    fn test_db_options_load_from_yaml_file() {
1775        figment::Jail::expect_with(|jail| {
1776            jail.create_file(
1777                "config.yaml",
1778                r#"
1779flush_interval: "1s"
1780metric_level: Debug
1781object_store_cache_options:
1782    root_folder: "/tmp/slatedb-root"
1783"#,
1784            )
1785            .expect("failed to create db options config file");
1786
1787            let options = Settings::from_file("config.yaml")
1788                .expect("failed to load db options from environment");
1789            assert_eq!(Some(Duration::from_secs(1)), options.flush_interval);
1790            assert_eq!(MetricLevel::Debug, options.metric_level);
1791            assert_eq!(
1792                Some(PathBuf::from("/tmp/slatedb-root")),
1793                options.object_store_cache_options.root_folder
1794            );
1795            Ok(())
1796        });
1797    }
1798
1799    #[test]
1800    fn test_db_options_load_with_default_locations() {
1801        figment::Jail::expect_with(|jail| {
1802            jail.set_env("SLATEDB_FLUSH_INTERVAL", "1s");
1803
1804            jail.create_file(
1805                "SlateDb.yaml",
1806                r#"
1807object_store_cache_options:
1808    root_folder: "/tmp/slatedb-root"
1809"#,
1810            )
1811            .expect("failed to create db options config file");
1812
1813            let options = Settings::load().expect("failed to load db options from environment");
1814            assert_eq!(Some(Duration::from_secs(1)), options.flush_interval);
1815            assert_eq!(
1816                Some(PathBuf::from("/tmp/slatedb-root")),
1817                options.object_store_cache_options.root_folder
1818            );
1819            Ok(())
1820        });
1821    }
1822
1823    #[test]
1824    fn test_default_read_options() {
1825        let options = ReadOptions::default();
1826        assert_eq!(options.durability_filter, DurabilityLevel::Memory);
1827        assert!(!options.dirty);
1828        assert!(options.cache_blocks);
1829
1830        let options = ScanOptions::default();
1831        assert_eq!(options.durability_filter, DurabilityLevel::Memory);
1832        assert!(!options.dirty);
1833        assert_eq!(options.read_ahead_bytes, 1);
1834        assert!(!options.cache_blocks);
1835        assert_eq!(options.max_fetch_tasks, 1);
1836    }
1837
1838    #[test]
1839    fn test_scan_options_with_max_fetch_tasks() {
1840        let options = ScanOptions::default().with_max_fetch_tasks(4);
1841        assert_eq!(options.max_fetch_tasks, 4);
1842
1843        // Verify other fields remain unchanged
1844        assert_eq!(options.durability_filter, DurabilityLevel::Memory);
1845        assert!(!options.dirty);
1846        assert_eq!(options.read_ahead_bytes, 1);
1847        assert!(!options.cache_blocks);
1848    }
1849
1850    #[test]
1851    fn test_size_tiered_compaction_scheduler_options_roundtrip() {
1852        let options = SizeTieredCompactionSchedulerOptions {
1853            min_compaction_sources: 3,
1854            max_compaction_sources: 9,
1855            include_size_threshold: 7.0,
1856        };
1857
1858        let map: HashMap<String, String> = options.into();
1859        let roundtripped = SizeTieredCompactionSchedulerOptions::from(map);
1860
1861        assert_eq!(roundtripped.min_compaction_sources, 3);
1862        assert_eq!(roundtripped.max_compaction_sources, 9);
1863        assert_eq!(roundtripped.include_size_threshold, 7.0);
1864    }
1865
1866    #[test]
1867    fn should_return_exact_timestamp_for_put_expire_at() {
1868        // given
1869        let opts = PutOptions {
1870            ttl: Ttl::ExpireAt(12345),
1871        };
1872
1873        // when
1874        let expire_ts = opts.expire_ts_from(None, 100);
1875
1876        // then
1877        assert_eq!(expire_ts, Some(12345));
1878    }
1879
1880    #[test]
1881    fn should_ignore_default_ttl_for_put_expire_at() {
1882        // given
1883        let opts = PutOptions {
1884            ttl: Ttl::ExpireAt(12345),
1885        };
1886
1887        // when
1888        let expire_ts = opts.expire_ts_from(Some(9999), 100);
1889
1890        // then
1891        assert_eq!(expire_ts, Some(12345));
1892    }
1893
1894    #[test]
1895    fn should_allow_past_timestamp_for_put_expire_at() {
1896        // given
1897        let opts = PutOptions {
1898            ttl: Ttl::ExpireAt(50),
1899        };
1900
1901        // when
1902        let expire_ts = opts.expire_ts_from(None, 100);
1903
1904        // then: past timestamp is allowed (entry will expire on next read/compaction)
1905        assert_eq!(expire_ts, Some(50));
1906    }
1907
1908    #[test]
1909    fn should_return_exact_timestamp_for_merge_expire_at() {
1910        // given
1911        let opts = MergeOptions {
1912            ttl: Ttl::ExpireAt(12345),
1913        };
1914
1915        // when
1916        let expire_ts = opts.expire_ts_from(None, 100);
1917
1918        // then
1919        assert_eq!(expire_ts, Some(12345));
1920    }
1921
1922    #[test]
1923    fn should_return_deterministic_expire_ts_for_expire_at() {
1924        // given: same ExpireAt value used at different times
1925        let opts = PutOptions {
1926            ttl: Ttl::ExpireAt(99999),
1927        };
1928
1929        // when
1930        let ts1 = opts.expire_ts_from(None, 100);
1931        let ts2 = opts.expire_ts_from(None, 200);
1932        let ts3 = opts.expire_ts_from(None, 300);
1933
1934        // then: all return the same absolute timestamp regardless of `now`
1935        assert_eq!(ts1, Some(99999));
1936        assert_eq!(ts2, Some(99999));
1937        assert_eq!(ts3, Some(99999));
1938    }
1939}