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        FlowStat {
418            state_size: BTreeMap::new(),
419            last_exec_time_map: self
420                .get_last_exec_time_map()
421                .await
422                .into_iter()
423                .map(|(flow_id, timestamp)| (flow_id as u32, timestamp))
424                .collect(),
425        }
426    }
427}
428
429async fn get_table_name(
430    table_info: &TableInfoManager,
431    table_id: &TableId,
432) -> Result<TableName, Error> {
433    get_table_info(table_info, table_id).await.map(|info| {
434        let name = info.table_name();
435        [name.catalog_name, name.schema_name, name.table_name]
436    })
437}
438
439async fn get_table_info(
440    table_info: &TableInfoManager,
441    table_id: &TableId,
442) -> Result<TableInfoValue, Error> {
443    table_info
444        .get(*table_id)
445        .await
446        .map_err(BoxedError::new)
447        .context(ExternalSnafu)?
448        .with_context(|| UnexpectedSnafu {
449            reason: format!("Table id = {:?}, couldn't found table name", table_id),
450        })
451        .map(|info| info.into_inner())
452}
453
454impl BatchingEngine {
455    fn batch_opts_for_flow_options(
456        &self,
457        flow_options: &HashMap<String, String>,
458    ) -> Result<Arc<BatchingModeOptions>, Error> {
459        let mut batch_opts = (*self.batch_opts).clone();
460        if let Some(enable_incremental_read) =
461            flow_options.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
462        {
463            batch_opts.experimental_enable_incremental_read = enable_incremental_read
464                .parse::<bool>()
465                .map_err(|_| {
466                    InvalidQuerySnafu {
467                        reason: format!(
468                            "Invalid flow option {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}: {enable_incremental_read}"
469                        ),
470                    }
471                    .build()
472                })?;
473        }
474
475        Ok(Arc::new(batch_opts))
476    }
477
478    fn table_options_enable_append_mode(extra_options: &HashMap<String, String>) -> bool {
479        extra_options
480            .get(APPEND_MODE_KEY)
481            .is_some_and(|value| value.eq_ignore_ascii_case("true"))
482    }
483
484    /// SQL flows without a usable time-window expression can only run as an
485    /// explicit full-query flow, so require `EVAL INTERVAL` at creation time.
486    fn ensure_sql_flow_has_twe_or_eval_interval(
487        eval_interval: Option<i64>,
488        has_time_window_expr: bool,
489    ) -> Result<(), Error> {
490        ensure!(
491            eval_interval.is_some() || has_time_window_expr,
492            InvalidQuerySnafu {
493                reason: "SQL batching flow without a time-window expression must specify EVAL INTERVAL to run as an explicit full-query flow"
494                    .to_string(),
495            }
496        );
497        Ok(())
498    }
499
500    fn ensure_incremental_source_append_only(
501        batch_opts: &BatchingModeOptions,
502        table_name: &[String; 3],
503        extra_options: &HashMap<String, String>,
504    ) -> Result<(), Error> {
505        if batch_opts.experimental_enable_incremental_read {
506            ensure!(
507                Self::table_options_enable_append_mode(extra_options),
508                UnsupportedSnafu {
509                    reason: format!(
510                        "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",
511                        table_name.join(".")
512                    ),
513                }
514            );
515        }
516
517        Ok(())
518    }
519
520    pub async fn create_flow_inner(&self, args: CreateFlowArgs) -> Result<Option<FlowId>, Error> {
521        let CreateFlowArgs {
522            flow_id,
523            sink_table_name,
524            source_table_ids,
525            create_if_not_exists,
526            or_replace,
527            expire_after,
528            eval_interval,
529            comment: _,
530            sql,
531            flow_options,
532            query_ctx,
533            eval_schedule: eval_schedule_config,
534        } = args;
535
536        // or replace logic
537        {
538            let is_exist = self.runtime.read().await.tasks.contains_key(&flow_id);
539            match (create_if_not_exists, or_replace, is_exist) {
540                // if replace, ignore that old flow exists
541                (_, true, true) => {
542                    info!("Replacing flow with id={}", flow_id);
543                }
544                (false, false, true) => FlowAlreadyExistSnafu { id: flow_id }.fail()?,
545                // already exists, and not replace, return None
546                (true, false, true) => {
547                    info!("Flow with id={} already exists, do nothing", flow_id);
548                    return Ok(None);
549                }
550
551                // continue as normal
552                (_, _, false) => (),
553            }
554        }
555
556        let query_ctx = query_ctx.context({
557            UnexpectedSnafu {
558                reason: "Query context is None".to_string(),
559            }
560        })?;
561        let query_ctx = Arc::new(query_ctx);
562        let is_tql = is_tql(query_ctx.sql_dialect(), &sql)
563            .map_err(BoxedError::new)
564            .context(CreateFlowSnafu { sql: &sql })?;
565
566        // optionally set a eval interval for the flow
567        if eval_interval.is_none() && is_tql {
568            InvalidQuerySnafu {
569                reason: "TQL query requires EVAL INTERVAL to be set".to_string(),
570            }
571            .fail()?;
572        }
573
574        let flow_type = flow_options.get(FlowType::FLOW_TYPE_KEY);
575
576        ensure!(
577            match flow_type {
578                None => true,
579                Some(ty) if ty == FlowType::BATCHING => true,
580                _ => false,
581            },
582            UnexpectedSnafu {
583                reason: format!("Flow type is not batching nor None, got {flow_type:?}")
584            }
585        );
586
587        let batch_opts = self.batch_opts_for_flow_options(&flow_options)?;
588
589        let mut source_table_names = Vec::with_capacity(2);
590        for src_id in source_table_ids {
591            // also check table option to see if ttl!=instant
592            let table_name = get_table_name(self.table_meta.table_info_manager(), &src_id).await?;
593            let table_info = get_table_info(self.table_meta.table_info_manager(), &src_id).await?;
594            ensure!(
595                table_info.table_info.meta.options.ttl != Some(TimeToLive::Instant),
596                UnsupportedSnafu {
597                    reason: format!(
598                        "Source table `{}`(id={}) has instant TTL, Instant TTL is not supported under batching mode. Consider using a TTL longer than flush interval",
599                        table_name.join("."),
600                        src_id
601                    ),
602                }
603            );
604            Self::ensure_incremental_source_append_only(
605                &batch_opts,
606                &table_name,
607                &table_info.table_info.meta.options.extra_options,
608            )?;
609
610            source_table_names.push(table_name);
611        }
612
613        let (tx, rx) = oneshot::channel();
614
615        let plan = sql_to_df_plan(query_ctx.clone(), self.query_engine.clone(), &sql, true).await?;
616
617        if is_tql {
618            self.check_is_tql_table(&plan, &query_ctx).await?;
619        }
620
621        let phy_expr = if !is_tql {
622            let (column_name, time_window_expr, _, df_schema) = find_time_window_expr(
623                &plan,
624                self.query_engine.engine_state().catalog_manager().clone(),
625                query_ctx.clone(),
626            )
627            .await?;
628            time_window_expr
629                .map(|expr| {
630                    TimeWindowExpr::from_expr(
631                        &expr,
632                        &column_name,
633                        &df_schema,
634                        &self.query_engine.engine_state().session_state(),
635                    )
636                })
637                .transpose()?
638        } else {
639            // tql control by `EVAL INTERVAL`, no need to find time window expr
640            None
641        };
642
643        debug!(
644            "Flow id={}, found time window expr={}",
645            flow_id,
646            phy_expr
647                .as_ref()
648                .map(|phy_expr| phy_expr.to_string())
649                .unwrap_or("None".to_string())
650        );
651
652        if !is_tql {
653            Self::ensure_sql_flow_has_twe_or_eval_interval(eval_interval, phy_expr.is_some())?;
654        }
655
656        // Compute typed EvalSchedule from FlowScheduleConfig.
657        let eval_schedule = {
658            let interval = eval_interval;
659            let config = eval_schedule_config.as_ref();
660            match EvalSchedule::from_config(interval, config) {
661                Ok(s) => s,
662                Err(e) => {
663                    return UnexpectedSnafu {
664                        reason: format!(
665                            "Failed to build eval schedule for flow {}: {}",
666                            flow_id, e
667                        ),
668                    }
669                    .fail();
670                }
671            }
672        };
673
674        let task_args = TaskArgs {
675            flow_id,
676            query: &sql,
677            plan,
678            time_window_expr: phy_expr,
679            expire_after,
680            sink_table_name,
681            source_table_names,
682            query_ctx,
683            catalog_manager: self.catalog_manager.clone(),
684            shutdown_rx: rx,
685            batch_opts,
686            flow_eval_interval: eval_interval.map(|secs| Duration::from_secs(secs as u64)),
687            eval_schedule,
688        };
689
690        let task = BatchingTask::try_new(task_args)?;
691
692        let task_inner = task.clone();
693        let engine = self.query_engine.clone();
694        let frontend = self.frontend_client.clone();
695
696        // Create sink table if needed, then validate an existing/created sink schema before
697        // spawning the background task. This catches user-created sink schema mismatches at
698        // CREATE FLOW time instead of surfacing them later in the execution loop.
699        task.check_or_create_sink_table(&engine, &frontend).await?;
700        task.validate_sink_table_schema(&engine).await?;
701
702        let (start_tx, start_rx) = oneshot::channel();
703
704        // TODO(discord9): use time wheel or what for better
705        let handle = common_runtime::spawn_global(async move {
706            if start_rx.await.is_ok() {
707                task_inner.start_executing_loop(engine, frontend).await;
708            }
709        });
710        task.state.write().unwrap().task_handle = Some(handle);
711        let task_for_rollback = task.clone();
712
713        // Only replace here, not earlier, because we want the old one intact if
714        // something went wrong before this line. Keep the task and shutdown
715        // sender in one registry lock so create/remove can't observe one
716        // without the other.
717        let (replaced_old_task_opt, replaced_old_shutdown_tx) = {
718            let mut runtime = self.runtime.write().await;
719
720            let is_exist = runtime.tasks.contains_key(&flow_id);
721            match (create_if_not_exists, or_replace, is_exist) {
722                (_, true, true) => {
723                    info!(
724                        "Replacing flow with id={} after final registry check",
725                        flow_id
726                    );
727                }
728                (false, false, true) => {
729                    abort_flow_task(flow_id, Some(task), "unregistered");
730                    return FlowAlreadyExistSnafu { id: flow_id }.fail();
731                }
732                (true, false, true) => {
733                    info!(
734                        "Flow with id={} already exists at final registry check, do nothing",
735                        flow_id
736                    );
737                    abort_flow_task(flow_id, Some(task), "unregistered");
738                    return Ok(None);
739                }
740                (_, _, false) => (),
741            }
742
743            runtime.insert(flow_id, task, tx)
744        };
745
746        notify_flow_shutdown(flow_id, replaced_old_shutdown_tx, "replaced");
747        abort_flow_task(flow_id, replaced_old_task_opt, "replaced");
748        if start_tx.send(()).is_err() {
749            self.rollback_flow_runtime_if_current(flow_id, &task_for_rollback)
750                .await;
751            UnexpectedSnafu {
752                reason: format!("Failed to start flow {flow_id} due to task already dropped"),
753            }
754            .fail()?;
755        }
756
757        Ok(Some(flow_id))
758    }
759
760    async fn check_is_tql_table(
761        &self,
762        query: &LogicalPlan,
763        query_ctx: &QueryContext,
764    ) -> Result<(), Error> {
765        struct CollectTableRef {
766            table_refs: HashSet<datafusion_common::TableReference>,
767        }
768
769        impl TreeNodeVisitor<'_> for CollectTableRef {
770            type Node = LogicalPlan;
771            fn f_down(
772                &mut self,
773                node: &Self::Node,
774            ) -> datafusion_common::Result<TreeNodeRecursion> {
775                if let LogicalPlan::TableScan(scan) = node {
776                    self.table_refs.insert(scan.table_name.clone());
777                }
778                Ok(TreeNodeRecursion::Continue)
779            }
780        }
781        let mut table_refs = CollectTableRef {
782            table_refs: HashSet::new(),
783        };
784        query
785            .visit_with_subqueries(&mut table_refs)
786            .context(DatafusionSnafu {
787                context: "Checking if all source tables are TQL tables",
788            })?;
789
790        let default_catalog = query_ctx.current_catalog();
791        let default_schema = query_ctx.current_schema();
792        let default_schema = &default_schema;
793
794        for table_ref in table_refs.table_refs {
795            let table_ref = match &table_ref {
796                datafusion_common::TableReference::Bare { table } => {
797                    TableReference::full(default_catalog, default_schema, table)
798                }
799                datafusion_common::TableReference::Partial { schema, table } => {
800                    TableReference::full(default_catalog, schema, table)
801                }
802                datafusion_common::TableReference::Full {
803                    catalog,
804                    schema,
805                    table,
806                } => TableReference::full(catalog, schema, table),
807            };
808
809            let table_id = self
810                .table_meta
811                .table_name_manager()
812                .get(table_ref.into())
813                .await
814                .map_err(BoxedError::new)
815                .context(ExternalSnafu)?
816                .with_context(|| UnexpectedSnafu {
817                    reason: format!("Failed to get table id for table: {}", table_ref),
818                })?
819                .table_id();
820            let table_info =
821                get_table_info(self.table_meta.table_info_manager(), &table_id).await?;
822            // first check if it's only one f64 value column
823            let value_cols = table_info
824                .table_info
825                .meta
826                .schema
827                .column_schemas()
828                .iter()
829                .filter(|col| col.data_type == ConcreteDataType::float64_datatype())
830                .collect::<Vec<_>>();
831            ensure!(
832                value_cols.len() == 1,
833                InvalidQuerySnafu {
834                    reason: format!(
835                        "TQL query only supports one f64 value column, table `{}`(id={}) has {} f64 value columns, columns are: {:?}",
836                        table_ref,
837                        table_id,
838                        value_cols.len(),
839                        value_cols
840                    ),
841                }
842            );
843            // TODO(discord9): do need to check rest columns is string and is tag column?
844            let pk_idxs = table_info
845                .table_info
846                .meta
847                .primary_key_indices
848                .iter()
849                .collect::<HashSet<_>>();
850
851            for (idx, col) in table_info
852                .table_info
853                .meta
854                .schema
855                .column_schemas()
856                .iter()
857                .enumerate()
858            {
859                if is_metric_engine_internal_column(&col.name) {
860                    continue;
861                }
862                // three cases:
863                // 1. val column
864                // 2. timestamp column
865                // 3. tag column (string)
866
867                let is_pk: bool = pk_idxs.contains(&&idx);
868
869                ensure!(
870                    col.data_type == ConcreteDataType::float64_datatype()
871                        || col.data_type.is_timestamp()
872                        || (col.data_type == ConcreteDataType::string_datatype() && is_pk),
873                    InvalidQuerySnafu {
874                        reason: format!(
875                            "TQL query only supports f64 value column, timestamp column and string tag columns, table `{}`(id={}) has column `{}` with type {:?} which is not supported",
876                            table_ref, table_id, col.name, col.data_type
877                        ),
878                    }
879                );
880            }
881        }
882        Ok(())
883    }
884
885    pub async fn remove_flow_inner(&self, flow_id: FlowId) -> Result<(), Error> {
886        let (task, shutdown_tx) = {
887            let mut runtime = self.runtime.write().await;
888            let Some((task, shutdown_tx)) = runtime.remove(flow_id) else {
889                warn!("Flow {flow_id} not found in tasks");
890                FlowNotFoundSnafu { id: flow_id }.fail()?
891            };
892            (task, shutdown_tx)
893        };
894
895        let had_shutdown_tx = notify_flow_shutdown(flow_id, shutdown_tx, "removed");
896        abort_flow_task(flow_id, Some(task), "removed");
897
898        if !had_shutdown_tx {
899            UnexpectedSnafu {
900                reason: format!("Can't found shutdown tx for flow {flow_id}"),
901            }
902            .fail()?
903        }
904
905        Ok(())
906    }
907
908    /// Only flush the dirty windows of the flow task with given flow id, by running the query on it.
909    /// As flush the whole time range is usually prohibitively expensive.
910    pub async fn flush_flow_inner(&self, flow_id: FlowId) -> Result<usize, Error> {
911        debug!("Try flush flow {flow_id}");
912        // need to wait a bit to ensure previous mirror insert is handled
913        // this is only useful for the case when we are flushing the flow right after inserting data into it
914        // TODO(discord9): find a better way to ensure the data is ready, maybe inform flownode from frontend?
915        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
916        let task = self.runtime.read().await.tasks.get(&flow_id).cloned();
917        let task = task.with_context(|| FlowNotFoundSnafu { id: flow_id })?;
918
919        let time_window_size = task
920            .config
921            .time_window_expr
922            .as_ref()
923            .and_then(|expr| *expr.time_window_size());
924
925        let cur_dirty_window_cnt = time_window_size.map(|time_window_size| {
926            task.state
927                .read()
928                .unwrap()
929                .dirty_time_windows
930                .effective_count(&time_window_size)
931        });
932
933        let res = task
934            .execute_once_serialized(
935                &self.query_engine,
936                &self.frontend_client,
937                cur_dirty_window_cnt,
938            )
939            .await?;
940
941        let affected_rows = res.map(|(r, _)| r).unwrap_or_default();
942        debug!(
943            "Successfully flush flow {flow_id}, affected rows={}",
944            affected_rows
945        );
946        Ok(affected_rows)
947    }
948
949    /// Determine if the batching mode flow task exists with given flow id
950    pub async fn flow_exist_inner(&self, flow_id: FlowId) -> bool {
951        self.runtime.read().await.tasks.contains_key(&flow_id)
952    }
953
954    async fn rollback_flow_runtime_if_current(&self, flow_id: FlowId, task: &BatchingTask) {
955        let (removed_task, removed_shutdown_tx) = {
956            let mut runtime = self.runtime.write().await;
957            runtime.remove_if_current(flow_id, task)
958        };
959
960        notify_flow_shutdown(flow_id, removed_shutdown_tx, "rolled back");
961        abort_flow_task(flow_id, removed_task, "rolled back");
962    }
963}
964
965fn notify_flow_shutdown(flow_id: FlowId, tx: Option<oneshot::Sender<()>>, action: &str) -> bool {
966    let Some(tx) = tx else {
967        return false;
968    };
969
970    if tx.send(()).is_err() {
971        warn!(
972            "Fail to shutdown {action} flow {flow_id} due to receiver already dropped, maybe flow {flow_id} is already dropped?"
973        );
974    }
975
976    true
977}
978
979fn abort_flow_task(flow_id: FlowId, task: Option<BatchingTask>, action: &str) -> bool {
980    let Some(task) = task else {
981        return false;
982    };
983
984    if let Some(handle) = task.state.write().unwrap().task_handle.take() {
985        handle.abort();
986        debug!("Aborted {action} flow task {flow_id}");
987        return true;
988    }
989
990    false
991}
992
993impl FlowEngine for BatchingEngine {
994    async fn create_flow(&self, args: CreateFlowArgs) -> Result<Option<FlowId>, Error> {
995        self.create_flow_inner(args).await
996    }
997    async fn remove_flow(&self, flow_id: FlowId) -> Result<(), Error> {
998        self.remove_flow_inner(flow_id).await
999    }
1000    async fn flush_flow(&self, flow_id: FlowId) -> Result<usize, Error> {
1001        self.flush_flow_inner(flow_id).await
1002    }
1003    async fn flow_exist(&self, flow_id: FlowId) -> Result<bool, Error> {
1004        Ok(self.flow_exist_inner(flow_id).await)
1005    }
1006    async fn list_flows(&self) -> Result<impl IntoIterator<Item = FlowId>, Error> {
1007        Ok(self
1008            .runtime
1009            .read()
1010            .await
1011            .tasks
1012            .keys()
1013            .cloned()
1014            .collect::<Vec<_>>())
1015    }
1016    async fn handle_flow_inserts(
1017        &self,
1018        request: api::v1::region::InsertRequests,
1019    ) -> Result<(), Error> {
1020        self.handle_inserts_inner(request).await
1021    }
1022    async fn handle_mark_window_dirty(
1023        &self,
1024        req: api::v1::flow::DirtyWindowRequests,
1025    ) -> Result<(), Error> {
1026        self.handle_mark_dirty_time_window(req).await
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use api::v1::flow::{DirtyWindowRequest, TimeRange};
1033    use catalog::memory::new_memory_catalog_manager;
1034    use common_meta::key::TableMetadataManager;
1035    use common_meta::key::flow::FlowMetadataManager;
1036    use common_meta::key::table_route::TableRouteValue;
1037    use common_meta::key::test_utils::new_test_table_info_with_name;
1038    use common_meta::kv_backend::memory::MemoryKvBackend;
1039    use common_time::timestamp::TimeUnit;
1040    use query::options::QueryOptions;
1041    use session::context::QueryContext;
1042
1043    use super::*;
1044    use crate::test_utils::create_test_query_engine;
1045
1046    struct DropNotify(Option<oneshot::Sender<()>>);
1047
1048    impl Drop for DropNotify {
1049        fn drop(&mut self) {
1050            if let Some(tx) = self.0.take() {
1051                let _ = tx.send(());
1052            }
1053        }
1054    }
1055
1056    async fn new_test_engine() -> BatchingEngine {
1057        let kv_backend = Arc::new(MemoryKvBackend::new());
1058        let table_meta = Arc::new(TableMetadataManager::new(kv_backend.clone()));
1059        table_meta.init().await.unwrap();
1060        let flow_meta = Arc::new(FlowMetadataManager::new(kv_backend));
1061        let catalog_manager = new_memory_catalog_manager().unwrap();
1062        let query_engine = create_test_query_engine();
1063        let (frontend_client, _handler) =
1064            FrontendClient::from_empty_grpc_handler(QueryOptions::default());
1065
1066        BatchingEngine::new(
1067            Arc::new(frontend_client),
1068            query_engine,
1069            flow_meta,
1070            table_meta,
1071            catalog_manager,
1072            BatchingModeOptions::default(),
1073        )
1074    }
1075
1076    #[tokio::test]
1077    async fn test_flow_option_overrides_incremental_read_switch() {
1078        let engine = new_test_engine().await;
1079
1080        let default_opts = engine.batch_opts_for_flow_options(&HashMap::new()).unwrap();
1081        assert!(!default_opts.experimental_enable_incremental_read);
1082
1083        let enabled_opts = engine
1084            .batch_opts_for_flow_options(&HashMap::from([(
1085                FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
1086                "true".to_string(),
1087            )]))
1088            .unwrap();
1089        assert!(enabled_opts.experimental_enable_incremental_read);
1090    }
1091
1092    #[test]
1093    fn test_table_options_enable_append_mode() {
1094        assert!(!BatchingEngine::table_options_enable_append_mode(
1095            &HashMap::new()
1096        ));
1097        assert!(!BatchingEngine::table_options_enable_append_mode(
1098            &HashMap::from([(APPEND_MODE_KEY.to_string(), "false".to_string())])
1099        ));
1100        assert!(BatchingEngine::table_options_enable_append_mode(
1101            &HashMap::from([(APPEND_MODE_KEY.to_string(), "TRUE".to_string())])
1102        ));
1103    }
1104
1105    #[test]
1106    fn test_sql_flow_requires_time_window_or_eval_interval() {
1107        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, true)
1108            .expect("SQL flow with a time-window expression should be accepted");
1109        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(Some(10), false).expect(
1110            "SQL flow with EVAL INTERVAL should be accepted as an explicit full-query flow",
1111        );
1112
1113        let err = BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, false)
1114            .expect_err("SQL flow without a time-window expression or EVAL INTERVAL should fail");
1115        assert!(matches!(err, Error::InvalidQuery { .. }), "{err}");
1116        assert!(
1117            err.to_string().contains("must specify EVAL INTERVAL"),
1118            "{err}"
1119        );
1120    }
1121
1122    #[tokio::test]
1123    async fn test_complex_sql_without_eval_interval_is_rejected_as_no_twe() {
1124        let query_engine = create_test_query_engine();
1125        let ctx = QueryContext::arc();
1126        let plan = sql_to_df_plan(
1127            ctx.clone(),
1128            query_engine.clone(),
1129            r#"
1130SELECT
1131    l.number,
1132    date_bin('5 minutes', l.ts) AS time_window
1133FROM numbers_with_ts l
1134JOIN numbers_with_ts r ON l.number = r.number
1135GROUP BY l.number, time_window
1136"#,
1137            true,
1138        )
1139        .await
1140        .unwrap();
1141
1142        let (_, time_window_expr, _, _) = find_time_window_expr(
1143            &plan,
1144            query_engine.engine_state().catalog_manager().clone(),
1145            ctx,
1146        )
1147        .await
1148        .unwrap();
1149        assert!(
1150            time_window_expr.is_none(),
1151            "complex SQL should be classified as having no safe TWE"
1152        );
1153
1154        BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(Some(10), false)
1155            .expect("complex SQL can run as an explicit full-query flow when EVAL INTERVAL is set");
1156        let err = BatchingEngine::ensure_sql_flow_has_twe_or_eval_interval(None, false)
1157            .expect_err("complex SQL without EVAL INTERVAL should fail creation");
1158        assert!(matches!(err, Error::InvalidQuery { .. }), "{err}");
1159    }
1160
1161    #[test]
1162    fn test_incremental_source_append_only_enforcement() {
1163        let table_name = [
1164            "greptime".to_string(),
1165            "public".to_string(),
1166            "numbers".to_string(),
1167        ];
1168        let disabled_opts = BatchingModeOptions::default();
1169        let enabled_opts = BatchingModeOptions {
1170            experimental_enable_incremental_read: true,
1171            ..Default::default()
1172        };
1173        let non_append_options = HashMap::new();
1174        let append_options = HashMap::from([(APPEND_MODE_KEY.to_string(), "true".to_string())]);
1175
1176        BatchingEngine::ensure_incremental_source_append_only(
1177            &disabled_opts,
1178            &table_name,
1179            &non_append_options,
1180        )
1181        .expect("disabled incremental read should not require append-only source");
1182        BatchingEngine::ensure_incremental_source_append_only(
1183            &enabled_opts,
1184            &table_name,
1185            &append_options,
1186        )
1187        .expect("append-only source should be accepted when incremental read is enabled");
1188
1189        let err = BatchingEngine::ensure_incremental_source_append_only(
1190            &enabled_opts,
1191            &table_name,
1192            &non_append_options,
1193        )
1194        .expect_err("non-append source should be rejected when incremental read is enabled");
1195        assert!(
1196            err.to_string()
1197                .contains("Flow incremental read requires append-only source table"),
1198            "{err}"
1199        );
1200    }
1201
1202    async fn new_test_task(flow_id: FlowId) -> (BatchingTask, oneshot::Sender<()>) {
1203        new_test_task_for_source(flow_id, "numbers_with_ts", None).await
1204    }
1205
1206    async fn new_test_task_with_time_window_expr(
1207        flow_id: FlowId,
1208        time_window_expr: Option<TimeWindowExpr>,
1209    ) -> (BatchingTask, oneshot::Sender<()>) {
1210        new_test_task_for_source(flow_id, "numbers_with_ts", time_window_expr).await
1211    }
1212
1213    fn test_table_info_with_ts_unit(
1214        table_id: TableId,
1215        table_name: &str,
1216        unit: TimeUnit,
1217    ) -> table::metadata::TableInfo {
1218        use datatypes::schema::{ColumnSchema, SchemaBuilder};
1219        use table::metadata::{TableInfoBuilder, TableMetaBuilder};
1220
1221        let ts_type = match unit {
1222            TimeUnit::Second => ConcreteDataType::timestamp_second_datatype(),
1223            TimeUnit::Millisecond => ConcreteDataType::timestamp_millisecond_datatype(),
1224            TimeUnit::Microsecond => ConcreteDataType::timestamp_microsecond_datatype(),
1225            TimeUnit::Nanosecond => ConcreteDataType::timestamp_nanosecond_datatype(),
1226        };
1227        let column_schemas = vec![
1228            ColumnSchema::new("col1", ConcreteDataType::int32_datatype(), true),
1229            ColumnSchema::new("ts", ts_type, false).with_time_index(true),
1230        ];
1231        let schema = SchemaBuilder::try_from(column_schemas)
1232            .unwrap()
1233            .build()
1234            .unwrap();
1235        let meta = TableMetaBuilder::empty()
1236            .schema(Arc::new(schema))
1237            .primary_key_indices(vec![0])
1238            .engine("engine")
1239            .next_column_id(3)
1240            .build()
1241            .unwrap();
1242        TableInfoBuilder::default()
1243            .table_id(table_id)
1244            .table_version(0)
1245            .name(table_name)
1246            .catalog_name("greptime")
1247            .schema_name("public")
1248            .meta(meta)
1249            .build()
1250            .unwrap()
1251    }
1252
1253    /// A 5-second `date_bin` time window expr over the test table's `ts` column.
1254    async fn test_time_window_expr() -> TimeWindowExpr {
1255        let query_engine = create_test_query_engine();
1256        let ctx = QueryContext::arc();
1257        let plan = sql_to_df_plan(
1258            ctx.clone(),
1259            query_engine.clone(),
1260            "SELECT date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window",
1261            true,
1262        )
1263        .await
1264        .unwrap();
1265        let (column_name, time_window_expr, _, df_schema) = find_time_window_expr(
1266            &plan,
1267            query_engine.engine_state().catalog_manager().clone(),
1268            ctx,
1269        )
1270        .await
1271        .unwrap();
1272        TimeWindowExpr::from_expr(
1273            &time_window_expr.unwrap(),
1274            &column_name,
1275            &df_schema,
1276            &query_engine.engine_state().session_state(),
1277        )
1278        .unwrap()
1279    }
1280
1281    async fn new_test_task_for_source(
1282        flow_id: FlowId,
1283        source_table_name: &str,
1284        time_window_expr: Option<TimeWindowExpr>,
1285    ) -> (BatchingTask, oneshot::Sender<()>) {
1286        let query_engine = create_test_query_engine();
1287        let ctx = QueryContext::arc();
1288        let plan = sql_to_df_plan(
1289            ctx.clone(),
1290            query_engine.clone(),
1291            "SELECT number, ts FROM numbers_with_ts",
1292            true,
1293        )
1294        .await
1295        .unwrap();
1296        let (tx, rx) = oneshot::channel();
1297
1298        let task = BatchingTask::try_new(TaskArgs {
1299            flow_id,
1300            query: "SELECT number, ts FROM numbers_with_ts",
1301            plan,
1302            time_window_expr,
1303            expire_after: None,
1304            sink_table_name: [
1305                "greptime".to_string(),
1306                "public".to_string(),
1307                "sink".to_string(),
1308            ],
1309            source_table_names: vec![[
1310                "greptime".to_string(),
1311                "public".to_string(),
1312                source_table_name.to_string(),
1313            ]],
1314            query_ctx: ctx,
1315            catalog_manager: query_engine.engine_state().catalog_manager().clone(),
1316            shutdown_rx: rx,
1317            batch_opts: Arc::new(BatchingModeOptions::default()),
1318            flow_eval_interval: None,
1319            eval_schedule: None,
1320        })
1321        .unwrap();
1322
1323        (task, tx)
1324    }
1325
1326    #[tokio::test]
1327    async fn test_handle_mark_dirty_time_window_with_time_ranges() {
1328        let engine = new_test_engine().await;
1329
1330        // Register the source table info so the engine can resolve the table
1331        // name and the time index unit (millisecond).
1332        let mut table_info = new_test_table_info_with_name(1, "numbers_with_ts");
1333        table_info.catalog_name = "greptime".to_string();
1334        table_info.schema_name = "public".to_string();
1335        engine
1336            .table_meta
1337            .create_table_metadata(
1338                table_info,
1339                TableRouteValue::physical(vec![]),
1340                HashMap::new(),
1341            )
1342            .await
1343            .unwrap();
1344
1345        // Build a task with a 5-second time window expr.
1346        let (task, shutdown_tx) =
1347            new_test_task_with_time_window_expr(1, Some(test_time_window_expr().await)).await;
1348        let task_identity = task.clone();
1349        engine.runtime.write().await.insert(1, task, shutdown_tx);
1350
1351        engine
1352            .handle_mark_dirty_time_window(DirtyWindowRequests {
1353                requests: vec![DirtyWindowRequest {
1354                    table_id: 1,
1355                    timestamps: vec![],
1356                    time_ranges: vec![
1357                        // [3s, 11s) aligns to window start 0s and window end 15s.
1358                        TimeRange {
1359                            start_inclusive: 3_000,
1360                            end_exclusive: 11_000,
1361                        },
1362                        // Empty and reversed ranges are invalid and skipped.
1363                        TimeRange {
1364                            start_inclusive: 5_000,
1365                            end_exclusive: 5_000,
1366                        },
1367                        TimeRange {
1368                            start_inclusive: 9_000,
1369                            end_exclusive: 4_000,
1370                        },
1371                    ],
1372                }],
1373            })
1374            .await
1375            .unwrap();
1376
1377        let state = task_identity.state.read().unwrap();
1378        assert_eq!(1, state.dirty_time_windows.len());
1379        assert_eq!(
1380            Duration::from_secs(15),
1381            state.dirty_time_windows.window_size()
1382        );
1383    }
1384
1385    /// Dirty timestamps and time ranges are interpreted in the source table's
1386    /// time index native unit. The same physical range [3s, 11s) expressed in
1387    /// second/millisecond/microsecond/nanosecond units must align to the same
1388    /// dirty window [0s, 15s).
1389    #[tokio::test]
1390    async fn test_handle_mark_dirty_time_window_time_index_units() {
1391        let engine = new_test_engine().await;
1392
1393        let cases = [
1394            (TimeUnit::Second, 1u32, "t_sec", 3i64, 11i64),
1395            (TimeUnit::Millisecond, 2, "t_ms", 3_000, 11_000),
1396            (TimeUnit::Microsecond, 3, "t_us", 3_000_000, 11_000_000),
1397            (
1398                TimeUnit::Nanosecond,
1399                4,
1400                "t_ns",
1401                3_000_000_000,
1402                11_000_000_000,
1403            ),
1404        ];
1405
1406        let mut task_identities = vec![];
1407        let mut requests = vec![];
1408        for (unit, table_id, table_name, start_inclusive, end_exclusive) in cases {
1409            engine
1410                .table_meta
1411                .create_table_metadata(
1412                    test_table_info_with_ts_unit(table_id, table_name, unit),
1413                    TableRouteValue::physical(vec![]),
1414                    HashMap::new(),
1415                )
1416                .await
1417                .unwrap();
1418
1419            let (task, shutdown_tx) = new_test_task_for_source(
1420                table_id as FlowId,
1421                table_name,
1422                Some(test_time_window_expr().await),
1423            )
1424            .await;
1425            task_identities.push((table_id, task.clone()));
1426            engine
1427                .runtime
1428                .write()
1429                .await
1430                .insert(table_id as FlowId, task, shutdown_tx);
1431
1432            requests.push(DirtyWindowRequest {
1433                table_id,
1434                timestamps: vec![],
1435                time_ranges: vec![TimeRange {
1436                    start_inclusive,
1437                    end_exclusive,
1438                }],
1439            });
1440        }
1441
1442        engine
1443            .handle_mark_dirty_time_window(DirtyWindowRequests { requests })
1444            .await
1445            .unwrap();
1446
1447        for (table_id, task) in task_identities {
1448            let state = task.state.read().unwrap();
1449            assert_eq!(1, state.dirty_time_windows.len(), "table id = {table_id}");
1450            assert_eq!(
1451                Duration::from_secs(15),
1452                state.dirty_time_windows.window_size(),
1453                "table id = {table_id}"
1454            );
1455        }
1456    }
1457
1458    #[tokio::test]
1459    async fn test_handle_mark_dirty_time_window_returns_error_on_alignment_failure() {
1460        let engine = new_test_engine().await;
1461        let table_id = 10;
1462        let table_name = "t_bad_timestamp";
1463
1464        engine
1465            .table_meta
1466            .create_table_metadata(
1467                test_table_info_with_ts_unit(table_id, table_name, TimeUnit::Second),
1468                TableRouteValue::physical(vec![]),
1469                HashMap::new(),
1470            )
1471            .await
1472            .unwrap();
1473
1474        let (task, shutdown_tx) = new_test_task_for_source(
1475            table_id as FlowId,
1476            table_name,
1477            Some(test_time_window_expr().await),
1478        )
1479        .await;
1480        engine
1481            .runtime
1482            .write()
1483            .await
1484            .insert(table_id as FlowId, task, shutdown_tx);
1485
1486        let result = engine
1487            .handle_mark_dirty_time_window(DirtyWindowRequests {
1488                requests: vec![DirtyWindowRequest {
1489                    table_id,
1490                    timestamps: vec![i64::MAX],
1491                    time_ranges: vec![],
1492                }],
1493            })
1494            .await;
1495
1496        assert!(
1497            result.is_err(),
1498            "invalid timestamp alignment should be returned to the caller"
1499        );
1500    }
1501
1502    async fn install_abort_observed_handle(task: &BatchingTask) -> oneshot::Receiver<()> {
1503        let (drop_tx, drop_rx) = oneshot::channel();
1504        let (entered_tx, entered_rx) = oneshot::channel();
1505        let handle = tokio::spawn(async move {
1506            let _guard = DropNotify(Some(drop_tx));
1507            let _ = entered_tx.send(());
1508            std::future::pending::<()>().await;
1509        });
1510        task.state.write().unwrap().task_handle = Some(handle);
1511        tokio::time::timeout(Duration::from_secs(1), entered_rx)
1512            .await
1513            .expect("test task handle should start")
1514            .expect("test task handle should report start");
1515        drop_rx
1516    }
1517
1518    #[tokio::test]
1519    async fn test_notify_flow_shutdown_sends_signal() {
1520        let (tx, rx) = oneshot::channel();
1521
1522        assert!(notify_flow_shutdown(42, Some(tx), "test"));
1523
1524        rx.await.expect("replaced flow should receive shutdown");
1525    }
1526
1527    #[test]
1528    fn test_notify_flow_shutdown_accepts_missing_sender() {
1529        assert!(!notify_flow_shutdown(42, None, "test"));
1530    }
1531
1532    #[tokio::test]
1533    async fn test_abort_flow_task_aborts_handle() {
1534        let (task, _shutdown_tx) = new_test_task(42).await;
1535        let drop_rx = install_abort_observed_handle(&task).await;
1536
1537        assert!(abort_flow_task(42, Some(task), "test"));
1538
1539        tokio::time::timeout(Duration::from_secs(1), drop_rx)
1540            .await
1541            .expect("aborted task should be dropped")
1542            .expect("drop notifier should fire");
1543    }
1544
1545    #[tokio::test]
1546    async fn test_remove_flow_inner_aborts_registered_task() {
1547        let engine = new_test_engine().await;
1548        let (task, shutdown_tx) = new_test_task(42).await;
1549        let drop_rx = install_abort_observed_handle(&task).await;
1550
1551        engine.runtime.write().await.insert(42, task, shutdown_tx);
1552
1553        engine.remove_flow_inner(42).await.unwrap();
1554
1555        tokio::time::timeout(Duration::from_secs(1), drop_rx)
1556            .await
1557            .expect("removed task should be dropped")
1558            .expect("drop notifier should fire");
1559        assert!(!engine.flow_exist_inner(42).await);
1560        assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42));
1561    }
1562
1563    #[tokio::test]
1564    async fn test_or_replace_flow_runtime_replaces_old_handles_and_keeps_new_task() {
1565        let engine = new_test_engine().await;
1566        let (old_task, old_shutdown_tx) = new_test_task(42).await;
1567        let old_task_identity = old_task.clone();
1568        let old_drop_rx = install_abort_observed_handle(&old_task).await;
1569        let (new_task, new_shutdown_tx) = new_test_task(42).await;
1570        let new_task_identity = new_task.clone();
1571
1572        engine
1573            .runtime
1574            .write()
1575            .await
1576            .insert(42, old_task, old_shutdown_tx);
1577        let (replaced_old_task, replaced_old_shutdown_tx) =
1578            engine
1579                .runtime
1580                .write()
1581                .await
1582                .insert(42, new_task, new_shutdown_tx);
1583
1584        let replaced_old_task = replaced_old_task.expect("old task should be returned");
1585        assert!(Arc::ptr_eq(
1586            &replaced_old_task.state,
1587            &old_task_identity.state
1588        ));
1589        assert!(notify_flow_shutdown(
1590            42,
1591            replaced_old_shutdown_tx,
1592            "replaced"
1593        ));
1594        old_task_identity
1595            .state
1596            .write()
1597            .unwrap()
1598            .shutdown_rx
1599            .try_recv()
1600            .expect("old shutdown receiver should receive signal");
1601        assert!(abort_flow_task(42, Some(replaced_old_task), "replaced"));
1602
1603        tokio::time::timeout(Duration::from_secs(1), old_drop_rx)
1604            .await
1605            .expect("replaced task should be dropped")
1606            .expect("drop notifier should fire");
1607
1608        let runtime = engine.runtime.read().await;
1609        assert_eq!(1, runtime.tasks.len());
1610        assert_eq!(1, runtime.shutdown_txs.len());
1611        let registered_task = runtime.tasks.get(&42).expect("new task should remain");
1612        assert!(Arc::ptr_eq(
1613            &registered_task.state,
1614            &new_task_identity.state
1615        ));
1616        assert!(runtime.shutdown_txs.contains_key(&42));
1617        assert!(matches!(
1618            new_task_identity
1619                .state
1620                .write()
1621                .unwrap()
1622                .shutdown_rx
1623                .try_recv(),
1624            Err(oneshot::error::TryRecvError::Empty)
1625        ));
1626    }
1627
1628    #[tokio::test]
1629    async fn test_rollback_flow_runtime_if_current_removes_matching_task_only() {
1630        let engine = new_test_engine().await;
1631        let (old_task, _old_shutdown_tx) = new_test_task(42).await;
1632        let (current_task, current_shutdown_tx) = new_test_task(42).await;
1633        let current_task_identity = current_task.clone();
1634
1635        engine
1636            .runtime
1637            .write()
1638            .await
1639            .insert(42, current_task, current_shutdown_tx);
1640
1641        engine.rollback_flow_runtime_if_current(42, &old_task).await;
1642
1643        let registered_task = engine.runtime.read().await.tasks.get(&42).cloned().unwrap();
1644        assert!(Arc::ptr_eq(
1645            &registered_task.state,
1646            &current_task_identity.state
1647        ));
1648        assert!(engine.runtime.read().await.shutdown_txs.contains_key(&42));
1649
1650        engine
1651            .rollback_flow_runtime_if_current(42, &current_task_identity)
1652            .await;
1653        assert!(!engine.flow_exist_inner(42).await);
1654        assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42));
1655    }
1656}