Skip to main content

flow/batching_mode/
task.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
15use std::collections::{BTreeSet, HashMap, HashSet};
16use std::sync::{Arc, RwLock};
17use std::time::{Duration, SystemTime, UNIX_EPOCH};
18
19use api::v1::{CreateTableExpr, TableName};
20use catalog::CatalogManagerRef;
21use common_error::ext::BoxedError;
22use common_query::logical_plan::breakup_insert_plan;
23use common_telemetry::tracing::warn;
24use common_telemetry::{debug, info};
25use common_time::Timestamp;
26use datafusion::datasource::DefaultTableSource;
27use datafusion::sql::unparser::expr_to_sql;
28use datafusion_common::tree_node::{Transformed, TreeNode};
29use datafusion_common::utils::quote_identifier;
30use datafusion_common::{DFSchemaRef, TableReference};
31use datafusion_expr::{DmlStatement, LogicalPlan, WriteOp, col, lit};
32use datatypes::schema::Schema;
33use query::QueryEngineRef;
34use query::options::FLOW_INCREMENTAL_MODE;
35use query::query_engine::DefaultSerializer;
36use session::context::QueryContextRef;
37use snafu::{OptionExt, ResultExt};
38use sql::parsers::utils::is_tql;
39use store_api::mito_engine_options::MERGE_MODE_KEY;
40use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan};
41use table::table::adapter::DfTableProviderAdapter;
42use tokio::sync::oneshot::error::TryRecvError;
43use tokio::sync::{Mutex, oneshot};
44use tokio::time::Instant;
45
46use crate::batching_mode::BatchingModeOptions;
47use crate::batching_mode::checkpoint::checkpoint_mode_label;
48use crate::batching_mode::frontend_client::{FrontendClient, PeerDesc};
49use crate::batching_mode::state::{
50    CheckpointMode, DirtyTimeWindows, FilterExprInfo, TaskState, to_df_literal,
51};
52use crate::batching_mode::table_creator::{QueryType, create_table_with_expr};
53use crate::batching_mode::time_window::TimeWindowExpr;
54use crate::batching_mode::utils::{
55    AddFilterRewriter, ColumnMatcherRewriter, df_plan_to_sql, gen_plan_with_matching_schema,
56    get_table_info_df_schema, sql_to_df_plan,
57};
58use crate::df_optimizer::apply_df_optimizer;
59use crate::error::{
60    DatafusionSnafu, ExternalSnafu, InvalidQuerySnafu, SubstraitEncodeLogicalPlanSnafu,
61    UnexpectedSnafu,
62};
63use crate::metrics::{
64    METRIC_FLOW_BATCHING_ENGINE_ERROR_CNT, METRIC_FLOW_BATCHING_ENGINE_QUERY_TIME,
65    METRIC_FLOW_BATCHING_ENGINE_SLOW_QUERY, METRIC_FLOW_BATCHING_ENGINE_START_QUERY_CNT,
66    METRIC_FLOW_ROWS,
67};
68use crate::{Error, FlowId};
69
70mod ckpt;
71mod inc;
72
73/// The task's config, immutable once created
74#[derive(Clone)]
75pub struct TaskConfig {
76    pub flow_id: FlowId,
77    pub query: String,
78    /// output schema of the query
79    pub output_schema: DFSchemaRef,
80    pub time_window_expr: Option<TimeWindowExpr>,
81    /// in seconds
82    pub expire_after: Option<i64>,
83    pub sink_table_name: [String; 3],
84    pub source_table_names: HashSet<[String; 3]>,
85    pub catalog_manager: CatalogManagerRef,
86    pub query_type: QueryType,
87    pub batch_opts: Arc<BatchingModeOptions>,
88    pub flow_eval_interval: Option<Duration>,
89}
90
91fn determine_query_type(query: &str, query_ctx: &QueryContextRef) -> Result<QueryType, Error> {
92    let is_tql = is_tql(query_ctx.sql_dialect(), query)
93        .map_err(BoxedError::new)
94        .context(ExternalSnafu)?;
95    Ok(if is_tql {
96        QueryType::Tql
97    } else {
98        QueryType::Sql
99    })
100}
101
102fn is_merge_mode_last_non_null(options: &HashMap<String, String>) -> bool {
103    options
104        .get(MERGE_MODE_KEY)
105        .map(|mode| mode.eq_ignore_ascii_case("last_non_null"))
106        .unwrap_or(false)
107}
108
109fn encode_insert_plan_request(
110    insert_to: TableName,
111    insert_input_plan: &LogicalPlan,
112) -> Result<api::v1::QueryRequest, Error> {
113    let message = DFLogicalSubstraitConvertor {}
114        .encode(insert_input_plan, DefaultSerializer)
115        .context(SubstraitEncodeLogicalPlanSnafu)?;
116    Ok(api::v1::QueryRequest {
117        query: Some(api::v1::query_request::Query::InsertIntoPlan(
118            api::v1::InsertIntoPlan {
119                table_name: Some(insert_to),
120                logical_plan: message.to_vec(),
121            },
122        )),
123    })
124}
125
126fn format_insert_target_columns(plan: &LogicalPlan) -> String {
127    plan.schema()
128        .fields()
129        .iter()
130        .map(|field| quote_identifier(field.name()).to_string())
131        .collect::<Vec<_>>()
132        .join(", ")
133}
134
135#[derive(Clone)]
136pub struct BatchingTask {
137    pub config: Arc<TaskConfig>,
138    pub state: Arc<RwLock<TaskState>>,
139    /// Serializes plan generation, execution, checkpoint advancement, and dirty
140    /// window restoration for this flow. Without this, a manual flush and the
141    /// background loop can process the same checkpoint range concurrently.
142    execution_lock: Arc<Mutex<()>>,
143}
144
145/// Arguments for creating batching task
146pub struct TaskArgs<'a> {
147    pub flow_id: FlowId,
148    pub query: &'a str,
149    pub plan: LogicalPlan,
150    pub time_window_expr: Option<TimeWindowExpr>,
151    pub expire_after: Option<i64>,
152    pub sink_table_name: [String; 3],
153    pub source_table_names: Vec<[String; 3]>,
154    pub query_ctx: QueryContextRef,
155    pub catalog_manager: CatalogManagerRef,
156    pub shutdown_rx: oneshot::Receiver<()>,
157    pub batch_opts: Arc<BatchingModeOptions>,
158    pub flow_eval_interval: Option<Duration>,
159}
160
161pub struct PlanInfo {
162    pub plan: LogicalPlan,
163    pub dirty_restore: DirtyRestore,
164    pub can_advance_checkpoints: bool,
165}
166
167pub enum DirtyRestore {
168    /// The query was scoped to dirty time ranges; restore those ranges if the
169    /// run fails.
170    Scoped(FilterExprInfo),
171    /// The query could not be scoped to dirty time ranges, so the dirty-window
172    /// state is only a dirty signal. Restore the consumed signal if the full
173    /// run fails.
174    ///
175    /// TODO(discord9): Full-query runs only need a dirty bool flag. Refactor
176    /// the unscoped path to stop reusing `DirtyTimeWindows` for this signal.
177    Unscoped(DirtyTimeWindows),
178}
179
180struct ExecuteOnceOutcome {
181    new_query: Option<PlanInfo>,
182    /// Execution result of the generated insert plan.
183    ///
184    /// `Ok(Some((affected_rows, elapsed)))` means a query was executed.
185    /// `Ok(None)` means no query was generated because there was no dirty signal.
186    /// `Err(_)` means plan generation or execution failed.
187    result: Result<Option<(usize, Duration)>, Error>,
188}
189
190impl BatchingTask {
191    #[allow(clippy::too_many_arguments)]
192    pub fn try_new(
193        TaskArgs {
194            flow_id,
195            query,
196            plan,
197            time_window_expr,
198            expire_after,
199            sink_table_name,
200            source_table_names,
201            query_ctx,
202            catalog_manager,
203            shutdown_rx,
204            batch_opts,
205            flow_eval_interval,
206        }: TaskArgs<'_>,
207    ) -> Result<Self, Error> {
208        let mut state = TaskState::with_dirty_time_windows(
209            query_ctx.clone(),
210            shutdown_rx,
211            DirtyTimeWindows::new(
212                batch_opts.experimental_max_filter_num_per_query,
213                batch_opts.experimental_time_window_merge_threshold,
214            ),
215        );
216        if !batch_opts.experimental_enable_incremental_read {
217            state.disable_incremental();
218        }
219
220        Ok(Self {
221            config: Arc::new(TaskConfig {
222                flow_id,
223                query: query.to_string(),
224                time_window_expr,
225                expire_after,
226                sink_table_name,
227                source_table_names: source_table_names.into_iter().collect(),
228                catalog_manager,
229                output_schema: plan.schema().clone(),
230                query_type: determine_query_type(query, &query_ctx)?,
231                batch_opts,
232                flow_eval_interval,
233            }),
234            state: Arc::new(RwLock::new(state)),
235            execution_lock: Arc::new(Mutex::new(())),
236        })
237    }
238
239    pub fn last_execution_time_millis(&self) -> Option<i64> {
240        self.state.read().unwrap().last_execution_time_millis()
241    }
242
243    /// mark time window range (now - expire_after, now) as dirty (or (0, now) if expire_after not set)
244    ///
245    /// useful for flush_flow to flush dirty time windows range
246    pub fn mark_all_windows_as_dirty(&self) -> Result<(), Error> {
247        let now = SystemTime::now();
248        let now = Timestamp::new_second(
249            now.duration_since(UNIX_EPOCH)
250                .expect("Time went backwards")
251                .as_secs() as _,
252        );
253        let lower_bound = self
254            .config
255            .expire_after
256            .map(|e| now.sub_duration(Duration::from_secs(e as _)))
257            .transpose()
258            .map_err(BoxedError::new)
259            .context(ExternalSnafu)?
260            .unwrap_or(Timestamp::new_second(0));
261        debug!(
262            "Flow {} mark range ({:?}, {:?}) as dirty",
263            self.config.flow_id, lower_bound, now
264        );
265        self.state
266            .write()
267            .unwrap()
268            .dirty_time_windows
269            .add_window(lower_bound, Some(now));
270        Ok(())
271    }
272
273    /// Create sink table if not exists
274    pub async fn check_or_create_sink_table(
275        &self,
276        engine: &QueryEngineRef,
277        frontend_client: &Arc<FrontendClient>,
278    ) -> Result<Option<(usize, Duration)>, Error> {
279        if !self.is_table_exist(&self.config.sink_table_name).await? {
280            let create_table = self.gen_create_table_expr(engine.clone()).await?;
281            info!(
282                "Try creating sink table(if not exists) with expr: {:?}",
283                create_table
284            );
285            self.create_table(frontend_client, create_table).await?;
286            info!(
287                "Sink table {}(if not exists) created",
288                self.config.sink_table_name.join(".")
289            );
290        }
291
292        Ok(None)
293    }
294
295    /// Validates that the sink table schema can accept this flow's output.
296    ///
297    /// This is a dry-run of the same schema matching logic used by runtime insert-plan
298    /// generation, but without adding dirty-window filters or executing the query. It is used
299    /// during CREATE FLOW to catch existing sink table mismatches early.
300    pub async fn validate_sink_table_schema(&self, engine: &QueryEngineRef) -> Result<(), Error> {
301        let (table, _) = get_table_info_df_schema(
302            self.config.catalog_manager.clone(),
303            self.config.sink_table_name.clone(),
304        )
305        .await?;
306
307        let table_meta = &table.table_info().meta;
308        let merge_mode_last_non_null =
309            is_merge_mode_last_non_null(&table_meta.options.extra_options);
310        let primary_key_indices = table_meta.primary_key_indices.clone();
311        let query_ctx = self.state.read().unwrap().query_ctx.clone();
312
313        gen_plan_with_matching_schema(
314            &self.config.query,
315            query_ctx,
316            engine.clone(),
317            table_meta.schema.clone(),
318            &primary_key_indices,
319            merge_mode_last_non_null,
320        )
321        .await
322        .map(|_| ())
323    }
324
325    async fn is_table_exist(&self, table_name: &[String; 3]) -> Result<bool, Error> {
326        self.config
327            .catalog_manager
328            .table_exists(&table_name[0], &table_name[1], &table_name[2], None)
329            .await
330            .map_err(BoxedError::new)
331            .context(ExternalSnafu)
332    }
333
334    pub(crate) async fn execute_once_serialized(
335        &self,
336        engine: &QueryEngineRef,
337        frontend_client: &Arc<FrontendClient>,
338        max_window_cnt: Option<usize>,
339    ) -> Result<Option<(usize, Duration)>, Error> {
340        let outcome = self
341            .execute_once_serialized_with_outcome(engine, frontend_client, max_window_cnt)
342            .await;
343        outcome.result
344    }
345
346    /// Executes one flow evaluation under `execution_lock` and keeps the
347    /// generated query context for the background loop's error logging/backoff.
348    async fn execute_once_serialized_with_outcome(
349        &self,
350        engine: &QueryEngineRef,
351        frontend_client: &Arc<FrontendClient>,
352        max_window_cnt: Option<usize>,
353    ) -> ExecuteOnceOutcome {
354        let _execution_guard = self.execution_lock.lock().await;
355        self.execute_once_unlocked(engine, frontend_client, max_window_cnt)
356            .await
357    }
358
359    /// Executes one flow evaluation. Caller must hold `execution_lock`.
360    async fn execute_once_unlocked(
361        &self,
362        engine: &QueryEngineRef,
363        frontend_client: &Arc<FrontendClient>,
364        max_window_cnt: Option<usize>,
365    ) -> ExecuteOnceOutcome {
366        let new_query = match self.gen_insert_plan_unlocked(engine, max_window_cnt).await {
367            Ok(new_query) => new_query,
368            Err(err) => {
369                return ExecuteOnceOutcome {
370                    new_query: None,
371                    result: Err(err),
372                };
373            }
374        };
375
376        if let Some(new_query) = new_query {
377            debug!("Generate new query: {}", new_query.plan);
378            let res = self
379                .execute_logical_plan_unlocked(
380                    frontend_client,
381                    &new_query.plan,
382                    new_query.can_advance_checkpoints,
383                )
384                .await;
385            if res.is_err() {
386                self.handle_executed_query_failure(Some(&new_query));
387            }
388            ExecuteOnceOutcome {
389                new_query: Some(new_query),
390                result: res,
391            }
392        } else {
393            debug!("Generate no query");
394            ExecuteOnceOutcome {
395                new_query: None,
396                result: Ok(None),
397            }
398        }
399    }
400
401    /// Generates the insert plan. Caller must reach this through the serialized path.
402    async fn gen_insert_plan_unlocked(
403        &self,
404        engine: &QueryEngineRef,
405        max_window_cnt: Option<usize>,
406    ) -> Result<Option<PlanInfo>, Error> {
407        let (table, df_schema) = get_table_info_df_schema(
408            self.config.catalog_manager.clone(),
409            self.config.sink_table_name.clone(),
410        )
411        .await?;
412
413        let table_meta = &table.table_info().meta;
414        let merge_mode_last_non_null =
415            is_merge_mode_last_non_null(&table_meta.options.extra_options);
416        let primary_key_indices = table_meta.primary_key_indices.clone();
417
418        let new_query = self
419            .gen_query_with_time_window(
420                engine.clone(),
421                &table.table_info().meta.schema,
422                &primary_key_indices,
423                merge_mode_last_non_null,
424                max_window_cnt,
425            )
426            .await?;
427
428        let Some(new_query) = new_query else {
429            return Ok(None);
430        };
431
432        // first check if all columns in input query exists in sink table
433        // since insert into ref to names in record batch generate by given query
434        let table_columns = df_schema
435            .columns()
436            .into_iter()
437            .map(|c| c.name)
438            .collect::<BTreeSet<_>>();
439        for column in new_query.plan.schema().columns() {
440            if !table_columns.contains(column.name()) {
441                self.restore_dirty_windows_after_failure(&new_query);
442                return InvalidQuerySnafu {
443                    reason: format!(
444                        "Column {} not found in sink table with columns {:?}",
445                        column, table_columns
446                    ),
447                }
448                .fail();
449            }
450        }
451
452        let table_provider = Arc::new(DfTableProviderAdapter::new(table));
453        let table_source = Arc::new(DefaultTableSource::new(table_provider));
454
455        // update_at& time index placeholder (if exists) should have default value
456        let plan = LogicalPlan::Dml(DmlStatement::new(
457            datafusion_common::TableReference::Full {
458                catalog: self.config.sink_table_name[0].clone().into(),
459                schema: self.config.sink_table_name[1].clone().into(),
460                table: self.config.sink_table_name[2].clone().into(),
461            },
462            table_source,
463            WriteOp::Insert(datafusion_expr::dml::InsertOp::Append),
464            Arc::new(new_query.plan.clone()),
465        ));
466        let insert_into_info = PlanInfo {
467            plan,
468            dirty_restore: new_query.dirty_restore,
469            can_advance_checkpoints: new_query.can_advance_checkpoints,
470        };
471        let insert_into =
472            match insert_into_info
473                .plan
474                .clone()
475                .recompute_schema()
476                .context(DatafusionSnafu {
477                    context: "Failed to recompute schema",
478                }) {
479                Ok(insert_into) => insert_into,
480                Err(err) => {
481                    self.restore_dirty_windows_after_failure(&insert_into_info);
482                    return Err(err);
483                }
484            };
485
486        Ok(Some(PlanInfo {
487            plan: insert_into,
488            dirty_restore: insert_into_info.dirty_restore,
489            can_advance_checkpoints: insert_into_info.can_advance_checkpoints,
490        }))
491    }
492
493    pub async fn create_table(
494        &self,
495        frontend_client: &Arc<FrontendClient>,
496        expr: CreateTableExpr,
497    ) -> Result<(), Error> {
498        let catalog = &self.config.sink_table_name[0];
499        let schema = &self.config.sink_table_name[1];
500        frontend_client
501            .create(expr.clone(), catalog, schema)
502            .await?;
503        Ok(())
504    }
505
506    /// Executes the insert plan. Caller must reach this through the serialized path.
507    async fn execute_logical_plan_unlocked(
508        &self,
509        frontend_client: &Arc<FrontendClient>,
510        plan: &LogicalPlan,
511        can_advance_checkpoints: bool,
512    ) -> Result<Option<(usize, Duration)>, Error> {
513        let instant = Instant::now();
514        let flow_id = self.config.flow_id;
515
516        debug!(
517            "Executing flow {flow_id}(expire_after={:?} secs) with query {}",
518            self.config.expire_after, &plan
519        );
520
521        let catalog = &self.config.sink_table_name[0];
522        let schema = &self.config.sink_table_name[1];
523
524        // fix all table ref by make it fully qualified, i.e. "table_name" => "catalog_name.schema_name.table_name"
525        let plan = plan
526            .clone()
527            .transform_down_with_subqueries(|p| {
528                if let LogicalPlan::TableScan(mut table_scan) = p {
529                    let resolved = table_scan.table_name.resolve(catalog, schema);
530                    table_scan.table_name = resolved.into();
531                    Ok(Transformed::yes(LogicalPlan::TableScan(table_scan)))
532                } else {
533                    Ok(Transformed::no(p))
534                }
535            })
536            .with_context(|_| DatafusionSnafu {
537                context: format!("Failed to fix table ref in logical plan, plan={:?}", plan),
538            })?
539            .data;
540
541        // For incremental-mode SQL queries, attempt to rewrite the delta aggregate
542        // plan into a safe delta-LEFT-JOIN-sink form before deciding on extensions.
543        let incremental_plan = if can_advance_checkpoints {
544            self.prepare_plan_for_incremental(&plan).await?
545        } else {
546            None
547        };
548        let incremental_safe = incremental_plan.is_some();
549        let plan = incremental_plan.unwrap_or_else(|| plan.clone());
550
551        let extensions = self
552            .build_flow_query_extensions(incremental_safe, can_advance_checkpoints)
553            .await?;
554        let extension_refs = extensions
555            .iter()
556            .map(|(key, value)| (*key, value.as_str()))
557            .collect::<Vec<_>>();
558        let query_mode = if extensions
559            .iter()
560            .any(|(key, _)| *key == FLOW_INCREMENTAL_MODE)
561        {
562            CheckpointMode::Incremental
563        } else {
564            CheckpointMode::FullSnapshot
565        };
566        Self::record_query_mode(flow_id, query_mode);
567        debug!(
568            "Flow {flow_id} executing batching query with checkpoint_mode={}, extension_count={}",
569            checkpoint_mode_label(query_mode),
570            extensions.len()
571        );
572
573        let mut peer_desc = None;
574        let res = {
575            let _timer = METRIC_FLOW_BATCHING_ENGINE_QUERY_TIME
576                .with_label_values(&[flow_id.to_string().as_str()])
577                .start_timer();
578
579            let req = if let Some((insert_to, insert_input_plan)) =
580                breakup_insert_plan(&plan, catalog, schema)
581            {
582                if query_mode == CheckpointMode::FullSnapshot
583                    && matches!(self.config.query_type, QueryType::Sql)
584                    && self.config.flow_eval_interval.is_some()
585                    && self.config.time_window_expr.is_none()
586                {
587                    // Evaluation-interval SQL flows without a time-window
588                    // expression execute as full-query snapshots. Send these
589                    // as SQL text instead of Substrait to avoid logical-plan
590                    // round-trip issues around complex joins/unions/CTEs and
591                    // duplicate field aliases. Keep ordinary SQL full snapshots
592                    // on the existing InsertIntoPlan path because SQL unparsing
593                    // is not valid for every planned aggregate shape yet.
594                    // If the local SQL unparser does not support this plan,
595                    // keep the previous InsertIntoPlan transport as a fallback.
596                    match df_plan_to_sql(&insert_input_plan) {
597                        Ok(select_sql) => {
598                            let target_columns = format_insert_target_columns(&insert_input_plan);
599                            let sql = format!(
600                                "INSERT INTO {} ({}) {}",
601                                TableReference::full(
602                                    insert_to.catalog_name.as_str(),
603                                    insert_to.schema_name.as_str(),
604                                    insert_to.table_name.as_str(),
605                                )
606                                .to_quoted_string(),
607                                target_columns,
608                                select_sql
609                            );
610                            api::v1::QueryRequest {
611                                query: Some(api::v1::query_request::Query::Sql(sql)),
612                            }
613                        }
614                        Err(err) => {
615                            warn!(
616                                "Failed to unparse full-snapshot SQL flow {} plan; \
617                                 falling back to InsertIntoPlan: {:?}",
618                                flow_id, err
619                            );
620                            encode_insert_plan_request(insert_to, &insert_input_plan)?
621                        }
622                    }
623                } else {
624                    encode_insert_plan_request(insert_to, &insert_input_plan)?
625                }
626            } else {
627                let message = DFLogicalSubstraitConvertor {}
628                    .encode(&plan, DefaultSerializer)
629                    .context(SubstraitEncodeLogicalPlanSnafu)?;
630
631                api::v1::QueryRequest {
632                    query: Some(api::v1::query_request::Query::LogicalPlan(message.to_vec())),
633                }
634            };
635
636            frontend_client
637                .query_with_terminal_metrics(catalog, schema, req, &extension_refs, &mut peer_desc)
638                .await
639        };
640
641        let elapsed = instant.elapsed();
642        let peer_label = peer_desc
643            .as_ref()
644            .map(ToString::to_string)
645            .unwrap_or_else(|| PeerDesc::default().to_string());
646        if let Err(err) = &res {
647            warn!(
648                "Failed to execute Flow {flow_id} on frontend {peer_label}, result: {err:?}, elapsed: {:?} with query: {}",
649                elapsed, &plan
650            );
651            let decision = {
652                let mut state = self.state.write().unwrap();
653                let reason = Self::query_failure_reason(err);
654                Self::apply_query_failure_to_state(&mut state, elapsed, reason)
655            };
656            if let Some(decision) = decision {
657                Self::record_checkpoint_decision(flow_id, decision);
658            }
659        }
660
661        // record slow query
662        if elapsed >= self.config.batch_opts.slow_query_threshold {
663            warn!(
664                "Flow {flow_id} on frontend {peer_label} executed for {:?} before complete, query: {}",
665                elapsed, &plan
666            );
667            let flow_id = flow_id.to_string();
668            METRIC_FLOW_BATCHING_ENGINE_SLOW_QUERY
669                .with_label_values(&[flow_id.as_str(), peer_label.as_str()])
670                .observe(elapsed.as_secs_f64());
671        }
672
673        let res = res?;
674        let (affected_rows, _) = res.output.extract_rows_and_cost();
675        debug!(
676            "Flow {flow_id} executed, affected_rows: {affected_rows:?}, elapsed: {:?}, watermark: {:?}",
677            elapsed,
678            res.region_watermark_map()
679        );
680        METRIC_FLOW_ROWS
681            .with_label_values(&[format!("{}-out-batching", flow_id).as_str()])
682            .inc_by(affected_rows as _);
683        {
684            let mut state = self.state.write().unwrap();
685            let decision = Self::apply_query_result_to_state(
686                &mut state,
687                &res,
688                elapsed,
689                can_advance_checkpoints,
690            );
691            Self::record_checkpoint_decision(flow_id, decision);
692        }
693
694        Ok(Some((affected_rows, elapsed)))
695    }
696
697    /// Restore dirty windows consumed by a failed query so they are retried on
698    /// the next execution.
699    ///
700    fn restore_dirty_windows_after_failure(&self, query: &PlanInfo) {
701        match &query.dirty_restore {
702            DirtyRestore::Scoped(filter) => self.restore_scoped_dirty_windows(filter),
703            DirtyRestore::Unscoped(dirty_windows) => self
704                .state
705                .write()
706                .unwrap()
707                .dirty_time_windows
708                .add_dirty_windows(dirty_windows),
709        }
710    }
711
712    fn restore_scoped_dirty_windows(&self, filter: &FilterExprInfo) {
713        self.state
714            .write()
715            .unwrap()
716            .dirty_time_windows
717            .add_windows(filter.time_ranges.clone());
718    }
719
720    fn restore_scoped_dirty_windows_on_err<T>(
721        &self,
722        filter: &FilterExprInfo,
723        result: Result<T, Error>,
724    ) -> Result<T, Error> {
725        result.inspect_err(|_| {
726            self.restore_scoped_dirty_windows(filter);
727        })
728    }
729
730    fn restore_unscoped_dirty_windows(&self, dirty_windows: &DirtyTimeWindows) {
731        self.state
732            .write()
733            .unwrap()
734            .dirty_time_windows
735            .add_dirty_windows(dirty_windows);
736    }
737
738    fn restore_unscoped_dirty_windows_on_err<T>(
739        &self,
740        dirty_windows: &DirtyTimeWindows,
741        result: Result<T, Error>,
742    ) -> Result<T, Error> {
743        result.inspect_err(|_| {
744            self.restore_unscoped_dirty_windows(dirty_windows);
745        })
746    }
747
748    fn drain_dirty_windows_signal(&self) -> (bool, DirtyTimeWindows) {
749        let mut state = self.state.write().unwrap();
750        let dirty_windows_to_restore = state.dirty_time_windows.clone();
751        let is_dirty = !dirty_windows_to_restore.is_empty();
752        state.dirty_time_windows.clean();
753        (is_dirty, dirty_windows_to_restore)
754    }
755
756    #[allow(clippy::too_many_arguments)]
757    async fn gen_unfiltered_plan_info(
758        &self,
759        engine: QueryEngineRef,
760        query_ctx: QueryContextRef,
761        sink_table_schema: Arc<Schema>,
762        primary_key_indices: &[usize],
763        allow_partial: bool,
764        dirty_windows_to_restore: DirtyTimeWindows,
765        retention_filter: Option<(&str, Timestamp, &'static str)>,
766    ) -> Result<PlanInfo, Error> {
767        let mut plan = self.restore_unscoped_dirty_windows_on_err(
768            &dirty_windows_to_restore,
769            gen_plan_with_matching_schema(
770                &self.config.query,
771                query_ctx,
772                engine,
773                sink_table_schema,
774                primary_key_indices,
775                allow_partial,
776            )
777            .await,
778        )?;
779
780        if let Some((col_name, lower_bound, context)) = retention_filter {
781            let lower = self.restore_unscoped_dirty_windows_on_err(
782                &dirty_windows_to_restore,
783                to_df_literal(lower_bound),
784            )?;
785            let retention_filter = col(col_name).gt_eq(lit(lower));
786            let mut add_filter = AddFilterRewriter::new(retention_filter);
787            plan = self.restore_unscoped_dirty_windows_on_err(
788                &dirty_windows_to_restore,
789                plan.clone()
790                    .rewrite(&mut add_filter)
791                    .with_context(|_| DatafusionSnafu {
792                        context: format!(
793                            "Failed to apply {context} expire_after filter to plan:\n {}\n",
794                            plan
795                        ),
796                    })
797                    .map(|rewrite| rewrite.data),
798            )?;
799        }
800
801        Ok(PlanInfo {
802            plan,
803            dirty_restore: DirtyRestore::Unscoped(dirty_windows_to_restore),
804            can_advance_checkpoints: true,
805        })
806    }
807
808    async fn gen_unfiltered_plan_info_if_dirty(
809        &self,
810        engine: QueryEngineRef,
811        query_ctx: QueryContextRef,
812        sink_table_schema: Arc<Schema>,
813        primary_key_indices: &[usize],
814        allow_partial: bool,
815        retention_filter: Option<(&str, Timestamp, &'static str)>,
816    ) -> Result<Option<PlanInfo>, Error> {
817        let (is_dirty, dirty_windows_to_restore) = self.drain_dirty_windows_signal();
818        if !is_dirty {
819            debug!("Flow id={:?}, no new data, not update", self.config.flow_id);
820            return Ok(None);
821        }
822
823        self.gen_unfiltered_plan_info(
824            engine,
825            query_ctx,
826            sink_table_schema,
827            primary_key_indices,
828            allow_partial,
829            dirty_windows_to_restore,
830            retention_filter,
831        )
832        .await
833        .map(Some)
834    }
835
836    fn handle_executed_query_failure(&self, query: Option<&PlanInfo>) {
837        if let Some(query) = query {
838            self.restore_dirty_windows_after_failure(query);
839        }
840    }
841
842    /// start executing query in a loop, break when receive shutdown signal
843    ///
844    /// any error will be logged when executing query
845    pub async fn start_executing_loop(
846        &self,
847        engine: QueryEngineRef,
848        frontend_client: Arc<FrontendClient>,
849    ) {
850        let flow_id_str = self.config.flow_id.to_string();
851        let mut max_window_cnt = None;
852        let mut interval = self
853            .config
854            .flow_eval_interval
855            .map(|d| tokio::time::interval(d));
856        if let Some(tick) = &mut interval {
857            tick.tick().await; // pass the first tick immediately
858        }
859        loop {
860            // first check if shutdown signal is received
861            // if so, break the loop
862            {
863                let mut state = self.state.write().unwrap();
864                match state.shutdown_rx.try_recv() {
865                    Ok(()) => break,
866                    Err(TryRecvError::Closed) => {
867                        warn!(
868                            "Unexpected shutdown flow {}, shutdown anyway",
869                            self.config.flow_id
870                        );
871                        break;
872                    }
873                    Err(TryRecvError::Empty) => (),
874                }
875            }
876            METRIC_FLOW_BATCHING_ENGINE_START_QUERY_CNT
877                .with_label_values(&[&flow_id_str])
878                .inc();
879
880            let min_refresh = self.config.batch_opts.experimental_min_refresh_duration;
881
882            let outcome = self
883                .execute_once_serialized_with_outcome(&engine, &frontend_client, max_window_cnt)
884                .await;
885
886            match outcome.result {
887                // normal execute, sleep for some time before doing next query
888                Ok(Some(_)) => {
889                    // can increase max_window_cnt to query more windows next time
890                    max_window_cnt = max_window_cnt.map(|cnt| {
891                        (cnt + 1).min(self.config.batch_opts.experimental_max_filter_num_per_query)
892                    });
893
894                    // here use proper ticking if set eval interval
895                    if let Some(eval_interval) = &mut interval {
896                        eval_interval.tick().await;
897                    } else {
898                        // if not explicitly set, just automatically calculate next start time
899                        // using time window size and more args
900                        let sleep_until = {
901                            let state = self.state.write().unwrap();
902
903                            let time_window_size = self
904                                .config
905                                .time_window_expr
906                                .as_ref()
907                                .and_then(|t| *t.time_window_size());
908
909                            let prefer_short_incremental_cadence = state.checkpoint_mode()
910                                == CheckpointMode::Incremental
911                                && !state.is_incremental_disabled();
912
913                            state.get_next_start_query_time(
914                                self.config.flow_id,
915                                &time_window_size,
916                                min_refresh,
917                                Some(self.config.batch_opts.query_timeout),
918                                self.config.batch_opts.experimental_max_filter_num_per_query,
919                                prefer_short_incremental_cadence,
920                            )
921                        };
922
923                        tokio::time::sleep_until(sleep_until).await;
924                    };
925                }
926                // no new data, sleep for some time before checking for new data
927                Ok(None) => {
928                    debug!(
929                        "Flow id = {:?} found no new data, sleep for {:?} then continue",
930                        self.config.flow_id, min_refresh
931                    );
932                    tokio::time::sleep(min_refresh).await;
933                    continue;
934                }
935                // TODO(discord9): this error should have better place to go, but for now just print error, also more context is needed
936                Err(err) => {
937                    METRIC_FLOW_BATCHING_ENGINE_ERROR_CNT
938                        .with_label_values(&[&flow_id_str])
939                        .inc();
940                    match outcome.new_query {
941                        Some(query) => {
942                            common_telemetry::error!(err; "Failed to execute query for flow={} with query: {}", self.config.flow_id, query.plan);
943                            // TODO(discord9): add some backoff here? half the query time window or what
944                            // backoff meaning use smaller `max_window_cnt` for next query
945
946                            // since last query failed, we should not try to query too many windows
947                            max_window_cnt = Some(1);
948                        }
949                        None => {
950                            common_telemetry::error!(err; "Failed to generate query for flow={}", self.config.flow_id)
951                        }
952                    }
953                    // also sleep for a little while before try again to prevent flooding logs
954                    tokio::time::sleep(min_refresh).await;
955                }
956            }
957        }
958    }
959
960    /// Generate the create table SQL
961    ///
962    /// the auto created table will automatically added a `update_at` Milliseconds DEFAULT now() column in the end
963    /// (for compatibility with flow streaming mode)
964    ///
965    /// and it will use first timestamp column as time index, all other columns will be added as normal columns and nullable
966    async fn gen_create_table_expr(
967        &self,
968        engine: QueryEngineRef,
969    ) -> Result<CreateTableExpr, Error> {
970        let query_ctx = self.state.read().unwrap().query_ctx.clone();
971        let plan =
972            sql_to_df_plan(query_ctx.clone(), engine.clone(), &self.config.query, true).await?;
973        create_table_with_expr(&plan, &self.config.sink_table_name, &self.config.query_type)
974    }
975
976    fn should_use_unfiltered_incremental_delta(&self) -> bool {
977        let state = self.state.read().unwrap();
978        state.checkpoint_mode() == CheckpointMode::Incremental
979            && !state.is_incremental_disabled()
980            && matches!(self.config.query_type, QueryType::Sql)
981    }
982
983    fn should_use_unfiltered_full_snapshot_seeding(&self) -> bool {
984        let state = self.state.read().unwrap();
985        state.checkpoint_mode() == CheckpointMode::FullSnapshot
986            && !state.is_incremental_disabled()
987            && matches!(self.config.query_type, QueryType::Sql)
988    }
989
990    /// will merge and use the first ten time window in query
991    async fn gen_query_with_time_window(
992        &self,
993        engine: QueryEngineRef,
994        sink_table_schema: &Arc<Schema>,
995        primary_key_indices: &[usize],
996        allow_partial: bool,
997        max_window_cnt: Option<usize>,
998    ) -> Result<Option<PlanInfo>, Error> {
999        let query_ctx = self.state.read().unwrap().query_ctx.clone();
1000        let start = SystemTime::now();
1001        let since_the_epoch = start
1002            .duration_since(UNIX_EPOCH)
1003            .expect("Time went backwards");
1004        let low_bound = self
1005            .config
1006            .expire_after
1007            .map(|e| since_the_epoch.as_secs() - e as u64)
1008            .unwrap_or(u64::MIN);
1009
1010        let low_bound = Timestamp::new_second(low_bound as i64);
1011
1012        let expire_time_window_bound = self
1013            .config
1014            .time_window_expr
1015            .as_ref()
1016            .map(|expr| expr.eval(low_bound))
1017            .transpose()?;
1018
1019        let (expire_lower_bound, expire_upper_bound) =
1020            match (expire_time_window_bound, &self.config.query_type) {
1021                (Some((Some(l), Some(u))), QueryType::Sql) => (l, u),
1022                (None, QueryType::Sql) if self.config.flow_eval_interval.is_none() => {
1023                    // if it's sql query and no time window lower/upper bound is found, just return the original query(with auto columns)
1024                    // use sink_table_meta to add to query the `update_at` and `__ts_placeholder` column's value too for compatibility reason
1025                    debug!(
1026                        "Flow id = {:?}, no time window, using the same query",
1027                        self.config.flow_id
1028                    );
1029                    // clean dirty time window too, this could be from create flow's check_execute
1030                    return self
1031                        .gen_unfiltered_plan_info_if_dirty(
1032                            engine,
1033                            query_ctx,
1034                            sink_table_schema.clone(),
1035                            primary_key_indices,
1036                            allow_partial,
1037                            None,
1038                        )
1039                        .await;
1040                }
1041                _ => {
1042                    // Clean dirty windows for full-query/non-scoped paths,
1043                    // such as TQL or evaluation-interval SQL without a recognized
1044                    // time-window expression, that cannot use a time-window filter.
1045                    let (_, dirty_windows_to_restore) = self.drain_dirty_windows_signal();
1046
1047                    let plan_info = self
1048                        .gen_unfiltered_plan_info(
1049                            engine,
1050                            query_ctx,
1051                            sink_table_schema.clone(),
1052                            primary_key_indices,
1053                            allow_partial,
1054                            dirty_windows_to_restore,
1055                            None,
1056                        )
1057                        .await?;
1058
1059                    return Ok(Some(plan_info));
1060                }
1061            };
1062
1063        debug!(
1064            "Flow id = {:?}, found time window: precise_lower_bound={:?}, precise_upper_bound={:?} with dirty time windows: {:?}",
1065            self.config.flow_id,
1066            expire_lower_bound,
1067            expire_upper_bound,
1068            self.state.read().unwrap().dirty_time_windows
1069        );
1070        let window_size = expire_upper_bound
1071            .sub(&expire_lower_bound)
1072            .with_context(|| UnexpectedSnafu {
1073                reason: format!(
1074                    "Can't get window size from {expire_upper_bound:?} - {expire_lower_bound:?}"
1075                ),
1076            })?;
1077        let col_name = self
1078            .config
1079            .time_window_expr
1080            .as_ref()
1081            .map(|expr| expr.column_name.clone())
1082            .with_context(|| UnexpectedSnafu {
1083                reason: format!(
1084                    "Flow id={:?}, Failed to get column name from time window expr",
1085                    self.config.flow_id
1086                ),
1087            })?;
1088
1089        if self.should_use_unfiltered_full_snapshot_seeding() {
1090            // A full-snapshot query that can seed/refresh incremental
1091            // checkpoints must not use dirty-window predicates. Rows can be
1092            // written after dirty windows are drained but before the source scan
1093            // snapshot opens; a stale dirty-window filter could exclude those
1094            // rows while the returned watermark includes them, causing the next
1095            // incremental read to skip them forever. Execute an unfiltered full
1096            // snapshot instead, and keep dirty windows only as the scheduling and
1097            // failure-restoration signal.
1098            let retention_filter = self
1099                .config
1100                .expire_after
1101                .map(|_| (col_name.as_str(), expire_lower_bound, "full-snapshot"));
1102            return self
1103                .gen_unfiltered_plan_info_if_dirty(
1104                    engine,
1105                    query_ctx,
1106                    sink_table_schema.clone(),
1107                    primary_key_indices,
1108                    allow_partial,
1109                    retention_filter,
1110                )
1111                .await;
1112        }
1113
1114        if self.should_use_unfiltered_incremental_delta() {
1115            // In incremental mode, source correctness is defined by the
1116            // per-region sequence range `(checkpoint, scan-open snapshot]`, not
1117            // by dirty-window predicates. Dirty windows are only a scheduling
1118            // signal here. Applying a stale dirty-window filter to the source can
1119            // exclude rows that are inside the returned watermark and make a
1120            // checkpoint advance skip them forever. The sink side is also left
1121            // unfiltered by dirty windows; the incremental rewrite joins the
1122            // delta groups with the full sink state for correctness. Future
1123            // dynamic filters can prune sink reads as a pure optimization.
1124            let retention_filter = self
1125                .config
1126                .expire_after
1127                .map(|_| (col_name.as_str(), expire_lower_bound, "incremental"));
1128            return self
1129                .gen_unfiltered_plan_info_if_dirty(
1130                    engine,
1131                    query_ctx,
1132                    sink_table_schema.clone(),
1133                    primary_key_indices,
1134                    allow_partial,
1135                    retention_filter,
1136                )
1137                .await;
1138        }
1139
1140        let (expr, can_advance_checkpoints) = {
1141            let mut state = self.state.write().unwrap();
1142            let window_cnt = max_window_cnt
1143                .unwrap_or(self.config.batch_opts.experimental_max_filter_num_per_query);
1144            let expr = state.dirty_time_windows.gen_filter_exprs(
1145                &col_name,
1146                Some(expire_lower_bound),
1147                window_size,
1148                window_cnt,
1149                self.config.flow_id,
1150                Some(self),
1151            )?;
1152            let can_advance_checkpoints = state.dirty_time_windows.is_empty();
1153            (expr, can_advance_checkpoints)
1154        };
1155
1156        let Some(expr) = expr else {
1157            // no new data, hence no need to update
1158            debug!("Flow id={:?}, no new data, not update", self.config.flow_id);
1159            return Ok(None);
1160        };
1161
1162        let filter_sql = expr_to_sql(&expr.expr)
1163            .map(|sql| sql.to_string())
1164            .unwrap_or_else(|err| format!("<failed to format filter expr: {err}>"));
1165
1166        debug!(
1167            "Flow id={:?}, Generated filter expr: {:?}",
1168            self.config.flow_id, filter_sql
1169        );
1170
1171        let mut add_filter = AddFilterRewriter::new(expr.expr.clone());
1172        let mut add_auto_column = ColumnMatcherRewriter::new(
1173            sink_table_schema.clone(),
1174            primary_key_indices.to_vec(),
1175            allow_partial,
1176        );
1177
1178        let plan = self.restore_scoped_dirty_windows_on_err(
1179            &expr,
1180            sql_to_df_plan(query_ctx.clone(), engine.clone(), &self.config.query, false).await,
1181        )?;
1182        let rewrite = self.restore_scoped_dirty_windows_on_err(
1183            &expr,
1184            plan.clone()
1185                .rewrite(&mut add_filter)
1186                .and_then(|p| p.data.rewrite(&mut add_auto_column))
1187                .with_context(|_| DatafusionSnafu {
1188                    context: format!("Failed to rewrite plan:\n {}\n", plan),
1189                })
1190                .map(|rewrite| rewrite.data),
1191        )?;
1192        // only apply optimize after complex rewrite is done
1193        let new_plan = self.restore_scoped_dirty_windows_on_err(
1194            &expr,
1195            apply_df_optimizer(rewrite, &query_ctx).await,
1196        )?;
1197
1198        let info = PlanInfo {
1199            plan: new_plan.clone(),
1200            dirty_restore: DirtyRestore::Scoped(expr),
1201            can_advance_checkpoints,
1202        };
1203
1204        Ok(Some(info))
1205    }
1206}
1207
1208#[cfg(test)]
1209mod test;