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}