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///
8/// # Every knob in #1696's CENSUS is read or deleted — and the exception (#1696 roborev r5 F3)
9///
10/// Scoped to the census deliberately, because the unqualified version ("every
11/// field here is read by something") is contradicted by our own standing guard,
12/// `cqlite-core/tests/config_knob_behavior_guard.rs`, which records every
13/// [`CompressionConfig`] field as DECORATIVE with zero production readers.
14///
15/// What #1696 (epic #1685, "config honesty") examined, it either kept because
16/// something reads it, or DELETED: `storage`'s `max_sstable_size` /
17/// `block_size` / `enable_bloom_filters` / `bloom_filter_fp_rate` /
18/// `io_threads` / `sync_mode`, `query`'s `plan_cache_size` /
19/// `enable_optimization` / `parallel`, and the entire `performance` tree are
20/// gone — setting any of them changed nothing, silently.
21///
22/// The KNOWN exception is [`CompressionConfig`] (`enabled` / `algorithm` /
23/// `level` / `min_block_size`). Those four were NOT in #1696's census and are
24/// deliberately left in place: the read path takes its algorithm from
25/// `CompressionInfo.db` as the no-heuristics mandate requires, and the write
26/// surface is uncompressed-only (**#1406** owns that boundary and the
27/// compressed-write wiring), so there is nothing for them to steer today. No dedicated
28/// removal issue exists; they belong to the open epic **#1685**. The guard is the
29/// authority on which fields are decorative — read it, do not read a claim of
30/// universal coverage into this heading.
31///
32/// Deleting a field is deliberately a COMPILE error for an embedder writing
33/// Rust: that is the loudest signal available, and it is preferred over a field
34/// that keeps deserializing while doing nothing.
35///
36/// # But this is ALSO a deserialization surface (#1696 roborev F1)
37///
38/// `Config` derives `Deserialize`, and serde DISCARDS unknown fields — so a
39/// caller who configures CQLite through JSON or a dict (the Python bindings'
40/// bridge) gets no compile step and, before #1696's F1 fix, no signal at all: a
41/// pre-change document naming a deleted knob loaded successfully and was
42/// silently ignored. The rule is stated at the layer where a knob is SET, so the
43/// authoring surfaces report removed keys by name instead:
44/// [`Self::from_json_str`] / [`Self::from_json_str_reporting_removed`] for this
45/// crate's JSON surface (see [`crate::config_removed_keys`]), and
46/// `cqlite_cli::config::removed_keys` for the CLI's file surface. Both use the
47/// same posture — parse-and-ignore PLUS a named warning, never
48/// `deny_unknown_fields` — because ONE posture crate-wide is the requirement,
49/// and hard-failing would leave an existing caller with no migration path over
50/// keys that never did anything.
51///
52/// # ENFORCED where, exactly — and the ONE surface that is not (#3520)
53///
54/// Those constructors are OPTIONAL, so they do not cover the serde boundary
55/// itself: `serde_json::from_str::<Config>` / `from_value::<Config>` bypass them
56/// and still DISCARD removed keys in SILENCE. Enforced surfaces are the CLI
57/// config-file loader, the Python bindings entry points, and Rust field access (a
58/// compile error, for Rust callers only). The unenforced one is a direct serde
59/// deserialization by an embedder — **issue #3520**, scoped out of #1696
60/// deliberately (roborev r2 F3) and pinned by
61/// `direct_serde_deserialization_is_the_unreported_surface`. Nothing here should
62/// be read as universal coverage.
63///
64/// The standing guard is `cqlite-core/tests/config_knob_behavior_guard.rs`:
65/// every leaf field below must be registered there with either a set-knob →
66/// assert-observable-difference test or an explicit reason why no observable
67/// difference is expressible. A newly added `pub` field with neither FAILS that
68/// test — which is the point, since "nobody asked whether this knob is read" is
69/// how the removed ones accumulated.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct Config {
72 /// Storage engine configuration
73 pub storage: StorageConfig,
74
75 /// Memory management configuration
76 pub memory: MemoryConfig,
77
78 /// Query engine configuration
79 pub query: QueryConfig,
80
81 /// WASM-specific configuration
82 #[cfg(target_arch = "wasm32")]
83 pub wasm: WasmConfig,
84}
85
86/// Storage engine configuration
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StorageConfig {
89 /// MemTable size threshold for flushing, in bytes (default: 64MB).
90 ///
91 /// This is the AUTHORITATIVE flush trigger for the write path: it is the
92 /// single value `WriteEngineConfig::from_config` translates into
93 /// `WriteEngineConfig::memtable_flush_threshold` (issue #1697).
94 ///
95 /// The default changed 16MB -> 64MB in #1697: before that fix this field had
96 /// no production reader — the engine carried its own private 64MB default,
97 /// so 64MB is the value that always actually ran. Keeping the RUNNING value
98 /// preserves behaviour; adopting the decorative 16MB would have silently
99 /// quadrupled everyone's flush rate.
100 pub memtable_size_threshold: u64,
101
102 /// MemTable HARD limit in bytes (default: 256MB) — the admission ceiling.
103 ///
104 /// Live knob: the write engine's `check_admission` REJECTS a write whose
105 /// mutation exceeds this on its own, or that would push the memtable over
106 /// it. Before issue #1697 it existed only as the private
107 /// `WriteEngineConfig::DEFAULT_HARD_LIMIT`, so an embedder could be
108 /// hard-failed by a ceiling they had no way to see or change. The default is
109 /// unchanged (256MB): this exposes the knob, it does not alter behaviour.
110 /// [`Config::validate`] requires it to be STRICTLY GREATER than
111 /// [`Self::memtable_size_threshold`], since a ceiling at or below the flush
112 /// threshold wedges the engine — writes are rejected before a flush can ever
113 /// relieve the memtable, and with zero headroom an ordinary write does it —
114 /// and requires BOTH knobs to fit in the target's `usize` (see `validate`;
115 /// only reachable on 32-bit/wasm32). Note that headroom alone is not a
116 /// wedge-freedom guarantee: a single mutation larger than the headroom still
117 /// wedges, which is an admission-side defect tracked as #3404.
118 #[serde(default = "default_memtable_hard_limit")]
119 pub memtable_hard_limit: u64,
120
121 /// Compaction configuration
122 pub compaction: CompactionConfig,
123
124 /// Compression configuration
125 pub compression: CompressionConfig,
126
127 /// Legacy promote-only flag: it upgrades an **explicit**
128 /// [`DiskAccessMode::Buffered`] request to [`DiskAccessMode::Mmap`].
129 ///
130 /// It does **not** select the backend — [`Self::disk_access_mode`] does, and its
131 /// `Auto` default already memory-maps most Data.db files (see that field). So
132 /// `false` does not mean "buffered I/O", and `true` changes nothing unless
133 /// something explicitly requested `Buffered`. A mapped file is served from the
134 /// page cache with no per-block `read` syscall, as Cassandra's mmap mode does.
135 ///
136 /// # Safety / platform constraints
137 ///
138 /// A memory map aliases the file's bytes for the reader's lifetime. Only
139 /// enable this when the SSTables are **immutable local files**:
140 /// - Mutating, truncating, or deleting a mapped file out from under a live
141 /// reader is undefined behaviour and can raise `SIGBUS`, terminating the
142 /// process. CQLite never rewrites its own mapped inputs, but external
143 /// tools must not either.
144 /// - Network and overlay filesystems (NFS, SMB, FUSE, some container
145 /// overlays) can fault mid-read after a successful map; prefer buffered
146 /// I/O there.
147 ///
148 /// # Interaction with the write engine (Issue #591)
149 ///
150 /// This setting only affects the read path. Compaction's input readers force
151 /// `use_mmap = false` + explicit `Buffered` (only `CQLITE_USE_MMAP=1` promotes even
152 /// those); each input is unpublished by removing its `TOC.txt` before the data
153 /// components, best-effort. So enabling mmap for queries is safe
154 /// alongside background compaction: a compaction never holds a mapping over a
155 /// file it then deletes, and on Windows a data file still pinned by a mapped
156 /// reader becomes an invisible orphan (reclaimed on the next startup) rather
157 /// than a failed delete or a source of duplicate rows.
158 ///
159 /// Can also be enabled at runtime by setting `CQLITE_USE_MMAP=1`.
160 ///
161 /// `#[serde(default)]` keeps configs serialized before this field existed
162 /// (which omit it) deserializing successfully, defaulting to no promotion.
163 #[serde(default = "default_use_mmap")]
164 pub use_mmap: bool,
165
166 /// Minimum Data.db size (bytes) at which [`DiskAccessMode::Auto`] maps. Default 4096.
167 ///
168 /// It gates ONLY `Auto`, which uses buffered I/O below it (a tiny file does not
169 /// repay the mapping setup); an explicit `Mmap` — including a `Buffered` promoted
170 /// by [`Self::use_mmap`] — is not size-gated, only a zero-length file falls back.
171 ///
172 /// `#[serde(default)]` for backward compatibility with older payloads.
173 #[serde(default = "default_mmap_min_size_bytes")]
174 pub mmap_min_size_bytes: usize,
175
176 /// How the SSTable read path accesses Data.db on disk.
177 ///
178 /// Defaults to [`DiskAccessMode::Auto`], which sizes each Data.db file
179 /// against system RAM and picks the backend automatically:
180 /// - files below [`Self::mmap_min_size_bytes`] use buffered I/O (mapping a
181 /// tiny file is not worth the setup cost);
182 /// - files up to [`Self::direct_io_memory_fraction`] of system memory are
183 /// **memory-mapped**, so repeated scans stay resident in the page cache;
184 /// - files larger than that fraction use **direct I/O** (`O_DIRECT` on
185 /// Linux, `F_NOCACHE` on macOS), which bypasses the page cache so a
186 /// single huge scan does not evict everything else the host has cached.
187 ///
188 /// Set an explicit [`DiskAccessMode::Buffered`], [`DiskAccessMode::Mmap`],
189 /// or [`DiskAccessMode::Direct`] to override the heuristic. The legacy
190 /// [`Self::use_mmap`] flag only PROMOTES an explicit `Buffered` request to
191 /// `Mmap`; it never changes what `Auto` resolves to.
192 ///
193 /// Can also be set at runtime via `CQLITE_DISK_ACCESS_MODE`
194 /// (`auto` / `buffered` / `mmap` / `direct`).
195 #[serde(default)]
196 pub disk_access_mode: DiskAccessMode,
197
198 /// Fraction of total system memory above which [`DiskAccessMode::Auto`]
199 /// switches a file from memory-mapped to direct I/O. Defaults to `0.5`
200 /// (half of RAM). Ignored when system memory cannot be determined (in which
201 /// case `Auto` never escalates to direct I/O).
202 ///
203 /// The legal range is `(0.0, 1.0]` and [`Config::validate`] REJECTS anything
204 /// outside it, NaN and the infinities included (issue #1696). It used to be
205 /// silently clamped instead — a `2.0` or a `-1` quietly became the `0.5`
206 /// default — so the value an operator set was not the value that ran. It is a
207 /// FRACTION, never a byte count; to always bypass the page cache, ask for
208 /// [`DiskAccessMode::Direct`].
209 #[serde(default = "default_direct_io_memory_fraction")]
210 pub direct_io_memory_fraction: f64,
211
212 /// Read-ahead / prefetch strategy applied to the chosen backend.
213 ///
214 /// Defaults to [`PrefetchMode::Auto`], which issues **no** mmap `madvise`
215 /// (relying on the kernel's default read-ahead) and only enables the
216 /// direct-I/O prefetch window of [`Self::direct_io_prefetch_bytes`]. Set
217 /// [`PrefetchMode::Off`] to disable explicit hints (relying only on default
218 /// kernel read-ahead / single-block direct reads). Can also be set via
219 /// `CQLITE_PREFETCH` (`off` / `sequential` / `willneed` / `auto`).
220 #[serde(default)]
221 pub prefetch: PrefetchMode,
222
223 /// Size in bytes of the read-ahead window used by the direct-I/O backend, and by
224 /// nothing else: the buffered backend ignores it (`open_buffered_sources` takes no
225 /// prefetch bytes; its `BufReader::new` capacity is tokio's 8 KiB default). Rounded
226 /// up to the I/O alignment; 1 MiB default; inert while `prefetch` is `Off`.
227 #[serde(default = "default_direct_io_prefetch_bytes")]
228 pub direct_io_prefetch_bytes: usize,
229}
230
231/// Selects which backend the SSTable read path uses for Data.db I/O.
232///
233/// See [`StorageConfig::disk_access_mode`] for the per-variant semantics and
234/// the [`DiskAccessMode::Auto`] sizing heuristic.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
236#[serde(rename_all = "lowercase")]
237pub enum DiskAccessMode {
238 /// Size each file against system RAM and pick buffered / mmap / direct.
239 #[default]
240 Auto,
241 /// Always use buffered file I/O through the OS page cache.
242 Buffered,
243 /// Always memory-map the file. Unlike the [`DiskAccessMode::Auto`] heuristic,
244 /// this honors the user's explicit request and is **not** gated by
245 /// [`StorageConfig::mmap_min_size_bytes`] (the size threshold only steers
246 /// `Auto`); a zero-length file still falls back to buffered I/O since an
247 /// empty map is invalid.
248 Mmap,
249 /// Always use direct I/O, bypassing the OS page cache.
250 Direct,
251}
252
253/// Selects the read-ahead hint applied to the active disk-access backend.
254///
255/// See [`StorageConfig::prefetch`]. `Sequential` / `WillNeed` map to the
256/// corresponding `madvise(2)` advice on the mmap backend; on the direct-I/O
257/// backend any non-`Off` value enables the [`StorageConfig::direct_io_prefetch_bytes`]
258/// read-ahead window.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
260#[serde(rename_all = "lowercase")]
261pub enum PrefetchMode {
262 /// No explicit prefetch hint; rely on default kernel behaviour.
263 Off,
264 /// Hint sequential access (aggressive read-ahead, drop-behind).
265 Sequential,
266 /// Hint that the mapped/region bytes will be needed soon (eager fault-in).
267 WillNeed,
268 /// Let the backend choose. For mmap this issues **no** madvise and relies on
269 /// the kernel's default read-ahead: `MADV_SEQUENTIAL`'s drop-behind evicts
270 /// hot pages under concurrent write load and inflates the read-side p99 tail
271 /// (issue #1143), so `Auto` avoids it while keeping the isolated mmap win.
272 /// For direct I/O it enables the windowed read-ahead
273 /// ([`StorageConfig::direct_io_prefetch_bytes`]). Request
274 /// [`PrefetchMode::Sequential`] explicitly for `MADV_SEQUENTIAL` behaviour.
275 #[default]
276 Auto,
277}
278
279/// Default for [`StorageConfig::use_mmap`]: no promotion (see the field doc).
280fn default_use_mmap() -> bool {
281 false
282}
283
284/// Default for [`StorageConfig::memtable_hard_limit`]: 256MB, the value the
285/// write engine always used privately (issue #1697).
286fn default_memtable_hard_limit() -> u64 {
287 256 * 1024 * 1024
288}
289
290/// Default for [`StorageConfig::mmap_min_size_bytes`]: one page.
291fn default_mmap_min_size_bytes() -> usize {
292 4096
293}
294
295/// Default for [`StorageConfig::direct_io_memory_fraction`]: half of RAM.
296fn default_direct_io_memory_fraction() -> f64 {
297 0.5
298}
299
300/// Default for [`StorageConfig::direct_io_prefetch_bytes`]: 1 MiB.
301fn default_direct_io_prefetch_bytes() -> usize {
302 1024 * 1024
303}
304
305impl Default for StorageConfig {
306 fn default() -> Self {
307 Self {
308 // 64MB / 256MB: the values the write engine always used (#1697).
309 // Shared with the serde defaults so the two can never drift.
310 memtable_size_threshold: 64 * 1024 * 1024,
311 memtable_hard_limit: default_memtable_hard_limit(),
312 compaction: CompactionConfig::default(),
313 compression: CompressionConfig::default(),
314 // Opt-in; buffered I/O is the portable, safe default. Shared with
315 // the serde defaults so the two can never drift.
316 use_mmap: default_use_mmap(),
317 mmap_min_size_bytes: default_mmap_min_size_bytes(),
318 disk_access_mode: DiskAccessMode::default(),
319 direct_io_memory_fraction: default_direct_io_memory_fraction(),
320 prefetch: PrefetchMode::default(),
321 direct_io_prefetch_bytes: default_direct_io_prefetch_bytes(),
322 }
323 }
324}
325
326/// Compaction strategy configuration — the authoritative source for the write
327/// path's Size-Tiered Compaction Strategy (STCS), consumed via
328/// `WriteEngineConfig::from_config` (issues #1619, #1697). Decorative
329/// `strategy`/`max_sstables`/`size_ratio`/`max_threads`/`background_interval`
330/// knobs, read by no behavior, were removed in #1619 rather than left in place.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct CompactionConfig {
333 /// Enable automatic (STCS) compaction. When `false`, the write engine
334 /// installs no merge policy and `maintenance_step` is a no-op.
335 pub auto_compaction: bool,
336
337 /// STCS `min_threshold`: minimum number of SSTables in a size bucket before
338 /// a compaction is triggered (default: 4). Ignored when
339 /// [`Self::auto_compaction`] is `false`. Wired to the write engine by
340 /// `WriteEngineConfig::from_config` (issue #1697).
341 #[serde(default = "default_compaction_min_threshold")]
342 pub min_threshold: usize,
343
344 /// STCS `max_threshold`: maximum number of SSTables merged together in one
345 /// compaction step (default: 32). Ignored when [`Self::auto_compaction`] is
346 /// `false`. Wired to the write engine by `WriteEngineConfig::from_config`
347 /// (issue #1697).
348 #[serde(default = "default_compaction_max_threshold")]
349 pub max_threshold: usize,
350}
351
352/// Default for [`CompactionConfig::min_threshold`]: Cassandra's STCS default.
353fn default_compaction_min_threshold() -> usize {
354 4
355}
356
357/// Default for [`CompactionConfig::max_threshold`]: Cassandra's STCS default.
358fn default_compaction_max_threshold() -> usize {
359 32
360}
361
362impl Default for CompactionConfig {
363 fn default() -> Self {
364 Self {
365 auto_compaction: true,
366 // Shared with the serde defaults so the two can never drift.
367 min_threshold: default_compaction_min_threshold(),
368 max_threshold: default_compaction_max_threshold(),
369 }
370 }
371}
372
373/// Memory management configuration.
374///
375/// Collapsed to exactly one real caching knob (issue #1568, Epic B/B2): the
376/// block/chunk-cache byte budget (`block_cache.max_size`), wired as the B1
377/// [`DecompressedChunkCache`](crate::storage::cache::DecompressedChunkCache)
378/// capacity. The former decorative `row_cache` / `query_cache` / `allocator`
379/// knobs (wired to nothing at runtime) were deleted. `deny_unknown_fields`
380/// makes a config that still names a removed knob **fail closed** on
381/// deserialization rather than silently ignoring it (which would suggest the
382/// removed knob still has effect).
383#[derive(Debug, Clone, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct MemoryConfig {
386 /// Maximum total memory usage (default: 1GB)
387 pub max_memory: u64,
388
389 /// Block/chunk cache configuration. `block_cache.max_size` is the real,
390 /// wired byte budget of the shared decompressed-chunk cache.
391 pub block_cache: CacheConfig,
392}
393
394impl Default for MemoryConfig {
395 fn default() -> Self {
396 let max_memory = 1024 * 1024 * 1024; // 1GB
397
398 Self {
399 max_memory,
400 block_cache: CacheConfig {
401 enabled: true,
402 max_size: max_memory / 4, // 256MB
403 policy: CachePolicy::Lru,
404 },
405 }
406 }
407}
408
409/// Cache configuration
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct CacheConfig {
412 /// Enable this cache
413 pub enabled: bool,
414
415 /// Maximum cache size in bytes
416 pub max_size: u64,
417
418 /// Cache eviction policy
419 pub policy: CachePolicy,
420}
421
422/// Cache eviction policy.
423///
424/// The shared decompressed-chunk cache is LRU (issue #1567/#1568). The
425/// never-selected `Lfu` / `Arc` variants were removed (Epic B/B2); a config
426/// naming them now fails to deserialize (unknown variant) rather than silently
427/// mapping to a default.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub enum CachePolicy {
430 /// Least Recently Used
431 Lru,
432}
433
434/// Default byte ceiling for a materialized SELECT result set (issue #1582).
435///
436/// 64 MiB. See [`QueryConfig::max_result_bytes`] for the derivation from the
437/// project's <128MB process memory target.
438pub const DEFAULT_MAX_RESULT_BYTES: u64 = 64 * 1024 * 1024;
439
440/// Serde default for [`QueryConfig::max_result_bytes`] (issue #1582).
441///
442/// Backward-compat: a `QueryConfig` serialized before this field existed (e.g.
443/// a Python JSON/dict config) has no `max_result_bytes` key. Without a serde
444/// default, deserialization fails with a missing-field error; with it, such a
445/// config takes the shipped [`DEFAULT_MAX_RESULT_BYTES`] budget.
446fn default_max_result_bytes() -> u64 {
447 DEFAULT_MAX_RESULT_BYTES
448}
449
450/// Serde default for [`QueryConfig::max_result_rows`] (issue #1582).
451///
452/// Backward-compat + robustness: a `QueryConfig` serialized without this key
453/// (or a partial JSON/dict config) still deserializes, taking the shipped
454/// 1,000,000-row secondary safety valve rather than failing with a missing
455/// field. Keeps the knob real (not decorative) and consistent with
456/// [`default_max_result_bytes`].
457fn default_max_result_rows() -> u64 {
458 1_000_000
459}
460
461/// Forced SELECT access path (issue #1918).
462///
463/// A **test/debug** control that removes doubt about which access path serves a
464/// `SELECT`. It never changes value decoding, tombstone/timestamp reconciliation,
465/// or WRITETIME/TTL semantics — it governs *routing only* — and is chosen
466/// exclusively from explicit operator config/env, never inferred from data bytes
467/// (no-heuristics mandate). Set programmatically via
468/// [`QueryConfig::forced_read_path`] or per-process via the `CQLITE_READ_PATH`
469/// environment variable (`auto|point|full`, case-insensitive), with config taking
470/// precedence over env. **Not a performance recommendation.**
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
472#[serde(rename_all = "lowercase")]
473pub enum ReadPathMode {
474 /// Today's behavior: the classifier chooses point-vs-full per query. An unset
475 /// knob is byte-for-byte this mode.
476 #[default]
477 Auto,
478 /// Force a genuinely partition-targeted lookup. **Fails closed** with
479 /// [`crate::Error::ForcedReadPathUnavailable`] whenever the executor would not
480 /// run a partition-targeted lookup — never a silent full scan.
481 Point,
482 /// Force the full-scan + reconciliation path regardless of classification,
483 /// recording [`crate::query::access_path::FallbackReason::ForcedFullScan`].
484 Full,
485}
486
487/// Query engine configuration
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct QueryConfig {
490 /// Maximum wall-clock budget for ONE query execution (issue #1695).
491 ///
492 /// ENFORCED, not advisory: every public query entry point on the engine
493 /// (`execute`, `execute_streaming`, `execute_with_params`, `execute_prepared`)
494 /// runs under a single `tokio::time::timeout` at the engine chokepoint and
495 /// fails with [`crate::Error::QueryTimeout`] when the budget elapses.
496 ///
497 /// **`Duration::ZERO` is the "no timeout" sentinel** — an explicitly LEGAL
498 /// value meaning unbounded execution ([`Config::validate`] never rejects it).
499 /// There is no `Option` here, so `ZERO` is the only way to disable the bound.
500 /// The CLI knob is `performance.query_timeout_ms` (0 ⇒ unbounded).
501 ///
502 /// For `execute_streaming` the budget covers the whole SETUP future — parse,
503 /// plan, stream setup, and (for the plan shapes that materialize before
504 /// streaming) the entire scan — but NOT the caller's later row consumption
505 /// from the returned iterator; see
506 /// [`crate::query::engine::QueryEngine::execute_streaming`] for the exact
507 /// scope.
508 ///
509 /// Default: 300s.
510 pub max_execution_time: Duration,
511
512 /// Force the SELECT access-path decision (issue #1918).
513 ///
514 /// `None` (the default) leaves routing to the per-query classifier and the
515 /// `CQLITE_READ_PATH` env knob; `Some(mode)` forces that mode and takes
516 /// precedence over the env var. A **test/debug** control — see
517 /// [`ReadPathMode`]. `#[serde(default)]` keeps configs serialized before this
518 /// field existed deserializing successfully (absent = `None`).
519 #[serde(default)]
520 pub forced_read_path: Option<ReadPathMode>,
521
522 /// Maximum number of rows to return in a result set.
523 ///
524 /// A *secondary* safety valve, retained for defense-in-depth (issue #1582).
525 /// The primary guard on a materialized result is now `max_result_bytes`: a
526 /// row count is the wrong unit because 1M skinny rows can fit comfortably
527 /// while 100k wide rows blow the <128MB memory target. Still load-bearing:
528 /// the materializing SELECT path enforces this row-count ceiling alongside
529 /// the byte budget (lowering it makes a wide-row-count result trip even
530 /// under the byte budget), so it is a real knob, not decoration.
531 #[serde(default = "default_max_result_rows")]
532 pub max_result_rows: u64,
533
534 /// Byte ceiling on a MATERIALIZED result set (issue #1582 / D6).
535 ///
536 /// While the SELECT executor collects a materialized `Vec<QueryRow>`, it
537 /// tracks a running estimate of the result's logical size (via the shared
538 /// `crate::memory::estimate_value_size` estimator) and fails with
539 /// [`crate::Error::ResultTooLarge`] once this ceiling is crossed — telling
540 /// the caller to add a `LIMIT` or use the streaming API. This is the
541 /// correct-unit primary guard; `max_result_rows` remains as a secondary
542 /// valve. Streaming queries are bounded by their channel buffer, so this
543 /// budget does not apply to them.
544 ///
545 /// Default: [`DEFAULT_MAX_RESULT_BYTES`] (64 MiB). Chosen well below the
546 /// project's <128MB process memory target: the estimator measures *logical*
547 /// content bytes and does not count per-row container overhead
548 /// (`HashMap<Arc<str>, Value>` slots, `String`/`Vec` capacity slack, row
549 /// metadata), which in practice roughly doubles real heap use — so a 64 MiB
550 /// logical ceiling keeps a fully-materialized result comfortably inside the
551 /// process budget while leaving headroom for readers, caches, and decode
552 /// buffers.
553 #[serde(default = "default_max_result_bytes")]
554 pub max_result_bytes: u64,
555
556 /// Query cache size (for plan caching)
557 pub query_cache_size: Option<usize>,
558
559 /// Query parallelism thread count
560 pub query_parallelism: Option<usize>,
561
562 /// Number of iterations for query analysis
563 pub analyze_iterations: Option<usize>,
564}
565
566impl Default for QueryConfig {
567 fn default() -> Self {
568 Self {
569 max_execution_time: Duration::from_secs(300), // 5 minutes
570 forced_read_path: None,
571 max_result_rows: 1_000_000,
572 max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
573 query_cache_size: Some(100),
574 query_parallelism: Some(num_cpus::get()),
575 analyze_iterations: Some(5),
576 }
577 }
578}
579
580/// WASM-specific configuration
581#[cfg(target_arch = "wasm32")]
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct WasmConfig {
584 /// Use IndexedDB for persistent storage
585 pub use_indexeddb: bool,
586
587 /// Maximum memory usage in WASM (default: 256MB)
588 pub max_memory: u64,
589
590 /// Enable WASM SIMD optimizations
591 pub enable_simd: bool,
592
593 /// Enable Web Workers for background tasks
594 pub enable_workers: bool,
595
596 /// Maximum number of Web Workers
597 pub max_workers: usize,
598}
599
600#[cfg(target_arch = "wasm32")]
601impl Default for WasmConfig {
602 fn default() -> Self {
603 Self {
604 use_indexeddb: true,
605 max_memory: 256 * 1024 * 1024, // 256MB
606 enable_simd: true,
607 enable_workers: true,
608 max_workers: 4,
609 }
610 }
611}
612
613/// Compression algorithms
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub enum CompressionAlgorithm {
616 /// No compression
617 None,
618 /// LZ4 compression (fast)
619 Lz4,
620 /// Snappy compression (balanced)
621 Snappy,
622 /// Deflate compression (good compression ratio)
623 Deflate,
624 /// ZSTD compression (high compression ratio)
625 Zstd,
626}
627
628/// Compression configuration
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct CompressionConfig {
631 /// Enable compression
632 pub enabled: bool,
633
634 /// Compression algorithm to use
635 pub algorithm: CompressionAlgorithm,
636
637 /// Compression level (algorithm-specific)
638 pub level: i32,
639
640 /// Minimum block size to compress (smaller blocks are stored uncompressed)
641 pub min_block_size: u32,
642}
643
644impl Default for CompressionConfig {
645 fn default() -> Self {
646 Self {
647 enabled: true,
648 algorithm: CompressionAlgorithm::Lz4,
649 level: 1, // Fast compression
650 min_block_size: 1024, // 1KB minimum
651 }
652 }
653}
654
655impl Config {
656 /// Create a configuration optimized for memory usage
657 pub fn memory_optimized() -> Self {
658 let mut config = Self::default();
659
660 // Reduce memory usage
661 config.storage.memtable_size_threshold = 4 * 1024 * 1024; // 4MB
662 config.memory.max_memory = 256 * 1024 * 1024; // 256MB
663 config.memory.block_cache.max_size = 64 * 1024 * 1024; // 64MB
664
665 // Enable aggressive compression
666 config.storage.compression.algorithm = CompressionAlgorithm::Zstd;
667 config.storage.compression.enabled = true;
668
669 config
670 }
671
672 /// Create a configuration optimized for performance
673 pub fn performance_optimized() -> Self {
674 let mut config = Self::default();
675
676 // Increase memory usage for better performance
677 // Above the 64MB default (#1697 raised the default to the value that
678 // always ran), so this preset still trades memory for throughput.
679 config.storage.memtable_size_threshold = 128 * 1024 * 1024; // 128MB
680 config.memory.max_memory = 4 * 1024 * 1024 * 1024; // 4GB
681
682 // Use faster compression
683 config.storage.compression.algorithm = CompressionAlgorithm::Lz4;
684 config.storage.compression.enabled = true;
685
686 // More aggressive caching
687 config.memory.block_cache.max_size = 1024 * 1024 * 1024; // 1GB
688
689 config
690 }
691
692 /// Create a configuration optimized for WASM deployment
693 #[cfg(target_arch = "wasm32")]
694 pub fn wasm_optimized() -> Self {
695 let mut config = Self::memory_optimized();
696
697 // WASM-specific optimizations
698 config.wasm.max_memory = 128 * 1024 * 1024; // 128MB
699 config.wasm.enable_simd = true;
700 config.wasm.enable_workers = false; // Conservative default
701
702 // Reduce overall memory usage for WASM
703 config.memory.max_memory = 128 * 1024 * 1024; // 128MB
704 config.storage.memtable_size_threshold = 2 * 1024 * 1024; // 2MB
705
706 // Disable background compaction, which may not work well in WASM.
707 config.storage.compaction.auto_compaction = false;
708
709 config
710 }
711
712 /// Create a test-optimized configuration
713 #[cfg(test)]
714 pub fn test_config() -> Self {
715 let mut config = Config::default();
716
717 // Disable background compaction, which can cause test hangs.
718 config.storage.compaction.auto_compaction = false;
719
720 // Reduce timeouts for faster test execution
721 config.query.max_execution_time = std::time::Duration::from_secs(1);
722
723 // Smaller memory usage for tests. The cache budget is scaled WITH
724 // `max_memory` (the same 1/4 ratio `MemoryConfig::default` uses), not
725 // left at the 1GB default's 256MB: `validate` requires
726 // `block_cache.max_size <= max_memory`, and a constructor that emits a
727 // config its own `validate` rejects is a latent contradiction — it went
728 // unnoticed only because nothing on the open path ever validated.
729 //
730 // This is NO LONGER load-bearing for any open path: `Database::open`
731 // enforces the `direct_io_memory_fraction` range alone, not the cache
732 // budget (#1696 roborev r3 F3 narrowed it, residual #3525). It is kept
733 // because it is correct on its own merits — the fix is to the
734 // constructor's self-consistency, not to whoever happens to validate.
735 config.memory.max_memory = 64 * 1024 * 1024; // 64MB
736 config.memory.block_cache.max_size = config.memory.max_memory / 4; // 16MB
737 config.storage.memtable_size_threshold = 1024 * 1024; // 1MB
738
739 config
740 }
741}
742
743/// JSON deserialization entry points (`Config::from_json_str`), split out under
744/// the campsite rule (epic #1116).
745#[path = "config_json.rs"]
746mod json;
747
748/// `Config::validate` and the rules it enforces, split out under the campsite
749/// rule (epic #1116).
750#[path = "config_validate.rs"]
751mod validate;
752
753#[cfg(test)]
754#[path = "config_tests.rs"]
755mod tests;