Skip to main content

flow/batching_mode/
state.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Batching mode task state, which changes frequently
16//!
17
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::time::Duration;
20
21use common_telemetry::debug;
22use common_time::Timestamp;
23use datatypes::value::Value;
24use session::context::QueryContextRef;
25use snafu::{OptionExt, ResultExt, ensure};
26use tokio::sync::oneshot;
27use tokio::time::Instant;
28
29use crate::batching_mode::task::BatchingTask;
30use crate::batching_mode::time_window::TimeWindowExpr;
31use crate::error::{DatatypesSnafu, InternalSnafu, TimeSnafu, UnexpectedSnafu};
32use crate::metrics::{
33    METRIC_FLOW_BATCHING_ENGINE_QUERY_WINDOW_CNT, METRIC_FLOW_BATCHING_ENGINE_QUERY_WINDOW_SIZE,
34    METRIC_FLOW_BATCHING_ENGINE_STALLED_WINDOW_SIZE,
35};
36use crate::{Error, FlowId};
37
38/// The state of the [`BatchingTask`].
39#[derive(Debug)]
40pub struct TaskState {
41    /// Query context
42    pub(crate) query_ctx: QueryContextRef,
43    /// last query complete time
44    last_update_time: Instant,
45    /// last time query duration
46    last_query_duration: Duration,
47    /// Last successful execution time in unix timestamp milliseconds.
48    last_exec_time_millis: Option<i64>,
49    /// First execution time in unix timestamp milliseconds, set once.
50    start_time_millis: Option<i64>,
51    /// Dirty Time windows need to be updated
52    /// mapping of `start -> end` and non-overlapping
53    pub(crate) dirty_time_windows: DirtyTimeWindows,
54    checkpoint_mode: CheckpointMode,
55    pending_fenced_repair: Option<FencedRepair>,
56    /// Region id -> last consumed watermark sequence. Incremental scans use
57    /// this as the next lower sequence bound for each source region.
58    checkpoints: BTreeMap<u64, u64>,
59    /// Once set, the task will never attempt incremental mode again.
60    /// Set when the flow's query shape is deterministically incompatible
61    /// with incremental execution (e.g. unsupported aggregate expressions).
62    incremental_disabled: bool,
63    exec_state: ExecState,
64    /// Shutdown receiver
65    pub(crate) shutdown_rx: oneshot::Receiver<()>,
66    /// Task handle
67    pub(crate) task_handle: Option<tokio::task::JoinHandle<()>>,
68}
69impl TaskState {
70    pub fn new(query_ctx: QueryContextRef, shutdown_rx: oneshot::Receiver<()>) -> Self {
71        Self::with_dirty_time_windows(query_ctx, shutdown_rx, DirtyTimeWindows::default())
72    }
73
74    pub fn with_dirty_time_windows(
75        query_ctx: QueryContextRef,
76        shutdown_rx: oneshot::Receiver<()>,
77        dirty_time_windows: DirtyTimeWindows,
78    ) -> Self {
79        Self {
80            query_ctx,
81            last_update_time: Instant::now(),
82            last_query_duration: Duration::from_secs(0),
83            last_exec_time_millis: None,
84            start_time_millis: None,
85            dirty_time_windows,
86            checkpoint_mode: CheckpointMode::FullSnapshot,
87            pending_fenced_repair: None,
88            checkpoints: Default::default(),
89            incremental_disabled: false,
90            exec_state: ExecState::Idle,
91            shutdown_rx,
92            task_handle: None,
93        }
94    }
95
96    /// Record the first-execution start time. Call this once, just before
97    /// the first frontend query is dispatched, not after it completes.
98    pub fn record_start_time_if_first(&mut self) {
99        if self.start_time_millis.is_none() {
100            // start_time is recorded just before the first frontend query is dispatched
101            // (pre-execution), so it may be marginally earlier than the streaming engine's
102            // start_time which is set post-execution. Both are valid approximations of
103            // "when this flow first ran".
104            self.start_time_millis = Some(common_time::util::current_time_millis());
105        }
106    }
107
108    pub fn after_query_exec(&mut self, elapsed: Duration, is_succ: bool) {
109        self.exec_state = ExecState::Idle;
110        self.last_query_duration = elapsed;
111        self.last_update_time = Instant::now();
112        if is_succ {
113            self.last_exec_time_millis = Some(common_time::util::current_time_millis());
114        }
115    }
116
117    pub fn last_execution_time_millis(&self) -> Option<i64> {
118        self.last_exec_time_millis
119    }
120
121    /// First execution time in unix timestamp milliseconds, set once.
122    pub fn start_time_millis(&self) -> Option<i64> {
123        self.start_time_millis
124    }
125
126    pub fn checkpoint_mode(&self) -> CheckpointMode {
127        self.checkpoint_mode
128    }
129
130    pub fn checkpoints(&self) -> &BTreeMap<u64, u64> {
131        &self.checkpoints
132    }
133
134    /// Returns the in-progress fenced repair, if the task is repairing dirty
135    /// windows under a frozen full-snapshot high watermark.
136    pub fn pending_fenced_repair(&self) -> Option<&FencedRepair> {
137        self.pending_fenced_repair.as_ref()
138    }
139
140    pub fn is_incremental_disabled(&self) -> bool {
141        self.incremental_disabled
142    }
143
144    /// Permanently disable incremental mode for this task and
145    /// immediately fall back to full snapshot for the current cycle.
146    pub fn disable_incremental(&mut self) {
147        self.incremental_disabled = true;
148        self.mark_full_snapshot();
149    }
150
151    /// Move back to top-level FullSnapshot mode. If a fenced repair is active,
152    /// restore its not-yet-in-flight pending windows to the live dirty queue so
153    /// the moved backlog is not lost.
154    pub fn mark_full_snapshot(&mut self) {
155        self.abandon_fenced_repair();
156    }
157
158    /// Replace full-snapshot checkpoints with a complete watermark proof.
159    /// Clears fenced repair state and enters Incremental unless disabled.
160    pub fn advance_checkpoints(&mut self, watermark_map: HashMap<u64, u64>) {
161        self.checkpoints = watermark_map.into_iter().collect();
162        self.pending_fenced_repair = None;
163        if !self.incremental_disabled {
164            self.checkpoint_mode = CheckpointMode::Incremental;
165        }
166    }
167
168    /// Advance only the participating regions for an incremental delta query.
169    /// This also clears any stale fenced repair sub-state.
170    pub fn advance_incremental_checkpoints_with_participation(
171        &mut self,
172        participating_regions: &BTreeSet<u64>,
173        watermark_map: HashMap<u64, u64>,
174    ) {
175        for region_id in participating_regions {
176            if let Some(seq) = watermark_map.get(region_id) {
177                self.checkpoints.insert(*region_id, *seq);
178            }
179        }
180        if !self.incremental_disabled {
181            self.checkpoint_mode = CheckpointMode::Incremental;
182        }
183        self.pending_fenced_repair = None;
184    }
185
186    /// Start repairing the current live dirty windows under a frozen high `H`.
187    /// The current live backlog is moved into the fenced repair so successful
188    /// chunks are consumed from that backlog. New post-`H` dirty signals can
189    /// still arrive in the live queue while the fenced repair is active.
190    pub fn start_fenced_repair(&mut self, high: BTreeMap<u64, u64>) -> Option<&FencedRepair> {
191        if self.dirty_time_windows.is_empty() {
192            self.pending_fenced_repair = None;
193            return None;
194        }
195
196        let pending_windows = self.dirty_time_windows.clone();
197        self.dirty_time_windows.clean();
198        self.pending_fenced_repair = Some(FencedRepair {
199            high,
200            pending_windows,
201        });
202        self.checkpoint_mode = CheckpointMode::FullSnapshot;
203        self.pending_fenced_repair.as_ref()
204    }
205
206    /// Finish the fenced repair and promote the frozen high watermark to the
207    /// checkpoint map. Incremental-disabled flows stay in FullSnapshot mode.
208    pub fn finish_fenced_repair(&mut self) -> Option<BTreeMap<u64, u64>> {
209        let repair = self.pending_fenced_repair.take()?;
210        self.checkpoints = repair.high;
211        if !self.incremental_disabled {
212            self.checkpoint_mode = CheckpointMode::Incremental;
213        }
214        Some(self.checkpoints.clone())
215    }
216
217    /// Abandon the current fenced repair and restore all not-yet-in-flight
218    /// pending windows to the live dirty queue for a fresh scoped repair.
219    pub fn abandon_fenced_repair(&mut self) -> bool {
220        self.checkpoint_mode = CheckpointMode::FullSnapshot;
221        let Some(repair) = self.pending_fenced_repair.take() else {
222            return false;
223        };
224
225        self.dirty_time_windows
226            .add_dirty_windows(&repair.pending_windows);
227        true
228    }
229
230    /// Restore a scoped query's windows after a failed or unproven run. During
231    /// an active fenced repair this requeues into `pending_windows`; otherwise
232    /// it restores to the live dirty queue.
233    pub fn restore_scoped_windows(&mut self, filter: &FilterExprInfo) {
234        if let Some(repair) = self.pending_fenced_repair.as_mut() {
235            repair
236                .pending_windows
237                .add_windows(filter.time_ranges.clone());
238            return;
239        }
240
241        self.dirty_time_windows
242            .add_windows(filter.time_ranges.clone());
243    }
244
245    /// Generate the next scoped filter from the fenced-repair queue when active;
246    /// otherwise consume windows from the live dirty queue.
247    pub fn gen_scoped_filter_exprs(
248        &mut self,
249        col_name: &str,
250        expire_lower_bound: Option<Timestamp>,
251        window_size: chrono::Duration,
252        window_cnt: usize,
253        flow_id: FlowId,
254        task_ctx: Option<&BatchingTask>,
255    ) -> Result<Option<FilterExprInfo>, Error> {
256        if let Some(repair) = self.pending_fenced_repair.as_mut() {
257            let expr = repair.pending_windows.gen_filter_exprs(
258                col_name,
259                expire_lower_bound,
260                window_size,
261                window_cnt,
262                flow_id,
263                task_ctx,
264            )?;
265            if expr.is_some() || !repair.pending_windows.is_empty() {
266                return Ok(expr);
267            }
268
269            // All pending repair windows may have expired during merge. Clear
270            // the empty repair so this call can fall back to live dirty windows
271            // instead of routing future executions to an empty queue forever.
272            self.pending_fenced_repair = None;
273        }
274
275        self.dirty_time_windows.gen_filter_exprs(
276            col_name,
277            expire_lower_bound,
278            window_size,
279            window_cnt,
280            flow_id,
281            task_ctx,
282        )
283    }
284
285    /// Returns true only when the query result's participating regions and
286    /// terminal watermarks exactly match the fenced repair's frozen high `H`.
287    pub fn fenced_repair_watermarks_match_high(
288        &self,
289        participating_regions: &BTreeSet<u64>,
290        watermark_map: &HashMap<u64, u64>,
291    ) -> bool {
292        let Some(repair) = self.pending_fenced_repair.as_ref() else {
293            return false;
294        };
295
296        !participating_regions.is_empty()
297            && participating_regions.len() == repair.high.len()
298            && watermark_map.len() == repair.high.len()
299            && participating_regions.iter().all(|region_id| {
300                repair
301                    .high
302                    .get(region_id)
303                    .zip(watermark_map.get(region_id))
304                    .is_some_and(|(high, watermark)| high == watermark)
305            })
306    }
307
308    /// Whether the active fenced repair has drained all pending windows.
309    pub fn fenced_repair_pending_is_empty(&self) -> bool {
310        self.pending_fenced_repair
311            .as_ref()
312            .is_some_and(|repair| repair.pending_windows.is_empty())
313    }
314
315    /// Full-snapshot checkpoint advances require a watermark for every region
316    /// that participated in the query.
317    pub fn can_advance_full_snapshot_checkpoints(
318        &self,
319        participating_regions: &BTreeSet<u64>,
320        watermark_map: &HashMap<u64, u64>,
321    ) -> bool {
322        !participating_regions.is_empty()
323            && participating_regions.len() == watermark_map.len()
324            && participating_regions
325                .iter()
326                .all(|region_id| watermark_map.contains_key(region_id))
327    }
328
329    /// Incremental advances are limited to participating regions whose returned
330    /// watermark is not older than the stored checkpoint.
331    pub fn can_advance_incremental_checkpoints_with_participation(
332        &self,
333        participating_regions: &BTreeSet<u64>,
334        watermark_map: &HashMap<u64, u64>,
335    ) -> bool {
336        !self.incremental_disabled
337            && !self.checkpoints.is_empty()
338            && !participating_regions.is_empty()
339            && participating_regions.len() == watermark_map.len()
340            && participating_regions
341                .iter()
342                .all(|region_id| self.checkpoints.contains_key(region_id))
343            && participating_regions.iter().all(|region_id| {
344                let checkpoint = self.checkpoints.get(region_id);
345                watermark_map
346                    .get(region_id)
347                    .zip(checkpoint)
348                    .is_some_and(|(seq, checkpoint)| seq >= checkpoint)
349            })
350    }
351
352    /// Compute the next query delay based on the time window size or the last query duration.
353    /// Aiming to avoid too frequent queries. But also not too long delay.
354    ///
355    /// next wait time is calculated as:
356    /// last query duration, capped by [max(min_run_interval, time_window_size), max_timeout],
357    /// note at most wait for `max_timeout`.
358    ///
359    /// if current the dirty time range is longer than one query can handle,
360    /// execute immediately to faster clean up dirty time windows.
361    /// Active fenced repairs also execute immediately while pending windows
362    /// remain: the current backlog has moved out of live dirty windows and into
363    /// `pending_fenced_repair.pending_windows`.
364    ///
365    /// If `prefer_short_incremental_cadence` is true, run incremental queries
366    /// more often when there is no large dirty backlog. This only reduces the
367    /// chance of hitting a stale cursor after flush; it is not required for
368    /// correctness.
369    pub fn get_next_start_query_time(
370        &self,
371        flow_id: FlowId,
372        time_window_size: &Option<Duration>,
373        min_refresh_duration: Duration,
374        max_timeout: Option<Duration>,
375        max_filter_num_per_query: usize,
376        prefer_short_incremental_cadence: bool,
377    ) -> Instant {
378        // = last query duration, capped by [max(min_run_interval, time_window_size), max_timeout], note at most `max_timeout`
379        let lower = time_window_size.unwrap_or(min_refresh_duration);
380        let next_duration = self.last_query_duration.max(lower);
381        let next_duration = if let Some(max_timeout) = max_timeout {
382            next_duration.min(max_timeout)
383        } else {
384            next_duration
385        };
386
387        if self
388            .pending_fenced_repair
389            .as_ref()
390            .is_some_and(|repair| !repair.pending_windows().is_empty())
391        {
392            debug!(
393                "Flow id = {}, active fenced repair still has pending windows, execute immediately",
394                flow_id,
395            );
396            return Instant::now();
397        }
398
399        let cur_dirty_window_size = self.dirty_time_windows.window_size();
400        // compute how much time range can be handled in one query
401        let max_query_update_range = (*time_window_size)
402            .unwrap_or_default()
403            .mul_f64(max_filter_num_per_query as f64);
404        // if dirty time range is more than one query can handle, execute immediately
405        // to faster clean up dirty time windows
406        if cur_dirty_window_size < max_query_update_range {
407            if prefer_short_incremental_cadence {
408                // Run incremental queries sooner than the normal time-window
409                // cadence, while still backing off by at least the previous
410                // query duration and respecting the max-timeout cap.
411                let next_duration = self.last_query_duration.max(min_refresh_duration);
412                let next_duration = if let Some(max_timeout) = max_timeout {
413                    next_duration.min(max_timeout)
414                } else {
415                    next_duration
416                };
417                self.last_update_time + next_duration
418            } else {
419                self.last_update_time + next_duration
420            }
421        } else {
422            // if dirty time windows can't be clean up in one query, execute immediately to faster
423            // clean up dirty time windows
424            debug!(
425                "Flow id = {}, still have too many {} dirty time window({:?}), execute immediately",
426                flow_id,
427                self.dirty_time_windows.windows.len(),
428                self.dirty_time_windows.windows
429            );
430            Instant::now()
431        }
432    }
433}
434
435/// For keep recording of dirty time windows, which is time window that have new data inserted
436/// since last query.
437#[derive(Debug, Clone)]
438pub struct DirtyTimeWindows {
439    /// windows's `start -> end` and non-overlapping
440    /// `end` is exclusive(and optional)
441    windows: BTreeMap<Timestamp, Option<Timestamp>>,
442    /// Maximum number of filters allowed in a single query
443    max_filter_num_per_query: usize,
444    /// Time window merge distance
445    ///
446    time_window_merge_threshold: usize,
447}
448
449impl DirtyTimeWindows {
450    pub fn new(max_filter_num_per_query: usize, time_window_merge_threshold: usize) -> Self {
451        Self {
452            windows: BTreeMap::new(),
453            max_filter_num_per_query,
454            time_window_merge_threshold,
455        }
456    }
457
458    #[cfg(test)]
459    pub(crate) fn max_filter_num_per_query(&self) -> usize {
460        self.max_filter_num_per_query
461    }
462
463    #[cfg(test)]
464    pub(crate) fn time_window_merge_threshold(&self) -> usize {
465        self.time_window_merge_threshold
466    }
467}
468
469impl Default for DirtyTimeWindows {
470    fn default() -> Self {
471        Self {
472            windows: BTreeMap::new(),
473            max_filter_num_per_query: 20,
474            time_window_merge_threshold: 3,
475        }
476    }
477}
478
479impl DirtyTimeWindows {
480    /// Time window merge distance
481    ///
482    /// TODO(discord9): make those configurable
483    pub const MERGE_DIST: i32 = 3;
484
485    /// Add lower bounds to the dirty time windows. Upper bounds are ignored.
486    ///
487    /// # Arguments
488    ///
489    /// * `lower_bounds` - An iterator of lower bounds to be added.
490    pub fn add_lower_bounds(&mut self, lower_bounds: impl Iterator<Item = Timestamp>) {
491        for lower_bound in lower_bounds {
492            let entry = self.windows.entry(lower_bound);
493            entry.or_insert(None);
494        }
495    }
496
497    pub fn window_size(&self) -> Duration {
498        let mut ret = Duration::from_secs(0);
499        for (start, end) in &self.windows {
500            if let Some(end) = end
501                && let Some(duration) = end.sub(start)
502            {
503                ret += duration.to_std().unwrap_or_default();
504            }
505        }
506        ret
507    }
508
509    pub fn add_window(&mut self, start: Timestamp, end: Option<Timestamp>) {
510        self.add_or_merge_window(start, end);
511    }
512
513    pub fn add_windows(&mut self, time_ranges: Vec<(Timestamp, Timestamp)>) {
514        for (start, end) in time_ranges {
515            self.add_or_merge_window(start, Some(end));
516        }
517    }
518
519    /// Add all dirty markers from another dirty-window set.
520    pub fn add_dirty_windows(&mut self, dirty_windows: &DirtyTimeWindows) {
521        for (start, end) in &dirty_windows.windows {
522            self.add_or_merge_window(*start, *end);
523        }
524    }
525
526    fn add_or_merge_window(&mut self, start: Timestamp, end: Option<Timestamp>) {
527        self.windows
528            .entry(start)
529            .and_modify(|current_end| {
530                *current_end = Self::union_window_end(*current_end, end);
531            })
532            .or_insert(end);
533    }
534
535    fn union_window_end(
536        current_end: Option<Timestamp>,
537        incoming_end: Option<Timestamp>,
538    ) -> Option<Timestamp> {
539        match (current_end, incoming_end) {
540            (Some(current), Some(incoming)) => Some(current.max(incoming)),
541            // `None` is a dirty marker without a known upper bound.  When one
542            // side has a concrete end, keep it so merging a restored snapshot
543            // never shrinks an already-known dirty range with the same start.
544            (Some(end), None) | (None, Some(end)) => Some(end),
545            (None, None) => None,
546        }
547    }
548
549    /// Clean all dirty time windows, useful when can't found time window expr
550    pub fn clean(&mut self) {
551        self.windows.clear();
552    }
553
554    /// Set windows to be dirty, only useful for full aggr without time window
555    /// to mark some new data is inserted
556    pub fn set_dirty(&mut self) {
557        self.add_or_merge_window(Timestamp::new_second(0), None);
558    }
559
560    /// Number of dirty windows.
561    pub fn len(&self) -> usize {
562        self.windows.len()
563    }
564
565    pub fn is_empty(&self) -> bool {
566        self.windows.is_empty()
567    }
568
569    /// Get the effective count of time windows, which is the number of time windows that can be
570    /// used for query, compute from total time window range divided by `window_size`.
571    pub fn effective_count(&self, window_size: &Duration) -> usize {
572        if self.windows.is_empty() {
573            return 0;
574        }
575        let window_size =
576            chrono::Duration::from_std(*window_size).unwrap_or(chrono::Duration::zero());
577        let total_window_time_range =
578            self.windows
579                .iter()
580                .fold(chrono::Duration::zero(), |acc, (start, end)| {
581                    if let Some(end) = end {
582                        acc + end.sub(start).unwrap_or(chrono::Duration::zero())
583                    } else {
584                        acc + window_size
585                    }
586                });
587
588        // not sure window_size is zero have any meaning, but just in case
589        if window_size.num_seconds() == 0 {
590            0
591        } else {
592            (total_window_time_range.num_seconds() / window_size.num_seconds()) as usize
593        }
594    }
595
596    /// Generate all filter expressions consuming all time windows
597    ///
598    /// there is two limits:
599    /// - shouldn't return a too long time range(<=`window_size * window_cnt`), so that the query can be executed in a reasonable time
600    /// - shouldn't return too many time range exprs, so that the query can be parsed properly instead of causing parser to overflow
601    pub fn gen_filter_exprs(
602        &mut self,
603        col_name: &str,
604        expire_lower_bound: Option<Timestamp>,
605        window_size: chrono::Duration,
606        window_cnt: usize,
607        flow_id: FlowId,
608        task_ctx: Option<&BatchingTask>,
609    ) -> Result<Option<FilterExprInfo>, Error> {
610        ensure!(
611            window_size.num_seconds() > 0,
612            UnexpectedSnafu {
613                reason: "window_size is zero, can't generate filter exprs",
614            }
615        );
616
617        debug!(
618            "expire_lower_bound: {:?}, window_size: {:?}",
619            expire_lower_bound.map(|t| t.to_iso8601_string()),
620            window_size
621        );
622        self.merge_dirty_time_windows(window_size, expire_lower_bound)?;
623
624        if self.windows.len() > window_cnt {
625            let first_time_window = self.windows.first_key_value();
626            let last_time_window = self.windows.last_key_value();
627
628            if let Some(task_ctx) = task_ctx {
629                debug!(
630                    "Flow id = {:?}, too many time windows: {}, only the first {} are taken for this query, the group by expression might be wrong. Time window expr={:?}, expire_after={:?}, first_time_window={:?}, last_time_window={:?}, the original query: {:?}",
631                    task_ctx.config.flow_id,
632                    self.windows.len(),
633                    window_cnt,
634                    task_ctx.config.time_window_expr,
635                    task_ctx.config.expire_after,
636                    first_time_window,
637                    last_time_window,
638                    task_ctx.config.query
639                );
640            } else {
641                debug!(
642                    "Flow id = {:?}, too many time windows: {}, only the first {} are taken for this query, the group by expression might be wrong. first_time_window={:?}, last_time_window={:?}",
643                    flow_id,
644                    self.windows.len(),
645                    window_cnt,
646                    first_time_window,
647                    last_time_window
648                )
649            }
650        }
651
652        // get the first `window_cnt` time windows
653        let max_time_range = window_size * window_cnt as i32;
654
655        let mut to_be_query = BTreeMap::new();
656        let mut new_windows = self.windows.clone();
657        let mut cur_time_range = chrono::Duration::zero();
658        for (idx, (start, end)) in self.windows.iter().enumerate() {
659            let first_end = start
660                .add_duration(window_size.to_std().unwrap())
661                .context(TimeSnafu)?;
662            let end = end.unwrap_or(first_end);
663
664            // if time range is too long, stop
665            if cur_time_range >= max_time_range {
666                break;
667            }
668
669            // if we have enough time windows, stop
670            if idx >= window_cnt {
671                break;
672            }
673
674            let Some(x) = end.sub(start) else {
675                continue;
676            };
677            if cur_time_range + x <= max_time_range {
678                to_be_query.insert(*start, Some(end));
679                new_windows.remove(start);
680                cur_time_range += x;
681            } else {
682                // too large a window, split it
683                // split at window_size * times
684                let surplus = max_time_range - cur_time_range;
685                if surplus.num_seconds() <= window_size.num_seconds() {
686                    // Skip splitting if surplus is smaller than window_size
687                    break;
688                }
689                let times = surplus.num_seconds() / window_size.num_seconds();
690
691                let split_offset = window_size * times as i32;
692                let split_at = start
693                    .add_duration(split_offset.to_std().unwrap())
694                    .context(TimeSnafu)?;
695                to_be_query.insert(*start, Some(split_at));
696
697                // remove the original window
698                new_windows.remove(start);
699                new_windows.insert(split_at, Some(end));
700                cur_time_range += split_offset;
701                break;
702            }
703        }
704
705        self.windows = new_windows;
706
707        METRIC_FLOW_BATCHING_ENGINE_QUERY_WINDOW_CNT
708            .with_label_values(&[flow_id.to_string().as_str()])
709            .observe(to_be_query.len() as f64);
710
711        let full_time_range = to_be_query
712            .iter()
713            .fold(chrono::Duration::zero(), |acc, (start, end)| {
714                if let Some(end) = end {
715                    acc + end.sub(start).unwrap_or(chrono::Duration::zero())
716                } else {
717                    acc + window_size
718                }
719            })
720            .num_seconds() as f64;
721        METRIC_FLOW_BATCHING_ENGINE_QUERY_WINDOW_SIZE
722            .with_label_values(&[flow_id.to_string().as_str()])
723            .observe(full_time_range);
724
725        let stalled_time_range =
726            self.windows
727                .iter()
728                .fold(chrono::Duration::zero(), |acc, (start, end)| {
729                    if let Some(end) = end {
730                        acc + end.sub(start).unwrap_or(chrono::Duration::zero())
731                    } else {
732                        acc + window_size
733                    }
734                });
735
736        METRIC_FLOW_BATCHING_ENGINE_STALLED_WINDOW_SIZE
737            .with_label_values(&[flow_id.to_string().as_str()])
738            .observe(stalled_time_range.num_seconds() as f64);
739
740        let std_window_size = window_size.to_std().map_err(|e| {
741            InternalSnafu {
742                reason: e.to_string(),
743            }
744            .build()
745        })?;
746
747        let mut expr_lst = vec![];
748        let mut time_ranges = vec![];
749        for (start, end) in to_be_query.into_iter() {
750            // align using time window exprs
751            let (start, end) = if let Some(ctx) = task_ctx {
752                let Some(time_window_expr) = &ctx.config.time_window_expr else {
753                    UnexpectedSnafu {
754                        reason: "time_window_expr is not set",
755                    }
756                    .fail()?
757                };
758                Self::align_time_window(start, end, time_window_expr)?
759            } else {
760                (start, end)
761            };
762            let end = end.unwrap_or(start.add_duration(std_window_size).context(TimeSnafu)?);
763            time_ranges.push((start, end));
764
765            debug!(
766                "Time window start: {:?}, end: {:?}",
767                start.to_iso8601_string(),
768                end.to_iso8601_string()
769            );
770
771            use datafusion_expr::{col, lit};
772            let lower = to_df_literal(start)?;
773            let upper = to_df_literal(end)?;
774            let expr = col(col_name)
775                .gt_eq(lit(lower))
776                .and(col(col_name).lt(lit(upper)));
777            expr_lst.push(expr);
778        }
779        let expr = expr_lst.into_iter().reduce(|a, b| a.or(b));
780        let ret = expr.map(|expr| FilterExprInfo {
781            expr,
782            col_name: col_name.to_string(),
783            time_ranges,
784            window_size,
785        });
786        Ok(ret)
787    }
788
789    /// Align a time range `[start, end)` (end is optional and exclusive) to
790    /// time window boundaries defined by the time window expr.
791    pub(crate) fn align_time_window(
792        start: Timestamp,
793        end: Option<Timestamp>,
794        time_window_expr: &TimeWindowExpr,
795    ) -> Result<(Timestamp, Option<Timestamp>), Error> {
796        let align_start = time_window_expr.eval(start)?.0.context(UnexpectedSnafu {
797            reason: format!(
798                "Failed to align start time {:?} with time window expr {:?}",
799                start, time_window_expr
800            ),
801        })?;
802        let align_end = end
803            .and_then(|end| {
804                time_window_expr
805                    .eval(end)
806                    // if after aligned, end is the same, then use end(because it's already aligned) else use aligned end
807                    .map(|r| if r.0 == Some(end) { r.0 } else { r.1 })
808                    .transpose()
809            })
810            .transpose()?;
811        Ok((align_start, align_end))
812    }
813
814    /// Merge time windows that overlaps or get too close
815    ///
816    /// TODO(discord9): not merge and prefer to send smaller time windows? how?
817    pub fn merge_dirty_time_windows(
818        &mut self,
819        window_size: chrono::Duration,
820        expire_lower_bound: Option<Timestamp>,
821    ) -> Result<(), Error> {
822        if self.windows.is_empty() {
823            return Ok(());
824        }
825
826        let mut new_windows = BTreeMap::new();
827
828        let std_window_size = window_size.to_std().map_err(|e| {
829            InternalSnafu {
830                reason: e.to_string(),
831            }
832            .build()
833        })?;
834
835        // previous time window
836        let mut prev_tw = None;
837        for (mut lower_bound, upper_bound) in std::mem::take(&mut self.windows) {
838            // filter out expired time window
839            if let Some(expire_lower_bound) = expire_lower_bound {
840                match upper_bound {
841                    // A bounded range ending at or before the expire bound is
842                    // fully expired, drop it.
843                    Some(upper_bound) if upper_bound <= expire_lower_bound => continue,
844                    // A bounded range crossing the expire bound keeps its
845                    // still-live suffix. The expire bound is aligned to the
846                    // time window boundary by the caller, so the clipped start
847                    // stays aligned.
848                    Some(_) if lower_bound < expire_lower_bound => {
849                        lower_bound = expire_lower_bound;
850                    }
851                    // Unbounded windows keep the start-based behavior.
852                    None if lower_bound < expire_lower_bound => continue,
853                    _ => {}
854                }
855            }
856
857            let Some(prev_tw) = &mut prev_tw else {
858                prev_tw = Some((lower_bound, upper_bound));
859                continue;
860            };
861
862            // if cur.lower - prev.upper <= window_size * MERGE_DIST, merge
863            // this also deal with overlap windows because cur.lower > prev.lower is always true
864            let prev_upper = prev_tw
865                .1
866                .unwrap_or(prev_tw.0.add_duration(std_window_size).context(TimeSnafu)?);
867            prev_tw.1 = Some(prev_upper);
868
869            let cur_upper = upper_bound.unwrap_or(
870                lower_bound
871                    .add_duration(std_window_size)
872                    .context(TimeSnafu)?,
873            );
874
875            if lower_bound
876                .sub(&prev_upper)
877                .map(|dist| dist <= window_size * self.time_window_merge_threshold as i32)
878                .unwrap_or(false)
879            {
880                // Union the two windows: the current window may be contained
881                // in the previous one, so keep the larger upper bound.
882                prev_tw.1 = Some(prev_upper.max(cur_upper));
883            } else {
884                new_windows.insert(prev_tw.0, prev_tw.1);
885                *prev_tw = (lower_bound, Some(cur_upper));
886            }
887        }
888
889        if let Some(prev_tw) = prev_tw {
890            new_windows.insert(prev_tw.0, prev_tw.1);
891        }
892
893        self.windows = new_windows;
894
895        Ok(())
896    }
897}
898
899pub(crate) fn to_df_literal(value: Timestamp) -> Result<datafusion_common::ScalarValue, Error> {
900    let value = Value::from(value);
901    let value = value
902        .try_to_scalar_value(&value.data_type())
903        .with_context(|_| DatatypesSnafu {
904            extra: format!("Failed to convert to scalar value: {}", value),
905        })?;
906    Ok(value)
907}
908
909#[derive(Debug, Clone)]
910enum ExecState {
911    Idle,
912    Executing,
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq)]
916pub enum CheckpointMode {
917    FullSnapshot,
918    Incremental,
919}
920
921/// Dirty windows that must be repaired under a frozen full-snapshot watermark.
922/// This is a FullSnapshot sub-state, not a separate checkpoint mode.
923#[derive(Debug, Clone)]
924pub struct FencedRepair {
925    high: BTreeMap<u64, u64>,
926    pending_windows: DirtyTimeWindows,
927}
928
929impl FencedRepair {
930    /// Frozen high watermark `H` used as the snapshot upper bound for chunks.
931    pub fn high(&self) -> &BTreeMap<u64, u64> {
932        &self.high
933    }
934
935    /// Dirty windows still waiting to be repaired under `high`.
936    pub fn pending_windows(&self) -> &DirtyTimeWindows {
937        &self.pending_windows
938    }
939}
940
941/// Filter Expression's information
942#[derive(Debug, Clone)]
943pub struct FilterExprInfo {
944    pub expr: datafusion_expr::Expr,
945    pub col_name: String,
946    pub time_ranges: Vec<(Timestamp, Timestamp)>,
947    pub window_size: chrono::Duration,
948}
949
950impl FilterExprInfo {
951    pub fn total_window_length(&self) -> chrono::Duration {
952        self.time_ranges
953            .iter()
954            .fold(chrono::Duration::zero(), |acc, (start, end)| {
955                acc + end.sub(start).unwrap_or(chrono::Duration::zero())
956            })
957    }
958
959    pub fn predicate_for_col(
960        &self,
961        col_name: &str,
962    ) -> Result<Option<datafusion_expr::Expr>, Error> {
963        use datafusion_common::Column;
964        use datafusion_expr::{Expr, lit};
965
966        let mut expr_lst = Vec::with_capacity(self.time_ranges.len());
967        for (start, end) in &self.time_ranges {
968            let lower = to_df_literal(*start)?;
969            let upper = to_df_literal(*end)?;
970            let filter_col = || Expr::Column(Column::new_unqualified(col_name));
971            expr_lst.push(
972                filter_col()
973                    .gt_eq(lit(lower))
974                    .and(filter_col().lt(lit(upper))),
975            );
976        }
977
978        Ok(expr_lst.into_iter().reduce(|a, b| a.or(b)))
979    }
980}
981
982#[cfg(test)]
983mod test {
984    use pretty_assertions::assert_eq;
985    use session::context::QueryContext;
986
987    use super::*;
988    use crate::batching_mode::time_window::find_time_window_expr;
989    use crate::batching_mode::utils::sql_to_df_plan;
990    use crate::test_utils::create_test_query_engine;
991
992    #[test]
993    fn test_task_state_records_last_execution_time() {
994        let query_ctx = QueryContext::arc();
995        let (_tx, rx) = tokio::sync::oneshot::channel();
996        let mut state = TaskState::new(query_ctx, rx);
997
998        assert_eq!(None, state.last_execution_time_millis());
999        state.after_query_exec(std::time::Duration::from_millis(1), false);
1000        assert_eq!(None, state.last_execution_time_millis());
1001
1002        state.after_query_exec(std::time::Duration::from_millis(1), true);
1003        assert!(state.last_execution_time_millis().is_some());
1004    }
1005
1006    #[test]
1007    fn test_merge_dirty_time_windows() {
1008        let merge_dist = DirtyTimeWindows::default().time_window_merge_threshold;
1009        let testcases = vec![
1010            // just enough to merge
1011            (
1012                vec![
1013                    Timestamp::new_second(0),
1014                    Timestamp::new_second((1 + merge_dist as i64) * 5 * 60),
1015                ],
1016                (chrono::Duration::seconds(5 * 60), None),
1017                BTreeMap::from([(
1018                    Timestamp::new_second(0),
1019                    Some(Timestamp::new_second((2 + merge_dist as i64) * 5 * 60)),
1020                )]),
1021                Some(
1022                    "((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:25:00' AS TIMESTAMP)))",
1023                ),
1024            ),
1025            // separate time window
1026            (
1027                vec![
1028                    Timestamp::new_second(0),
1029                    Timestamp::new_second((2 + merge_dist as i64) * 5 * 60),
1030                ],
1031                (chrono::Duration::seconds(5 * 60), None),
1032                BTreeMap::from([
1033                    (
1034                        Timestamp::new_second(0),
1035                        Some(Timestamp::new_second(5 * 60)),
1036                    ),
1037                    (
1038                        Timestamp::new_second((2 + merge_dist as i64) * 5 * 60),
1039                        Some(Timestamp::new_second((3 + merge_dist as i64) * 5 * 60)),
1040                    ),
1041                ]),
1042                Some(
1043                    "(((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:05:00' AS TIMESTAMP))) OR ((ts >= CAST('1970-01-01 00:25:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:30:00' AS TIMESTAMP))))",
1044                ),
1045            ),
1046            // overlapping
1047            (
1048                vec![
1049                    Timestamp::new_second(0),
1050                    Timestamp::new_second((merge_dist as i64) * 5 * 60),
1051                ],
1052                (chrono::Duration::seconds(5 * 60), None),
1053                BTreeMap::from([(
1054                    Timestamp::new_second(0),
1055                    Some(Timestamp::new_second((1 + merge_dist as i64) * 5 * 60)),
1056                )]),
1057                Some(
1058                    "((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:20:00' AS TIMESTAMP)))",
1059                ),
1060            ),
1061            // complex overlapping
1062            (
1063                vec![
1064                    Timestamp::new_second(0),
1065                    Timestamp::new_second((merge_dist as i64) * 3),
1066                    Timestamp::new_second((merge_dist as i64) * 3 * 2),
1067                ],
1068                (chrono::Duration::seconds(3), None),
1069                BTreeMap::from([(
1070                    Timestamp::new_second(0),
1071                    Some(Timestamp::new_second((merge_dist as i64) * 7)),
1072                )]),
1073                Some(
1074                    "((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:00:21' AS TIMESTAMP)))",
1075                ),
1076            ),
1077            // split range
1078            (
1079                Vec::from_iter((0..20).map(|i| Timestamp::new_second(i * 3)).chain(
1080                    std::iter::once(Timestamp::new_second(
1081                        60 + 3 * (DirtyTimeWindows::MERGE_DIST as i64 + 1),
1082                    )),
1083                )),
1084                (chrono::Duration::seconds(3), None),
1085                BTreeMap::from([
1086                    (Timestamp::new_second(0), Some(Timestamp::new_second(60))),
1087                    (
1088                        Timestamp::new_second(60 + 3 * (DirtyTimeWindows::MERGE_DIST as i64 + 1)),
1089                        Some(Timestamp::new_second(
1090                            60 + 3 * (DirtyTimeWindows::MERGE_DIST as i64 + 1) + 3,
1091                        )),
1092                    ),
1093                ]),
1094                Some(
1095                    "((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:01:00' AS TIMESTAMP)))",
1096                ),
1097            ),
1098            // split 2 min into 1 min
1099            (
1100                Vec::from_iter((0..40).map(|i| Timestamp::new_second(i * 3))),
1101                (chrono::Duration::seconds(3), None),
1102                BTreeMap::from([(
1103                    Timestamp::new_second(0),
1104                    Some(Timestamp::new_second(40 * 3)),
1105                )]),
1106                Some(
1107                    "((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:01:00' AS TIMESTAMP)))",
1108                ),
1109            ),
1110            // split 3s + 1min into 3s + 57s
1111            (
1112                Vec::from_iter(
1113                    std::iter::once(Timestamp::new_second(0))
1114                        .chain((0..40).map(|i| Timestamp::new_second(20 + i * 3))),
1115                ),
1116                (chrono::Duration::seconds(3), None),
1117                BTreeMap::from([
1118                    (Timestamp::new_second(0), Some(Timestamp::new_second(3))),
1119                    (Timestamp::new_second(20), Some(Timestamp::new_second(140))),
1120                ]),
1121                Some(
1122                    "(((ts >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:00:03' AS TIMESTAMP))) OR ((ts >= CAST('1970-01-01 00:00:20' AS TIMESTAMP)) AND (ts < CAST('1970-01-01 00:01:17' AS TIMESTAMP))))",
1123                ),
1124            ),
1125            // expired
1126            (
1127                vec![
1128                    Timestamp::new_second(0),
1129                    Timestamp::new_second((merge_dist as i64) * 5 * 60),
1130                ],
1131                (
1132                    chrono::Duration::seconds(5 * 60),
1133                    Some(Timestamp::new_second((merge_dist as i64) * 6 * 60)),
1134                ),
1135                BTreeMap::from([]),
1136                None,
1137            ),
1138        ];
1139        // let len = testcases.len();
1140        // let testcases = testcases[(len - 2)..(len - 1)].to_vec();
1141        for (lower_bounds, (window_size, expire_lower_bound), expected, expected_filter_expr) in
1142            testcases
1143        {
1144            let mut dirty = DirtyTimeWindows::default();
1145            dirty.add_lower_bounds(lower_bounds.into_iter());
1146            dirty
1147                .merge_dirty_time_windows(window_size, expire_lower_bound)
1148                .unwrap();
1149            assert_eq!(expected, dirty.windows);
1150            let filter_expr = dirty
1151                .gen_filter_exprs(
1152                    "ts",
1153                    expire_lower_bound,
1154                    window_size,
1155                    dirty.max_filter_num_per_query,
1156                    0,
1157                    None,
1158                )
1159                .unwrap()
1160                .map(|e| e.expr);
1161
1162            let unparser = datafusion::sql::unparser::Unparser::default();
1163            let to_sql = filter_expr
1164                .as_ref()
1165                .map(|e| unparser.expr_to_sql(e).unwrap().to_string());
1166            assert_eq!(expected_filter_expr, to_sql.as_deref());
1167        }
1168    }
1169
1170    #[test]
1171    fn test_merge_dirty_time_windows_with_bounded_ranges() {
1172        let window_size = chrono::Duration::seconds(5);
1173        let testcases = vec![
1174            // A contained bounded range must not shrink the containing window:
1175            // [0s, 15s) merged with nested [5s, 10s) stays [0s, 15s).
1176            (
1177                vec![
1178                    (Timestamp::new_second(0), Some(Timestamp::new_second(15))),
1179                    (Timestamp::new_second(5), Some(Timestamp::new_second(10))),
1180                ],
1181                BTreeMap::from([(Timestamp::new_second(0), Some(Timestamp::new_second(15)))]),
1182            ),
1183            // An unbounded dirty window nested in a bounded range must not
1184            // shrink the range either: [0s, 15s) merged with 3s (window end
1185            // 8s) stays [0s, 15s).
1186            (
1187                vec![
1188                    (Timestamp::new_second(0), Some(Timestamp::new_second(15))),
1189                    (Timestamp::new_second(3), None),
1190                ],
1191                BTreeMap::from([(Timestamp::new_second(0), Some(Timestamp::new_second(15)))]),
1192            ),
1193            // Disjoint bounded ranges far apart are kept separate.
1194            (
1195                vec![
1196                    (Timestamp::new_second(0), Some(Timestamp::new_second(5))),
1197                    (Timestamp::new_second(100), Some(Timestamp::new_second(110))),
1198                ],
1199                BTreeMap::from([
1200                    (Timestamp::new_second(0), Some(Timestamp::new_second(5))),
1201                    (Timestamp::new_second(100), Some(Timestamp::new_second(110))),
1202                ]),
1203            ),
1204            // Overlapping bounded ranges are unioned: [0s, 10s) and [5s, 20s)
1205            // become [0s, 20s).
1206            (
1207                vec![
1208                    (Timestamp::new_second(0), Some(Timestamp::new_second(10))),
1209                    (Timestamp::new_second(5), Some(Timestamp::new_second(20))),
1210                ],
1211                BTreeMap::from([(Timestamp::new_second(0), Some(Timestamp::new_second(20)))]),
1212            ),
1213        ];
1214
1215        for (windows, expected) in testcases {
1216            let mut dirty = DirtyTimeWindows::default();
1217            for (start, end) in windows {
1218                dirty.add_window(start, end);
1219            }
1220            dirty.merge_dirty_time_windows(window_size, None).unwrap();
1221            assert_eq!(expected, dirty.windows);
1222        }
1223
1224        // Expire bound handling for bounded ranges vs unbounded windows.
1225        let expire_testcases = vec![
1226            // A bounded range ending at the expire bound is fully expired.
1227            (
1228                vec![(Timestamp::new_second(0), Some(Timestamp::new_second(10)))],
1229                BTreeMap::from([]),
1230            ),
1231            // A bounded range ending before the expire bound is fully expired.
1232            (
1233                vec![(Timestamp::new_second(0), Some(Timestamp::new_second(5)))],
1234                BTreeMap::from([]),
1235            ),
1236            // A bounded range crossing the expire bound keeps its live
1237            // suffix: [0s, 15s) with expire 10s becomes [10s, 15s).
1238            (
1239                vec![(Timestamp::new_second(0), Some(Timestamp::new_second(15)))],
1240                BTreeMap::from([(Timestamp::new_second(10), Some(Timestamp::new_second(15)))]),
1241            ),
1242            // A bounded range starting at the expire bound is kept intact.
1243            (
1244                vec![(Timestamp::new_second(10), Some(Timestamp::new_second(15)))],
1245                BTreeMap::from([(Timestamp::new_second(10), Some(Timestamp::new_second(15)))]),
1246            ),
1247            // An unbounded window starting before the expire bound is
1248            // dropped, preserving the existing start-based behavior.
1249            (vec![(Timestamp::new_second(5), None)], BTreeMap::from([])),
1250            // An unbounded window starting at the expire bound is kept.
1251            (
1252                vec![(Timestamp::new_second(10), None)],
1253                BTreeMap::from([(Timestamp::new_second(10), None)]),
1254            ),
1255        ];
1256
1257        for (windows, expected) in expire_testcases {
1258            let mut dirty = DirtyTimeWindows::default();
1259            for (start, end) in windows {
1260                dirty.add_window(start, end);
1261            }
1262            dirty
1263                .merge_dirty_time_windows(window_size, Some(Timestamp::new_second(10)))
1264                .unwrap();
1265            assert_eq!(expected, dirty.windows);
1266        }
1267    }
1268
1269    #[tokio::test]
1270    async fn test_align_time_window() {
1271        type TimeWindow = (Timestamp, Option<Timestamp>);
1272        struct TestCase {
1273            sql: String,
1274            aligns: Vec<(TimeWindow, TimeWindow)>,
1275        }
1276        let testcases: Vec<TestCase> = vec![TestCase{
1277            sql: "SELECT date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window;".to_string(),
1278            aligns: vec![
1279                ((Timestamp::new_second(3), None), (Timestamp::new_second(0), None)),
1280                ((Timestamp::new_second(8), None), (Timestamp::new_second(5), None)),
1281                ((Timestamp::new_second(8), Some(Timestamp::new_second(10))), (Timestamp::new_second(5), Some(Timestamp::new_second(10)))),
1282                ((Timestamp::new_second(8), Some(Timestamp::new_second(9))), (Timestamp::new_second(5), Some(Timestamp::new_second(10)))),
1283            ],
1284        }];
1285
1286        let query_engine = create_test_query_engine();
1287        let ctx = QueryContext::arc();
1288        for TestCase { sql, aligns } in testcases {
1289            let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), &sql, true)
1290                .await
1291                .unwrap();
1292
1293            let (column_name, time_window_expr, _, df_schema) = find_time_window_expr(
1294                &plan,
1295                query_engine.engine_state().catalog_manager().clone(),
1296                ctx.clone(),
1297            )
1298            .await
1299            .unwrap();
1300
1301            let time_window_expr = time_window_expr
1302                .map(|expr| {
1303                    TimeWindowExpr::from_expr(
1304                        &expr,
1305                        &column_name,
1306                        &df_schema,
1307                        &query_engine.engine_state().session_state(),
1308                    )
1309                })
1310                .transpose()
1311                .unwrap()
1312                .unwrap();
1313
1314            for (before_align, expected_after_align) in aligns {
1315                let after_align = DirtyTimeWindows::align_time_window(
1316                    before_align.0,
1317                    before_align.1,
1318                    &time_window_expr,
1319                )
1320                .unwrap();
1321                assert_eq!(expected_after_align, after_align);
1322            }
1323        }
1324    }
1325
1326    #[test]
1327    fn test_task_state_checkpoint_mode_and_advancement() {
1328        let query_ctx = QueryContext::arc();
1329        let (_tx, rx) = tokio::sync::oneshot::channel();
1330        let mut state = TaskState::new(query_ctx, rx);
1331
1332        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1333        assert!(state.checkpoints().is_empty());
1334
1335        state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]));
1336        assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental);
1337        assert_eq!(
1338            state.checkpoints(),
1339            &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])
1340        );
1341
1342        state.mark_full_snapshot();
1343        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1344        assert_eq!(
1345            state.checkpoints(),
1346            &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])
1347        );
1348    }
1349
1350    #[test]
1351    fn test_mark_full_snapshot_restores_pending_fenced_repair_windows() {
1352        let query_ctx = QueryContext::arc();
1353        let (_tx, rx) = tokio::sync::oneshot::channel();
1354        let mut state = TaskState::new(query_ctx, rx);
1355        state
1356            .dirty_time_windows
1357            .add_window(Timestamp::new_second(10), Some(Timestamp::new_second(15)));
1358        state
1359            .dirty_time_windows
1360            .add_window(Timestamp::new_second(100), Some(Timestamp::new_second(105)));
1361
1362        state
1363            .start_fenced_repair(BTreeMap::from([(1_u64, 10_u64)]))
1364            .unwrap();
1365        assert!(state.dirty_time_windows.is_empty());
1366        assert_eq!(
1367            state
1368                .pending_fenced_repair()
1369                .unwrap()
1370                .pending_windows()
1371                .len(),
1372            2
1373        );
1374
1375        state.mark_full_snapshot();
1376
1377        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1378        assert!(state.pending_fenced_repair().is_none());
1379        assert_eq!(state.dirty_time_windows.len(), 2);
1380    }
1381
1382    #[test]
1383    fn test_disable_incremental_persists_full_snapshot_mode() {
1384        let query_ctx = QueryContext::arc();
1385        let (_tx, rx) = tokio::sync::oneshot::channel();
1386        let mut state = TaskState::new(query_ctx, rx);
1387
1388        assert!(!state.is_incremental_disabled());
1389
1390        // After disable, mode becomes FullSnapshot and flag is set.
1391        state.disable_incremental();
1392        assert!(state.is_incremental_disabled());
1393        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1394
1395        // `advance_checkpoints` will NOT transition to Incremental when disabled.
1396        state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]));
1397        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1398        assert_eq!(
1399            state.checkpoints(),
1400            &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])
1401        );
1402
1403        // `mark_full_snapshot` does not re-enable incremental.
1404        state.mark_full_snapshot();
1405        assert!(state.is_incremental_disabled());
1406        assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot);
1407    }
1408
1409    #[test]
1410    fn test_full_snapshot_checkpoint_advancement_requires_participating_regions() {
1411        let query_ctx = QueryContext::arc();
1412        let (_tx, rx) = tokio::sync::oneshot::channel();
1413        let state = TaskState::new(query_ctx, rx);
1414
1415        assert!(!state.can_advance_full_snapshot_checkpoints(&BTreeSet::new(), &HashMap::new()));
1416        assert!(!state.can_advance_full_snapshot_checkpoints(
1417            &BTreeSet::from([1_u64, 2_u64]),
1418            &HashMap::from([(1_u64, 10_u64)]),
1419        ));
1420        assert!(state.can_advance_full_snapshot_checkpoints(
1421            &BTreeSet::from([1_u64, 2_u64]),
1422            &HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]),
1423        ));
1424    }
1425
1426    #[test]
1427    fn test_incremental_checkpoint_advancement_requires_participation_alignment() {
1428        let query_ctx = QueryContext::arc();
1429        let (_tx, rx) = tokio::sync::oneshot::channel();
1430        let mut state = TaskState::new(query_ctx, rx);
1431        state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]));
1432
1433        assert!(
1434            state.can_advance_incremental_checkpoints_with_participation(
1435                &BTreeSet::from([1_u64]),
1436                &HashMap::from([(1_u64, 11_u64)]),
1437            )
1438        );
1439        assert!(
1440            !state.can_advance_incremental_checkpoints_with_participation(
1441                &BTreeSet::from([1_u64, 2_u64]),
1442                &HashMap::from([(1_u64, 11_u64)]),
1443            )
1444        );
1445        assert!(
1446            !state.can_advance_incremental_checkpoints_with_participation(
1447                &BTreeSet::from([3_u64]),
1448                &HashMap::from([(3_u64, 11_u64)]),
1449            )
1450        );
1451        assert!(
1452            !state.can_advance_incremental_checkpoints_with_participation(
1453                &BTreeSet::from([1_u64]),
1454                &HashMap::from([(1_u64, 9_u64)]),
1455            )
1456        );
1457        assert!(
1458            state.can_advance_incremental_checkpoints_with_participation(
1459                &BTreeSet::from([1_u64, 2_u64]),
1460                &HashMap::from([(1_u64, 11_u64), (2_u64, 21_u64)]),
1461            )
1462        );
1463
1464        state.disable_incremental();
1465        assert!(
1466            !state.can_advance_incremental_checkpoints_with_participation(
1467                &BTreeSet::from([1_u64, 2_u64]),
1468                &HashMap::from([(1_u64, 12_u64), (2_u64, 22_u64)]),
1469            )
1470        );
1471    }
1472
1473    #[test]
1474    fn test_incremental_checkpoint_advancement_merges_participating_subset() {
1475        let query_ctx = QueryContext::arc();
1476        let (_tx, rx) = tokio::sync::oneshot::channel();
1477        let mut state = TaskState::new(query_ctx, rx);
1478        state.advance_checkpoints(HashMap::from([
1479            (1_u64, 10_u64),
1480            (2_u64, 20_u64),
1481            (3_u64, 30_u64),
1482        ]));
1483
1484        state.advance_incremental_checkpoints_with_participation(
1485            &BTreeSet::from([1_u64, 3_u64]),
1486            HashMap::from([(1_u64, 12_u64), (3_u64, 35_u64)]),
1487        );
1488
1489        assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental);
1490        assert_eq!(
1491            state.checkpoints(),
1492            &BTreeMap::from([(1_u64, 12_u64), (2_u64, 20_u64), (3_u64, 35_u64)])
1493        );
1494    }
1495
1496    #[test]
1497    fn test_filter_expr_info_predicate_for_col_empty_ranges() {
1498        let filter = FilterExprInfo {
1499            expr: datafusion_expr::col("ts"),
1500            col_name: "ts".to_string(),
1501            time_ranges: vec![],
1502            window_size: chrono::Duration::seconds(1),
1503        };
1504
1505        assert!(filter.predicate_for_col("time_window").unwrap().is_none());
1506    }
1507
1508    #[test]
1509    fn test_filter_expr_info_predicate_for_col_single_range() {
1510        let filter = FilterExprInfo {
1511            expr: datafusion_expr::col("ts"),
1512            col_name: "ts".to_string(),
1513            time_ranges: vec![(Timestamp::new_second(0), Timestamp::new_second(1))],
1514            window_size: chrono::Duration::seconds(1),
1515        };
1516
1517        let predicate = filter.predicate_for_col("time_window").unwrap().unwrap();
1518        let unparser = datafusion::sql::unparser::Unparser::default();
1519        assert_eq!(
1520            "((time_window >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:01' AS TIMESTAMP)))",
1521            unparser.expr_to_sql(&predicate).unwrap().to_string()
1522        );
1523    }
1524
1525    #[test]
1526    fn test_filter_expr_info_predicate_for_col_multiple_ranges() {
1527        let filter = FilterExprInfo {
1528            expr: datafusion_expr::col("ts"),
1529            col_name: "ts".to_string(),
1530            time_ranges: vec![
1531                (Timestamp::new_second(0), Timestamp::new_second(1)),
1532                (Timestamp::new_second(10), Timestamp::new_second(11)),
1533            ],
1534            window_size: chrono::Duration::seconds(1),
1535        };
1536
1537        let predicate = filter.predicate_for_col("time_window").unwrap().unwrap();
1538        let unparser = datafusion::sql::unparser::Unparser::default();
1539        assert_eq!(
1540            "(((time_window >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:01' AS TIMESTAMP))) OR ((time_window >= CAST('1970-01-01 00:00:10' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:11' AS TIMESTAMP))))",
1541            unparser.expr_to_sql(&predicate).unwrap().to_string()
1542        );
1543    }
1544
1545    /// Helper: create a `TaskState` whose `last_update_time` is a known duration in the past.
1546    fn state_with_past_update(age: Duration) -> TaskState {
1547        let query_ctx = QueryContext::arc();
1548        let (_tx, rx) = tokio::sync::oneshot::channel();
1549        let mut state = TaskState::new(query_ctx, rx);
1550        state.last_update_time = Instant::now() - age;
1551        state
1552    }
1553
1554    #[test]
1555    fn test_short_incremental_cadence_uses_min_refresh() {
1556        // When prefer_short_incremental_cadence is true and dirty backlog is manageable,
1557        // the next start time should be last_update_time + min_refresh (short cadence),
1558        // ignoring the longer time_window_size.
1559        let state = state_with_past_update(Duration::from_secs(10));
1560
1561        let time_window_size = Some(Duration::from_secs(60)); // large window
1562        let min_refresh = Duration::from_secs(5);
1563        let flow_id = 1;
1564
1565        let result = state.get_next_start_query_time(
1566            flow_id,
1567            &time_window_size,
1568            min_refresh,
1569            None,
1570            20,
1571            true, // prefer_short_incremental_cadence
1572        );
1573
1574        // With short cadence, result should be last_update_time + min_refresh.
1575        let expected = state.last_update_time + min_refresh;
1576        assert_eq!(result, expected);
1577    }
1578
1579    #[test]
1580    fn test_short_incremental_cadence_respects_last_query_duration() {
1581        let mut state = state_with_past_update(Duration::from_secs(10));
1582        state.last_query_duration = Duration::from_secs(20);
1583
1584        let time_window_size = Some(Duration::from_secs(60));
1585        let min_refresh = Duration::from_secs(5);
1586        let flow_id = 1;
1587
1588        let result = state.get_next_start_query_time(
1589            flow_id,
1590            &time_window_size,
1591            min_refresh,
1592            None,
1593            20,
1594            true,
1595        );
1596
1597        assert_eq!(result, state.last_update_time + state.last_query_duration);
1598    }
1599
1600    #[test]
1601    fn test_short_incremental_cadence_respects_max_timeout() {
1602        let mut state = state_with_past_update(Duration::from_secs(10));
1603        state.last_query_duration = Duration::from_secs(20);
1604
1605        let time_window_size = Some(Duration::from_secs(60));
1606        let min_refresh = Duration::from_secs(30);
1607        let max_timeout = Duration::from_secs(5);
1608        let flow_id = 1;
1609
1610        let result = state.get_next_start_query_time(
1611            flow_id,
1612            &time_window_size,
1613            min_refresh,
1614            Some(max_timeout),
1615            20,
1616            true,
1617        );
1618
1619        assert_eq!(result, state.last_update_time + max_timeout);
1620    }
1621
1622    #[test]
1623    fn test_full_snapshot_ignores_short_cadence() {
1624        // When prefer_short_incremental_cadence is false (full snapshot mode),
1625        // the normal long-cadence based on time_window_size applies.
1626        let mut state = state_with_past_update(Duration::from_secs(10));
1627        // Make last_query_duration small so the lower bound (time_window_size) dominates.
1628        state.last_query_duration = Duration::from_secs(1);
1629
1630        let time_window_size = Some(Duration::from_secs(60)); // large window
1631        let min_refresh = Duration::from_secs(5);
1632        let flow_id = 1;
1633
1634        let result = state.get_next_start_query_time(
1635            flow_id,
1636            &time_window_size,
1637            min_refresh,
1638            None,
1639            20,
1640            false, // prefer_short_incremental_cadence = false
1641        );
1642
1643        // With normal cadence, result should be last_update_time + time_window_size
1644        // (since last_query_duration < time_window_size).
1645        let expected = state.last_update_time + Duration::from_secs(60);
1646        assert_eq!(result, expected);
1647    }
1648
1649    #[test]
1650    fn test_dirty_window_overflow_schedules_immediately_even_with_short_cadence() {
1651        // Dirty-window overflow must always schedule immediately,
1652        // regardless of prefer_short_incremental_cadence.
1653        let mut state = state_with_past_update(Duration::from_secs(10));
1654        // Create a very large dirty backlog.
1655        state
1656            .dirty_time_windows
1657            .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(3600)));
1658
1659        let time_window_size = Some(Duration::from_secs(1)); // tiny window => overflow
1660        let min_refresh = Duration::from_secs(5);
1661        let flow_id = 1;
1662
1663        // With short cadence flag.
1664        let result = state.get_next_start_query_time(
1665            flow_id,
1666            &time_window_size,
1667            min_refresh,
1668            None,
1669            1, // max 1 filter => tiny capacity
1670            true,
1671        );
1672        assert!(
1673            result <= Instant::now(),
1674            "dirty overflow should schedule immediately"
1675        );
1676
1677        // Without short cadence flag — same behavior.
1678        let result2 = state.get_next_start_query_time(
1679            flow_id,
1680            &time_window_size,
1681            min_refresh,
1682            None,
1683            1,
1684            false,
1685        );
1686        assert!(
1687            result2 <= Instant::now(),
1688            "dirty overflow should schedule immediately"
1689        );
1690    }
1691
1692    #[test]
1693    fn test_pending_fenced_repair_schedules_immediately() {
1694        let mut state = state_with_past_update(Duration::from_secs(10));
1695        state
1696            .dirty_time_windows
1697            .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(5)));
1698        state
1699            .start_fenced_repair(BTreeMap::from([(1_u64, 10_u64)]))
1700            .unwrap();
1701        assert!(state.dirty_time_windows.is_empty());
1702        assert!(!state.fenced_repair_pending_is_empty());
1703
1704        let result = state.get_next_start_query_time(
1705            1,
1706            &Some(Duration::from_secs(60)),
1707            Duration::from_secs(5),
1708            None,
1709            20,
1710            false,
1711        );
1712
1713        assert!(
1714            result <= Instant::now(),
1715            "pending fenced repair backlog should schedule immediately"
1716        );
1717    }
1718
1719    #[test]
1720    fn test_incremental_disabled_ignores_short_cadence() {
1721        // When prefer_short_incremental_cadence is true but the dirty backlog is
1722        // manageable, the short cadence is applied. This test verifies that the
1723        // caller-side guard (checkpoint_mode + !is_incremental_disabled) controls
1724        // whether short cadence is requested at all — when incremental is disabled,
1725        // the flag is false, and the long cadence applies.
1726        //
1727        // This simulates the case where the caller computed
1728        // prefer_short_incremental_cadence = false (e.g. incremental disabled
1729        // or FullSnapshot mode), so the long cadence is used.
1730        let mut state = state_with_past_update(Duration::from_secs(10));
1731        state.last_query_duration = Duration::from_secs(1);
1732
1733        let time_window_size = Some(Duration::from_secs(60));
1734        let min_refresh = Duration::from_secs(5);
1735        let flow_id = 1;
1736
1737        let result = state.get_next_start_query_time(
1738            flow_id,
1739            &time_window_size,
1740            min_refresh,
1741            None,
1742            20,
1743            false, // prefer_short_incremental_cadence = false
1744        );
1745
1746        // With normal cadence, result should be last_update_time + time_window_size.
1747        let expected = state.last_update_time + Duration::from_secs(60);
1748        assert_eq!(result, expected);
1749    }
1750}