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}