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/// Query engine configuration
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct QueryConfig {
361    /// Maximum query execution time
362    pub max_execution_time: Duration,
363
364    /// Maximum number of rows to return in a result set.
365    ///
366    /// A *secondary* safety valve, retained for defense-in-depth (issue #1582).
367    /// The primary guard on a materialized result is now `max_result_bytes`: a
368    /// row count is the wrong unit because 1M skinny rows can fit comfortably
369    /// while 100k wide rows blow the <128MB memory target. Still load-bearing:
370    /// the materializing SELECT path enforces this row-count ceiling alongside
371    /// the byte budget (lowering it makes a wide-row-count result trip even
372    /// under the byte budget), so it is a real knob, not decoration.
373    #[serde(default = "default_max_result_rows")]
374    pub max_result_rows: u64,
375
376    /// Byte ceiling on a MATERIALIZED result set (issue #1582 / D6).
377    ///
378    /// While the SELECT executor collects a materialized `Vec<QueryRow>`, it
379    /// tracks a running estimate of the result's logical size (via the shared
380    /// `crate::memory::estimate_value_size` estimator) and fails with
381    /// [`crate::Error::ResultTooLarge`] once this ceiling is crossed — telling
382    /// the caller to add a `LIMIT` or use the streaming API. This is the
383    /// correct-unit primary guard; `max_result_rows` remains as a secondary
384    /// valve. Streaming queries are bounded by their channel buffer, so this
385    /// budget does not apply to them.
386    ///
387    /// Default: [`DEFAULT_MAX_RESULT_BYTES`] (64 MiB). Chosen well below the
388    /// project's <128MB process memory target: the estimator measures *logical*
389    /// content bytes and does not count per-row container overhead
390    /// (`HashMap<Arc<str>, Value>` slots, `String`/`Vec` capacity slack, row
391    /// metadata), which in practice roughly doubles real heap use — so a 64 MiB
392    /// logical ceiling keeps a fully-materialized result comfortably inside the
393    /// process budget while leaving headroom for readers, caches, and decode
394    /// buffers.
395    #[serde(default = "default_max_result_bytes")]
396    pub max_result_bytes: u64,
397
398    /// Query plan cache size
399    pub plan_cache_size: usize,
400
401    /// Enable query optimization
402    pub enable_optimization: bool,
403
404    /// Parallel query execution configuration
405    pub parallel: ParallelQueryConfig,
406
407    /// Query cache size (for plan caching)
408    pub query_cache_size: Option<usize>,
409
410    /// Query parallelism thread count
411    pub query_parallelism: Option<usize>,
412
413    /// Number of iterations for query analysis
414    pub analyze_iterations: Option<usize>,
415}
416
417impl Default for QueryConfig {
418    fn default() -> Self {
419        Self {
420            max_execution_time: Duration::from_secs(300), // 5 minutes
421            max_result_rows: 1_000_000,
422            max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
423            plan_cache_size: 1000,
424            enable_optimization: true,
425            parallel: ParallelQueryConfig::default(),
426            query_cache_size: Some(100),
427            query_parallelism: Some(num_cpus::get()),
428            analyze_iterations: Some(5),
429        }
430    }
431}
432
433/// Parallel query execution configuration
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct ParallelQueryConfig {
436    /// Enable parallel query execution
437    pub enabled: bool,
438
439    /// Maximum number of parallel threads
440    pub max_threads: usize,
441
442    /// Minimum result set size to trigger parallel execution
443    pub min_parallel_rows: u64,
444}
445
446impl Default for ParallelQueryConfig {
447    fn default() -> Self {
448        Self {
449            enabled: true,
450            max_threads: num_cpus::get(),
451            min_parallel_rows: 10_000,
452        }
453    }
454}
455
456/// Performance and optimization configuration
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct PerformanceConfig {
459    /// Enable performance metrics collection
460    pub enable_metrics: bool,
461
462    /// Metrics collection interval
463    pub metrics_interval: Duration,
464
465    /// Enable detailed profiling
466    pub enable_profiling: bool,
467
468    /// Background task configuration
469    pub background_tasks: BackgroundTaskConfig,
470}
471
472impl Default for PerformanceConfig {
473    fn default() -> Self {
474        Self {
475            enable_metrics: true,
476            metrics_interval: Duration::from_secs(60),
477            enable_profiling: false,
478            background_tasks: BackgroundTaskConfig::default(),
479        }
480    }
481}
482
483/// Background task configuration
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct BackgroundTaskConfig {
486    /// Enable background statistics collection
487    pub enable_stats: bool,
488
489    /// Statistics collection interval
490    pub stats_interval: Duration,
491
492    /// Enable background cleanup tasks
493    pub enable_cleanup: bool,
494
495    /// Cleanup task interval
496    pub cleanup_interval: Duration,
497}
498
499impl Default for BackgroundTaskConfig {
500    fn default() -> Self {
501        Self {
502            enable_stats: true,
503            stats_interval: Duration::from_secs(300), // 5 minutes
504            enable_cleanup: true,
505            cleanup_interval: Duration::from_secs(3600), // 1 hour
506        }
507    }
508}
509
510/// WASM-specific configuration
511#[cfg(target_arch = "wasm32")]
512#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct WasmConfig {
514    /// Use IndexedDB for persistent storage
515    pub use_indexeddb: bool,
516
517    /// Maximum memory usage in WASM (default: 256MB)
518    pub max_memory: u64,
519
520    /// Enable WASM SIMD optimizations
521    pub enable_simd: bool,
522
523    /// Enable Web Workers for background tasks
524    pub enable_workers: bool,
525
526    /// Maximum number of Web Workers
527    pub max_workers: usize,
528}
529
530#[cfg(target_arch = "wasm32")]
531impl Default for WasmConfig {
532    fn default() -> Self {
533        Self {
534            use_indexeddb: true,
535            max_memory: 256 * 1024 * 1024, // 256MB
536            enable_simd: true,
537            enable_workers: true,
538            max_workers: 4,
539        }
540    }
541}
542
543/// Compression algorithms
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub enum CompressionAlgorithm {
546    /// No compression
547    None,
548    /// LZ4 compression (fast)
549    Lz4,
550    /// Snappy compression (balanced)
551    Snappy,
552    /// Deflate compression (good compression ratio)
553    Deflate,
554    /// ZSTD compression (high compression ratio)
555    Zstd,
556}
557
558/// Compression configuration
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct CompressionConfig {
561    /// Enable compression
562    pub enabled: bool,
563
564    /// Compression algorithm to use
565    pub algorithm: CompressionAlgorithm,
566
567    /// Compression level (algorithm-specific)
568    pub level: i32,
569
570    /// Minimum block size to compress (smaller blocks are stored uncompressed)
571    pub min_block_size: u32,
572}
573
574impl Default for CompressionConfig {
575    fn default() -> Self {
576        Self {
577            enabled: true,
578            algorithm: CompressionAlgorithm::Lz4,
579            level: 1,             // Fast compression
580            min_block_size: 1024, // 1KB minimum
581        }
582    }
583}
584
585/// Durability sync modes
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub enum SyncMode {
588    /// No explicit syncing (fastest, least durable)
589    None,
590    /// Normal syncing (balanced)
591    Normal,
592    /// Full sync for every write (slowest, most durable)
593    Full,
594}
595
596impl Config {
597    /// Create a configuration optimized for memory usage
598    pub fn memory_optimized() -> Self {
599        let mut config = Self::default();
600
601        // Reduce memory usage
602        config.storage.memtable_size_threshold = 4 * 1024 * 1024; // 4MB
603        config.storage.max_sstable_size = 16 * 1024 * 1024; // 16MB
604        config.memory.max_memory = 256 * 1024 * 1024; // 256MB
605        config.memory.block_cache.max_size = 64 * 1024 * 1024; // 64MB
606
607        // Enable aggressive compression
608        config.storage.compression.algorithm = CompressionAlgorithm::Zstd;
609        config.storage.compression.enabled = true;
610
611        config
612    }
613
614    /// Create a configuration optimized for performance
615    pub fn performance_optimized() -> Self {
616        let mut config = Self::default();
617
618        // Increase memory usage for better performance
619        config.storage.memtable_size_threshold = 64 * 1024 * 1024; // 64MB
620        config.storage.max_sstable_size = 256 * 1024 * 1024; // 256MB
621        config.memory.max_memory = 4 * 1024 * 1024 * 1024; // 4GB
622
623        // Use faster compression
624        config.storage.compression.algorithm = CompressionAlgorithm::Lz4;
625        config.storage.compression.enabled = true;
626
627        // More aggressive caching
628        config.memory.block_cache.max_size = 1024 * 1024 * 1024; // 1GB
629
630        // More I/O threads
631        config.storage.io_threads = num_cpus::get();
632
633        config
634    }
635
636    /// Create a configuration optimized for WASM deployment
637    #[cfg(target_arch = "wasm32")]
638    pub fn wasm_optimized() -> Self {
639        let mut config = Self::memory_optimized();
640
641        // WASM-specific optimizations
642        config.wasm.max_memory = 128 * 1024 * 1024; // 128MB
643        config.wasm.enable_simd = true;
644        config.wasm.enable_workers = false; // Conservative default
645
646        // Reduce overall memory usage for WASM
647        config.memory.max_memory = 128 * 1024 * 1024; // 128MB
648        config.storage.memtable_size_threshold = 2 * 1024 * 1024; // 2MB
649        config.storage.max_sstable_size = 8 * 1024 * 1024; // 8MB
650
651        // Disable background tasks that may not work well in WASM
652        config.storage.compaction.auto_compaction = false;
653        config.performance.background_tasks.enable_stats = false;
654        config.performance.background_tasks.enable_cleanup = false;
655
656        config
657    }
658
659    /// Create a test-optimized configuration
660    #[cfg(test)]
661    pub fn test_config() -> Self {
662        let mut config = Config::default();
663
664        // Disable background tasks that can cause test hangs
665        config.storage.compaction.auto_compaction = false;
666        config.performance.background_tasks.enable_stats = false;
667        config.performance.background_tasks.enable_cleanup = false;
668
669        // Reduce timeouts for faster test execution
670        config.query.max_execution_time = std::time::Duration::from_secs(1);
671
672        // Smaller memory usage for tests
673        config.memory.max_memory = 64 * 1024 * 1024; // 64MB
674        config.storage.memtable_size_threshold = 1024 * 1024; // 1MB
675        config.storage.max_sstable_size = 4 * 1024 * 1024; // 4MB
676
677        config
678    }
679
680    /// Validate the configuration
681    pub fn validate(&self) -> crate::Result<()> {
682        // Validate memory limits
683        if self.memory.max_memory == 0 {
684            return Err(crate::Error::configuration(
685                "max_memory must be greater than 0",
686            ));
687        }
688
689        // Validate the (single) cache budget does not exceed total memory.
690        if self.memory.block_cache.max_size > self.memory.max_memory {
691            return Err(crate::Error::configuration(
692                "block_cache.max_size exceeds max_memory",
693            ));
694        }
695
696        // Validate storage settings
697        if self.storage.block_size == 0 {
698            return Err(crate::Error::configuration(
699                "block_size must be greater than 0",
700            ));
701        }
702
703        if self.storage.memtable_size_threshold == 0 {
704            return Err(crate::Error::configuration(
705                "memtable_size_threshold must be greater than 0",
706            ));
707        }
708
709        // Validate bloom filter settings
710        if self.storage.enable_bloom_filters
711            && (self.storage.bloom_filter_fp_rate <= 0.0
712                || self.storage.bloom_filter_fp_rate >= 1.0)
713        {
714            return Err(crate::Error::configuration(
715                "bloom_filter_fp_rate must be between 0 and 1",
716            ));
717        }
718
719        Ok(())
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn test_default_config() {
729        let config = Config::default();
730        assert!(config.storage.compression.enabled);
731        assert!(config.storage.enable_bloom_filters);
732        assert!(config.memory.block_cache.enabled);
733    }
734
735    #[test]
736    fn test_memory_optimized_config() {
737        let config = Config::memory_optimized();
738        assert!(
739            config.storage.memtable_size_threshold
740                < Config::default().storage.memtable_size_threshold
741        );
742        assert!(config.memory.max_memory < Config::default().memory.max_memory);
743    }
744
745    #[test]
746    fn test_performance_optimized_config() {
747        let config = Config::performance_optimized();
748        assert!(
749            config.storage.memtable_size_threshold
750                > Config::default().storage.memtable_size_threshold
751        );
752        assert!(config.memory.max_memory > Config::default().memory.max_memory);
753    }
754
755    /// Issue #1582: a `QueryConfig` serialized BEFORE the byte-budget fields
756    /// existed (e.g. a pre-upgrade Python JSON/dict config) has no
757    /// `max_result_bytes`/`max_result_rows` keys. The `#[serde(default = ...)]`
758    /// on both fields must let it deserialize, taking the shipped defaults rather
759    /// than failing with a missing-field error.
760    #[test]
761    fn budget_fields_deserialize_with_serde_default_when_absent() {
762        // Serialize a default QueryConfig, then STRIP both budget fields to
763        // emulate an old serialized config that predates them.
764        let mut value =
765            serde_json::to_value(QueryConfig::default()).expect("serialize QueryConfig");
766        let obj = value
767            .as_object_mut()
768            .expect("QueryConfig serializes as object");
769        obj.remove("max_result_bytes");
770        obj.remove("max_result_rows");
771        assert!(
772            !obj.contains_key("max_result_bytes") && !obj.contains_key("max_result_rows"),
773            "both fields must be absent for this regression to be meaningful"
774        );
775
776        let restored: QueryConfig =
777            serde_json::from_value(value).expect("old config (no budget fields) must deserialize");
778        assert_eq!(
779            restored.max_result_bytes, DEFAULT_MAX_RESULT_BYTES,
780            "absent max_result_bytes must take the serde default"
781        );
782        assert_eq!(
783            restored.max_result_rows,
784            default_max_result_rows(),
785            "absent max_result_rows must take the serde default"
786        );
787    }
788
789    #[test]
790    fn test_config_validation() {
791        let mut config = Config::default();
792        assert!(config.validate().is_ok());
793
794        // Test invalid max_memory
795        config.memory.max_memory = 0;
796        assert!(config.validate().is_err());
797
798        // Reset and test invalid cache sizes
799        config = Config::default();
800        config.memory.block_cache.max_size = config.memory.max_memory + 1;
801        assert!(config.validate().is_err());
802    }
803
804    #[test]
805    fn test_storage_validation_errors() {
806        let mut config = Config::default();
807
808        // Test invalid block_size (should trigger line 573-574)
809        config.storage.block_size = 0;
810        let result = config.validate();
811        assert!(result.is_err());
812        assert!(result
813            .unwrap_err()
814            .to_string()
815            .contains("block_size must be greater than 0"));
816
817        // Reset and test invalid memtable_size_threshold (should trigger line 579-580)
818        config = Config::default();
819        config.storage.memtable_size_threshold = 0;
820        let result = config.validate();
821        assert!(result.is_err());
822        assert!(result
823            .unwrap_err()
824            .to_string()
825            .contains("memtable_size_threshold must be greater than 0"));
826
827        // Reset and test invalid bloom filter false positive rate (should trigger line 589-590)
828        config = Config::default();
829        config.storage.enable_bloom_filters = true;
830        config.storage.bloom_filter_fp_rate = 0.0; // Invalid: exactly 0
831        let result = config.validate();
832        assert!(result.is_err());
833        assert!(result
834            .unwrap_err()
835            .to_string()
836            .contains("bloom_filter_fp_rate must be between 0 and 1"));
837
838        // Test another invalid bloom filter false positive rate
839        config.storage.bloom_filter_fp_rate = 1.0; // Invalid: exactly 1
840        let result = config.validate();
841        assert!(result.is_err());
842
843        // Test bloom filter rate above 1
844        config.storage.bloom_filter_fp_rate = 1.5; // Invalid: greater than 1
845        let result = config.validate();
846        assert!(result.is_err());
847
848        // Test bloom filter rate below 0
849        config.storage.bloom_filter_fp_rate = -0.1; // Invalid: less than 0
850        let result = config.validate();
851        assert!(result.is_err());
852    }
853
854    #[test]
855    fn test_valid_bloom_filter_config() {
856        let mut config = Config::default();
857        config.storage.enable_bloom_filters = true;
858        config.storage.bloom_filter_fp_rate = 0.01; // Valid rate
859        assert!(config.validate().is_ok());
860
861        config.storage.bloom_filter_fp_rate = 0.5; // Valid rate
862        assert!(config.validate().is_ok());
863
864        config.storage.bloom_filter_fp_rate = 0.99; // Valid rate
865        assert!(config.validate().is_ok());
866    }
867
868    #[test]
869    fn test_storage_config_deserializes_without_mmap_fields() {
870        // Backward compatibility: a config payload serialized before the mmap
871        // fields existed omits `use_mmap` / `mmap_min_size_bytes`. It must still
872        // deserialize, defaulting to the safe buffered backend.
873        let mut value = serde_json::to_value(StorageConfig::default()).unwrap();
874        let obj = value.as_object_mut().unwrap();
875        obj.remove("use_mmap");
876        obj.remove("mmap_min_size_bytes");
877        assert!(!obj.contains_key("use_mmap"));
878
879        let restored: StorageConfig =
880            serde_json::from_value(value).expect("old payload must still deserialize");
881        assert!(!restored.use_mmap, "missing use_mmap must default to false");
882        assert_eq!(
883            restored.mmap_min_size_bytes, 4096,
884            "missing mmap_min_size_bytes must default to one page"
885        );
886    }
887
888    #[test]
889    fn test_full_config_deserializes_without_mmap_fields() {
890        // Same guarantee through the top-level Config, mirroring how the Python
891        // bindings parse a JSON/dict payload into `cqlite_core::Config`.
892        let mut value = serde_json::to_value(Config::default()).unwrap();
893        let storage = value
894            .get_mut("storage")
895            .and_then(|s| s.as_object_mut())
896            .unwrap();
897        storage.remove("use_mmap");
898        storage.remove("mmap_min_size_bytes");
899
900        let restored: Config =
901            serde_json::from_value(value).expect("old Config payload must still deserialize");
902        assert!(!restored.storage.use_mmap);
903        assert_eq!(restored.storage.mmap_min_size_bytes, 4096);
904        restored.validate().expect("restored config must validate");
905    }
906
907    #[test]
908    fn test_mmap_fields_roundtrip_when_present() {
909        // When the fields ARE present (e.g. a user opting in), they round-trip.
910        let mut config = StorageConfig::default();
911        config.use_mmap = true;
912        config.mmap_min_size_bytes = 8192;
913        let json = serde_json::to_string(&config).unwrap();
914        let restored: StorageConfig = serde_json::from_str(&json).unwrap();
915        assert!(restored.use_mmap);
916        assert_eq!(restored.mmap_min_size_bytes, 8192);
917    }
918
919    #[test]
920    fn test_disk_access_defaults() {
921        // The new fields default to the size-aware Auto backend with Auto
922        // prefetch, a half-RAM direct-I/O threshold, and a 1 MiB window.
923        let config = StorageConfig::default();
924        assert_eq!(config.disk_access_mode, DiskAccessMode::Auto);
925        assert_eq!(config.prefetch, PrefetchMode::Auto);
926        assert_eq!(config.direct_io_memory_fraction, 0.5);
927        assert_eq!(config.direct_io_prefetch_bytes, 1024 * 1024);
928    }
929
930    #[test]
931    fn test_storage_config_deserializes_without_disk_access_fields() {
932        // Backward compatibility: a payload predating the disk-access fields must
933        // still deserialize, defaulting to Auto / Auto / 0.5 / 1 MiB.
934        let mut value = serde_json::to_value(StorageConfig::default()).unwrap();
935        let obj = value.as_object_mut().unwrap();
936        for key in [
937            "disk_access_mode",
938            "direct_io_memory_fraction",
939            "prefetch",
940            "direct_io_prefetch_bytes",
941        ] {
942            obj.remove(key);
943        }
944
945        let restored: StorageConfig =
946            serde_json::from_value(value).expect("old payload must still deserialize");
947        assert_eq!(restored.disk_access_mode, DiskAccessMode::Auto);
948        assert_eq!(restored.prefetch, PrefetchMode::Auto);
949        assert_eq!(restored.direct_io_memory_fraction, 0.5);
950        assert_eq!(restored.direct_io_prefetch_bytes, 1024 * 1024);
951    }
952
953    #[test]
954    fn test_disk_access_fields_roundtrip() {
955        // Explicit selections round-trip, including the lowercase enum encoding.
956        let mut config = StorageConfig::default();
957        config.disk_access_mode = DiskAccessMode::Direct;
958        config.prefetch = PrefetchMode::WillNeed;
959        config.direct_io_memory_fraction = 0.25;
960        config.direct_io_prefetch_bytes = 2 * 1024 * 1024;
961        let json = serde_json::to_string(&config).unwrap();
962        assert!(json.contains("\"direct\""), "enum must serialize lowercase");
963        assert!(json.contains("\"willneed\""));
964        let restored: StorageConfig = serde_json::from_str(&json).unwrap();
965        assert_eq!(restored.disk_access_mode, DiskAccessMode::Direct);
966        assert_eq!(restored.prefetch, PrefetchMode::WillNeed);
967        assert_eq!(restored.direct_io_memory_fraction, 0.25);
968        assert_eq!(restored.direct_io_prefetch_bytes, 2 * 1024 * 1024);
969    }
970
971    #[test]
972    fn test_bloom_filter_disabled() {
973        let mut config = Config::default();
974        config.storage.enable_bloom_filters = false;
975        config.storage.bloom_filter_fp_rate = 0.0; // Should be ignored when bloom filters disabled
976        assert!(config.validate().is_ok());
977
978        config.storage.bloom_filter_fp_rate = 1.0; // Should be ignored when bloom filters disabled
979        assert!(config.validate().is_ok());
980
981        config.storage.bloom_filter_fp_rate = -1.0; // Should be ignored when bloom filters disabled
982        assert!(config.validate().is_ok());
983    }
984
985    // ---- issue #1568 (Epic B / B2): dead-cache config collapse ----
986
987    /// Spec: "A config using a removed knob is rejected." The pre-change
988    /// `MemoryConfig` shape (`row_cache` / `query_cache` / `allocator` alongside
989    /// `block_cache`) must FAIL CLOSED after the collapse — those keys are gone,
990    /// and `#[serde(deny_unknown_fields)]` rejects them instead of silently
991    /// ignoring them (which would suggest they still have effect).
992    #[test]
993    fn removed_memory_knobs_fail_closed() {
994        // The full OLD memory-config shape: valid on pre-change code (RED),
995        // rejected after the collapse (GREEN).
996        let old_shape = serde_json::json!({
997            "max_memory": 1_073_741_824u64,
998            "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
999            "row_cache":   { "enabled": true, "max_size": 134_217_728u64, "policy": "Lru" },
1000            "query_cache": { "enabled": true, "max_size": 67_108_864u64,  "policy": "Lru" },
1001            "allocator":   { "use_custom": false, "small_pool_size": 1u64, "large_pool_size": 2u64 }
1002        });
1003        assert!(
1004            serde_json::from_value::<MemoryConfig>(old_shape).is_err(),
1005            "a MemoryConfig naming removed knobs (row_cache/query_cache/allocator) must fail closed"
1006        );
1007
1008        // Each removed knob, added to the retained-only base, is rejected.
1009        for removed in ["row_cache", "query_cache", "allocator"] {
1010            let mut v = serde_json::json!({
1011                "max_memory": 1_073_741_824u64,
1012                "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
1013            });
1014            v.as_object_mut()
1015                .unwrap()
1016                .insert(removed.to_string(), serde_json::json!({ "enabled": true }));
1017            assert!(
1018                serde_json::from_value::<MemoryConfig>(v).is_err(),
1019                "a MemoryConfig naming the removed `{removed}` knob must fail closed"
1020            );
1021        }
1022    }
1023
1024    /// Spec: "A config using a removed knob is rejected" (CachePolicy variants).
1025    /// The never-selected `Lfu` / `Arc` variants are gone, so a cache config
1026    /// naming them fails to deserialize (unknown variant) rather than silently
1027    /// mapping to some default.
1028    #[test]
1029    fn removed_cache_policy_variants_fail_closed() {
1030        for variant in ["Lfu", "Arc"] {
1031            let v = serde_json::json!({ "enabled": true, "max_size": 1u64, "policy": variant });
1032            assert!(
1033                serde_json::from_value::<CacheConfig>(v).is_err(),
1034                "a CacheConfig naming the removed CachePolicy::{variant} variant must fail closed"
1035            );
1036        }
1037        // The retained `Lru` variant still deserializes.
1038        let ok = serde_json::json!({ "enabled": true, "max_size": 1u64, "policy": "Lru" });
1039        assert!(serde_json::from_value::<CacheConfig>(ok).is_ok());
1040    }
1041
1042    /// Spec: "The retained budget knob still deserializes and validates." A
1043    /// config specifying only `max_memory` and `block_cache` deserializes, passes
1044    /// `Config::validate()`, and its `block_cache.max_size` is the retained knob.
1045    #[test]
1046    fn retained_budget_knob_deserializes_and_validates() {
1047        let mem: MemoryConfig = serde_json::from_value(serde_json::json!({
1048            "max_memory": 1_073_741_824u64,
1049            "block_cache": { "enabled": true, "max_size": 268_435_456u64, "policy": "Lru" },
1050        }))
1051        .expect("retained-only MemoryConfig must deserialize");
1052        assert_eq!(mem.block_cache.max_size, 268_435_456);
1053
1054        // A default Config (which now carries only the collapsed MemoryConfig)
1055        // still validates, and the block-cache budget is the wired knob.
1056        let config = Config::default();
1057        assert!(config.validate().is_ok());
1058        assert!(config.memory.block_cache.max_size > 0);
1059    }
1060}