Skip to main content

cqlite_core/
config_validate.rs

1//! Configuration validation for [`Config`] (issues #1695, #1696, #1697).
2//!
3//! Split out of `config.rs` under the campsite rule (epic #1116): that file is
4//! already over the size target, and validation is a single responsibility with
5//! commentary long enough to dominate it. Every rule states WHY it exists, because
6//! a rule with no recorded reason is the thing that gets deleted by the next
7//! person who finds it inconvenient.
8
9use super::{Config, StorageConfig};
10
11impl Config {
12    /// Validate the configuration
13    pub fn validate(&self) -> crate::Result<()> {
14        // Validate memory limits
15        if self.memory.max_memory == 0 {
16            return Err(crate::Error::configuration(
17                "max_memory must be greater than 0",
18            ));
19        }
20
21        // Validate the (single) cache budget does not exceed total memory.
22        if self.memory.block_cache.max_size > self.memory.max_memory {
23            return Err(crate::Error::configuration(
24                "block_cache.max_size exceeds max_memory",
25            ));
26        }
27
28        // Validate storage settings
29        if self.storage.memtable_size_threshold == 0 {
30            return Err(crate::Error::configuration(
31                "memtable_size_threshold must be greater than 0",
32            ));
33        }
34
35        // Both memtable byte knobs are `u64` on the public surface but `usize`
36        // in the engine (see `WriteEngineConfig::from_config`). On a 32-bit or
37        // wasm32 target a value above `usize::MAX` cannot be represented, and
38        // the bridge's clamp would land it exactly on `usize::MAX` — the state
39        // `memtable.rs` names degenerate: `should_flush` never fires and
40        // `check_admission`'s `projected > hard_limit` is UNREACHABLE because
41        // `saturating_add` caps at `usize::MAX`. That is never-flush AND
42        // never-reject: grow until OOM. Reject it here instead (#1697).
43        //
44        // `usize_max_bytes` is the target's `usize::MAX` widened to `u64` — via
45        // `try_from`, never an `as` cast — so on a 64-bit target it equals
46        // `u64::MAX` and the comparisons below are trivially false rather than
47        // ill-typed. A hypothetical target with `usize` WIDER than `u64` falls
48        // back to `u64::MAX`, which is also correct: every `u64` value is then
49        // addressable. The bridge keeps its clamp as defense in depth for any
50        // path that skips `validate`.
51        let usize_max_bytes = u64::try_from(usize::MAX).unwrap_or(u64::MAX);
52        for (knob, bytes) in [
53            (
54                "memtable_size_threshold",
55                self.storage.memtable_size_threshold,
56            ),
57            ("memtable_hard_limit", self.storage.memtable_hard_limit),
58        ] {
59            if bytes > usize_max_bytes {
60                return Err(crate::Error::configuration(format!(
61                    "{knob} ({bytes} bytes) exceeds this target's addressable maximum \
62                     ({usize_max_bytes} bytes); a memtable that large can never flush \
63                     and can never reject a write"
64                )));
65            }
66        }
67
68        // A hard limit below the flush threshold wedges the write engine for
69        // EVERY write: the memtable is rejected at the ceiling before a flush can
70        // relieve it. Only expressible as a rule now that both knobs live here
71        // (#1697).
72        //
73        // SCOPE OF THIS RULE, stated because it is narrower than it looks
74        // (#1697 roborev r2; the engine defect is #3404): passing it does NOT
75        // make the write path wedge-free. `WriteEngine::check_admission` rejects
76        // `memtable_size + incoming > memtable_hard_limit` without attempting a
77        // flush, while auto-flush fires only AFTER a successful insert. So any
78        // single mutation larger than `memtable_hard_limit - memtable_size` is
79        // rejected while the memtable sits below the flush threshold, and
80        // retrying it is rejected forever.
81        //
82        // NO INEQUALITY BETWEEN THESE TWO KNOBS CAN CLOSE THAT: with one byte of
83        // headroom a 3-byte mutation still wedges, and the wedge is a function of
84        // the largest single mutation, which config cannot know. So this rule is
85        // NOT a wedge-freedom guarantee and must not be read as one; #3404 owns
86        // the real fix (flush a nonempty memtable before rejecting a mutation
87        // that fits by itself).
88        //
89        // It nonetheless requires STRICT headroom, because equality is
90        // qualitatively worse than any positive headroom rather than merely one
91        // step along a continuum. For a mutation of `m` bytes the wedge window is
92        // `m - headroom` bytes wide, so at equality an ORDINARY 4 KiB write
93        // wedges over a 4 KiB window of memtable sizes — a state normal operation
94        // passes through routinely — while at the default 192 MiB of headroom
95        // even a 64 MiB mutation cannot wedge at all. Equality also has no
96        // legitimate use: it asks the engine to flush at exactly the size where
97        // it must instead reject. Rejecting it removes the only regime in which
98        // everyday writes livelock, which is worth doing even though it proves
99        // nothing about the general case.
100        if self.storage.memtable_hard_limit <= self.storage.memtable_size_threshold {
101            return Err(crate::Error::configuration(format!(
102                "memtable_hard_limit ({} bytes) must be strictly greater than \
103                 memtable_size_threshold ({} bytes); with no headroom between them \
104                 an ordinary write is rejected at the ceiling while the memtable \
105                 sits below the flush trigger, and retrying it never recovers",
106                self.storage.memtable_hard_limit, self.storage.memtable_size_threshold
107            )));
108        }
109
110        // Validate the STCS thresholds threaded into the write engine (#1697).
111        // `STCSPolicy::new` rejects these too, but failing here surfaces the
112        // problem at config time rather than at engine construction.
113        //
114        // ONLY when `auto_compaction` is on (#1697 roborev r4). Both fields are
115        // documented as "Ignored when `auto_compaction` is `false`", and that is
116        // literally true of the code: `WriteEngine::new` constructs
117        // `STCSPolicy::new(min, max, ..)` inside `if config.auto_compaction`, and
118        // leaves the policy unset otherwise. Judging them unconditionally
119        // therefore rejected configurations that work — the thresholds are never
120        // read — while contradicting their own documented contract.
121        let compaction = &self.storage.compaction;
122        if compaction.auto_compaction && compaction.min_threshold == 0 {
123            return Err(crate::Error::configuration(
124                "compaction.min_threshold must be greater than 0",
125            ));
126        }
127        if compaction.auto_compaction && compaction.max_threshold < compaction.min_threshold {
128            return Err(crate::Error::configuration(format!(
129                "compaction.max_threshold ({}) must be >= compaction.min_threshold ({})",
130                compaction.max_threshold, compaction.min_threshold
131            )));
132        }
133
134        // Query execution budget (issue #1695). `Duration::ZERO` is the documented
135        // "no timeout" sentinel and is therefore explicitly LEGAL: validation must
136        // never reject it (pinned by `config_validate_accepts_the_zero_sentinel`
137        // in `tests/issue_1695_query_timeout.rs`). Every non-zero value is a real
138        // budget honoured at the engine chokepoint — a `Duration` cannot be
139        // negative and any positive budget is enforceable — so there is nothing
140        // further to reject here. This arm exists so a future "must be > 0" rule
141        // cannot be added without confronting the sentinel contract.
142
143        // `direct_io_memory_fraction` is a FRACTION of system RAM (issue #1696,
144        // AH3). Before this arm existed it was live but unvalidated: the reader's
145        // `resolve_disk_access_mode` silently CLAMPED nonsense — `<= 0.0`, NaN and
146        // the infinities fell back to the 0.5 default, and anything above `1.0`
147        // was pinned at `1.0`. An operator who wrote `2.0` (meaning "twice RAM")
148        // or `-1` therefore got the default and no word about it, which is the
149        // same dishonesty as a decorative knob: the value they set was not the
150        // value that ran.
151        //
152        // The rule itself, the range's endpoints and the reasoning for each live
153        // on `StorageConfig::validated_direct_io_memory_fraction`, because the
154        // open boundaries — `Database::open`, `StorageEngine::open`,
155        // `SSTableManager::new`, `SSTableReader::open` — enforce the same rule
156        // without going through here (#1696 roborev F2/r3 F2) and one rule must
157        // have one definition.
158        self.storage.validated_direct_io_memory_fraction()?;
159
160        Ok(())
161    }
162}
163
164impl StorageConfig {
165    /// [`Self::direct_io_memory_fraction`] if it is a legal fraction, else a
166    /// configuration error (issue #1696, AH3).
167    ///
168    /// # Why this is a method and not an inline check in `validate`
169    ///
170    /// It is enforced at EVERY public boundary that can act on the value, and
171    /// several of them are reachable without a `Database`: [`Config::validate`],
172    /// `Database::open`, `StorageEngine::open`, `StorageEngine::open_with_sstables`,
173    /// `SSTableManager::new`, `SSTableManager::new_from_discovered_paths` and
174    /// `SSTableReader::open`. That many call sites is precisely why the rule needs
175    /// ONE definition — restated inline they would drift.
176    ///
177    /// The discovery boundaries matter for a second reason (#1696 roborev r3 F2):
178    /// discovery treats a per-file reader-open error as best-effort, logging and
179    /// skipping it, so an unvalidated bad fraction there would fail every reader
180    /// open and the engine would report SUCCESS with ZERO SSTables — a silent
181    /// empty result instead of a named config error.
182    ///
183    /// # The rule, and why the ends of the range are where they are
184    ///
185    /// The legal range is the documented `(0.0, 1.0]`. Before this existed the
186    /// value was live but unvalidated: the reader's `resolve_disk_access_mode`
187    /// silently CLAMPED nonsense — `<= 0.0`, NaN and the infinities fell back to
188    /// the `0.5` default, and anything above `1.0` was pinned at `1.0`. An
189    /// operator who wrote `2.0` (meaning "twice RAM") or `-1` got the default and
190    /// no word about it, which is the same dishonesty as a decorative knob: the
191    /// value they set was not the value that ran.
192    ///
193    /// * **`1.0` is LEGAL** — "all of RAM" is a coherent ceiling.
194    /// * **`0.0` is REJECTED, and is NOT read as "never use direct I/O"** — that
195    ///   is the whole reason it cannot be accepted. A zero threshold makes EVERY
196    ///   nonempty file exceed it, so `Auto` would escalate everything to direct
197    ///   I/O: the value reads as "never" and behaves as "always". Inferring which
198    ///   one the operator meant would be a guess, and CQLite does not guess
199    ///   (issue #28). "Never use direct I/O" is spelled
200    ///   [`super::DiskAccessMode::Mmap`] (or [`super::DiskAccessMode::Buffered`]); "always" is
201    ///   spelled [`super::DiskAccessMode::Direct`].
202    /// * **A subnormal or otherwise tiny positive fraction is LEGAL** and is
203    ///   honoured LITERALLY: `1e-300` of RAM rounds to a 0-byte threshold, so
204    ///   every nonempty file uses direct I/O. That is the honest consequence of
205    ///   what was asked for, and unlike `0.0` it is unambiguous — a real, if
206    ///   degenerate, fraction rather than a value whose plain reading contradicts
207    ///   its behaviour. It is not clamped and not second-guessed.
208    /// * **NaN and both infinities are REJECTED.** The test is written as
209    ///   `!(fraction > 0.0 && fraction <= 1.0)` rather than a chain of `<`/`>`
210    ///   precisely so NaN — for which every ordered comparison is false — is
211    ///   rejected instead of sailing through.
212    ///
213    /// The reader keeps its internal clamp as defense in depth for any future
214    /// caller that reaches `resolve_disk_access_mode` without validating.
215    pub fn validated_direct_io_memory_fraction(&self) -> crate::Result<f64> {
216        let fraction = self.direct_io_memory_fraction;
217        if !(fraction > 0.0 && fraction <= 1.0) {
218            return Err(crate::Error::configuration(format!(
219                "direct_io_memory_fraction ({fraction}) must be a fraction of system memory in \
220                 (0.0, 1.0]; it is not a byte count, and a value outside that range was \
221                 previously clamped silently. For \"always bypass the page cache\" set \
222                 disk_access_mode = Direct; for \"never\" set Mmap or Buffered"
223            )));
224        }
225        Ok(fraction)
226    }
227}