Skip to main content

cqlite_core/
config.rs

1//! Configuration management for CQLite
2
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6/// Main configuration structure for CQLite database.
7///
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;