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}