Skip to main content

mace/utils/
options.rs

1use std::{
2    ops::Deref,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use crate::io;
8use crate::must_ok;
9use crate::utils::merge_operator::MergeOperator;
10#[cfg(feature = "metrics")]
11use crate::utils::observe::{NoopObserver, Observer};
12use serde::{Deserialize, Serialize};
13
14use super::OpCode;
15
16pub(crate) fn dir_parent_for_sync(path: &Path) -> Option<&Path> {
17    match path.parent() {
18        Some(parent) if parent.as_os_str().is_empty() => Some(Path::new(".")),
19        Some(parent) => Some(parent),
20        None => None,
21    }
22}
23
24fn create_dir_all_and_sync(fs: &dyn io::FileSystem, path: &Path) -> std::io::Result<()> {
25    if fs.try_exists(path)? {
26        return Ok(());
27    }
28
29    let mut created = Vec::new();
30    let mut cursor = Some(path);
31    while let Some(dir) = cursor {
32        if fs.try_exists(dir)? {
33            break;
34        }
35        created.push(dir.to_path_buf());
36        cursor = dir.parent();
37    }
38
39    fs.create_dir_all(path)?;
40
41    created.reverse();
42    for dir in created {
43        if let Some(parent) = dir_parent_for_sync(&dir) {
44            fs.sync_dir(parent)?;
45        }
46    }
47    Ok(())
48}
49
50/// Configuration options for the Mace storage engine.
51#[derive(Clone)]
52pub struct Options {
53    /// WAL durability route. The default value is `true`.
54    ///
55    /// Enabled, every logical group appends to one durable shared stream: concurrent terminal
56    /// records and explicit barriers join a caller-led sync generation, and a commit returns only
57    /// after its durability target completes. Disabled, each logical group appends to its own
58    /// stream and a commit only reaches the page cache, so a crash can lose recently committed
59    /// transactions.
60    ///
61    /// The route is persisted with the database. A reopen that requests a different value starts a
62    /// route transition on the same rebuild path a width change uses, so the requested route takes
63    /// effect once that transition completes.
64    pub sync_on_write: bool,
65    /// Writer group count. Default is [`Self::CONCURRENT_WRITE`]; validation clamps the value to
66    /// `[1, Self::MAX_CONCURRENT_WRITE]` and rounds it up to a power of two.
67    ///
68    /// The width is persisted with the database. A reopen that requests a different width migrates
69    /// the WAL epoch instead of being rejected: recovery keeps reading at the persisted (source)
70    /// width, the wipe and recycle bound cover both widths plus the shared stream, and the
71    /// requested width takes effect for the runtime that follows the migration. The persisted value
72    /// is replaced once that migration completes, so a crash before then leaves the source width in
73    /// charge of the next open.
74    pub concurrent_write: u8,
75    /// Garbage collection cycle interval (milliseconds).
76    ///
77    /// Under `extra_check` builds, set to 0 to disable the background timer
78    /// branch entirely: the gc thread then only runs on explicit
79    /// [`crate::Mace::start_gc`] calls (deterministic-test mode). Do not set 0
80    /// in other builds: there the zero interval degenerates into a busy poll.
81    pub gc_timeout: u64,
82    /// Proactive page-checkpoint trigger interval (milliseconds).
83    ///
84    /// When a bucket has pending dirty pages but no foreground write reaches checkpoint thresholds,
85    /// the evictor triggers checkpoint near this interval to prevent WAL checkpoint stalling.
86    ///
87    /// Set to 0 to disable proactive triggering.
88    pub checkpoint_nudge_ms: u64,
89    /// Perform compaction when the garbage ratio exceeds this value, in the range `[0, 100]`
90    pub data_garbage_ratio: u32,
91    /// If true, compact immediately when [`Self::data_garbage_ratio`] is reached.
92    pub gc_eager: bool,
93    /// Size limit of a blob file. Default is [`Self::BLOB_FILE_SIZE`]
94    pub blob_file_size: usize,
95    /// Trigger blob GC when the garbage ratio exceeds this value, in the range `[0, 100]`
96    pub blob_garbage_ratio: u32,
97    /// Whether this is temporary storage.
98    ///
99    /// If true, `db_root` will be removed on exit.
100    pub tmp_store: bool,
101    /// Directory where database files are stored.
102    pub(crate) db_root: PathBuf,
103    /// Directory where log files are stored.
104    ///
105    /// The default value is `db_root/log`.
106    pub log_root: PathBuf,
107    /// Shared logical-address cache capacity in bytes.
108    ///
109    /// This cache keeps file-loaded blob values and auxiliary history/sibling pages.
110    /// Resident tree pages and dirty pool pages are accounted elsewhere and are not inserted here.
111    /// Trimming is best-effort and happens in small rounds, so short-term overshoot is possible.
112    ///
113    /// Different subsystems may transiently hold refs to the same allocation.
114    pub lru_capacity: usize,
115    /// Bitmap-cache entry count for data and blob stats.
116    pub stat_mask_cache_count: usize,
117    /// Maximum number of open data-file handles cached concurrently, used for loading data pages.
118    pub data_handle_cache_capacity: usize,
119    /// Maximum number of open blob-file handles cached concurrently, used for loading blob pages.
120    pub blob_handle_cache_capacity: usize,
121    /// Size limit of a data file. Zero selects the default [`Self::DATA_FILE_SIZE`]
122    pub data_file_size: usize,
123    /// WAL ring buffer size. Must be a power of two and large enough for the largest record the
124    /// engine appends: a record is staged in the ring before it is written out.
125    pub wal_buffer_size: usize,
126    /// Number of checkpoints a transaction can span (i.e., transaction length limit).
127    ///
128    /// If a transaction exceeds this limit, it is forcibly aborted.
129    pub max_ckpt_per_txn: usize,
130    /// WAL file size limit that triggers switching to a new WAL file.
131    pub wal_file_size: u32,
132    /// If true, a malformed or incomplete WAL tail is truncated at the first bad record during
133    /// recovery and the remaining files of that stream are ignored; otherwise startup fails with a
134    /// corruption error instead of truncating.
135    ///
136    /// Default is true.
137    pub truncate_corrupted_wal: bool,
138    /// durable-route merge window in microseconds
139    pub sync_merge_window_us: u64,
140    /// Observability callback. Default is no-op. Only available with the
141    /// `metrics` feature; without it no observer is ever invoked.
142    #[cfg(feature = "metrics")]
143    pub observer: Arc<dyn Observer>,
144    /// Filesystem hook for namespace operations and runtime file opens.
145    ///
146    /// The default value uses the host operating system filesystem.
147    pub(crate) fs: Arc<dyn io::FileSystem>,
148}
149
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151#[repr(C)]
152pub struct PersistedBucketOptions {
153    /// Per-bucket target resident bytes for mapped B+Tree pages.
154    pub cache_capacity: usize,
155    /// Percentage of items evicted per round. Range is `[10, 80]`, default is `20%`
156    pub cache_evict_pct: usize,
157    /// Per-bucket pool target bytes. Default is [`BucketOptions::POOL_CAP`]
158    /// Dirty memory (hot + sealed) gate: admission may add a bounded
159    /// drain burst (<= pool/8, <= checkpoint_size) while a checkpoint is
160    /// in flight, so projected dirty is capped at pool_capacity + burst
161    /// (actual dirty can overshoot between snapshots via per-op estimate
162    /// gaps and unadmitted eviction growth)
163    pub pool_capacity: usize,
164    /// Maximum bytes a single checkpoint round should emit. Default is [`BucketOptions::CHECKPOINT_SIZE`]
165    pub checkpoint_size: usize,
166    /// For branch nodes, keys and indexes are always inlined. For leaf nodes, only values smaller
167    /// than `inline_size` are inlined; values whose size is equal to or greater than `inline_size`
168    /// are stored as blobs. For existing buckets, `update_bucket_opt` only permits lowering this
169    /// threshold. Default is [`BucketOptions::INLINE_SIZE`]
170    pub inline_size: usize,
171    /// Maximum number of elements in an SST (B+Tree node). Default is [`BucketOptions::MAX_SPLIT_ELEMS`]
172    pub split_elems: u16,
173    /// Threshold for consolidating delta chains. Range is `[16, Self::split_elems / 2]`
174    pub consolidate_threshold: u16,
175    /// Enable zstd compression for persisted data/blob records generated by this bucket
176    pub enable_compression: bool,
177    /// Enable foreground write backpressure. Default is `true`
178    pub enable_backpressure: bool,
179    pub _padding: [u8; 2],
180}
181
182impl From<&BucketOptions> for PersistedBucketOptions {
183    fn from(opt: &BucketOptions) -> Self {
184        Self {
185            cache_capacity: opt.cache_capacity,
186            cache_evict_pct: opt.cache_evict_pct,
187            pool_capacity: opt.pool_capacity,
188            checkpoint_size: opt.checkpoint_size,
189            inline_size: opt.inline_size,
190            split_elems: opt.split_elems,
191            consolidate_threshold: opt.consolidate_threshold,
192            enable_compression: opt.enable_compression,
193            enable_backpressure: opt.enable_backpressure,
194            _padding: [0u8; 2],
195        }
196    }
197}
198
199impl PersistedBucketOptions {
200    pub(crate) fn to_runtime(self) -> BucketOptions {
201        let mut opt = BucketOptions::new();
202        opt.cache_capacity = self.cache_capacity;
203        opt.cache_evict_pct = self.cache_evict_pct;
204        opt.pool_capacity = self.pool_capacity;
205        opt.checkpoint_size = self.checkpoint_size;
206        opt.inline_size = self.inline_size;
207        opt.split_elems = self.split_elems;
208        opt.consolidate_threshold = self.consolidate_threshold;
209        opt.enable_compression = self.enable_compression;
210        opt.enable_backpressure = self.enable_backpressure;
211        opt
212    }
213}
214
215/// Runtime bucket options.
216///
217/// Persisted fields are mirrored into [`PersistedBucketOptions`] when a bucket is created;
218/// `merge_operator` is runtime-only and never reaches manifest, WAL, or pages.
219#[derive(Clone)]
220pub struct BucketOptions {
221    /// Per-bucket target resident bytes for mapped B+Tree pages.
222    pub cache_capacity: usize,
223    /// Percentage of items evicted per round. Range is `[10, 80]`, default is `20%`
224    pub cache_evict_pct: usize,
225    /// Per-bucket pool target bytes. Default is [`Self::POOL_CAP`]
226    /// Dirty memory (hot + sealed) gate: admission may add a bounded
227    /// drain burst (<= pool/8, <= checkpoint_size) while a checkpoint is
228    /// in flight, so projected dirty is capped at pool_capacity + burst
229    /// (actual dirty can overshoot between snapshots via per-op estimate
230    /// gaps and unadmitted eviction growth)
231    pub pool_capacity: usize,
232    /// Maximum bytes a single checkpoint round should emit. Default is [`Self::CHECKPOINT_SIZE`]
233    pub checkpoint_size: usize,
234    /// For branch nodes, keys and indexes are always inlined. For leaf nodes, only values smaller
235    /// than `inline_size` are inlined; values whose size is equal to or greater than `inline_size`
236    /// are stored as blobs. For existing buckets, `update_bucket_opt` only permits lowering this
237    /// threshold. Default is [`Self::INLINE_SIZE`]
238    pub inline_size: usize,
239    /// Maximum number of elements in an SST (B+Tree node). Default is [`Self::MAX_SPLIT_ELEMS`]
240    pub split_elems: u16,
241    /// Threshold for consolidating delta chains. Range is `[16, Self::split_elems / 2]`
242    pub consolidate_threshold: u16,
243    /// Enable zstd compression for persisted data/blob records generated by this bucket
244    pub enable_compression: bool,
245    /// Enable foreground write backpressure. Default is `true`
246    pub enable_backpressure: bool,
247    /// Runtime merge operator (Runtime-only: never persisted) for this bucket. See [`crate::MergeOperator`] for the required laws.
248    pub merge_operator: Option<Arc<dyn MergeOperator>>,
249}
250
251impl Default for BucketOptions {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257impl std::fmt::Debug for BucketOptions {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        let mut d = f.debug_struct("BucketOptions");
260        d.field("cache_capacity", &self.cache_capacity)
261            .field("cache_evict_pct", &self.cache_evict_pct)
262            .field("pool_capacity", &self.pool_capacity)
263            .field("checkpoint_size", &self.checkpoint_size)
264            .field("inline_size", &self.inline_size)
265            .field("split_elems", &self.split_elems)
266            .field("consolidate_threshold", &self.consolidate_threshold)
267            .field("enable_compression", &self.enable_compression)
268            .field("enable_backpressure", &self.enable_backpressure)
269            .field("merge_operator", &self.merge_operator.is_some());
270        d.finish()
271    }
272}
273
274impl BucketOptions {
275    pub const MIN_SPLIT_ELEMS: u16 = 64;
276    pub const MAX_SPLIT_ELEMS: u16 = 512;
277    pub const CACHE_CAP: usize = 1 << 30; // 1GB
278    pub const POOL_CAP: usize = 1 << 30; // 1GB
279    pub const CHECKPOINT_SIZE: usize = 256 << 20; // 256MB
280    pub const MIN_INLINE_SIZE: usize = 256;
281    pub const INLINE_SIZE: usize = 1024;
282    pub const MAX_INLINE_SIZE: usize = 4096;
283
284    pub fn new() -> Self {
285        Self {
286            cache_capacity: Self::CACHE_CAP,
287            cache_evict_pct: 20,
288            pool_capacity: Self::POOL_CAP,
289            checkpoint_size: Self::CHECKPOINT_SIZE,
290            consolidate_threshold: Self::MAX_SPLIT_ELEMS / 2,
291            inline_size: Self::INLINE_SIZE,
292            split_elems: Self::MAX_SPLIT_ELEMS,
293            enable_compression: false,
294            enable_backpressure: true,
295            merge_operator: None,
296        }
297    }
298
299    pub fn validate(mut self) -> BucketOptions {
300        if self.checkpoint_size == 0 {
301            self.checkpoint_size = Self::CHECKPOINT_SIZE;
302        }
303        if self.pool_capacity == 0 {
304            self.pool_capacity = Self::POOL_CAP;
305        }
306        if self.cache_capacity == 0 {
307            self.cache_capacity = Self::CACHE_CAP;
308        }
309        if self.checkpoint_size > self.pool_capacity {
310            self.checkpoint_size = self.pool_capacity;
311        }
312        self.cache_evict_pct = self.cache_evict_pct.clamp(10, 80);
313        self.split_elems = self
314            .split_elems
315            .clamp(Self::MIN_SPLIT_ELEMS, Self::MAX_SPLIT_ELEMS);
316        self.consolidate_threshold = self.consolidate_threshold.clamp(16, self.split_elems / 2);
317        self.inline_size = self
318            .inline_size
319            .clamp(Self::MIN_INLINE_SIZE, Self::MAX_INLINE_SIZE);
320        self
321    }
322}
323
324impl Options {
325    pub const CONCURRENT_WRITE: u8 = 16;
326    pub const MAX_CONCURRENT_WRITE: u8 = 128;
327    pub const DATA_FILE_SIZE: usize = 64 << 20; // 64MB
328    pub const BLOB_FILE_SIZE: usize = 256 << 20; // 256MB
329    pub const LRU_CAPACITY: usize = 256 << 20; // 256MB
330    // Assuming a MemData/BlobStat is 32 KB, 16,384 stats use ~512 MB of memory, which is reasonable.
331    pub const STAT_MASK_CACHE_CNT: usize = 16384;
332    pub const WAL_BUF_SZ: usize = 16 << 20; // 16MB
333    pub const WAL_FILE_SZ: usize = 64 << 20; // 64MB
334
335    pub(crate) const MAX_KEY_SIZE: usize = 64 << 10;
336    pub(crate) const MAX_KV_SIZE: usize = 1 << 30; // 1GB
337
338    /// Creates a new Options instance with default values and the given database root.
339    pub fn new<P: AsRef<Path>>(db_root: P) -> Self {
340        Self {
341            sync_on_write: true,
342            concurrent_write: Self::CONCURRENT_WRITE,
343            tmp_store: false,
344            gc_timeout: 60 * 1000,          // 1min
345            checkpoint_nudge_ms: 60 * 1000, // 1min
346            data_garbage_ratio: 20,         // 20%
347            gc_eager: true,
348            blob_file_size: Self::BLOB_FILE_SIZE,
349            blob_garbage_ratio: 50, // 50%
350            db_root: db_root.as_ref().to_path_buf(),
351            log_root: db_root.as_ref().to_path_buf(),
352            lru_capacity: Self::LRU_CAPACITY,
353            stat_mask_cache_count: Self::STAT_MASK_CACHE_CNT,
354            data_handle_cache_capacity: 128,
355            blob_handle_cache_capacity: 128,
356            data_file_size: Self::DATA_FILE_SIZE,
357            wal_buffer_size: Self::WAL_BUF_SZ,
358            max_ckpt_per_txn: 1_000_000, // 1 million
359            wal_file_size: Self::WAL_FILE_SZ as u32,
360            truncate_corrupted_wal: true,
361            sync_merge_window_us: 125,
362            #[cfg(feature = "metrics")]
363            observer: Arc::new(NoopObserver),
364            fs: Arc::new(io::OsFileSystem),
365        }
366    }
367
368    /// Validates the options and returns a ParsedOptions instance.
369    pub fn validate(mut self) -> Result<ParsedOptions, OpCode> {
370        self.concurrent_write = self
371            .concurrent_write
372            .clamp(1, Self::MAX_CONCURRENT_WRITE)
373            .next_power_of_two();
374        if self.stat_mask_cache_count == 0 {
375            self.stat_mask_cache_count = Self::STAT_MASK_CACHE_CNT;
376        }
377        if self.lru_capacity == 0 {
378            self.lru_capacity = Self::LRU_CAPACITY;
379        }
380        if self.data_file_size == 0 {
381            self.data_file_size = Self::DATA_FILE_SIZE;
382        }
383        if self.blob_file_size == 0 {
384            self.blob_file_size = Self::BLOB_FILE_SIZE;
385        }
386
387        self.create_dir().map_err(|e| {
388            eprintln!("create dir fail {e:?}");
389            OpCode::IoError
390        })?;
391        Ok(ParsedOptions { inner: self })
392    }
393
394    /// Creates the directory structure for the database.
395    pub fn create_dir(&self) -> std::io::Result<()> {
396        let (db_root, data_root, log_root) = (self.db_root(), self.data_root(), self.log_root());
397
398        if !self.fs.try_exists(&db_root)? {
399            create_dir_all_and_sync(self.fs.as_ref(), &db_root)?;
400        }
401        if !self.fs.try_exists(&data_root)? {
402            create_dir_all_and_sync(self.fs.as_ref(), &data_root)?;
403        }
404        if !self.fs.try_exists(&log_root)? {
405            create_dir_all_and_sync(self.fs.as_ref(), &log_root)?;
406        }
407        Ok(())
408    }
409}
410
411pub struct ParsedOptions {
412    pub(crate) inner: Options,
413}
414
415impl Deref for ParsedOptions {
416    type Target = Options;
417    fn deref(&self) -> &Self::Target {
418        &self.inner
419    }
420}
421
422impl Options {
423    pub const SEP: &'static str = "_";
424    pub const DATA_PREFIX: &'static str = "data";
425    pub const BLOB_PREFIX: &'static str = "blob";
426    pub const WAL_PREFIX: &'static str = "wal";
427    pub const GROUP_WAL_PREFIX: &'static str = "group_wal";
428    pub const MANIFEST: &'static str = "manifest";
429    /// reserved physical id for the durable shared stream
430    pub const SHARED_ID: u8 = 255;
431
432    pub fn data_root(&self) -> PathBuf {
433        self.db_root().join("data")
434    }
435
436    pub fn data_file(&self, id: u64) -> PathBuf {
437        self.data_root()
438            .join(format!("{}{}{}", Self::DATA_PREFIX, Self::SEP, id))
439    }
440
441    pub fn blob_file(&self, id: u64) -> PathBuf {
442        self.data_root()
443            .join(format!("{}{}{}", Self::BLOB_PREFIX, Self::SEP, id))
444    }
445
446    pub fn log_root(&self) -> PathBuf {
447        if self.log_root == self.db_root {
448            self.db_root.join("log")
449        } else {
450            self.log_root.clone()
451        }
452    }
453
454    pub fn db_root(&self) -> PathBuf {
455        self.db_root.clone()
456    }
457
458    pub fn wal_file(&self, group_id: u8, seq: u64) -> PathBuf {
459        self.log_root().join(format!(
460            "{}{}{}{}{}",
461            Self::WAL_PREFIX,
462            Self::SEP,
463            group_id,
464            Self::SEP,
465            seq
466        ))
467    }
468
469    /// durable shared stream file: `group_wal_<seq>`
470    pub fn group_wal_file(&self, seq: u64) -> PathBuf {
471        self.log_root()
472            .join(format!("{}{}{}", Self::GROUP_WAL_PREFIX, Self::SEP, seq))
473    }
474
475    /// path for a physical WAL stream
476    pub fn physical_wal_path(&self, physical_wal_id: u8, seq: u64) -> PathBuf {
477        if physical_wal_id == Self::SHARED_ID {
478            self.group_wal_file(seq)
479        } else {
480            self.wal_file(physical_wal_id, seq)
481        }
482    }
483
484    pub fn manifest(&self) -> PathBuf {
485        self.log_root().join(Self::MANIFEST)
486    }
487
488    pub(crate) fn sync_data_dir(&self) {
489        must_ok!(
490            self.try_sync_data_dir(),
491            "can't sync the data directory {:?}",
492            self.data_root()
493        );
494    }
495
496    /// fallible variant for the open path: orphan cleanup and obsolete-file deletion run before
497    /// recovery, where a failed directory sync can still fail the open with an error instead of
498    /// killing the process. the runtime publish path keeps the fatal form
499    pub(crate) fn try_sync_data_dir(&self) -> Result<(), OpCode> {
500        self.fs
501            .as_ref()
502            .sync_dir(&self.data_root())
503            .map_err(OpCode::from)
504    }
505
506    pub(crate) fn sync_log_dir(&self) {
507        must_ok!(
508            self.try_sync_log_dir(),
509            "can't sync the log directory {:?}",
510            self.log_root()
511        );
512    }
513
514    /// fallible variant for the recovery-open sequence: the epoch handoff must return an error
515    /// through the open's cleanup instead of unwinding mid-handoff, because a panic there runs
516    /// destructors on a half-migrated namespace
517    pub(crate) fn try_sync_log_dir(&self) -> Result<(), OpCode> {
518        self.fs
519            .as_ref()
520            .sync_dir(&self.log_root())
521            .map_err(OpCode::from)
522    }
523
524    /// whether the log directory already holds legacy per-group `wal_<group>_<seq>` files.
525    ///
526    /// the durable steady state never writes that namespace, so a durable open that finds it
527    /// must migrate the layout before continuing.
528    pub(crate) fn has_legacy_per_group_wal(&self) -> Result<bool, OpCode> {
529        let prefix = format!("{}{}", Self::WAL_PREFIX, Self::SEP);
530        for entry in self.fs.read_dir(&self.log_root())? {
531            let Some(name) = entry.file_name() else {
532                continue;
533            };
534            let Some(raw) = name.to_str() else {
535                continue;
536            };
537            if raw.starts_with(&prefix) {
538                return Ok(true);
539            }
540        }
541        Ok(false)
542    }
543}
544
545#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(default)]
547pub(crate) struct PersistedOptions {
548    pub concurrent_write: u8,
549    pub sync_on_write: bool,
550    pub gc_timeout: u64,
551    pub checkpoint_nudge_ms: u64,
552    pub data_garbage_ratio: u32,
553    pub gc_eager: bool,
554    pub blob_file_size: usize,
555    pub blob_garbage_ratio: u32,
556    pub lru_capacity: usize,
557    pub stat_mask_cache_count: usize,
558    pub data_handle_cache_capacity: usize,
559    pub blob_handle_cache_capacity: usize,
560    pub data_file_size: usize,
561    pub wal_buffer_size: usize,
562    pub max_ckpt_per_txn: usize,
563    pub wal_file_size: u32,
564    pub truncate_corrupted_wal: bool,
565}
566
567impl Default for PersistedOptions {
568    fn default() -> Self {
569        Self {
570            concurrent_write: Options::CONCURRENT_WRITE,
571            sync_on_write: true,
572            gc_timeout: 60 * 1000,
573            checkpoint_nudge_ms: 60 * 1000,
574            data_garbage_ratio: 20,
575            gc_eager: true,
576            blob_file_size: Options::BLOB_FILE_SIZE,
577            blob_garbage_ratio: 50,
578            lru_capacity: Options::LRU_CAPACITY,
579            stat_mask_cache_count: Options::STAT_MASK_CACHE_CNT,
580            data_handle_cache_capacity: 128,
581            blob_handle_cache_capacity: 128,
582            data_file_size: Options::DATA_FILE_SIZE,
583            wal_buffer_size: Options::WAL_BUF_SZ,
584            max_ckpt_per_txn: 1_000_000,
585            wal_file_size: Options::WAL_FILE_SZ as u32,
586            truncate_corrupted_wal: true,
587        }
588    }
589}
590
591impl PersistedOptions {
592    pub(crate) fn from_options(opt: &Options) -> Self {
593        Self {
594            concurrent_write: opt.concurrent_write,
595            sync_on_write: opt.sync_on_write,
596            gc_timeout: opt.gc_timeout,
597            checkpoint_nudge_ms: opt.checkpoint_nudge_ms,
598            data_garbage_ratio: opt.data_garbage_ratio,
599            gc_eager: opt.gc_eager,
600            blob_file_size: opt.blob_file_size,
601            blob_garbage_ratio: opt.blob_garbage_ratio,
602            lru_capacity: opt.lru_capacity,
603            stat_mask_cache_count: opt.stat_mask_cache_count,
604            data_handle_cache_capacity: opt.data_handle_cache_capacity,
605            blob_handle_cache_capacity: opt.blob_handle_cache_capacity,
606            data_file_size: opt.data_file_size,
607            wal_buffer_size: opt.wal_buffer_size,
608            max_ckpt_per_txn: opt.max_ckpt_per_txn,
609            wal_file_size: opt.wal_file_size,
610            truncate_corrupted_wal: opt.truncate_corrupted_wal,
611        }
612    }
613
614    pub(crate) fn from_json(buf: &[u8]) -> Result<Self, OpCode> {
615        serde_json::from_slice(buf).map_err(|_| OpCode::Corruption)
616    }
617
618    pub(crate) fn to_json(&self) -> Result<Vec<u8>, OpCode> {
619        serde_json::to_vec(self).map_err(|_| OpCode::Corruption)
620    }
621}
622
623impl Drop for ParsedOptions {
624    fn drop(&mut self) {
625        if self.inner.tmp_store {
626            log::info!("remove db_root {:?}", self.inner.db_root);
627            let _ = std::fs::remove_dir_all(&self.inner.db_root);
628        }
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use std::{io::ErrorKind, sync::Arc};
635
636    use crate::{
637        OpCode, RandomPath,
638        io::testfs::{InjectOp, InjectedFileSystem},
639    };
640
641    use super::{Options, PersistedOptions};
642
643    #[test]
644    fn validate_returns_io_error_when_create_dir_all_fails() {
645        let root = RandomPath::tmp();
646        let mut opt = Options::new(&*root);
647        let fs = Arc::new(InjectedFileSystem::new());
648        fs.fail_once(
649            InjectOp::CreateDirAll,
650            opt.db_root(),
651            ErrorKind::PermissionDenied,
652        );
653        opt.fs = fs;
654        let err = opt.validate().err().expect("validate must fail");
655        assert_eq!(err, OpCode::IoError);
656    }
657
658    #[test]
659    fn try_sync_data_dir_propagates_a_directory_sync_failure() {
660        let root = RandomPath::tmp();
661        let mut opt = Options::new(&*root);
662        let fs = Arc::new(InjectedFileSystem::new());
663        opt.fs = fs.clone();
664        let parsed = opt.validate().expect("options must validate");
665        // arm the rule after validation so only the call under test can consume it
666        fs.fail_once(
667            InjectOp::SyncDir,
668            parsed.data_root(),
669            ErrorKind::PermissionDenied,
670        );
671
672        assert_eq!(
673            parsed.try_sync_data_dir(),
674            Err(OpCode::IoError),
675            "the open path must see a failed data-directory sync as an error"
676        );
677        assert_eq!(
678            parsed.try_sync_log_dir(),
679            Ok(()),
680            "the failure must be the data directory's, not a device-wide one"
681        );
682    }
683
684    #[test]
685    fn validate_returns_io_error_when_parent_sync_fails() {
686        let root = RandomPath::tmp();
687        let mut opt = Options::new(&*root);
688        let parent = opt
689            .db_root()
690            .parent()
691            .expect("db_root must have parent")
692            .to_path_buf();
693        let fs = Arc::new(InjectedFileSystem::new());
694        fs.fail_once(InjectOp::SyncDir, parent, ErrorKind::PermissionDenied);
695        opt.fs = fs;
696        let err = opt.validate().err().expect("validate must fail");
697        assert_eq!(err, OpCode::IoError);
698    }
699
700    #[test]
701    fn persisted_options_fill_missing_fields_with_defaults() {
702        let json = br#"{"concurrent_write":8}"#;
703        let opt = PersistedOptions::from_json(json).expect("decode persisted options");
704        let expected = PersistedOptions {
705            concurrent_write: 8,
706            ..Default::default()
707        };
708        assert_eq!(opt, expected);
709    }
710
711    #[test]
712    fn persisted_options_ignore_removed_fields() {
713        let json = br#"{"concurrent_write":16,"old_field":true}"#;
714        let opt = PersistedOptions::from_json(json).expect("decode persisted options");
715        let expected = PersistedOptions {
716            concurrent_write: 16,
717            ..Default::default()
718        };
719        assert_eq!(opt, expected);
720    }
721
722    #[test]
723    fn persisted_options_keep_a_foreign_concurrent_write() {
724        // the width is no longer an immutable gate: a mismatching persisted value decodes
725        // as-is and the open plan decides whether a migration is required
726        let root = RandomPath::tmp();
727        let mut opt = Options::new(&*root);
728        opt.concurrent_write = 8;
729        let persisted = PersistedOptions::from_options(&opt.validate().unwrap());
730
731        let json = persisted.to_json().expect("encode persisted options");
732        let decoded = PersistedOptions::from_json(&json).expect("decode persisted options");
733        assert_eq!(decoded.concurrent_write, 8);
734    }
735}