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