Skip to main content

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}