Skip to main content

cqlite_core/
config.rs

1//! Configuration management for CQLite
2
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6/// Main configuration structure for CQLite database
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct Config {
9    /// Storage engine configuration
10    pub storage: StorageConfig,
11
12    /// Memory management configuration
13    pub memory: MemoryConfig,
14
15    /// Query engine configuration
16    pub query: QueryConfig,
17
18    /// Performance and optimization settings
19    pub performance: PerformanceConfig,
20
21    /// WASM-specific configuration
22    #[cfg(target_arch = "wasm32")]
23    pub wasm: WasmConfig,
24}
25
26/// Storage engine configuration
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct StorageConfig {
29    /// Maximum SSTable file size in bytes (default: 64MB)
30    pub max_sstable_size: u64,
31
32    /// MemTable size threshold for flushing (default: 16MB)
33    pub memtable_size_threshold: u64,
34
35    /// Compaction configuration
36    pub compaction: CompactionConfig,
37
38    /// Block size for SSTable data blocks (default: 64KB)
39    pub block_size: u32,
40
41    /// Compression configuration
42    pub compression: CompressionConfig,
43
44    /// Enable bloom filters for SSTables
45    pub enable_bloom_filters: bool,
46
47    /// Bloom filter false positive rate (default: 0.01)
48    pub bloom_filter_fp_rate: f64,
49
50    /// Number of background threads for I/O operations
51    pub io_threads: usize,
52
53    /// Sync mode for durability
54    pub sync_mode: SyncMode,
55
56    /// Memory-map SSTable Data.db files instead of using buffered file I/O.
57    ///
58    /// **Opt-in.** Defaults to `false` (buffered I/O), which is portable and
59    /// safe on every filesystem. When enabled, the reader maps Data.db files at
60    /// or above [`Self::mmap_min_size_bytes`] into the process address space and
61    /// serves reads from the OS page cache with no per-block `read` syscall,
62    /// mirroring Cassandra's `disk_access_mode: mmap`. This speeds up repeated
63    /// local scans of the same files.
64    ///
65    /// # Safety / platform constraints
66    ///
67    /// A memory map aliases the file's bytes for the reader's lifetime. Only
68    /// enable this when the SSTables are **immutable local files**:
69    /// - Mutating, truncating, or deleting a mapped file out from under a live
70    ///   reader is undefined behaviour and can raise `SIGBUS`, terminating the
71    ///   process. CQLite never rewrites its own mapped inputs, but external
72    ///   tools must not either.
73    /// - Network and overlay filesystems (NFS, SMB, FUSE, some container
74    ///   overlays) can fault mid-read after a successful map; prefer buffered
75    ///   I/O there.
76    ///
77    /// # Interaction with the write engine (Issue #591)
78    ///
79    /// This setting only affects the read path. Compaction always reads its
80    /// input SSTables through buffered I/O regardless of `use_mmap`, and deletes
81    /// each input by removing its `TOC.txt` first (unpublishing it) before the
82    /// data components, best-effort. So enabling mmap for queries is safe
83    /// alongside background compaction: a compaction never holds a mapping over a
84    /// file it then deletes, and on Windows a data file still pinned by a mapped
85    /// reader becomes an invisible orphan (reclaimed on the next startup) rather
86    /// than a failed delete or a source of duplicate rows.
87    ///
88    /// Can also be enabled at runtime by setting `CQLITE_USE_MMAP=1`.
89    ///
90    /// `#[serde(default)]` keeps configs serialized before this field existed
91    /// (which omit it) deserializing successfully, defaulting to buffered I/O.
92    #[serde(default = "default_use_mmap")]
93    pub use_mmap: bool,
94
95    /// Minimum Data.db file size (bytes) before [`Self::use_mmap`] takes effect.
96    ///
97    /// Files smaller than this use buffered I/O even when `use_mmap` is set,
98    /// since the per-file mapping overhead is not worthwhile for tiny files and
99    /// mapping a zero-length file is invalid. Defaults to one page (4096).
100    ///
101    /// `#[serde(default)]` for backward compatibility with older payloads.
102    #[serde(default = "default_mmap_min_size_bytes")]
103    pub mmap_min_size_bytes: usize,
104
105    /// How the SSTable read path accesses Data.db on disk.
106    ///
107    /// Defaults to [`DiskAccessMode::Auto`], which sizes each Data.db file
108    /// against system RAM and picks the backend automatically:
109    /// - files below [`Self::mmap_min_size_bytes`] use buffered I/O (mapping a
110    ///   tiny file is not worth the setup cost);
111    /// - files up to [`Self::direct_io_memory_fraction`] of system memory are
112    ///   **memory-mapped**, so repeated scans stay resident in the page cache;
113    /// - files larger than that fraction use **direct I/O** (`O_DIRECT` on
114    ///   Linux, `F_NOCACHE` on macOS), which bypasses the page cache so a
115    ///   single huge scan does not evict everything else the host has cached.
116    ///
117    /// Set an explicit [`DiskAccessMode::Buffered`], [`DiskAccessMode::Mmap`],
118    /// or [`DiskAccessMode::Direct`] to override the heuristic. The legacy
119    /// [`Self::use_mmap`] flag still forces mmap when `Auto` would otherwise
120    /// pick buffered, for backward compatibility.
121    ///
122    /// Can also be set at runtime via `CQLITE_DISK_ACCESS_MODE`
123    /// (`auto` / `buffered` / `mmap` / `direct`).
124    #[serde(default)]
125    pub disk_access_mode: DiskAccessMode,
126
127    /// Fraction of total system memory above which [`DiskAccessMode::Auto`]
128    /// switches a file from memory-mapped to direct I/O. Defaults to `0.5`
129    /// (half of RAM). Clamped to `(0.0, 1.0]`; values outside that range fall
130    /// back to the default. Ignored when system memory cannot be determined
131    /// (in which case `Auto` never escalates to direct I/O).
132    #[serde(default = "default_direct_io_memory_fraction")]
133    pub direct_io_memory_fraction: f64,
134
135    /// Read-ahead / prefetch strategy applied to the chosen backend.
136    ///
137    /// Defaults to [`PrefetchMode::Auto`], which issues **no** mmap `madvise`
138    /// (relying on the kernel's default read-ahead) and only enables the
139    /// direct-I/O prefetch window of [`Self::direct_io_prefetch_bytes`]. Set
140    /// [`PrefetchMode::Off`] to disable explicit hints (relying only on default
141    /// kernel read-ahead / single-block direct reads). Can also be set via
142    /// `CQLITE_PREFETCH` (`off` / `sequential` / `willneed` / `auto`).
143    #[serde(default)]
144    pub prefetch: PrefetchMode,
145
146    /// Size in bytes of the read-ahead window used by the direct-I/O backend
147    /// (and the buffered backend's reader capacity hint). Rounded up to the
148    /// I/O alignment. Defaults to 1 MiB. Only takes effect when
149    /// [`Self::prefetch`] is not [`PrefetchMode::Off`].
150    #[serde(default = "default_direct_io_prefetch_bytes")]
151    pub direct_io_prefetch_bytes: usize,
152}
153
154/// Selects which backend the SSTable read path uses for Data.db I/O.
155///
156/// See [`StorageConfig::disk_access_mode`] for the per-variant semantics and
157/// the [`DiskAccessMode::Auto`] sizing heuristic.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
159#[serde(rename_all = "lowercase")]
160pub enum DiskAccessMode {
161    /// Size each file against system RAM and pick buffered / mmap / direct.
162    #[default]
163    Auto,
164    /// Always use buffered file I/O through the OS page cache.
165    Buffered,
166    /// Always memory-map the file. Unlike the [`DiskAccessMode::Auto`] heuristic,
167    /// this honors the user's explicit request and is **not** gated by
168    /// [`StorageConfig::mmap_min_size_bytes`] (the size threshold only steers
169    /// `Auto`); a zero-length file still falls back to buffered I/O since an
170    /// empty map is invalid.
171    Mmap,
172    /// Always use direct I/O, bypassing the OS page cache.
173    Direct,
174}
175
176/// Selects the read-ahead hint applied to the active disk-access backend.
177///
178/// See [`StorageConfig::prefetch`]. `Sequential` / `WillNeed` map to the
179/// corresponding `madvise(2)` advice on the mmap backend; on the direct-I/O
180/// backend any non-`Off` value enables the [`StorageConfig::direct_io_prefetch_bytes`]
181/// read-ahead window.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(rename_all = "lowercase")]
184pub enum PrefetchMode {
185    /// No explicit prefetch hint; rely on default kernel behaviour.
186    Off,
187    /// Hint sequential access (aggressive read-ahead, drop-behind).
188    Sequential,
189    /// Hint that the mapped/region bytes will be needed soon (eager fault-in).
190    WillNeed,
191    /// Let the backend choose. For mmap this issues **no** madvise and relies on
192    /// the kernel's default read-ahead: `MADV_SEQUENTIAL`'s drop-behind evicts
193    /// hot pages under concurrent write load and inflates the read-side p99 tail
194    /// (issue #1143), so `Auto` avoids it while keeping the isolated mmap win.
195    /// For direct I/O it enables the windowed read-ahead
196    /// ([`StorageConfig::direct_io_prefetch_bytes`]). Request
197    /// [`PrefetchMode::Sequential`] explicitly for `MADV_SEQUENTIAL` behaviour.
198    #[default]
199    Auto,
200}
201
202/// Default for [`StorageConfig::use_mmap`]: mmap is opt-in, so buffered I/O.
203fn default_use_mmap() -> bool {
204    false
205}
206
207/// Default for [`StorageConfig::mmap_min_size_bytes`]: one page.
208fn default_mmap_min_size_bytes() -> usize {
209    4096
210}
211
212/// Default for [`StorageConfig::direct_io_memory_fraction`]: half of RAM.
213fn default_direct_io_memory_fraction() -> f64 {
214    0.5
215}
216
217/// Default for [`StorageConfig::direct_io_prefetch_bytes`]: 1 MiB.
218fn default_direct_io_prefetch_bytes() -> usize {
219    1024 * 1024
220}
221
222impl Default for StorageConfig {
223    fn default() -> Self {
224        Self {
225            max_sstable_size: 64 * 1024 * 1024,        // 64MB
226            memtable_size_threshold: 16 * 1024 * 1024, // 16MB
227            compaction: CompactionConfig::default(),
228            block_size: 64 * 1024, // 64KB
229            compression: CompressionConfig::default(),
230            enable_bloom_filters: true,
231            bloom_filter_fp_rate: 0.01,
232            io_threads: num_cpus::get().min(4),
233            sync_mode: SyncMode::Normal,
234            // Opt-in; buffered I/O is the portable, safe default. Shared with
235            // the serde defaults so the two can never drift.
236            use_mmap: default_use_mmap(),
237            mmap_min_size_bytes: default_mmap_min_size_bytes(),
238            disk_access_mode: DiskAccessMode::default(),
239            direct_io_memory_fraction: default_direct_io_memory_fraction(),
240            prefetch: PrefetchMode::default(),
241            direct_io_prefetch_bytes: default_direct_io_prefetch_bytes(),
242        }
243    }
244}
245
246/// Compaction strategy configuration.
247///
248/// The write engine implements Size-Tiered Compaction Strategy (STCS) and
249/// installs it by default (issue #1619). `auto_compaction` is the authoritative
250/// on/off switch consumed by the write path via
251/// `WriteEngineConfig::with_compaction_config`. Previously this struct also
252/// carried `strategy`/`max_sstables`/`size_ratio`/`max_threads`/
253/// `background_interval` fields that were never read by any behavior; they were
254/// removed (issue #1619) rather than left decorative.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct CompactionConfig {
257    /// Enable automatic (STCS) compaction. When `false`, the write engine
258    /// installs no merge policy and `maintenance_step` is a no-op.
259    pub auto_compaction: bool,
260}
261
262impl Default for CompactionConfig {
263    fn default() -> Self {
264        Self {
265            auto_compaction: true,
266        }
267    }
268}
269
270/// Memory management configuration.
271///
272/// Collapsed to exactly one real caching knob (issue #1568, Epic B/B2): the
273/// block/chunk-cache byte budget (`block_cache.max_size`), wired as the B1
274/// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache)
275/// capacity. The former decorative `row_cache` / `query_cache` / `allocator`
276/// knobs (wired to nothing at runtime) were deleted. `deny_unknown_fields`
277/// makes a config that still names a removed knob **fail closed** on
278/// deserialization rather than silently ignoring it (which would suggest the
279/// removed knob still has effect).
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct MemoryConfig {
283    /// Maximum total memory usage (default: 1GB)
284    pub max_memory: u64,
285
286    /// Block/chunk cache configuration. `block_cache.max_size` is the real,
287    /// wired byte budget of the shared decompressed-chunk cache.
288    pub block_cache: CacheConfig,
289}
290
291impl Default for MemoryConfig {
292    fn default() -> Self {
293        let max_memory = 1024 * 1024 * 1024; // 1GB
294
295        Self {
296            max_memory,
297            block_cache: CacheConfig {
298                enabled: true,
299                max_size: max_memory / 4, // 256MB
300                policy: CachePolicy::Lru,
301            },
302        }
303    }
304}
305
306/// Cache configuration
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct CacheConfig {
309    /// Enable this cache
310    pub enabled: bool,
311
312    /// Maximum cache size in bytes
313    pub max_size: u64,
314
315    /// Cache eviction policy
316    pub policy: CachePolicy,
317}
318
319/// Cache eviction policy.
320///
321/// The shared decompressed-chunk cache is LRU (issue #1567/#1568). The
322/// never-selected `Lfu` / `Arc` variants were removed (Epic B/B2); a config
323/// naming them now fails to deserialize (unknown variant) rather than silently
324/// mapping to a default.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub enum CachePolicy {
327    /// Least Recently Used
328    Lru,
329}
330
331/// Default byte ceiling for a materialized SELECT result set (issue #1582).
332///
333/// 64 MiB. See [`QueryConfig::max_result_bytes`] for the derivation from the
334/// project's <128MB process memory target.
335pub const DEFAULT_MAX_RESULT_BYTES: u64 = 64 * 1024 * 1024;
336
337/// Serde default for [`QueryConfig::max_result_bytes`] (issue #1582).
338///
339/// Backward-compat: a `QueryConfig` serialized before this field existed (e.g.
340/// a Python JSON/dict config) has no `max_result_bytes` key. Without a serde
341/// default, deserialization fails with a missing-field error; with it, such a
342/// config takes the shipped [`DEFAULT_MAX_RESULT_BYTES`] budget.
343fn default_max_result_bytes() -> u64 {
344    DEFAULT_MAX_RESULT_BYTES
345}
346
347/// Serde default for [`QueryConfig::max_result_rows`] (issue #1582).
348///
349/// Backward-compat + robustness: a `QueryConfig` serialized without this key
350/// (or a partial JSON/dict config) still deserializes, taking the shipped
351/// 1,000,000-row secondary safety valve rather than failing with a missing
352/// field. Keeps the knob real (not decorative) and consistent with
353/// [`default_max_result_bytes`].
354fn default_max_result_rows() -> u64 {
355    1_000_000
356}
357
358/// Forced SELECT access path (issue #1918).
359///
360/// A **test/debug** control that removes doubt about which access path serves a
361/// `SELECT`. It never changes value decoding, tombstone/timestamp reconciliation,
362/// or WRITETIME/TTL semantics — it governs *routing only* — and is chosen
363/// exclusively from explicit operator config/env, never inferred from data bytes
364/// (no-heuristics mandate). Set programmatically via
365/// [`QueryConfig::forced_read_path`] or per-process via the `CQLITE_READ_PATH`
366/// environment variable (`auto|point|full`, case-insensitive), with config taking
367/// precedence over env. **Not a performance recommendation.**
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
369#[serde(rename_all = "lowercase")]
370pub enum ReadPathMode {
371    /// Today's behavior: the classifier chooses point-vs-full per query. An unset
372    /// knob is byte-for-byte this mode.
373    #[default]
374    Auto,
375    /// Force a genuinely partition-targeted lookup. **Fails closed** with
376    /// [`crate::Error::ForcedReadPathUnavailable`] whenever the executor would not
377    /// run a partition-targeted lookup — never a silent full scan.
378    Point,
379    /// Force the full-scan + reconciliation path regardless of classification,
380    /// recording [`crate::query::access_path::FallbackReason::ForcedFullScan`].
381    Full,
382}
383
384/// Query engine configuration
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct QueryConfig {
387    /// Maximum query execution time
388    pub max_execution_time: Duration,
389
390    /// Force the SELECT access-path decision (issue #1918).
391    ///
392    /// `None` (the default) leaves routing to the per-query classifier and the
393    /// `CQLITE_READ_PATH` env knob; `Some(mode)` forces that mode and takes
394    /// precedence over the env var. A **test/debug** control — see
395    /// [`ReadPathMode`]. `#[serde(default)]` keeps configs serialized before this
396    /// field existed deserializing successfully (absent = `None`).
397    #[serde(default)]
398    pub forced_read_path: Option<ReadPathMode>,
399
400    /// Maximum number of rows to return in a result set.
401    ///
402    /// A *secondary* safety valve, retained for defense-in-depth (issue #1582).
403    /// The primary guard on a materialized result is now `max_result_bytes`: a
404    /// row count is the wrong unit because 1M skinny rows can fit comfortably
405    /// while 100k wide rows blow the <128MB memory target. Still load-bearing:
406    /// the materializing SELECT path enforces this row-count ceiling alongside
407    /// the byte budget (lowering it makes a wide-row-count result trip even
408    /// under the byte budget), so it is a real knob, not decoration.
409    #[serde(default = "default_max_result_rows")]
410    pub max_result_rows: u64,
411
412    /// Byte ceiling on a MATERIALIZED result set (issue #1582 / D6).
413    ///
414    /// While the SELECT executor collects a materialized `Vec<QueryRow>`, it
415    /// tracks a running estimate of the result's logical size (via the shared
416    /// `crate::memory::estimate_value_size` estimator) and fails with
417    /// [`crate::Error::ResultTooLarge`] once this ceiling is crossed — telling
418    /// the caller to add a `LIMIT` or use the streaming API. This is the
419    /// correct-unit primary guard; `max_result_rows` remains as a secondary
420    /// valve. Streaming queries are bounded by their channel buffer, so this
421    /// budget does not apply to them.
422    ///
423    /// Default: [`DEFAULT_MAX_RESULT_BYTES`] (64 MiB). Chosen well below the
424    /// project's <128MB process memory target: the estimator measures *logical*
425    /// content bytes and does not count per-row container overhead
426    /// (`HashMap<Arc<str>, Value>` slots, `String`/`Vec` capacity slack, row
427    /// metadata), which in practice roughly doubles real heap use — so a 64 MiB
428    /// logical ceiling keeps a fully-materialized result comfortably inside the
429    /// process budget while leaving headroom for readers, caches, and decode
430    /// buffers.
431    #[serde(default = "default_max_result_bytes")]
432    pub max_result_bytes: u64,
433
434    /// Query plan cache size
435    pub plan_cache_size: usize,
436
437    /// Enable query optimization
438    pub enable_optimization: bool,
439
440    /// Parallel query execution configuration
441    pub parallel: ParallelQueryConfig,
442
443    /// Query cache size (for plan caching)
444    pub query_cache_size: Option<usize>,
445
446    /// Query parallelism thread count
447    pub query_parallelism: Option<usize>,
448
449    /// Number of iterations for query analysis
450    pub analyze_iterations: Option<usize>,
451}
452
453impl Default for QueryConfig {
454    fn default() -> Self {
455        Self {
456            max_execution_time: Duration::from_secs(300), // 5 minutes
457            forced_read_path: None,
458            max_result_rows: 1_000_000,
459            max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
460            plan_cache_size: 1000,
461            enable_optimization: true,
462            parallel: ParallelQueryConfig::default(),
463            query_cache_size: Some(100),
464            query_parallelism: Some(num_cpus::get()),
465            analyze_iterations: Some(5),
466        }
467    }
468}
469
470/// Parallel query execution configuration
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct ParallelQueryConfig {
473    /// Enable parallel query execution
474    pub enabled: bool,
475
476    /// Maximum number of parallel threads
477    pub max_threads: usize,
478
479    /// Minimum result set size to trigger parallel execution
480    pub min_parallel_rows: u64,
481}
482
483impl Default for ParallelQueryConfig {
484    fn default() -> Self {
485        Self {
486            enabled: true,
487            max_threads: num_cpus::get(),
488            min_parallel_rows: 10_000,
489        }
490    }
491}
492
493/// Performance and optimization configuration
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct PerformanceConfig {
496    /// Enable performance metrics collection
497    pub enable_metrics: bool,
498
499    /// Metrics collection interval
500    pub metrics_interval: Duration,
501
502    /// Enable detailed profiling
503    pub enable_profiling: bool,
504
505    /// Background task configuration
506    pub background_tasks: BackgroundTaskConfig,
507}
508
509impl Default for PerformanceConfig {
510    fn default() -> Self {
511        Self {
512            enable_metrics: true,
513            metrics_interval: Duration::from_secs(60),
514            enable_profiling: false,
515            background_tasks: BackgroundTaskConfig::default(),
516        }
517    }
518}
519
520/// Background task configuration
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct BackgroundTaskConfig {
523    /// Enable background statistics collection
524    pub enable_stats: bool,
525
526    /// Statistics collection interval
527    pub stats_interval: Duration,
528
529    /// Enable background cleanup tasks
530    pub enable_cleanup: bool,
531
532    /// Cleanup task interval
533    pub cleanup_interval: Duration,
534}
535
536impl Default for BackgroundTaskConfig {
537    fn default() -> Self {
538        Self {
539            enable_stats: true,
540            stats_interval: Duration::from_secs(300), // 5 minutes
541            enable_cleanup: true,
542            cleanup_interval: Duration::from_secs(3600), // 1 hour
543        }
544    }
545}
546
547/// WASM-specific configuration
548#[cfg(target_arch = "wasm32")]
549#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct WasmConfig {
551    /// Use IndexedDB for persistent storage
552    pub use_indexeddb: bool,
553
554    /// Maximum memory usage in WASM (default: 256MB)
555    pub max_memory: u64,
556
557    /// Enable WASM SIMD optimizations
558    pub enable_simd: bool,
559
560    /// Enable Web Workers for background tasks
561    pub enable_workers: bool,
562
563    /// Maximum number of Web Workers
564    pub max_workers: usize,
565}
566
567#[cfg(target_arch = "wasm32")]
568impl Default for WasmConfig {
569    fn default() -> Self {
570        Self {
571            use_indexeddb: true,
572            max_memory: 256 * 1024 * 1024, // 256MB
573            enable_simd: true,
574            enable_workers: true,
575            max_workers: 4,
576        }
577    }
578}
579
580/// Compression algorithms
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub enum CompressionAlgorithm {
583    /// No compression
584    None,
585    /// LZ4 compression (fast)
586    Lz4,
587    /// Snappy compression (balanced)
588    Snappy,
589    /// Deflate compression (good compression ratio)
590    Deflate,
591    /// ZSTD compression (high compression ratio)
592    Zstd,
593}
594
595/// Compression configuration
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct CompressionConfig {
598    /// Enable compression
599    pub enabled: bool,
600
601    /// Compression algorithm to use
602    pub algorithm: CompressionAlgorithm,
603
604    /// Compression level (algorithm-specific)
605    pub level: i32,
606
607    /// Minimum block size to compress (smaller blocks are stored uncompressed)
608    pub min_block_size: u32,
609}
610
611impl Default for CompressionConfig {
612    fn default() -> Self {
613        Self {
614            enabled: true,
615            algorithm: CompressionAlgorithm::Lz4,
616            level: 1,             // Fast compression
617            min_block_size: 1024, // 1KB minimum
618        }
619    }
620}
621
622/// Durability sync modes
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub enum SyncMode {
625    /// No explicit syncing (fastest, least durable)
626    None,
627    /// Normal syncing (balanced)
628    Normal,
629    /// Full sync for every write (slowest, most durable)
630    Full,
631}
632
633impl Config {
634    /// Create a configuration optimized for memory usage
635    pub fn memory_optimized() -> Self {
636        let mut config = Self::default();
637
638        // Reduce memory usage
639        config.storage.memtable_size_threshold = 4 * 1024 * 1024; // 4MB
640        config.storage.max_sstable_size = 16 * 1024 * 1024; // 16MB
641        config.memory.max_memory = 256 * 1024 * 1024; // 256MB
642        config.memory.block_cache.max_size = 64 * 1024 * 1024; // 64MB
643
644        // Enable aggressive compression
645        config.storage.compression.algorithm = CompressionAlgorithm::Zstd;
646        config.storage.compression.enabled = true;
647
648        config
649    }
650
651    /// Create a configuration optimized for performance
652    pub fn performance_optimized() -> Self {
653        let mut config = Self::default();
654
655        // Increase memory usage for better performance
656        config.storage.memtable_size_threshold = 64 * 1024 * 1024; // 64MB
657        config.storage.max_sstable_size = 256 * 1024 * 1024; // 256MB
658        config.memory.max_memory = 4 * 1024 * 1024 * 1024; // 4GB
659
660        // Use faster compression
661        config.storage.compression.algorithm = CompressionAlgorithm::Lz4;
662        config.storage.compression.enabled = true;
663
664        // More aggressive caching
665        config.memory.block_cache.max_size = 1024 * 1024 * 1024; // 1GB
666
667        // More I/O threads
668        config.storage.io_threads = num_cpus::get();
669
670        config
671    }
672
673    /// Create a configuration optimized for WASM deployment
674    #[cfg(target_arch = "wasm32")]
675    pub fn wasm_optimized() -> Self {
676        let mut config = Self::memory_optimized();
677
678        // WASM-specific optimizations
679        config.wasm.max_memory = 128 * 1024 * 1024; // 128MB
680        config.wasm.enable_simd = true;
681        config.wasm.enable_workers = false; // Conservative default
682
683        // Reduce overall memory usage for WASM
684        config.memory.max_memory = 128 * 1024 * 1024; // 128MB
685        config.storage.memtable_size_threshold = 2 * 1024 * 1024; // 2MB
686        config.storage.max_sstable_size = 8 * 1024 * 1024; // 8MB
687
688        // Disable background tasks that may not work well in WASM
689        config.storage.compaction.auto_compaction = false;
690        config.performance.background_tasks.enable_stats = false;
691        config.performance.background_tasks.enable_cleanup = false;
692
693        config
694    }
695
696    /// Create a test-optimized configuration
697    #[cfg(test)]
698    pub fn test_config() -> Self {
699        let mut config = Config::default();
700
701        // Disable background tasks that can cause test hangs
702        config.storage.compaction.auto_compaction = false;
703        config.performance.background_tasks.enable_stats = false;
704        config.performance.background_tasks.enable_cleanup = false;
705
706        // Reduce timeouts for faster test execution
707        config.query.max_execution_time = std::time::Duration::from_secs(1);
708
709        // Smaller memory usage for tests
710        config.memory.max_memory = 64 * 1024 * 1024; // 64MB
711        config.storage.memtable_size_threshold = 1024 * 1024; // 1MB
712        config.storage.max_sstable_size = 4 * 1024 * 1024; // 4MB
713
714        config
715    }
716
717    /// Validate the configuration
718    pub fn validate(&self) -> crate::Result<()> {
719        // Validate memory limits
720        if self.memory.max_memory == 0 {
721            return Err(crate::Error::configuration(
722                "max_memory must be greater than 0",
723            ));
724        }
725
726        // Validate the (single) cache budget does not exceed total memory.
727        if self.memory.block_cache.max_size > self.memory.max_memory {
728            return Err(crate::Error::configuration(
729                "block_cache.max_size exceeds max_memory",
730            ));
731        }
732
733        // Validate storage settings
734        if self.storage.block_size == 0 {
735            return Err(crate::Error::configuration(
736                "block_size must be greater than 0",
737            ));
738        }
739
740        if self.storage.memtable_size_threshold == 0 {
741            return Err(crate::Error::configuration(
742                "memtable_size_threshold must be greater than 0",
743            ));
744        }
745
746        // Validate bloom filter settings
747        if self.storage.enable_bloom_filters
748            && (self.storage.bloom_filter_fp_rate <= 0.0
749                || self.storage.bloom_filter_fp_rate >= 1.0)
750        {
751            return Err(crate::Error::configuration(
752                "bloom_filter_fp_rate must be between 0 and 1",
753            ));
754        }
755
756        Ok(())
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    #[test]
765    fn test_default_config() {
766        let config = Config::default();
767        assert!(config.storage.compression.enabled);
768        assert!(config.storage.enable_bloom_filters);
769        assert!(config.memory.block_cache.enabled);
770    }
771
772    #[test]
773    fn test_memory_optimized_config() {
774        let config = Config::memory_optimized();
775        assert!(
776            config.storage.memtable_size_threshold
777                < Config::default().storage.memtable_size_threshold
778        );
779        assert!(config.memory.max_memory < Config::default().memory.max_memory);
780    }
781
782    #[test]
783    fn test_performance_optimized_config() {
784        let config = Config::performance_optimized();
785        assert!(
786            config.storage.memtable_size_threshold
787                > Config::default().storage.memtable_size_threshold
788        );
789        assert!(config.memory.max_memory > Config::default().memory.max_memory);
790    }
791
792    /// Issue #1582: a `QueryConfig` serialized BEFORE the byte-budget fields
793    /// existed (e.g. a pre-upgrade Python JSON/dict config) has no
794    /// `max_result_bytes`/`max_result_rows` keys. The `#[serde(default = ...)]`
795    /// on both fields must let it deserialize, taking the shipped defaults rather
796    /// than failing with a missing-field error.
797    #[test]
798    fn budget_fields_deserialize_with_serde_default_when_absent() {
799        // Serialize a default QueryConfig, then STRIP both budget fields to
800        // emulate an old serialized config that predates them.
801        let mut value =
802            serde_json::to_value(QueryConfig::default()).expect("serialize QueryConfig");
803        let obj = value
804            .as_object_mut()
805            .expect("QueryConfig serializes as object");
806        obj.remove("max_result_bytes");
807        obj.remove("max_result_rows");
808        assert!(
809            !obj.contains_key("max_result_bytes") && !obj.contains_key("max_result_rows"),
810            "both fields must be absent for this regression to be meaningful"
811        );
812
813        let restored: QueryConfig =
814            serde_json::from_value(value).expect("old config (no budget fields) must deserialize");
815        assert_eq!(
816            restored.max_result_bytes, DEFAULT_MAX_RESULT_BYTES,
817            "absent max_result_bytes must take the serde default"
818        );
819        assert_eq!(
820            restored.max_result_rows,
821            default_max_result_rows(),
822            "absent max_result_rows must take the serde default"
823        );
824    }
825
826    #[test]
827    fn test_config_validation() {
828        let mut config = Config::default();
829        assert!(config.validate().is_ok());
830
831        // Test invalid max_memory
832        config.memory.max_memory = 0;
833        assert!(config.validate().is_err());
834
835        // Reset and test invalid cache sizes
836        config = Config::default();
837        config.memory.block_cache.max_size = config.memory.max_memory + 1;
838        assert!(config.validate().is_err());
839    }
840
841    /// Issue #1918: `forced_read_path` defaults to `None` (auto), round-trips its
842    /// lowercase encoding, and a config serialized before the field existed still
843    /// deserializes (absent → `None`).
844    #[test]
845    fn forced_read_path_defaults_absent_and_roundtrips() {
846        // Default is None (auto).
847        assert_eq!(QueryConfig::default().forced_read_path, None);
848
849        // A config predating the field (key absent) deserializes to None.
850        let mut value = serde_json::to_value(QueryConfig::default()).unwrap();
851        value
852            .as_object_mut()
853            .unwrap()
854            .remove("forced_read_path")
855            .expect("field present when serialized");
856        let restored: QueryConfig = serde_json::from_value(value).unwrap();
857        assert_eq!(restored.forced_read_path, None);
858
859        // Explicit values round-trip via the lowercase serde encoding.
860        for (mode, tag) in [
861            (ReadPathMode::Point, "point"),
862            (ReadPathMode::Full, "full"),
863            (ReadPathMode::Auto, "auto"),
864        ] {
865            let mut cfg = QueryConfig::default();
866            cfg.forced_read_path = Some(mode);
867            let json = serde_json::to_string(&cfg).unwrap();
868            assert!(
869                json.contains(tag),
870                "mode {mode:?} must serialize as {tag:?}: {json}"
871            );
872            let restored: QueryConfig = serde_json::from_str(&json).unwrap();
873            assert_eq!(restored.forced_read_path, Some(mode));
874        }
875    }
876
877    #[test]
878    fn test_storage_validation_errors() {
879        let mut config = Config::default();
880
881        // Test invalid block_size (should trigger line 573-574)
882        config.storage.block_size = 0;
883        let result = config.validate();
884        assert!(result.is_err());
885        assert!(result
886            .unwrap_err()
887            .to_string()
888            .contains("block_size must be greater than 0"));
889
890        // Reset and test invalid memtable_size_threshold (should trigger line 579-580)
891        config = Config::default();
892        config.storage.memtable_size_threshold = 0;
893        let result = config.validate();
894        assert!(result.is_err());
895        assert!(result
896            .unwrap_err()
897            .to_string()
898            .contains("memtable_size_threshold must be greater than 0"));
899
900        // Reset and test invalid bloom filter false positive rate (should trigger line 589-590)
901        config = Config::default();
902        config.storage.enable_bloom_filters = true;
903        config.storage.bloom_filter_fp_rate = 0.0; // Invalid: exactly 0
904        let result = config.validate();
905        assert!(result.is_err());
906        assert!(result
907            .unwrap_err()
908            .to_string()
909            .contains("bloom_filter_fp_rate must be between 0 and 1"));
910
911        // Test another invalid bloom filter false positive rate
912        config.storage.bloom_filter_fp_rate = 1.0; // Invalid: exactly 1
913        let result = config.validate();
914        assert!(result.is_err());
915
916        // Test bloom filter rate above 1
917        config.storage.bloom_filter_fp_rate = 1.5; // Invalid: greater than 1
918        let result = config.validate();
919        assert!(result.is_err());
920
921        // Test bloom filter rate below 0
922        config.storage.bloom_filter_fp_rate = -0.1; // Invalid: less than 0
923        let result = config.validate();
924        assert!(result.is_err());
925    }
926
927    #[test]
928    fn test_valid_bloom_filter_config() {
929        let mut config = Config::default();
930        config.storage.enable_bloom_filters = true;
931        config.storage.bloom_filter_fp_rate = 0.01; // Valid rate
932        assert!(config.validate().is_ok());
933
934        config.storage.bloom_filter_fp_rate = 0.5; // Valid rate
935        assert!(config.validate().is_ok());
936
937        config.storage.bloom_filter_fp_rate = 0.99; // Valid rate
938        assert!(config.validate().is_ok());
939    }
940
941    #[test]
942    fn test_storage_config_deserializes_without_mmap_fields() {
943        // Backward compatibility: a config payload serialized before the mmap
944        // fields existed omits `use_mmap` / `mmap_min_size_bytes`. It must still
945        // deserialize, defaulting to the safe buffered backend.
946        let mut value = serde_json::to_value(StorageConfig::default()).unwrap();
947        let obj = value.as_object_mut().unwrap();
948        obj.remove("use_mmap");
949        obj.remove("mmap_min_size_bytes");
950        assert!(!obj.contains_key("use_mmap"));
951
952        let restored: StorageConfig =
953            serde_json::from_value(value).expect("old payload must still deserialize");
954        assert!(!restored.use_mmap, "missing use_mmap must default to false");
955        assert_eq!(
956            restored.mmap_min_size_bytes, 4096,
957            "missing mmap_min_size_bytes must default to one page"
958        );
959    }
960
961    #[test]
962    fn test_full_config_deserializes_without_mmap_fields() {
963        // Same guarantee through the top-level Config, mirroring how the Python
964        // bindings parse a JSON/dict payload into `cqlite_core::Config`.
965        let mut value = serde_json::to_value(Config::default()).unwrap();
966        let storage = value
967            .get_mut("storage")
968            .and_then(|s| s.as_object_mut())
969            .unwrap();
970        storage.remove("use_mmap");
971        storage.remove("mmap_min_size_bytes");
972
973        let restored: Config =
974            serde_json::from_value(value).expect("old Config payload must still deserialize");
975        assert!(!restored.storage.use_mmap);
976        assert_eq!(restored.storage.mmap_min_size_bytes, 4096);
977        restored.validate().expect("restored config must validate");
978    }
979
980    #[test]
981    fn test_mmap_fields_roundtrip_when_present() {
982        // When the fields ARE present (e.g. a user opting in), they round-trip.
983        let mut config = StorageConfig::default();
984        config.use_mmap = true;
985        config.mmap_min_size_bytes = 8192;
986        let json = serde_json::to_string(&config).unwrap();
987        let restored: StorageConfig = serde_json::from_str(&json).unwrap();
988        assert!(restored.use_mmap);
989        assert_eq!(restored.mmap_min_size_bytes, 8192);
990    }
991
992    #[test]
993    fn test_disk_access_defaults() {
994        // The new fields default to the size-aware Auto backend with Auto
995        // prefetch, a half-RAM direct-I/O threshold, and a 1 MiB window.
996        let config = StorageConfig::default();
997        assert_eq!(config.disk_access_mode, DiskAccessMode::Auto);
998        assert_eq!(config.prefetch, PrefetchMode::Auto);
999        assert_eq!(config.direct_io_memory_fraction, 0.5);
1000        assert_eq!(config.direct_io_prefetch_bytes, 1024 * 1024);
1001    }
1002
1003    #[test]
1004    fn test_storage_config_deserializes_without_disk_access_fields() {
1005        // Backward compatibility: a payload predating the disk-access fields must
1006        // still deserialize, defaulting to Auto / Auto / 0.5 / 1 MiB.
1007        let mut value = serde_json::to_value(StorageConfig::default()).unwrap();
1008        let obj = value.as_object_mut().unwrap();
1009        for key in [
1010            "disk_access_mode",
1011            "direct_io_memory_fraction",
1012            "prefetch",
1013            "direct_io_prefetch_bytes",
1014        ] {
1015            obj.remove(key);
1016        }
1017
1018        let restored: StorageConfig =
1019            serde_json::from_value(value).expect("old payload must still deserialize");
1020        assert_eq!(restored.disk_access_mode, DiskAccessMode::Auto);
1021        assert_eq!(restored.prefetch, PrefetchMode::Auto);
1022        assert_eq!(restored.direct_io_memory_fraction, 0.5);
1023        assert_eq!(restored.direct_io_prefetch_bytes, 1024 * 1024);
1024    }
1025
1026    #[test]
1027    fn test_disk_access_fields_roundtrip() {
1028        // Explicit selections round-trip, including the lowercase enum encoding.
1029        let mut config = StorageConfig::default();
1030        config.disk_access_mode = DiskAccessMode::Direct;
1031        config.prefetch = PrefetchMode::WillNeed;
1032        config.direct_io_memory_fraction = 0.25;
1033        config.direct_io_prefetch_bytes = 2 * 1024 * 1024;
1034        let json = serde_json::to_string(&config).unwrap();
1035        assert!(json.contains("\"direct\""), "enum must serialize lowercase");
1036        assert!(json.contains("\"willneed\""));
1037        let restored: StorageConfig = serde_json::from_str(&json).unwrap();
1038        assert_eq!(restored.disk_access_mode, DiskAccessMode::Direct);
1039        assert_eq!(restored.prefetch, PrefetchMode::WillNeed);
1040        assert_eq!(restored.direct_io_memory_fraction, 0.25);
1041        assert_eq!(restored.direct_io_prefetch_bytes, 2 * 1024 * 1024);
1042    }
1043
1044    #[test]
1045    fn test_bloom_filter_disabled() {
1046        let mut config = Config::default();
1047        config.storage.enable_bloom_filters = false;
1048        config.storage.bloom_filter_fp_rate = 0.0; // Should be ignored when bloom filters disabled
1049        assert!(config.validate().is_ok());
1050
1051        config.storage.bloom_filter_fp_rate = 1.0; // Should be ignored when bloom filters disabled
1052        assert!(config.validate().is_ok());
1053
1054        config.storage.bloom_filter_fp_rate = -1.0; // Should be ignored when bloom filters disabled
1055        assert!(config.validate().is_ok());
1056    }
1057
1058    // ---- issue #1568 (Epic B / B2): dead-cache config collapse ----
1059
1060    /// Spec: "A config using a removed knob is rejected." The pre-change
1061    /// `MemoryConfig` shape (`row_cache` / `query_cache` / `allocator` alongside
1062    /// `block_cache`) must FAIL CLOSED after the collapse — those keys are gone,
1063    /// and `#[serde(deny_unknown_fields)]` rejects them instead of silently
1064    /// ignoring them (which would suggest they still have effect).
1065    #[test]
1066    fn removed_memory_knobs_fail_closed() {
1067        // The full OLD memory-config shape: valid on pre-change code (RED),
1068        // rejected after the collapse (GREEN).
1069        let old_shape = serde_json::json!({
1070            "max_memory": 1_073_741_824u64,
1071            "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
1072            "row_cache":   { "enabled": true, "max_size": 134_217_728u64, "policy": "Lru" },
1073            "query_cache": { "enabled": true, "max_size": 67_108_864u64,  "policy": "Lru" },
1074            "allocator":   { "use_custom": false, "small_pool_size": 1u64, "large_pool_size": 2u64 }
1075        });
1076        assert!(
1077            serde_json::from_value::<MemoryConfig>(old_shape).is_err(),
1078            "a MemoryConfig naming removed knobs (row_cache/query_cache/allocator) must fail closed"
1079        );
1080
1081        // Each removed knob, added to the retained-only base, is rejected.
1082        for removed in ["row_cache", "query_cache", "allocator"] {
1083            let mut v = serde_json::json!({
1084                "max_memory": 1_073_741_824u64,
1085                "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
1086            });
1087            v.as_object_mut()
1088                .unwrap()
1089                .insert(removed.to_string(), serde_json::json!({ "enabled": true }));
1090            assert!(
1091                serde_json::from_value::<MemoryConfig>(v).is_err(),
1092                "a MemoryConfig naming the removed `{removed}` knob must fail closed"
1093            );
1094        }
1095    }
1096
1097    /// Spec: "A config using a removed knob is rejected" (CachePolicy variants).
1098    /// The never-selected `Lfu` / `Arc` variants are gone, so a cache config
1099    /// naming them fails to deserialize (unknown variant) rather than silently
1100    /// mapping to some default.
1101    #[test]
1102    fn removed_cache_policy_variants_fail_closed() {
1103        for variant in ["Lfu", "Arc"] {
1104            let v = serde_json::json!({ "enabled": true, "max_size": 1u64, "policy": variant });
1105            assert!(
1106                serde_json::from_value::<CacheConfig>(v).is_err(),
1107                "a CacheConfig naming the removed CachePolicy::{variant} variant must fail closed"
1108            );
1109        }
1110        // The retained `Lru` variant still deserializes.
1111        let ok = serde_json::json!({ "enabled": true, "max_size": 1u64, "policy": "Lru" });
1112        assert!(serde_json::from_value::<CacheConfig>(ok).is_ok());
1113    }
1114
1115    /// Spec: "The retained budget knob still deserializes and validates." A
1116    /// config specifying only `max_memory` and `block_cache` deserializes, passes
1117    /// `Config::validate()`, and its `block_cache.max_size` is the retained knob.
1118    #[test]
1119    fn retained_budget_knob_deserializes_and_validates() {
1120        let mem: MemoryConfig = serde_json::from_value(serde_json::json!({
1121            "max_memory": 1_073_741_824u64,
1122            "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
1123        }))
1124        .expect("retained-only MemoryConfig must deserialize");
1125        assert_eq!(mem.block_cache.max_size, 268_435_456);
1126
1127        // A default Config (which now carries only the collapsed MemoryConfig)
1128        // still validates, and the block-cache budget is the wired knob.
1129        let config = Config::default();
1130        assert!(config.validate().is_ok());
1131        assert!(config.memory.block_cache.max_size > 0);
1132    }
1133}