linkrs_query/executor/streaming/chunk/policy.rs
1//! ColumnarPolicy Module
2//!
3//! Cross-query adaptive policy for the typed columnar chunk layout.
4//!
5//! The policy learns from per-query [`crate::executor::streaming::runtime::ColumnarStats`]:
6//! each query's columnar hit/miss counters are merged into the shared policy
7//! at query completion, and the policy decides whether the typed columnar
8//! path is worth building for subsequent queries.
9
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12
13use crate::executor::streaming::runtime::ExecutionRuntime;
14
15/// Whether the typed columnar layout should be built for this operator's
16/// chunks.
17///
18/// The shared [`ColumnarPolicy`] provides the adaptive decision; a per-query
19/// override (carried by the runtime) wins over it. The policy is only mutated
20/// between queries (stats merge at query completion), so the decision is
21/// stable for the whole query even though it is read per chunk. Operators
22/// that produce a fresh column set (storage scan, join outputs) gate their
23/// `build_typed_columns` call on this so a disabled policy pays nothing.
24/// Small 1:1 rebuilds (project, assign) rebuild unconditionally by design:
25/// their output is bounded by the input chunk and the downstream fast path
26/// benefit outweighs the build cost, so they skip this gate.
27pub(crate) fn use_columnar_path(runtime: &Option<Arc<ExecutionRuntime>>) -> bool {
28 let query_override = runtime
29 .as_ref()
30 .map(|runtime| runtime.columnar_override())
31 .unwrap_or_else(QueryColumnarOverride::inherit);
32 runtime
33 .as_ref()
34 .and_then(|runtime| runtime.columnar_policy())
35 .is_none_or(|policy| policy.should_use_columnar_with(query_override))
36}
37
38/// Adaptive gate for the typed columnar chunk layout (cross-query shared).
39///
40/// Uses accumulated columnar hit/miss counts with a hit-rate threshold and a
41/// minimum sample count. Below `min_samples` the policy defaults to
42/// columnar (matching the historical default-on behavior, so there is no
43/// performance regression at startup).
44///
45/// # Thread Safety
46///
47/// All counters are `Relaxed` atomics; `should_use_columnar` never blocks.
48/// The policy values are only mutated between queries (when merging per-query
49/// snapshots), so reads within a single query are stable and a query never
50/// flips layout mid-flight.
51#[derive(Debug)]
52pub struct ColumnarPolicy {
53 /// Cumulative columnar fast-path hits across queries.
54 hits: AtomicU64,
55 /// Cumulative row-wise fallbacks across queries.
56 misses: AtomicU64,
57 /// Hit-rate threshold: use the columnar path when hits/total >= this.
58 threshold: f64,
59 /// Minimum total samples before the threshold decision applies.
60 min_samples: u64,
61}
62
63impl ColumnarPolicy {
64 /// Create a policy with explicit threshold and minimum sample count.
65 pub fn new(threshold: f64, min_samples: u64) -> Self {
66 Self {
67 hits: AtomicU64::new(0),
68 misses: AtomicU64::new(0),
69 threshold: threshold.clamp(0.0, 1.0),
70 min_samples: min_samples.max(1),
71 }
72 }
73
74 /// Decide whether the typed columnar path should be used.
75 ///
76 /// Returns `true` while the sample count is below `min_samples`
77 /// (default-on), and thereafter when the cumulative hit rate stays at or
78 /// above the threshold.
79 pub fn should_use_columnar(&self) -> bool {
80 self.should_use_columnar_with(QueryColumnarOverride::inherit())
81 }
82
83 /// Decide with a per-query override applied first.
84 ///
85 /// A forced override wins over the adaptive decision so a single query
86 /// can be pinned without mutating the shared counters; otherwise the
87 /// shared threshold logic applies unchanged.
88 pub fn should_use_columnar_with(&self, query_override: QueryColumnarOverride) -> bool {
89 if let Some(forced) = query_override.force_columnar {
90 return forced;
91 }
92 let total = self.hits.load(Ordering::Relaxed) + self.misses.load(Ordering::Relaxed);
93 if total < self.min_samples {
94 return true;
95 }
96 self.hits.load(Ordering::Relaxed) as f64 / total as f64 >= self.threshold
97 }
98
99 /// Record a single hit / miss decision (used by tests and single-thread
100 /// simulations).
101 pub fn record(&self, hit: bool) {
102 if hit {
103 self.record_hit();
104 } else {
105 self.record_miss();
106 }
107 }
108
109 /// Record one columnar fast-path hit.
110 pub fn record_hit(&self) {
111 self.hits.fetch_add(1, Ordering::Relaxed);
112 }
113
114 /// Record one row-wise fallback.
115 pub fn record_miss(&self) {
116 self.misses.fetch_add(1, Ordering::Relaxed);
117 }
118
119 /// Merge a per-query columnar snapshot into the shared counters.
120 ///
121 /// Called once per completed query (materialized and streaming paths);
122 /// `hits` / `misses` are the deltas of that query's
123 /// [`crate::executor::streaming::runtime::ColumnarStats`].
124 pub fn merge(&self, hits: u64, misses: u64) {
125 if hits > 0 {
126 self.hits.fetch_add(hits, Ordering::Relaxed);
127 }
128 if misses > 0 {
129 self.misses.fetch_add(misses, Ordering::Relaxed);
130 }
131 }
132
133 /// Point-in-time snapshot of `(hits, misses)`.
134 pub fn snapshot(&self) -> (u64, u64) {
135 (
136 self.hits.load(Ordering::Relaxed),
137 self.misses.load(Ordering::Relaxed),
138 )
139 }
140
141 /// Cumulative hit rate over all recorded samples (1.0 when empty).
142 pub fn hit_rate(&self) -> f64 {
143 let (hits, misses) = self.snapshot();
144 let total = hits + misses;
145 if total == 0 {
146 1.0
147 } else {
148 hits as f64 / total as f64
149 }
150 }
151
152 /// The configured hit-rate threshold.
153 pub fn threshold(&self) -> f64 {
154 self.threshold
155 }
156
157 /// The configured minimum sample count.
158 pub fn min_samples(&self) -> u64 {
159 self.min_samples
160 }
161}
162
163impl Default for ColumnarPolicy {
164 fn default() -> Self {
165 Self::new(0.8, 100)
166 }
167}
168
169/// Per-query override for the shared [`ColumnarPolicy`] decision.
170///
171/// `None` (default) inherits the adaptive global decision; `Some(true/false)`
172/// forces the columnar path on/off for one query. Carried by
173/// [`crate::executor::streaming::runtime::ExecutionRuntime`]; a future SQL
174/// hint may populate it, but today only internal callers set it.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct QueryColumnarOverride {
177 pub force_columnar: Option<bool>,
178}
179
180impl QueryColumnarOverride {
181 /// Inherit the shared policy decision.
182 pub fn inherit() -> Self {
183 Self {
184 force_columnar: None,
185 }
186 }
187
188 /// Force the columnar path on/off for one query.
189 pub fn force(enabled: bool) -> Self {
190 Self {
191 force_columnar: Some(enabled),
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn test_default_on_below_min_samples() {
202 let policy = ColumnarPolicy::new(0.8, 100);
203 // No samples at all: default to columnar.
204 assert!(policy.should_use_columnar());
205 // Below the sample floor the decision stays columnar even when the
206 // early hit rate is poor.
207 for _ in 0..50 {
208 policy.record_miss();
209 }
210 assert!(policy.should_use_columnar());
211 }
212
213 #[test]
214 fn test_flips_to_row_after_enough_fallbacks() {
215 let policy = ColumnarPolicy::new(0.8, 100);
216 // 150 samples with 90% fallbacks: hit rate 0.1 < 0.8 -> row path.
217 for _ in 0..15 {
218 policy.record_hit();
219 }
220 for _ in 0..135 {
221 policy.record_miss();
222 }
223 assert!(!policy.should_use_columnar());
224 assert_eq!(policy.snapshot(), (15, 135));
225 }
226
227 #[test]
228 fn test_recovers_to_columnar_after_hits() {
229 let policy = ColumnarPolicy::new(0.8, 100);
230 for _ in 0..100 {
231 policy.record_miss();
232 }
233 assert!(!policy.should_use_columnar());
234
235 // Sustained hits flip the decision back.
236 for _ in 0..400 {
237 policy.record_hit();
238 }
239 assert!(policy.should_use_columnar());
240 }
241
242 #[test]
243 fn test_merge_accumulates_deltas() {
244 let policy = ColumnarPolicy::new(0.8, 100);
245 policy.merge(60, 40);
246 policy.merge(60, 40);
247 assert_eq!(policy.snapshot(), (120, 80));
248 // 60% hit rate: below the 0.8 threshold, so the row path wins.
249 assert!(policy.hit_rate() > 0.5);
250 assert!(!policy.should_use_columnar());
251
252 // Sustained hits from later queries bring the rate back up.
253 for _ in 0..8 {
254 policy.merge(60, 0);
255 }
256 assert!(policy.should_use_columnar());
257 }
258
259 #[test]
260 fn test_query_override_pins_decision() {
261 let policy = ColumnarPolicy::new(0.8, 10);
262 for _ in 0..10 {
263 policy.record_miss();
264 }
265 assert!(!policy.should_use_columnar());
266 assert!(policy.should_use_columnar_with(QueryColumnarOverride::force(true)));
267 assert!(!policy.should_use_columnar_with(QueryColumnarOverride::force(false)));
268 assert!(!policy.should_use_columnar_with(QueryColumnarOverride::inherit()));
269 }
270
271 #[test]
272 fn test_threshold_boundary() {
273 // Exactly at the threshold: columnar stays selected.
274 let policy = ColumnarPolicy::new(0.5, 100);
275 for _ in 0..50 {
276 policy.record_hit();
277 policy.record_miss();
278 }
279 assert!(policy.should_use_columnar());
280 }
281}