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}