Skip to main content

flow/batching_mode/
engine.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 engine
16
17use std::collections::{BTreeMap, HashMap, HashSet};
18use std::sync::Arc;
19use std::time::Duration;
20
21use api::v1::flow::DirtyWindowRequests;
22use catalog::CatalogManagerRef;
23use common_error::ext::BoxedError;
24use common_meta::ddl::create_flow::{FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY, FlowType};
25use common_meta::key::TableMetadataManagerRef;
26use common_meta::key::flow::FlowMetadataManagerRef;
27use common_meta::key::flow::flow_state::FlowStat;
28use common_meta::key::table_info::{TableInfoManager, TableInfoValue};
29use common_runtime::JoinHandle;
30use common_telemetry::tracing::warn;
31use common_telemetry::{debug, info};
32use common_time::TimeToLive;
33use datafusion_common::tree_node::{TreeNodeRecursion, TreeNodeVisitor};
34use datafusion_expr::LogicalPlan;
35use datatypes::prelude::ConcreteDataType;
36use query::QueryEngineRef;
37use session::context::QueryContext;
38use snafu::{OptionExt, ResultExt, ensure};
39use sql::parsers::utils::is_tql;
40use store_api::metric_engine_consts::is_metric_engine_internal_column;
41use store_api::mito_engine_options::APPEND_MODE_KEY;
42use store_api::storage::{RegionId, TableId};
43use table::table_reference::TableReference;
44use tokio::sync::{RwLock, oneshot};
45
46use crate::batching_mode::BatchingModeOptions;
47use crate::batching_mode::eval_schedule::EvalSchedule;
48use crate::batching_mode::frontend_client::FrontendClient;
49use crate::batching_mode::state::DirtyTimeWindows;
50use crate::batching_mode::task::{BatchingTask, TaskArgs};
51use crate::batching_mode::time_window::{TimeWindowExpr, find_time_window_expr};
52use crate::batching_mode::utils::sql_to_df_plan;
53use crate::engine::{FlowEngine, FlowStatProvider};
54use crate::error::{
55    CreateFlowSnafu, DatafusionSnafu, ExternalSnafu, FlowAlreadyExistSnafu, FlowNotFoundSnafu,
56    InvalidQuerySnafu, JoinTaskSnafu, TableNotFoundMetaSnafu, UnexpectedSnafu, UnsupportedSnafu,
57};
58use crate::metrics::METRIC_FLOW_BATCHING_ENGINE_BULK_MARK_TIME_WINDOW;
59use crate::{CreateFlowArgs, Error, FlowId, TableName};
60
61/// Batching mode Engine, responsible for driving all the batching mode tasks
62///
63/// TODO(discord9): determine how to configure refresh rate
64pub struct BatchingEngine {
65    runtime: RwLock<FlowRuntimeRegistry>,
66    /// frontend client for insert request
67    pub(crate) frontend_client: Arc<FrontendClient>,
68    flow_metadata_manager: FlowMetadataManagerRef,
69    table_meta: TableMetadataManagerRef,
70    catalog_manager: CatalogManagerRef,
71    query_engine: QueryEngineRef,
72    /// Batching mode options for control how batching mode query works
73    ///
74    pub(crate) batch_opts: Arc<BatchingModeOptions>,
75}
76
77#[derive(Default)]
78struct FlowRuntimeRegistry {
79    tasks: BTreeMap<FlowId, BatchingTask>,
80    shutdown_txs: BTreeMap<FlowId, oneshot::Sender<()>>,
81}
82
83impl FlowRuntimeRegistry {
84    fn insert(
85        &mut self,
86        flow_id: FlowId,
87        task: BatchingTask,
88        shutdown_tx: oneshot::Sender<()>,
89    ) -> (Option<BatchingTask>, Option<oneshot::Sender<()>>) {
90        (
91            self.tasks.insert(flow_id, task),
92            self.shutdown_txs.insert(flow_id, shutdown_tx),
93        )
94    }
95
96    fn remove(&mut self, flow_id: FlowId) -> Option<(BatchingTask, Option<oneshot::Sender<()>>)> {
97        let task = self.tasks.remove(&flow_id)?;
98        let shutdown_tx = self.shutdown_txs.remove(&flow_id);
99        Some((task, shutdown_tx))
100    }
101
102    fn remove_if_current(
103        &mut self,
104        flow_id: FlowId,
105        task: &BatchingTask,
106    ) -> (Option<BatchingTask>, Option<oneshot::Sender<()>>) {
107        if self
108            .tasks
109            .get(&flow_id)
110            .is_some_and(|current| Arc::ptr_eq(&current.state, &task.state))
111        {
112            let Some((removed_task, removed_shutdown_tx)) = self.remove(flow_id) else {
113                return (None, None);
114            };
115            (Some(removed_task), removed_shutdown_tx)
116        } else {
117            (None, None)
118        }
119    }
120}
121
122impl BatchingEngine {
123    pub fn new(
124        frontend_client: Arc<FrontendClient>,
125        query_engine: QueryEngineRef,
126        flow_metadata_manager: FlowMetadataManagerRef,
127        table_meta: TableMetadataManagerRef,
128        catalog_manager: CatalogManagerRef,
129        batch_opts: BatchingModeOptions,
130    ) -> Self {
131        Self {
132            runtime: Default::default(),
133            frontend_client,
134            flow_metadata_manager,
135            table_meta,
136            catalog_manager,
137            query_engine,
138            batch_opts: Arc::new(batch_opts),
139        }
140    }
141
142    /// Returns last execution timestamps (millisecond) for all batching flows.
143    pub async fn get_last_exec_time_map(&self) -> BTreeMap<FlowId, i64> {
144        let runtime = self.runtime.read().await;
145        runtime
146            .tasks
147            .iter()
148            .filter_map(|(flow_id, task)| {
149                task.last_execution_time_millis()
150                    .map(|timestamp| (*flow_id, timestamp))
151            })
152            .collect()
153    }
154
155    /// Mark dirty time windows for batching flows.
156    ///
157    /// Both `timestamps` and `time_ranges` (`[start_inclusive, end_exclusive)`)
158    /// in each `DirtyWindowRequest` are bare `i64`s interpreted in the source
159    /// table's time index column native unit, resolved via table metadata.
160    pub async fn handle_mark_dirty_time_window(
161        &self,
162        reqs: DirtyWindowRequests,
163    ) -> Result<(), Error> {
164        let table_info_mgr = self.table_meta.table_info_manager();
165
166        let mut group_by_table_id: HashMap<u32, (Vec<i64>, Vec<api::v1::flow::TimeRange>)> =
167            HashMap::new();
168        for r in reqs.requests {
169            let tid = TableId::from(r.table_id);
170            let entry = group_by_table_id.entry(tid).or_default();
171            entry.0.extend(r.timestamps);
172            entry.1.extend(r.time_ranges);
173        }
174        let tids = group_by_table_id.keys().cloned().collect::<Vec<TableId>>();
175        let table_infos =
176            table_info_mgr
177                .batch_get(&tids)
178                .await
179                .with_context(|_| TableNotFoundMetaSnafu {
180                    msg: format!("Failed to get table info for table ids: {:?}", tids),
181                })?;
182
183        let group_by_table_name = group_by_table_id
184            .into_iter()
185            .filter_map(|(id, (timestamps, time_ranges))| {
186                let table_name = table_infos.get(&id).map(|info| info.table_name());
187                let Some(table_name) = table_name else {
188                    warn!("Failed to get table infos for table id: {:?}", id);
189                    return None;
190                };
191                let table_name = [
192                    table_name.catalog_name,
193                    table_name.schema_name,
194                    table_name.table_name,
195                ];
196                let schema = &table_infos.get(&id).unwrap().table_info.meta.schema;
197                let time_index_unit = schema.column_schemas()[schema.timestamp_index().unwrap()]
198                    .data_type
199                    .as_timestamp()
200                    .unwrap()
201                    .unit();
202                Some((table_name, (timestamps, time_ranges, time_index_unit)))
203            })
204            .collect::<HashMap<_, _>>();
205
206        let group_by_table_name = Arc::new(group_by_table_name);
207
208        let tasks = self
209            .runtime
210            .read()
211            .await
212            .tasks
213            .values()
214            .cloned()
215            .collect::<Vec<_>>();
216        let mut handles = Vec::new();
217
218        for task in tasks {
219            let src_table_names = &task.config.source_table_names;
220
221            if src_table_names
222                .iter()
223                .all(|name| !group_by_table_name.contains_key(name))
224            {
225                continue;
226            }
227
228            let group_by_table_name = group_by_table_name.clone();
229            let task = task.clone();
230            let handle: JoinHandle<Result<(), Error>> = tokio::spawn(async move {
231                let src_table_names = &task.config.source_table_names;
232                let mut all_dirty_windows = HashSet::new();
233                let mut all_dirty_ranges = Vec::new();
234                let mut is_dirty = false;
235                for src_table_name in src_table_names {
236                    if let Some((timestamps, time_ranges, unit)) =
237                        group_by_table_name.get(src_table_name)
238                    {
239                        let Some(expr) = &task.config.time_window_expr else {
240                            is_dirty = true;
241                            continue;
242                        };
243                        for timestamp in timestamps {
244                            let align_start = expr
245                                .eval(common_time::Timestamp::new(*timestamp, *unit))?
246                                .0
247                                .context(UnexpectedSnafu {
248                                    reason: format!(
249                                        "Failed to align dirty timestamp {timestamp}: missing window lower bound"
250                                    ),
251                                })?;
252                            all_dirty_windows.insert(align_start);
253                        }
254                        for time_range in time_ranges {
255                            if time_range.end_exclusive <= time_range.start_inclusive {
256                                warn!(
257                                    "Ignoring invalid dirty time range with start_inclusive={} >= end_exclusive={}",
258                                    time_range.start_inclusive, time_range.end_exclusive
259                                );
260                                continue;
261                            }
262                            let (align_start, align_end) = DirtyTimeWindows::align_time_window(
263                                common_time::Timestamp::new(time_range.start_inclusive, *unit),
264                                Some(common_time::Timestamp::new(time_range.end_exclusive, *unit)),
265                                expr,
266                            )?;
267                            all_dirty_ranges.push((align_start, align_end));
268                        }
269                    }
270                }
271                let mut state = task.state.write().unwrap();
272                if is_dirty {
273                    state.dirty_time_windows.set_dirty();
274                }
275                let flow_id_label = task.config.flow_id.to_string();
276                for timestamp in all_dirty_windows {
277                    state.dirty_time_windows.add_window(timestamp, None);
278                }
279                for (start, end) in all_dirty_ranges {
280                    state.dirty_time_windows.add_window(start, end);
281                }
282
283                METRIC_FLOW_BATCHING_ENGINE_BULK_MARK_TIME_WINDOW
284                    .with_label_values(&[&flow_id_label])
285                    .set(state.dirty_time_windows.len() as f64);
286                Ok(())
287            });
288            handles.push(handle);
289        }
290        for handle in handles {
291            handle.await.context(JoinTaskSnafu)??;
292        }
293
294        Ok(())
295    }
296
297    pub async fn handle_inserts_inner(
298        &self,
299        request: api::v1::region::InsertRequests,
300    ) -> Result<(), Error> {
301        let table_info_mgr = self.table_meta.table_info_manager();
302        let mut group_by_table_id: HashMap<TableId, Vec<api::v1::Rows>> = HashMap::new();
303
304        for r in request.requests {
305            let tid = RegionId::from(r.region_id).table_id();
306            let entry = group_by_table_id.entry(tid).or_default();
307            if let Some(rows) = r.rows {
308                entry.push(rows);
309            }
310        }
311
312        let tids = group_by_table_id.keys().cloned().collect::<Vec<TableId>>();
313        let table_infos =
314            table_info_mgr
315                .batch_get(&tids)
316                .await
317                .with_context(|_| TableNotFoundMetaSnafu {
318                    msg: format!("Failed to get table info for table ids: {:?}", tids),
319                })?;
320
321        let missing_tids = tids
322            .iter()
323            .filter(|id| !table_infos.contains_key(id))
324            .collect::<Vec<_>>();
325        if !missing_tids.is_empty() {
326            warn!(
327                "Failed to get all the table info for table ids, expected table ids: {:?}, those table doesn't exist: {:?}",
328                tids, missing_tids
329            );
330        }
331
332        let group_by_table_name = group_by_table_id
333            .into_iter()
334            .filter_map(|(id, rows)| {
335                let table_name = table_infos.get(&id).map(|info| info.table_name());
336                let Some(table_name) = table_name else {
337                    warn!("Failed to get table infos for table id: {:?}", id);
338                    return None;
339                };
340                let table_name = [
341                    table_name.catalog_name,
342                    table_name.schema_name,
343                    table_name.table_name,
344                ];
345                Some((table_name, rows))
346            })
347            .collect::<HashMap<_, _>>();
348
349        let group_by_table_name = Arc::new(group_by_table_name);
350
351        let tasks = self
352            .runtime
353            .read()
354            .await
355            .tasks
356            .values()
357            .cloned()
358            .collect::<Vec<_>>();
359        let mut handles = Vec::new();
360        for task in tasks {
361            let src_table_names = &task.config.source_table_names;
362
363            if src_table_names
364                .iter()
365                .all(|name| !group_by_table_name.contains_key(name))
366            {
367                continue;
368            }
369
370            let group_by_table_name = group_by_table_name.clone();
371            let task = task.clone();
372
373            let handle: JoinHandle<Result<(), Error>> = tokio::spawn(async move {
374                let src_table_names = &task.config.source_table_names;
375
376                let mut is_dirty = false;
377
378                for src_table_name in src_table_names {
379                    if let Some(entry) = group_by_table_name.get(src_table_name) {
380                        let Some(expr) = &task.config.time_window_expr else {
381                            is_dirty = true;
382                            continue;
383                        };
384                        let involved_time_windows = expr.handle_rows(entry.clone()).await?;
385                        let mut state = task.state.write().unwrap();
386                        state
387                            .dirty_time_windows
388                            .add_lower_bounds(involved_time_windows.into_iter());
389                    }
390                }
391                if is_dirty {
392                    task.state.write().unwrap().dirty_time_windows.set_dirty();
393                }
394
395                Ok(())
396            });
397            handles.push(handle);
398        }
399
400        for handle in handles {
401            match handle.await {
402                Err(e) => {
403                    warn!("Failed to handle inserts: {e}");
404                }
405                Ok(Ok(())) => (),
406                Ok(Err(e)) => {
407                    warn!("Failed to handle inserts: {e}");
408                }
409            }
410        }
411        Ok(())
412    }
413}
414
415impl FlowStatProvider for BatchingEngine {
416    async fn flow_stat(&self) -> FlowStat {
417        let runtime = self.runtime.read().await;
418        let mut last_exec_time_map = BTreeMap::new();
419        let mut start_time_map = BTreeMap::new();
420
421        for (flow_id, task) in runtime.tasks.iter() {
422            let id = *flow_id as u32;
423            if let Some(ts) = task.last_execution_time_millis() {
424                last_exec_time_map.insert(id, ts);
425            }
426            if let Some(ts) = task.start_time_millis() {
427                start_time_map.insert(id, ts);
428            }
429        }
430
431        FlowStat {
432            state_size: BTreeMap::new(),
433            last_exec_time_map,
434            start_time_map,
435        }
436    }
437}
438
439async fn get_table_name(
440    table_info: &TableInfoManager,
441    table_id: &TableId,
442) -> Result<TableName, Error> {
443    get_table_info(table_info, table_id).await.map(|info| {
444        let name = info.table_name();
445        [name.catalog_name, name.schema_name, name.table_name]
446    })
447}
448
449async fn get_table_info(
450    table_info: &TableInfoManager,
451    table_id: &TableId,
452) -> Result<TableInfoValue, Error> {
453    table_info
454        .get(*table_id)
455        .await
456        .map_err(BoxedError::new)
457        .context(ExternalSnafu)?
458        .with_context(|| UnexpectedSnafu {
459            reason: format!("Table id = {:?}, couldn't found table name", table_id),
460        })
461        .map(|info| info.into_inner())
462}
463
464impl BatchingEngine {
465    fn batch_opts_for_flow_options(
466        &self,
467        flow_options: &HashMap<String, String>,
468    ) -> Result<Arc<BatchingModeOptions>, Error> {
469        let mut batch_opts = (*self.batch_opts).clone();
470        if let Some(enable_incremental_read) =
471            flow_options.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
472        {
473            batch_opts.experimental_enable_incremental_read = enable_incremental_read
474                .parse::<bool>()
475                .map_err(|_| {
476                    InvalidQuerySnafu {
477                        reason: format!(
478                            "Invalid flow option {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}: {enable_incremental_read}"
479                        ),
480                    }
481                    .build()
482                })?;
483        }
484
485        Ok(Arc::new(batch_opts))
486    }
487
488    fn table_options_enable_append_mode(extra_options: &HashMap<String, String>) -> bool {
489        extra_options
490            .get(APPEND_MODE_KEY)
491            .is_some_and(|value| value.eq_ignore_ascii_case("true"))
492    }
493
494    /// SQL flows without a usable time-window expression can only run as an
495    /// explicit full-query flow, so require `EVAL INTERVAL` at creation time.
496    fn ensure_sql_flow_has_twe_or_eval_interval(
497        eval_interval: Option<i64>,
498        has_time_window_expr: bool,
499    ) -> Result<(), Error> {
500        ensure!(
501            eval_interval.is_some() || has_time_window_expr,
502            InvalidQuerySnafu {
503                reason: "SQL batching flow without a time-window expression must specify EVAL INTERVAL to run as an explicit full-query flow"
504                    .to_string(),
505            }
506        );
507        Ok(())
508    }
509
510    fn ensure_incremental_source_append_only(
511        batch_opts: &BatchingModeOptions,
512        table_name: &[String; 3],
513        extra_options: &HashMap<String, String>,
514    ) -> Result<(), Error> {
515        if batch_opts.experimental_enable_incremental_read {
516            ensure!(
517                Self::table_options_enable_append_mode(extra_options),
518                UnsupportedSnafu {
519                    reason: format!(
520                        "Flow incremental read requires append-only source table, but source table `{}` is not append-only. Consider setting append_mode='true' on the source table or disabling experimental_enable_incremental_read",
521                        table_name.join(".")
522                    ),
523                }
524            );
525        }
526
527        Ok(())
528    }
529
530    pub async fn create_flow_inner(&self, args: CreateFlowArgs) -> Result<Option<FlowId>, Error> {
531        let CreateFlowArgs {
532            flow_id,
533            sink_table_name,
534            source_table_ids,
535            create_if_not_exists,
536            or_replace,
537            expire_after,
538            eval_interval,
539            comment: _,
540            sql,
541            flow_options,
542            query_ctx,
543            eval_schedule: eval_schedule_config,
544        } = args;
545
546        // or replace logic
547        {
548            let is_exist = self.runtime.read().await.tasks.contains_key(&flow_id);
549            match (create_if_not_exists, or_replace, is_exist) {
550                // if replace, ignore that old flow exists
551                (_, true, true) => {
552                    info!("Replacing flow with id={}", flow_id);
553                }
554                (false, false, true) => FlowAlreadyExistSnafu { id: flow_id }.fail()?,
555                // already exists, and not replace, return None
556                (true, false, true) => {
557                    info!("Flow with id={} already exists, do nothing", flow_id);
558                    return Ok(None);
559                }
560
561                // continue as normal
562                (_, _, false) => (),
563            }
564        }
565
566        let query_ctx = query_ctx.context({
567            UnexpectedSnafu {
568                reason: "Query context is None".to_string(),
569            }
570        })?;
571        let query_ctx = Arc::new(query_ctx);
572        let is_tql = is_tql(query_ctx.sql_dialect(), &sql)
573            .map_err(BoxedError::new)
574            .context(CreateFlowSnafu { sql: &sql })?;
575
576        // optionally set a eval interval for the flow
577        if eval_interval.is_none() && is_tql {
578            InvalidQuerySnafu {
579                reason: "TQL query requires EVAL INTERVAL to be set".to_string(),
580            }
581            .fail()?;
582        }
583
584        let flow_type = flow_options.get(FlowType::FLOW_TYPE_KEY);
585
586        ensure!(
587            match flow_type {
588                None => true,
589                Some(ty) if ty == FlowType::BATCHING => true,
590                _ => false,
591            },
592            UnexpectedSnafu {
593                reason: format!("Flow type is not batching nor None, got {flow_type:?}")
594            }
595        );
596
597        let batch_opts = self.batch_opts_for_flow_options(&flow_options)?;
598
599        let mut source_table_names = Vec::with_capacity(2);
600        for src_id in source_table_ids {
601            // also check table option to see if ttl!=instant
602            let table_name = get_table_name(self.table_meta.table_info_manager(), &src_id).await?;
603            let table_info = get_table_info(self.table_meta.table_info_manager(), &src_id).await?;
604            ensure!(
605                table_info.table_info.meta.options.ttl != Some(TimeToLive::Instant),
606                UnsupportedSnafu {
607                    reason: format!(
608                        "Source table `{}`(id={}) has instant TTL, Instant TTL is not supported under batching mode. Consider using a TTL longer than flush interval",
609                        table_name.join("."),
610                        src_id
611                    ),
612                }
613            );
614            Self::ensure_incremental_source_append_only(
615                &batch_opts,
616                &table_name,
617                &table_info.table_info.meta.options.extra_options,
618            )?;
619
620            source_table_names.push(table_name);
621        }
622
623        let (tx, rx) = oneshot::channel();
624
625        let plan = sql_to_df_plan(query_ctx.clone(), self.query_engine.clone(), &sql, true).await?;
626
627        if is_tql {
628            self.check_is_tql_table(&plan, &query_ctx).await?;
629        }
630
631        let phy_expr = if !is_tql {
632            let (column_name, time_window_expr, _, df_schema) = find_time_window_expr(
633                &plan,
634                self.query_engine.engine_state().catalog_manager().clone(),
635                query_ctx.clone(),
636            )
637            .await?;
638            time_window_expr
639                .map(|expr| {
640                    TimeWindowExpr::from_expr(
641                        &expr,
642                        &column_name,
643                        &df_schema,
644                        &self.query_engine.engine_state().session_state(),
645                    )
646                })
647                .transpose()?
648        } else {
649            // tql control by `EVAL INTERVAL`, no need to find time window expr
650            None
651        };
652
653        debug!(
654            "Flow id={}, found time window expr={}",
655            flow_id,
656            phy_expr
657                .as_ref()
658                .map(|phy_expr| phy_expr.to_string())
659                .unwrap_or("None".to_string())
660        );
661
662        if !is_tql {
663            Self::ensure_sql_flow_has_twe_or_eval_interval(eval_interval, phy_expr.is_some())?;
664        }
665
666        // Compute typed EvalSchedule from FlowScheduleConfig.
667        let eval_schedule = {
668            let interval = eval_interval;
669            let config = eval_schedule_config.as_ref();
670            match EvalSchedule::from_config(interval, config) {
671                Ok(s) => s,
672                Err(e) => {
673                    return UnexpectedSnafu {
674                        reason: format!(
675                            "Failed to build eval schedule for flow {}: {}",
676                            flow_id, e
677                        ),
678                    }
679                    .fail();
680                }
681            }
682        };
683
684        let task_args = TaskArgs {
685            flow_id,
686            query: &sql,
687            plan,
688            time_window_expr: phy_expr,
689            expire_after,
690            sink_table_name,
691            source_table_names,
692            query_ctx,
693            catalog_manager: self.catalog_manager.clone(),
694            shutdown_rx: rx,
695            batch_opts,
696            flow_eval_interval: eval_interval.map(|secs| Duration::from_secs(secs as u64)),
697            eval_schedule,
698        };
699
700        let task = BatchingTask::try_new(task_args)?;
701
702        let task_inner = task.clone();
703        let engine = self.query_engine.clone();
704        let frontend = self.frontend_client.clone();
705
706        // Create sink table if needed, then validate an existing/created sink schema before
707        // spawning the background task. This catches user-created sink schema mismatches at
708        // CREATE FLOW time instead of surfacing them later in the execution loop.
709        task.check_or_create_sink_table(&engine, &frontend).await?;
710        task.validate_sink_table_schema(&engine).await?;
711
712        let (start_tx, start_rx) = oneshot::channel();
713
714        // TODO(discord9): use time wheel or what for better
715        let handle = common_runtime::spawn_global(async move {
716            if start_rx.await.is_ok() {
717                task_inner.start_executing_loop(engine, frontend).await;
718            }
719        });
720        task.state.write().unwrap().task_handle = Some(handle);
721        let task_for_rollback = task.clone();
722
723        // Only replace here, not earlier, because we want the old one intact if
724        // something went wrong before this line. Keep the task and shutdown
725        // sender in one registry lock so create/remove can't observe one
726        // without the other.
727        let (replaced_old_task_opt, replaced_old_shutdown_tx) = {
728            let mut runtime = self.runtime.write().await;
729
730            let is_exist = runtime.tasks.contains_key(&flow_id);
731            match (create_if_not_exists, or_replace, is_exist) {
732                (_, true, true) => {
733                    info!(
734                        "Replacing flow with id={} after final registry check",
735                        flow_id
736                    );
737                }
738                (false, false, true) => {
739                    abort_flow_task(flow_id, Some(task), "unregistered");
740                    return FlowAlreadyExistSnafu { id: flow_id }.fail();
741                }
742                (true, false, true) => {
743                    info!(
744                        "Flow with id={} already exists at final registry check, do nothing",
745                        flow_id
746                    );
747                    abort_flow_task(flow_id, Some(task), "unregistered");
748                    return Ok(None);
749                }
750                (_, _, false) => (),
751            }
752
753            runtime.insert(flow_id, task, tx)
754        };
755
756        notify_flow_shutdown(flow_id, replaced_old_shutdown_tx, "replaced");
757        abort_flow_task(flow_id, replaced_old_task_opt, "replaced");
758        if start_tx.send(()).is_err() {
759            self.rollback_flow_runtime_if_current(flow_id, &task_for_rollback)
760                .await;
761            UnexpectedSnafu {
762                reason: format!("Failed to start flow {flow_id} due to task already dropped"),
763            }
764            .fail()?;
765        }
766
767        Ok(Some(flow_id))
768    }
769
770    async fn check_is_tql_table(
771        &self,
772        query: &LogicalPlan,
773        query_ctx: &QueryContext,
774    ) -> Result<(), Error> {
775        struct CollectTableRef {
776            table_refs: HashSet<datafusion_common::TableReference>,
777        }
778
779        impl TreeNodeVisitor<'_> for CollectTableRef {
780            type Node = LogicalPlan;
781            fn f_down(
782                &mut self,
783                node: &Self::Node,
784            ) -> datafusion_common::Result<TreeNodeRecursion> {
785                if let LogicalPlan::TableScan(scan) = node {
786                    self.table_refs.insert(scan.table_name.clone());
787                }
788                Ok(TreeNodeRecursion::Continue)
789            }
790        }
791        let mut table_refs = CollectTableRef {
792            table_refs: HashSet::new(),
793        };
794        query
795            .visit_with_subqueries(&mut table_refs)
796            .context(DatafusionSnafu {
797                context: "Checking if all source tables are TQL tables",
798            })?;
799
800        let default_catalog = query_ctx.current_catalog();
801        let default_schema = query_ctx.current_schema();
802        let default_schema = &default_schema;
803
804        for table_ref in table_refs.table_refs {
805            let table_ref = match &table_ref {
806                datafusion_common::TableReference::Bare { table } => {
807                    TableReference::full(default_catalog, default_schema, table)
808                }
809                datafusion_common::TableReference::Partial { schema, table } => {
810                    TableReference::full(default_catalog, schema, table)
811                }
812                datafusion_common::TableReference::Full {
813                    catalog,
814                    schema,
815                    table,
816                } => TableReference::full(catalog, schema, table),
817            };
818
819            let table_id = self
820                .table_meta
821                .table_name_manager()
822                .get(table_ref.into())
823                .await
824                .map_err(BoxedError::new)
825                .context(ExternalSnafu)?
826                .with_context(|| UnexpectedSnafu {
827                    reason: format!("Failed to get table id for table: {}", table_ref),
828                })?
829                .table_id();
830            let table_info =
831                get_table_info(self.table_meta.table_info_manager(), &table_id).await?;
832            // first check if it's only one f64 value column
833            let value_cols = table_info
834                .table_info
835                .meta
836                .schema
837                .column_schemas()
838                .iter()
839                .filter(|col| col.data_type == ConcreteDataType::float64_datatype())
840                .collect::<Vec<_>>();
841            ensure!(
842                value_cols.len() == 1,
843                InvalidQuerySnafu {
844                    reason: format!(
845                        "TQL query only supports one f64 value column, table `{}`(id={}) has {} f64 value columns, columns are: {:?}",
846                        table_ref,
847                        table_id,
848                        value_cols.len(),
849                        value_cols
850                    ),
851                }
852            );
853            // TODO(discord9): do need to check rest columns is string and is tag column?
854            let pk_idxs = table_info
855                .table_info
856                .meta
857                .primary_key_indices
858                .iter()
859                .collect::<HashSet<_>>();
860
861            for (idx, col) in table_info
862                .table_info
863                .meta
864                .schema
865                .column_schemas()
866                .iter()
867                .enumerate()
868            {
869                if is_metric_engine_internal_column(&col.name) {
870                    continue;
871                }
872                // three cases:
873                // 1. val column
874                // 2. timestamp column
875                // 3. tag column (string)
876
877                let is_pk: bool = pk_idxs.contains(&&idx);
878
879                ensure!(
880                    col.data_type == ConcreteDataType::float64_datatype()
881                        || col.data_type.is_timestamp()
882                        || (col.data_type == ConcreteDataType::string_datatype() && is_pk),
883                    InvalidQuerySnafu {
884                        reason: format!(
885                            "TQL query only supports f64 value column, timestamp column and string tag columns, table `{}`(id={}) has column `{}` with type {:?} which is not supported",
886                            table_ref, table_id, col.name, col.data_type
887                        ),
888                    }
889                );
890            }
891        }
892        Ok(())
893    }
894
895    pub async fn remove_flow_inner(&self, flow_id: FlowId) -> Result<(), Error> {
896        let (task, shutdown_tx) = {
897            let mut runtime = self.runtime.write().await;
898            let Some((task, shutdown_tx)) = runtime.remove(flow_id) else {
899                warn!("Flow {flow_id} not found in tasks");
900                FlowNotFoundSnafu { id: flow_id }.fail()?
901            };
902            (task, shutdown_tx)
903        };
904
905        let had_shutdown_tx = notify_flow_shutdown(flow_id, shutdown_tx, "removed");
906        abort_flow_task(flow_id, Some(task), "removed");
907
908        if !had_shutdown_tx {
909            UnexpectedSnafu {
910                reason: format!("Can't found shutdown tx for flow {flow_id}"),
911            }
912            .fail()?
913        }
914
915        Ok(())
916    }
917
918    /// Only flush the dirty windows of the flow task with given flow id, by running the query on it.
919    /// As flush the whole time range is usually prohibitively expensive.
920    pub async fn flush_flow_inner(&self, flow_id: FlowId) -> Result<usize, Error> {
921        debug!("Try flush flow {flow_id}");
922        // need to wait a bit to ensure previous mirror insert is handled
923        // this is only useful for the case when we are flushing the flow right after inserting data into it
924        // TODO(discord9): find a better way to ensure the data is ready, maybe inform flownode from frontend?
925        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
926        let task = self.runtime.read().await.tasks.get(&flow_id).cloned();
927        let task = task.with_context(|| FlowNotFoundSnafu { id: flow_id })?;
928
929        let time_window_size = task
930            .config
931            .time_window_expr
932            .as_ref()
933            .and_then(|expr| *expr.time_window_size());
934
935        let cur_dirty_window_cnt = time_window_size.map(|time_window_size| {
936            task.state
937                .read()
938                .unwrap()
939                .dirty_time_windows
940                .effective_count(&time_window_size)
941        });
942
943        let res = task
944            .execute_once_serialized(
945                &self.query_engine,
946                &self.frontend_client,
947                cur_dirty_window_cnt,
948            )
949            .await?;
950
951        let affected_rows = res.map(|(r, _)| r).unwrap_or_default();
952        debug!(
953            "Successfully flush flow {flow_id}, affected rows={}",
954            affected_rows
955        );
956        Ok(affected_rows)
957    }
958
959    /// Determine if the batching mode flow task exists with given flow id
960    pub async fn flow_exist_inner(&self, flow_id: FlowId) -> bool {
961        self.runtime.read().await.tasks.contains_key(&flow_id)
962    }
963
964    async fn rollback_flow_runtime_if_current(&self, flow_id: FlowId, task: &BatchingTask) {
965        let (removed_task, removed_shutdown_tx) = {
966            let mut runtime = self.runtime.write().await;
967            runtime.remove_if_current(flow_id, task)
968        };
969
970        notify_flow_shutdown(flow_id, removed_shutdown_tx, "rolled back");
971        abort_flow_task(flow_id, removed_task, "rolled back");
972    }
973}
974
975fn notify_flow_shutdown(flow_id: FlowId, tx: Option<oneshot::Sender<()>>, action: &str) -> bool {
976    let Some(tx) = tx else {
977        return false;
978    };
979
980    if tx.send(()).is_err() {
981        warn!(
982            "Fail to shutdown {action} flow {flow_id} due to receiver already dropped, maybe flow {flow_id} is already dropped?"
983        );
984    }
985
986    true
987}
988
989fn abort_flow_task(flow_id: FlowId, task: Option<BatchingTask>, action: &str) -> bool {
990    let Some(task) = task else {
991        return false;
992    };
993
994    if let Some(handle) = task.state.write().unwrap().task_handle.take() {
995        handle.abort();
996        debug!("Aborted {action} flow task {flow_id}");
997        return true;
998    }
999
1000    false
1001}
1002
1003impl FlowEngine for BatchingEngine {
1004    async fn create_flow(&self, args: CreateFlowArgs) -> Result<Option<FlowId>, Error> {
1005        self.create_flow_inner(args).await
1006    }
1007    async fn remove_flow(&self, flow_id: FlowId) -> Result<(), Error> {
1008        self.remove_flow_inner(flow_id).await
1009    }
1010    async fn flush_flow(&self, flow_id: FlowId) -> Result<usize, Error> {
1011        self.flush_flow_inner(flow_id).await
1012    }
1013    async fn flow_exist(&self, flow_id: FlowId) -> Result<bool, Error> {
1014        Ok(self.flow_exist_inner(flow_id).await)
1015    }
1016    async fn list_flows(&self) -> Result<impl IntoIterator<Item = FlowId>, Error> {
1017        Ok(self
1018            .runtime
1019            .read()
1020            .await
1021            .tasks
1022            .keys()
1023            .cloned()
1024            .collect::<Vec<_>>())
1025    }
1026    async fn handle_flow_inserts(
1027        &self,
1028        request: api::v1::region::InsertRequests,
1029    ) -> Result<(), Error> {
1030        self.handle_inserts_inner(request).await
1031    }
1032    async fn handle_mark_window_dirty(
1033        &self,
1034        req: api::v1::flow::DirtyWindowRequests,
1035    ) -> Result<(), Error> {
1036        self.handle_mark_dirty_time_window(req).await
1037    }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use api::v1::flow::{DirtyWindowRequest, TimeRange};
1043    use catalog::memory::new_memory_catalog_manager;
1044    use common_meta::key::TableMetadataManager;
1045    use common_meta::key::flow::FlowMetadataManager;
1046    use common_meta::key::table_route::TableRouteValue;
1047    use common_meta::key::test_utils::new_test_table_info_with_name;
1048    use common_meta::kv_backend::memory::MemoryKvBackend;
1049    use common_time::timestamp::TimeUnit;
1050    use query::options::QueryOptions;
1051    use session::context::QueryContext;
1052
1053    use super::*;
1054    use crate::test_utils::create_test_query_engine;
1055
1056    struct DropNotify(Option<oneshot::Sender<()>>);
1057
1058    impl Drop for DropNotify {
1059        fn drop(&mut self) {
1060            if let Some(tx) = self.0.take() {
1061                let _ = tx.send(());
1062            }
1063        }
1064    }
1065
1066    async fn new_test_engine() -> BatchingEngine {
1067        let kv_backend = Arc::new(MemoryKvBackend::new());
1068        let table_meta = Arc::new(TableMetadataManager::new(kv_backend.clone()));
1069        table_meta.init().await.unwrap();
1070        let flow_meta = Arc::new(FlowMetadataManager::new(kv_backend));
1071        let catalog_manager = new_memory_catalog_manager().unwrap();
1072        let query_engine = create_test_query_engine();
1073        let (frontend_client, _handler) =
1074            FrontendClient::from_empty_grpc_handler(QueryOptions::default());
1075
1076        BatchingEngine::new(
1077            Arc::new(frontend_client),
1078            query_engine,
1079            flow_meta,
1080            table_meta,
1081            catalog_manager,
1082            BatchingModeOptions::default(),
1083        )
1084    }
1085
1086    #[tokio::test]
1087    async fn test_flow_option_overrides_incremental_read_switch() {
1088        let engine = new_test_engine().await;
1089
1090        let default_opts = engine.batch_opts_for_flow_options(&HashMap::new()).unwrap();
1091        assert!(!default_opts.experimental_enable_incremental_read);
1092
1093        let enabled_opts = engine
1094            .batch_opts_for_flow_options(&HashMap::from([(
1095                FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
1096                "true".to_string(),
1097            )]))
1098            .unwrap();
1099        assert!(enabled_opts.experimental_enable_incremental_read);
1100    }
1101
1102    #[test]
1103    fn test_table_options_enable_append_mode() {
1104        assert!(!BatchingEngine::table_options_enable_append_mode(
1105            &HashMap::new()
1106        ));
1107        assert!(!BatchingEngine::table_options_enable_append_mode(
1108            &HashMap::from([(APPEND_MODE_KEY.to_string(), "false".to_string())])
1109        ));
1110        assert!(BatchingEngine::table_options_enable_append_mode(
1111            &HashMap::from([(APPEND_MODE_KEY.to_string(), "TRUE".to_string())])
1112        ));
1113    }
1114
1115    #[test]
1116    fn test_sql_flow_requires_time_window_or_eval_interval() {
1117        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, true)
1118            .expect("SQL flow with a time-window expression should be accepted");
1119        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(Some(10), false).expect(
1120            "SQL flow with EVAL INTERVAL should be accepted as an explicit full-query flow",
1121        );
1122
1123        let err = BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, false)
1124            .expect_err("SQL flow without a time-window expression or EVAL INTERVAL should fail");
1125        assert!(matches!(err, Error::InvalidQuery { .. }), "{err}");
1126        assert!(
1127            err.to_string().contains("must specify EVAL INTERVAL"),
1128            "{err}"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn test_complex_sql_without_eval_interval_is_rejected_as_no_twe() {
1134        let query_engine = create_test_query_engine();
1135        let ctx = QueryContext::arc();
1136        let plan = sql_to_df_plan(
1137            ctx.clone(),
1138            query_engine.clone(),
1139            r#"
1140SELECT
1141    l.number,
1142    date_bin('5 minutes', l.ts) AS time_window
1143FROM numbers_with_ts l
1144JOIN numbers_with_ts r ON l.number = r.number
1145GROUP BY l.number, time_window
1146"#,
1147            true,
1148        )
1149        .await
1150        .unwrap();
1151
1152        let (_, time_window_expr, _, _) = find_time_window_expr(
1153            &plan,
1154            query_engine.engine_state().catalog_manager().clone(),
1155            ctx,
1156        )
1157        .await
1158        .unwrap();
1159        assert!(
1160            time_window_expr.is_none(),
1161            "complex SQL should be classified as having no safe TWE"
1162        );
1163
1164        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(Some(10), false)
1165            .expect("complex SQL can run as an explicit full-query flow when EVAL INTERVAL is set");
1166        let err = BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, false)
1167            .expect_err("complex SQL without EVAL INTERVAL should fail creation");
1168        assert!(matches!(err, Error::InvalidQuery { .. }), "{err}");
1169    }
1170
1171    #[test]
1172    fn test_incremental_source_append_only_enforcement() {
1173        let table_name = [
1174            "greptime".to_string(),
1175            "public".to_string(),
1176            "numbers".to_string(),
1177        ];
1178        let disabled_opts = BatchingModeOptions::default();
1179        let enabled_opts = BatchingModeOptions {
1180            experimental_enable_incremental_read: true,
1181            ..Default::default()
1182        };
1183        let non_append_options = HashMap::new();
1184        let append_options = HashMap::from([(APPEND_MODE_KEY.to_string(), "true".to_string())]);
1185
1186        BatchingEngine::ensure_incremental_source_append_only(
1187            &disabled_opts,
1188            &table_name,
1189            &non_append_options,
1190        )
1191        .expect("disabled incremental read should not require append-only source");
1192        BatchingEngine::ensure_incremental_source_append_only(
1193            &enabled_opts,
1194            &table_name,
1195            &append_options,
1196        )
1197        .expect("append-only source should be accepted when incremental read is enabled");
1198
1199        let err = BatchingEngine::ensure_incremental_source_append_only(
1200            &enabled_opts,
1201            &table_name,
1202            &non_append_options,
1203        )
1204        .expect_err("non-append source should be rejected when incremental read is enabled");
1205        assert!(
1206            err.to_string()
1207                .contains("Flow incremental read requires append-only source table"),
1208            "{err}"
1209        );
1210    }
1211
1212    async fn new_test_task(flow_id: FlowId) -> (BatchingTask, oneshot::Sender<()>) {
1213        new_test_task_for_source(flow_id, "numbers_with_ts", None).await
1214    }
1215
1216    async fn new_test_task_with_time_window_expr(
1217        flow_id: FlowId,
1218        time_window_expr: Option<TimeWindowExpr>,
1219    ) -> (BatchingTask, oneshot::Sender<()>) {
1220        new_test_task_for_source(flow_id, "numbers_with_ts", time_window_expr).await
1221    }
1222
1223    fn test_table_info_with_ts_unit(
1224        table_id: TableId,
1225        table_name: &str,
1226        unit: TimeUnit,
1227    ) -> table::metadata::TableInfo {
1228        use datatypes::schema::{ColumnSchema, SchemaBuilder};
1229        use table::metadata::{TableInfoBuilder, TableMetaBuilder};
1230
1231        let ts_type = match unit {
1232            TimeUnit::Second => ConcreteDataType::timestamp_second_datatype(),
1233            TimeUnit::Millisecond => ConcreteDataType::timestamp_millisecond_datatype(),
1234            TimeUnit::Microsecond => ConcreteDataType::timestamp_microsecond_datatype(),
1235            TimeUnit::Nanosecond => ConcreteDataType::timestamp_nanosecond_datatype(),
1236        };
1237        let column_schemas = vec![
1238            ColumnSchema::new("col1", ConcreteDataType::int32_datatype(), true),
1239            ColumnSchema::new("ts", ts_type, false).with_time_index(true),
1240        ];
1241        let schema = SchemaBuilder::try_from(column_schemas)
1242            .unwrap()
1243            .build()
1244            .unwrap();
1245        let meta = TableMetaBuilder::empty()
1246            .schema(Arc::new(schema))
1247            .primary_key_indices(vec![0])
1248            .engine("engine")
1249            .next_column_id(3)
1250            .build()
1251            .unwrap();
1252        TableInfoBuilder::default()
1253            .table_id(table_id)
1254            .table_version(0)
1255            .name(table_name)
1256            .catalog_name("greptime")
1257            .schema_name("public")
1258            .meta(meta)
1259            .build()
1260            .unwrap()
1261    }
1262
1263    /// A 5-second `date_bin` time window expr over the test table's `ts` column.
1264    async fn test_time_window_expr() -> TimeWindowExpr {
1265        let query_engine = create_test_query_engine();
1266        let ctx = QueryContext::arc();
1267        let plan = sql_to_df_plan(
1268            ctx.clone(),
1269            query_engine.clone(),
1270            "SELECT date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window",
1271            true,
1272        )
1273        .await
1274        .unwrap();
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,
1279        )
1280        .await
1281        .unwrap();
1282        TimeWindowExpr::from_expr(
1283            &time_window_expr.unwrap(),
1284            &column_name,
1285            &df_schema,
1286            &query_engine.engine_state().session_state(),
1287        )
1288        .unwrap()
1289    }
1290
1291    async fn new_test_task_for_source(
1292        flow_id: FlowId,
1293        source_table_name: &str,
1294        time_window_expr: Option<TimeWindowExpr>,
1295    ) -> (BatchingTask, oneshot::Sender<()>) {
1296        let query_engine = create_test_query_engine();
1297        let ctx = QueryContext::arc();
1298        let plan = sql_to_df_plan(
1299            ctx.clone(),
1300            query_engine.clone(),
1301            "SELECT number, ts FROM numbers_with_ts",
1302            true,
1303        )
1304        .await
1305        .unwrap();
1306        let (tx, rx) = oneshot::channel();
1307
1308        let task = BatchingTask::try_new(TaskArgs {
1309            flow_id,
1310            query: "SELECT number, ts FROM numbers_with_ts",
1311            plan,
1312            time_window_expr,
1313            expire_after: None,
1314            sink_table_name: [
1315                "greptime".to_string(),
1316                "public".to_string(),
1317                "sink".to_string(),
1318            ],
1319            source_table_names: vec![[
1320                "greptime".to_string(),
1321                "public".to_string(),
1322                source_table_name.to_string(),
1323            ]],
1324            query_ctx: ctx,
1325            catalog_manager: query_engine.engine_state().catalog_manager().clone(),
1326            shutdown_rx: rx,
1327            batch_opts: Arc::new(BatchingModeOptions::default()),
1328            flow_eval_interval: None,
1329            eval_schedule: None,
1330        })
1331        .unwrap();
1332
1333        (task, tx)
1334    }
1335
1336    #[tokio::test]
1337    async fn test_handle_mark_dirty_time_window_with_time_ranges() {
1338        let engine = new_test_engine().await;
1339
1340        // Register the source table info so the engine can resolve the table
1341        // name and the time index unit (millisecond).
1342        let mut table_info = new_test_table_info_with_name(1, "numbers_with_ts");
1343        table_info.catalog_name = "greptime".to_string();
1344        table_info.schema_name = "public".to_string();
1345        engine
1346            .table_meta
1347            .create_table_metadata(
1348                table_info,
1349                TableRouteValue::physical(vec![]),
1350                HashMap::new(),
1351            )
1352            .await
1353            .unwrap();
1354
1355        // Build a task with a 5-second time window expr.
1356        let (task, shutdown_tx) =
1357            new_test_task_with_time_window_expr(1, Some(test_time_window_expr().await)).await;
1358        let task_identity = task.clone();
1359        engine.runtime.write().await.insert(1, task, shutdown_tx);
1360
1361        engine
1362            .handle_mark_dirty_time_window(DirtyWindowRequests {
1363                requests: vec![DirtyWindowRequest {
1364                    table_id: 1,
1365                    timestamps: vec![],
1366                    time_ranges: vec![
1367                        // [3s, 11s) aligns to window start 0s and window end 15s.
1368                        TimeRange {
1369                            start_inclusive: 3_000,
1370                            end_exclusive: 11_000,
1371                        },
1372                        // Empty and reversed ranges are invalid and skipped.
1373                        TimeRange {
1374                            start_inclusive: 5_000,
1375                            end_exclusive: 5_000,
1376                        },
1377                        TimeRange {
1378                            start_inclusive: 9_000,
1379                            end_exclusive: 4_000,
1380                        },
1381                    ],
1382                }],
1383            })
1384            .await
1385            .unwrap();
1386
1387        let state = task_identity.state.read().unwrap();
1388        assert_eq!(1, state.dirty_time_windows.len());
1389        assert_eq!(
1390            Duration::from_secs(15),
1391            state.dirty_time_windows.window_size()
1392        );
1393    }
1394
1395    /// Dirty timestamps and time ranges are interpreted in the source table's
1396    /// time index native unit. The same physical range [3s, 11s) expressed in
1397    /// second/millisecond/microsecond/nanosecond units must align to the same
1398    /// dirty window [0s, 15s).
1399    #[tokio::test]
1400    async fn test_handle_mark_dirty_time_window_time_index_units() {
1401        let engine = new_test_engine().await;
1402
1403        let cases = [
1404            (TimeUnit::Second, 1u32, "t_sec", 3i64, 11i64),
1405            (TimeUnit::Millisecond, 2, "t_ms", 3_000, 11_000),
1406            (TimeUnit::Microsecond, 3, "t_us", 3_000_000, 11_000_000),
1407            (
1408                TimeUnit::Nanosecond,
1409                4,
1410                "t_ns",
1411                3_000_000_000,
1412                11_000_000_000,
1413            ),
1414        ];
1415
1416        let mut task_identities = vec![];
1417        let mut requests = vec![];
1418        for (unit, table_id, table_name, start_inclusive, end_exclusive) in cases {
1419            engine
1420                .table_meta
1421                .create_table_metadata(
1422                    test_table_info_with_ts_unit(table_id, table_name, unit),
1423                    TableRouteValue::physical(vec![]),
1424                    HashMap::new(),
1425                )
1426                .await
1427                .unwrap();
1428
1429            let (task, shutdown_tx) = new_test_task_for_source(
1430                table_id as FlowId,
1431                table_name,
1432                Some(test_time_window_expr().await),
1433            )
1434            .await;
1435            task_identities.push((table_id, task.clone()));
1436            engine
1437                .runtime
1438                .write()
1439                .await
1440                .insert(table_id as FlowId, task, shutdown_tx);
1441
1442            requests.push(DirtyWindowRequest {
1443                table_id,
1444                timestamps: vec![],
1445                time_ranges: vec![TimeRange {
1446                    start_inclusive,
1447                    end_exclusive,
1448                }],
1449            });
1450        }
1451
1452        engine
1453            .handle_mark_dirty_time_window(DirtyWindowRequests { requests })
1454            .await
1455            .unwrap();
1456
1457        for (table_id, task) in task_identities {
1458            let state = task.state.read().unwrap();
1459            assert_eq!(1, state.dirty_time_windows.len(), "table id = {table_id}");
1460            assert_eq!(
1461                Duration::from_secs(15),
1462                state.dirty_time_windows.window_size(),
1463                "table id = {table_id}"
1464            );
1465        }
1466    }
1467
1468    #[tokio::test]
1469    async fn test_handle_mark_dirty_time_window_returns_error_on_alignment_failure() {
1470        let engine = new_test_engine().await;
1471        let table_id = 10;
1472        let table_name = "t_bad_timestamp";
1473
1474        engine
1475            .table_meta
1476            .create_table_metadata(
1477                test_table_info_with_ts_unit(table_id, table_name, TimeUnit::Second),
1478                TableRouteValue::physical(vec![]),
1479                HashMap::new(),
1480            )
1481            .await
1482            .unwrap();
1483
1484        let (task, shutdown_tx) = new_test_task_for_source(
1485            table_id as FlowId,
1486            table_name,
1487            Some(test_time_window_expr().await),
1488        )
1489        .await;
1490        engine
1491            .runtime
1492            .write()
1493            .await
1494            .insert(table_id as FlowId, task, shutdown_tx);
1495
1496        let result = engine
1497            .handle_mark_dirty_time_window(DirtyWindowRequests {
1498                requests: vec![DirtyWindowRequest {
1499                    table_id,
1500                    timestamps: vec![i64::MAX],
1501                    time_ranges: vec![],
1502                }],
1503            })
1504            .await;
1505
1506        assert!(
1507            result.is_err(),
1508            "invalid timestamp alignment should be returned to the caller"
1509        );
1510    }
1511
1512    async fn install_abort_observed_handle(task: &BatchingTask) -> oneshot::Receiver<()> {
1513        let (drop_tx, drop_rx) = oneshot::channel();
1514        let (entered_tx, entered_rx) = oneshot::channel();
1515        let handle = tokio::spawn(async move {
1516            let _guard = DropNotify(Some(drop_tx));
1517            let _ = entered_tx.send(());
1518            std::future::pending::<()>().await;
1519        });
1520        task.state.write().unwrap().task_handle = Some(handle);
1521        tokio::time::timeout(Duration::from_secs(1), entered_rx)
1522            .await
1523            .expect("test task handle should start")
1524            .expect("test task handle should report start");
1525        drop_rx
1526    }
1527
1528    #[tokio::test]
1529    async fn test_notify_flow_shutdown_sends_signal() {
1530        let (tx, rx) = oneshot::channel();
1531
1532        assert!(notify_flow_shutdown(42, Some(tx), "test"));
1533
1534        rx.await.expect("replaced flow should receive shutdown");
1535    }
1536
1537    #[test]
1538    fn test_notify_flow_shutdown_accepts_missing_sender() {
1539        assert!(!notify_flow_shutdown(42, None, "test"));
1540    }
1541
1542    #[tokio::test]
1543    async fn test_abort_flow_task_aborts_handle() {
1544        let (task, _shutdown_tx) = new_test_task(42).await;
1545        let drop_rx = install_abort_observed_handle(&task).await;
1546
1547        assert!(abort_flow_task(42, Some(task), "test"));
1548
1549        tokio::time::timeout(Duration::from_secs(1), drop_rx)
1550            .await
1551            .expect("aborted task should be dropped")
1552            .expect("drop notifier should fire");
1553    }
1554
1555    #[tokio::test]
1556    async fn test_remove_flow_inner_aborts_registered_task() {
1557        let engine = new_test_engine().await;
1558        let (task, shutdown_tx) = new_test_task(42).await;
1559        let drop_rx = install_abort_observed_handle(&task).await;
1560
1561        engine.runtime.write().await.insert(42, task, shutdown_tx);
1562
1563        engine.remove_flow_inner(42).await.unwrap();
1564
1565        tokio::time::timeout(Duration::from_secs(1), drop_rx)
1566            .await
1567            .expect("removed task should be dropped")
1568            .expect("drop notifier should fire");
1569        assert!(!engine.flow_exist_inner(42).await);
1570        assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42));
1571    }
1572
1573    #[tokio::test]
1574    async fn test_or_replace_flow_runtime_replaces_old_handles_and_keeps_new_task() {
1575        let engine = new_test_engine().await;
1576        let (old_task, old_shutdown_tx) = new_test_task(42).await;
1577        let old_task_identity = old_task.clone();
1578        let old_drop_rx = install_abort_observed_handle(&old_task).await;
1579        let (new_task, new_shutdown_tx) = new_test_task(42).await;
1580        let new_task_identity = new_task.clone();
1581
1582        engine
1583            .runtime
1584            .write()
1585            .await
1586            .insert(42, old_task, old_shutdown_tx);
1587        let (replaced_old_task, replaced_old_shutdown_tx) =
1588            engine
1589                .runtime
1590                .write()
1591                .await
1592                .insert(42, new_task, new_shutdown_tx);
1593
1594        let replaced_old_task = replaced_old_task.expect("old task should be returned");
1595        assert!(Arc::ptr_eq(
1596            &replaced_old_task.state,
1597            &old_task_identity.state
1598        ));
1599        assert!(notify_flow_shutdown(
1600            42,
1601            replaced_old_shutdown_tx,
1602            "replaced"
1603        ));
1604        old_task_identity
1605            .state
1606            .write()
1607            .unwrap()
1608            .shutdown_rx
1609            .try_recv()
1610            .expect("old shutdown receiver should receive signal");
1611        assert!(abort_flow_task(42, Some(replaced_old_task), "replaced"));
1612
1613        tokio::time::timeout(Duration::from_secs(1), old_drop_rx)
1614            .await
1615            .expect("replaced task should be dropped")
1616            .expect("drop notifier should fire");
1617
1618        let runtime = engine.runtime.read().await;
1619        assert_eq!(1, runtime.tasks.len());
1620        assert_eq!(1, runtime.shutdown_txs.len());
1621        let registered_task = runtime.tasks.get(&42).expect("new task should remain");
1622        assert!(Arc::ptr_eq(
1623            &registered_task.state,
1624            &new_task_identity.state
1625        ));
1626        assert!(runtime.shutdown_txs.contains_key(&42));
1627        assert!(matches!(
1628            new_task_identity
1629                .state
1630                .write()
1631                .unwrap()
1632                .shutdown_rx
1633                .try_recv(),
1634            Err(oneshot::error::TryRecvError::Empty)
1635        ));
1636    }
1637
1638    #[tokio::test]
1639    async fn test_rollback_flow_runtime_if_current_removes_matching_task_only() {
1640        let engine = new_test_engine().await;
1641        let (old_task, _old_shutdown_tx) = new_test_task(42).await;
1642        let (current_task, current_shutdown_tx) = new_test_task(42).await;
1643        let current_task_identity = current_task.clone();
1644
1645        engine
1646            .runtime
1647            .write()
1648            .await
1649            .insert(42, current_task, current_shutdown_tx);
1650
1651        engine.rollback_flow_runtime_if_current(42, &old_task).await;
1652
1653        let registered_task = engine.runtime.read().await.tasks.get(&42).cloned().unwrap();
1654        assert!(Arc::ptr_eq(
1655            &registered_task.state,
1656            &current_task_identity.state
1657        ));
1658        assert!(engine.runtime.read().await.shutdown_txs.contains_key(&42));
1659
1660        engine
1661            .rollback_flow_runtime_if_current(42, &current_task_identity)
1662            .await;
1663        assert!(!engine.flow_exist_inner(42).await);
1664        assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42));
1665    }
1666}