From f1ad4720752c3dc5b2489ced150671c22b41a65b Mon Sep 17 00:00:00 2001 From: discord9 Date: Fri, 22 May 2026 07:40:10 +0800 Subject: [PATCH 01/32] fix(mysql): infer LIMIT placeholders in prepare (#8149) Signed-off-by: discord9 --- src/query/src/planner.rs | 56 ++++++++++++++++---- src/servers/tests/mysql/mysql_server_test.rs | 32 +++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/query/src/planner.rs b/src/query/src/planner.rs index b95803a52b..c4f4af3c6a 100644 --- a/src/query/src/planner.rs +++ b/src/query/src/planner.rs @@ -494,6 +494,36 @@ impl DfLogicalPlanner { Ok(()) } + fn infer_limit_placeholder_types( + plan: &LogicalPlan, + placeholder_types: &mut HashMap>, + ) -> Result<()> { + plan.apply(|node| { + if let LogicalPlan::Limit(limit) = node { + for expr in limit.skip.iter().chain(limit.fetch.iter()) { + expr.apply(|e| { + if let DfExpr::Placeholder(ph) = e { + placeholder_types + .entry(ph.id.clone()) + .and_modify(|existing| { + if existing.is_none() { + *existing = Some(DataType::Int64); + } + }) + .or_insert(Some(DataType::Int64)); + } + + Ok(TreeNodeRecursion::Continue) + })?; + } + } + + Ok(TreeNodeRecursion::Continue) + })?; + + Ok(()) + } + /// Gets inferred parameter types from a logical plan. /// Returns a map where each parameter ID is mapped to: /// - Some(DataType) if the parameter type could be inferred @@ -501,7 +531,8 @@ impl DfLogicalPlanner { /// /// This function first uses DataFusion's `get_parameter_types()` to infer types. /// If any parameters have `None` values (i.e., DataFusion couldn't infer their types), - /// it falls back to using `extract_placeholder_cast_types()` to detect explicit casts. + /// it falls back to using `extract_placeholder_cast_types()` to detect explicit casts + /// and applies context-specific inference such as LIMIT/OFFSET placeholders. /// /// This is because datafusion can only infer types for a limited cases. /// @@ -510,19 +541,15 @@ impl DfLogicalPlanner { pub fn get_inferred_parameter_types( plan: &LogicalPlan, ) -> Result>> { - let param_types = plan.get_parameter_types().context(PlanSqlSnafu)?; + let mut param_types = plan.get_parameter_types().context(PlanSqlSnafu)?; let has_none = param_types.values().any(|v| v.is_none()); - if !has_none { - Ok(param_types) - } else { + if has_none { let cast_types = Self::extract_placeholder_cast_types(plan)?; - let mut merged = param_types; - for (id, opt_type) in cast_types { - merged + param_types .entry(id) .and_modify(|existing| { if existing.is_none() { @@ -532,8 +559,10 @@ impl DfLogicalPlanner { .or_insert(opt_type); } - Ok(merged) + Self::infer_limit_placeholder_types(plan, &mut param_types)?; } + + Ok(param_types) } } @@ -793,6 +822,15 @@ mod tests { assert_eq!(type_3, &Some(DataType::Int32)); } + #[tokio::test] + async fn test_get_inferred_parameter_types_limit_offset() { + let plan = parse_sql_to_plan("SELECT id FROM test LIMIT $1 OFFSET $2").await; + let types = DfLogicalPlanner::get_inferred_parameter_types(&plan).unwrap(); + + assert_eq!(types.get("$1"), Some(&Some(DataType::Int64))); + assert_eq!(types.get("$2"), Some(&Some(DataType::Int64))); + } + #[tokio::test] async fn test_plan_pql_applies_extension_rules() { for inner_agg in ["count", "sum", "avg", "min", "max", "stddev", "stdvar"] { diff --git a/src/servers/tests/mysql/mysql_server_test.rs b/src/servers/tests/mysql/mysql_server_test.rs index e0cb086dda..0694cc7746 100644 --- a/src/servers/tests/mysql/mysql_server_test.rs +++ b/src/servers/tests/mysql/mysql_server_test.rs @@ -516,6 +516,38 @@ async fn test_query_prepared() -> Result<()> { _ => unreachable!(), } + // Regression test for #8142: LIMIT ? should work in prepared statements. + // The LIMIT placeholder should be inferred as Int64 so the MySQL prepare + // response advertises the correct parameter count. + { + let stmt = connection + .prep("SELECT uint32s FROM all_datatypes LIMIT ?") + .await + .unwrap(); + let rows: Vec = connection + .exec(stmt, vec![mysql_async::Value::Int(1)]) + .await + .unwrap(); + assert_eq!(rows.len(), 1, "LIMIT 1 should return 1 row"); + } + + // Also cover mixed placeholders: the WHERE placeholder is inferred from + // the column type and the LIMIT placeholder is inferred from its context. + { + let stmt = connection + .prep("SELECT uint32s FROM all_datatypes WHERE uint32s >= ? LIMIT ?") + .await + .unwrap(); + let rows: Vec = connection + .exec( + stmt, + vec![mysql_async::Value::UInt(0), mysql_async::Value::UInt(1)], + ) + .await + .unwrap(); + assert_eq!(rows.len(), 1, "LIMIT 1 should return 1 row"); + } + Ok(()) } From 1cd6b30058272ef98d3613ee8fb3cfc4d4e36edc Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Fri, 22 May 2026 19:57:43 +0800 Subject: [PATCH 02/32] fix: reject physical metric table writes (#8153) Signed-off-by: WenyXu --- src/metric-engine/src/engine/put.rs | 105 ++++++++++++++++-- src/metric-engine/src/error.rs | 7 ++ .../physical_metric_table_insert.result | 28 +++++ .../insert/physical_metric_table_insert.sql | 21 ++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 tests/cases/standalone/common/insert/physical_metric_table_insert.result create mode 100644 tests/cases/standalone/common/insert/physical_metric_table_insert.sql diff --git a/src/metric-engine/src/engine/put.rs b/src/metric-engine/src/engine/put.rs index 438a8fcad3..103b15e596 100644 --- a/src/metric-engine/src/engine/put.rs +++ b/src/metric-engine/src/engine/put.rs @@ -31,7 +31,7 @@ use store_api::storage::{RegionId, TableId}; use crate::engine::MetricEngineInner; use crate::error::{ - ColumnNotFoundSnafu, CreateDefaultSnafu, ForbiddenPhysicalAlterSnafu, InvalidRequestSnafu, + ColumnNotFoundSnafu, CreateDefaultSnafu, ForbiddenPhysicalWriteSnafu, InvalidRequestSnafu, LogicalRegionNotFoundSnafu, PhysicalRegionNotFoundSnafu, Result, UnexpectedRequestSnafu, UnsupportedRegionRequestSnafu, }; @@ -55,7 +55,7 @@ impl MetricEngineInner { ); FORBIDDEN_OPERATION_COUNT.inc(); - ForbiddenPhysicalAlterSnafu.fail() + ForbiddenPhysicalWriteSnafu.fail() } else { self.put_logical_region(region_id, request).await } @@ -86,18 +86,31 @@ impl MetricEngineInner { // Fast path: single request, no batching overhead if len == 1 { - let (logical_id, req) = requests.into_iter().next().unwrap(); - return self.put_logical_region(logical_id, req).await; + let (region_id, req) = requests.into_iter().next().unwrap(); + let is_putting_physical_region = + self.state.read().unwrap().exist_physical_region(region_id); + if is_putting_physical_region { + FORBIDDEN_OPERATION_COUNT.inc(); + return ForbiddenPhysicalWriteSnafu.fail(); + } + + return self.put_logical_region(region_id, req).await; } let mut requests_per_physical: HashMap> = HashMap::new(); - for (logical_region_id, request) in requests { - let physical_region_id = self.find_physical_region_id(logical_region_id)?; + for (region_id, request) in requests { + let is_putting_physical_region = + self.state.read().unwrap().exist_physical_region(region_id); + if is_putting_physical_region { + FORBIDDEN_OPERATION_COUNT.inc(); + return ForbiddenPhysicalWriteSnafu.fail(); + } + let physical_region_id = self.find_physical_region_id(region_id)?; requests_per_physical .entry(physical_region_id) .or_default() - .push((logical_region_id, request)); + .push((region_id, request)); } let mut total_affected_rows: AffectedRows = 0; @@ -1226,6 +1239,84 @@ mod tests { assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 0); } + #[tokio::test] + async fn test_batch_write_single_physical_region_forbidden() { + let env = TestEnv::new().await; + env.init_metric_region().await; + let engine = env.metric(); + + let physical_region_id = env.default_physical_region_id(); + let schema = test_util::row_schema_with_tags(&["job"]); + let requests = vec![( + physical_region_id, + RegionPutRequest { + rows: Rows { + schema, + rows: test_util::build_rows(1, 1), + }, + hint: None, + partition_expr_version: None, + }, + )]; + + let err = engine + .inner + .put_regions_batch(requests.into_iter()) + .await + .unwrap_err(); + + assert!(matches!( + err, + crate::error::Error::ForbiddenPhysicalWrite { .. } + )); + } + + #[tokio::test] + async fn test_batch_write_physical_region_forbidden() { + let env = TestEnv::new().await; + env.init_metric_region().await; + let engine = env.metric(); + + let physical_region_id = env.default_physical_region_id(); + let logical_region_id = env.default_logical_region_id(); + let schema = test_util::row_schema_with_tags(&["job"]); + let requests = vec![ + ( + logical_region_id, + RegionPutRequest { + rows: Rows { + schema: schema.clone(), + rows: test_util::build_rows(1, 1), + }, + hint: None, + partition_expr_version: None, + }, + ), + ( + physical_region_id, + RegionPutRequest { + rows: Rows { + schema, + rows: test_util::build_rows(1, 1), + }, + hint: None, + partition_expr_version: None, + }, + ), + ]; + + let err = engine + .inner + .put_regions_batch(requests.into_iter()) + .await + .unwrap_err(); + + assert!(matches!( + err, + crate::error::Error::ForbiddenPhysicalWrite { .. } + )); + } + #[tokio::test] async fn test_batch_write_single_request_fast_path() { let env = TestEnv::new().await; diff --git a/src/metric-engine/src/error.rs b/src/metric-engine/src/error.rs index 284b1b0298..f01737e764 100644 --- a/src/metric-engine/src/error.rs +++ b/src/metric-engine/src/error.rs @@ -254,6 +254,12 @@ pub enum Error { location: Location, }, + #[snafu(display("Write request to physical region is forbidden"))] + ForbiddenPhysicalWrite { + #[snafu(implicit)] + location: Location, + }, + #[snafu(display("Invalid region metadata"))] InvalidMetadata { source: store_api::metadata::MetadataError, @@ -411,6 +417,7 @@ impl ErrorExt for Error { | CreateDefault { .. } => StatusCode::InvalidArguments, ForbiddenPhysicalAlter { .. } + | ForbiddenPhysicalWrite { .. } | UnsupportedRegionRequest { .. } | MissingFiles { .. } => StatusCode::Unsupported, diff --git a/tests/cases/standalone/common/insert/physical_metric_table_insert.result b/tests/cases/standalone/common/insert/physical_metric_table_insert.result new file mode 100644 index 0000000000..63b2997209 --- /dev/null +++ b/tests/cases/standalone/common/insert/physical_metric_table_insert.result @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS demo_metric_table ( + label STRING NULL, + ts TIMESTAMP(3) NOT NULL, + val DOUBLE NULL, + TIME INDEX (ts), + PRIMARY KEY (label) +) +PARTITION ON COLUMNS (label) ( + label < 'M', + label >= 'M' +) +ENGINE=metric +WITH( + physical_metric_table = 'true', + skip_wal = 'true' +); + +Affected Rows: 0 + +INSERT INTO demo_metric_table (label, ts, val) +VALUES ('A', '2026-05-19 00:00:00', 1.0); + +Error: 1001(Unsupported), Write request to physical region is forbidden + +DROP TABLE demo_metric_table; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/insert/physical_metric_table_insert.sql b/tests/cases/standalone/common/insert/physical_metric_table_insert.sql new file mode 100644 index 0000000000..0be352986f --- /dev/null +++ b/tests/cases/standalone/common/insert/physical_metric_table_insert.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS demo_metric_table ( + label STRING NULL, + ts TIMESTAMP(3) NOT NULL, + val DOUBLE NULL, + TIME INDEX (ts), + PRIMARY KEY (label) +) +PARTITION ON COLUMNS (label) ( + label < 'M', + label >= 'M' +) +ENGINE=metric +WITH( + physical_metric_table = 'true', + skip_wal = 'true' +); + +INSERT INTO demo_metric_table (label, ts, val) +VALUES ('A', '2026-05-19 00:00:00', 1.0); + +DROP TABLE demo_metric_table; From 9916027ca2dcb0523deebd0a7373400830526a8d Mon Sep 17 00:00:00 2001 From: QuakeWang <45645138+QuakeWang@users.noreply.github.com> Date: Sat, 23 May 2026 23:50:07 +0800 Subject: [PATCH 03/32] test: verify KILL cancels INSERT SELECT (#8151) * test: verify kill cancels insert select Signed-off-by: QuakeWang * test: propagate insert select kill test errors Signed-off-by: QuakeWang --------- Signed-off-by: QuakeWang --- src/frontend/src/instance.rs | 368 +++++++++++++++++++++++++++++++---- 1 file changed, 335 insertions(+), 33 deletions(-) diff --git a/src/frontend/src/instance.rs b/src/frontend/src/instance.rs index 24075601f6..8cc6195e5f 100644 --- a/src/frontend/src/instance.rs +++ b/src/frontend/src/instance.rs @@ -1204,14 +1204,19 @@ fn should_track_plan_process(stmt: Option<&Statement>, plan: &LogicalPlan) -> bo #[cfg(test)] mod tests { use std::collections::HashMap; + use std::future::Future; + use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Barrier}; + use std::task::{Context, Poll}; use std::thread; use std::time::{Duration, Instant}; use api::v1::meta::{ProcedureDetailResponse, ReconcileRequest, ReconcileResponse}; use catalog::process_manager::ProcessManager; use common_base::Plugins; + use common_error::ext::{BoxedError, PlainError}; + use common_error::status_code::StatusCode; use common_meta::cache::LayeredCacheRegistryBuilder; use common_meta::kv_backend::memory::MemoryKvBackend; use common_meta::procedure_executor::{ExecutorContext, ProcedureExecutor}; @@ -1220,23 +1225,142 @@ mod tests { MigrateRegionRequest, MigrateRegionResponse, ProcedureStateResponse, }; use common_query::Output; + use common_recordbatch::{ + OrderOption, RecordBatch, RecordBatchStream, SendableRecordBatchStream, + }; use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef}; use datafusion_expr::dml::InsertOp; use datafusion_expr::{LogicalPlanBuilder, LogicalTableSource}; use datatypes::prelude::ConcreteDataType; - use datatypes::schema::{ColumnSchema, Schema as GtSchema}; + use datatypes::schema::{ColumnSchema, Schema as GtSchema, SchemaRef as GtSchemaRef}; use query::query_engine::options::QueryOptions; use session::context::{Channel, ConnInfo, QueryContext, QueryContextBuilder}; + use snafu::{Location, Snafu}; use sql::dialect::GreptimeDbDialect; + use store_api::data_source::DataSource; + use store_api::storage::ScanRequest; use strfmt::Format; - use table::metadata::{TableInfoBuilder, TableMetaBuilder}; + use table::metadata::{FilterPushDownType, TableInfo, TableInfoBuilder, TableMetaBuilder}; use table::test_util::EmptyTable; + use table::{Table, TableRef}; use tokio::sync::{mpsc, oneshot}; use super::*; use crate::frontend::FrontendOptions; use crate::instance::builder::FrontendBuilder; + #[derive(Debug, Snafu)] + enum TestError { + #[snafu(display("Failed to build test cache registry"))] + BuildCacheRegistry { + source: cache::error::Error, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to build test table meta for table: {table_name}"))] + BuildTableMeta { + table_name: String, + source: table::metadata::TableMetaBuilderError, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to build test table info for table: {table_name}"))] + BuildTableInfo { + table_name: String, + source: table::metadata::TableInfoBuilderError, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to register test table: {table_name}"))] + RegisterTable { + table_name: String, + source: catalog::error::Error, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to build test frontend instance"))] + BuildFrontend { + source: crate::error::Error, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Expected exactly one output for SQL `{sql}`, got {actual}"))] + UnexpectedOutputCount { + sql: String, + actual: usize, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to execute SQL `{sql}`"))] + ExecuteSql { + sql: String, + source: crate::error::Error, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Timed out waiting for insert-select start notification"))] + InsertStartTimeout { + source: tokio::time::error::Elapsed, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Insert-select start notification channel closed"))] + InsertStartChannelClosed { + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Failed to release blocking insert-select interceptor"))] + ReleaseBlockedInsert { + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Timed out waiting for insert-select source to be polled"))] + SourcePollTimeout { + source: tokio::time::error::Elapsed, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Insert-select source poll notification channel closed"))] + SourcePollChannelClosed { + source: oneshot::error::RecvError, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Timed out waiting for insert task to finish"))] + InsertTaskTimeout { + source: tokio::time::error::Elapsed, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Insert task panicked"))] + InsertTaskPanic { + source: tokio::task::JoinError, + #[snafu(implicit)] + location: Location, + }, + + #[snafu(display("Expected insert-select to be cancelled"))] + InsertSelectNotCancelled { + #[snafu(implicit)] + location: Location, + }, + } + + type TestResult = std::result::Result; + fn parse_one_sql(sql: &str) -> Statement { parse_stmt(sql, &GreptimeDbDialect {}).unwrap().remove(0) } @@ -1292,6 +1416,70 @@ mod tests { } } + struct PendingRecordBatchStream { + schema: GtSchemaRef, + polled_tx: Option>, + _finish_tx: oneshot::Sender<()>, + finish_rx: Pin>>, + } + + impl RecordBatchStream for PendingRecordBatchStream { + fn schema(&self) -> GtSchemaRef { + self.schema.clone() + } + + fn output_ordering(&self) -> Option<&[OrderOption]> { + None + } + + fn metrics(&self) -> Option { + None + } + } + + impl Stream for PendingRecordBatchStream { + type Item = common_recordbatch::error::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(polled_tx) = self.polled_tx.take() { + let _ = polled_tx.send(()); + } + + match self.finish_rx.as_mut().poll(cx) { + Poll::Ready(_) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } + } + + impl Unpin for PendingRecordBatchStream {} + + struct PendingDataSource { + schema: GtSchemaRef, + polled_tx: std::sync::Mutex>>, + } + + impl DataSource for PendingDataSource { + fn get_stream( + &self, + _request: ScanRequest, + ) -> std::result::Result { + let (finish_tx, finish_rx) = oneshot::channel(); + let mut polled_tx = self.polled_tx.lock().map_err(|_| { + BoxedError::new(PlainError::new( + "pending data source lock poisoned".to_string(), + StatusCode::Unexpected, + )) + })?; + Ok(Box::pin(PendingRecordBatchStream { + schema: self.schema.clone(), + polled_tx: polled_tx.take(), + _finish_tx: finish_tx, + finish_rx: Box::pin(finish_rx), + })) + } + } + struct NoopProcedureExecutor; #[async_trait::async_trait] @@ -1353,18 +1541,18 @@ mod tests { fn test_cache_registry( kv_backend: common_meta::kv_backend::KvBackendRef, - ) -> common_meta::cache::LayeredCacheRegistryRef { - Arc::new( + ) -> TestResult { + Ok(Arc::new( cache::with_default_composite_cache_registry( LayeredCacheRegistryBuilder::default() .add_cache_registry(cache::build_fundamental_cache_registry(kv_backend)), ) - .unwrap() + .context(BuildCacheRegistrySnafu)? .build(), - ) + )) } - fn test_table(table_id: u32, table_name: &str) -> table::TableRef { + fn test_table_info(table_id: u32, table_name: &str) -> TestResult { let schema = Arc::new(GtSchema::new(vec![ ColumnSchema::new("id", ConcreteDataType::int32_datatype(), false), ColumnSchema::new( @@ -1380,36 +1568,85 @@ mod tests { .value_indices(vec![1]) .next_column_id(1024) .build() - .unwrap(); - let table_info = TableInfoBuilder::new(table_name, table_meta) + .with_context(|_| BuildTableMetaSnafu { + table_name: table_name.to_string(), + })?; + + TableInfoBuilder::new(table_name, table_meta) .table_id(table_id) .build() - .unwrap(); + .with_context(|_| BuildTableInfoSnafu { + table_name: table_name.to_string(), + }) + } - EmptyTable::from_table_info(&table_info) + fn test_table(table_id: u32, table_name: &str) -> TestResult { + let table_info = test_table_info(table_id, table_name)?; + Ok(EmptyTable::from_table_info(&table_info)) + } + + fn pending_table( + table_id: u32, + table_name: &str, + polled_tx: oneshot::Sender<()>, + ) -> TestResult { + let table_info = test_table_info(table_id, table_name)?; + let data_source = Arc::new(PendingDataSource { + schema: table_info.meta.schema.clone(), + polled_tx: std::sync::Mutex::new(Some(polled_tx)), + }); + + Ok(Arc::new(Table::new( + Arc::new(table_info), + FilterPushDownType::Unsupported, + data_source, + ))) + } + + async fn test_instance_with_tables( + source_table: TableRef, + target_table: TableRef, + ) -> TestResult { + test_instance_with_plugins(source_table, target_table, Plugins::new()).await } async fn test_instance_with_insert_select_interceptor( interceptor: SqlQueryInterceptorRef, - ) -> Instance { + ) -> TestResult { + let plugins = Plugins::new(); + plugins.insert::>(interceptor); + + test_instance_with_plugins( + test_table(1024, "source")?, + test_table(1025, "target")?, + plugins, + ) + .await + } + + async fn test_instance_with_plugins( + source_table: TableRef, + target_table: TableRef, + plugins: Plugins, + ) -> TestResult { let kv_backend = Arc::new(MemoryKvBackend::new()); let process_manager = Arc::new(ProcessManager::new("test-frontend".to_string(), None)); - let catalog_manager = - catalog::memory::MemoryCatalogManager::new_with_table(test_table(1024, "source")); + let catalog_manager = catalog::memory::MemoryCatalogManager::new_with_table(source_table); + let target_table_name = "target"; catalog_manager .register_table_sync(catalog::RegisterTableRequest { catalog: "greptime".to_string(), schema: "public".to_string(), - table_name: "target".to_string(), + table_name: target_table_name.to_string(), table_id: 1025, - table: test_table(1025, "target"), + table: target_table, }) - .unwrap(); + .with_context(|_| RegisterTableSnafu { + table_name: target_table_name.to_string(), + })?; catalog_manager.register_process_list_table(process_manager.clone()); - let cache_registry = test_cache_registry(kv_backend.clone()); - let plugins = Plugins::new(); - plugins.insert::>(interceptor); + let cache_registry = test_cache_registry(kv_backend.clone())?; FrontendBuilder::new( FrontendOptions::default(), @@ -1423,17 +1660,25 @@ mod tests { .with_plugin(plugins) .try_build() .await - .unwrap() + .context(BuildFrontendSnafu) } async fn execute_one_sql( instance: &Instance, sql: &str, query_ctx: QueryContextRef, - ) -> Result { + ) -> TestResult { let mut results = instance.do_query_inner(sql, query_ctx).await; - assert_eq!(1, results.len()); - results.remove(0) + ensure!( + results.len() == 1, + UnexpectedOutputCountSnafu { + sql: sql.to_string(), + actual: results.len(), + } + ); + results.remove(0).with_context(|_| ExecuteSqlSnafu { + sql: sql.to_string(), + }) } #[test] @@ -1588,12 +1833,12 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_insert_select_is_visible_in_show_processlist() { + async fn test_insert_select_is_visible_in_show_processlist() -> TestResult<()> { let insert_sql = "INSERT INTO target SELECT * FROM source"; let (started_tx, mut started_rx) = mpsc::unbounded_channel(); let (finish_tx, finish_rx) = oneshot::channel(); let interceptor = Arc::new(BlockingInsertSelectInterceptor::new(started_tx, finish_rx)); - let instance = Arc::new(test_instance_with_insert_select_interceptor(interceptor).await); + let instance = Arc::new(test_instance_with_insert_select_interceptor(interceptor).await?); let insert_task = tokio::spawn({ let instance = instance.clone(); @@ -1602,20 +1847,77 @@ mod tests { tokio::time::timeout(Duration::from_secs(5), started_rx.recv()) .await - .unwrap() - .unwrap(); + .context(InsertStartTimeoutSnafu)? + .context(InsertStartChannelClosedSnafu)?; - let output = execute_one_sql(&instance, "SHOW PROCESSLIST", test_query_ctx(43)) - .await - .unwrap(); + let output = execute_one_sql(&instance, "SHOW PROCESSLIST", test_query_ctx(43)).await?; let process_list = output.data.pretty_print().await; assert!( process_list.contains(insert_sql), "process list did not contain running insert:\n{process_list}" ); - finish_tx.send(()).unwrap(); - insert_task.await.unwrap().unwrap(); + finish_tx + .send(()) + .map_err(|_| ReleaseBlockedInsertSnafu.build())?; + insert_task.await.context(InsertTaskPanicSnafu)??; + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_kill_query_cancels_insert_select() -> TestResult<()> { + assert_kill_cancels_insert_select("KILL QUERY 4242").await + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_kill_process_id_cancels_insert_select() -> TestResult<()> { + assert_kill_cancels_insert_select("KILL 'test-frontend/4242'").await + } + + async fn assert_kill_cancels_insert_select(kill_sql: &str) -> TestResult<()> { + let insert_sql = "INSERT INTO target SELECT * FROM source"; + let (source_polled_tx, source_polled_rx) = oneshot::channel(); + let instance = Arc::new( + test_instance_with_tables( + pending_table(1024, "source", source_polled_tx)?, + test_table(1025, "target")?, + ) + .await?, + ); + + let insert_task = tokio::spawn({ + let instance = instance.clone(); + async move { execute_one_sql(&instance, insert_sql, test_query_ctx(4242)).await } + }); + + tokio::time::timeout(Duration::from_secs(5), source_polled_rx) + .await + .context(SourcePollTimeoutSnafu)? + .context(SourcePollChannelClosedSnafu)?; + + let output = execute_one_sql(&instance, kill_sql, test_query_ctx(43)).await?; + assert!(matches!(output.data, OutputData::AffectedRows(1))); + + let insert_result = tokio::time::timeout(Duration::from_secs(5), insert_task) + .await + .context(InsertTaskTimeoutSnafu)? + .context(InsertTaskPanicSnafu)?; + let err = match insert_result { + Ok(_) => return InsertSelectNotCancelledSnafu.fail(), + Err(TestError::ExecuteSql { source, .. }) => source, + Err(err) => return Err(err), + }; + assert_eq!(StatusCode::Cancelled, err.status_code()); + + let output = execute_one_sql(&instance, "SHOW PROCESSLIST", test_query_ctx(43)).await?; + let process_list = output.data.pretty_print().await; + assert!( + !process_list.contains(insert_sql), + "process list still contains killed insert:\n{process_list}" + ); + + Ok(()) } fn insert_dml_plan() -> LogicalPlan { From 8c267f3844f915e57f70b4e5afc9bdf193f7d031 Mon Sep 17 00:00:00 2001 From: discord9 Date: Sun, 24 May 2026 14:18:22 +0800 Subject: [PATCH 04/32] fix(mysql): keep unknown prepare placeholders (#8150) * fix(mysql): keep unknown prepare placeholders Signed-off-by: discord9 * fix(mysql): use span-based placeholder fallback Signed-off-by: discord9 * fix(mysql): visit placeholders in all statements Signed-off-by: discord9 * refactor(mysql): remove placeholder transform wrapper Signed-off-by: discord9 --------- Signed-off-by: discord9 --- Cargo.lock | 1 + src/servers/Cargo.toml | 1 + src/servers/src/mysql/handler.rs | 306 +++++++++++++++++-- src/servers/src/mysql/helper.rs | 102 +++++-- src/servers/tests/mysql/mysql_server_test.rs | 134 ++++++++ 5 files changed, 493 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aafa225b4b..45b726ab82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12705,6 +12705,7 @@ dependencies = [ "metric-engine", "mime_guess", "mysql_async", + "mysql_common 0.34.1", "notify", "object-pool", "once_cell", diff --git a/src/servers/Cargo.toml b/src/servers/Cargo.toml index b35b30968a..f870d3c3ca 100644 --- a/src/servers/Cargo.toml +++ b/src/servers/Cargo.toml @@ -82,6 +82,7 @@ log-query.workspace = true loki-proto.workspace = true metric-engine.workspace = true mime_guess = "2.0" +mysql_common = "0.34" notify.workspace = true object-pool = "0.5" once_cell.workspace = true diff --git a/src/servers/src/mysql/handler.rs b/src/servers/src/mysql/handler.rs index 3a80593c63..88a539ea21 100644 --- a/src/servers/src/mysql/handler.rs +++ b/src/servers/src/mysql/handler.rs @@ -30,6 +30,7 @@ use datafusion_expr::LogicalPlan; use datatypes::prelude::ConcreteDataType; use datatypes::schema::Schema; use itertools::Itertools; +use mysql_common::Value as MysqlValue; use opensrv_mysql::{ AsyncMysqlShim, Column, ErrorKind, InitWriter, ParamParser, ParamValue, QueryResultWriter, StatementMetaWriter, ValueInner, @@ -51,9 +52,7 @@ use crate::error::{ self, DataFrameSnafu, InferParameterTypesSnafu, InvalidPrepareStatementSnafu, Result, }; use crate::metrics::METRIC_AUTH_FAILURE; -use crate::mysql::helper::{ - self, format_placeholder, replace_placeholders, transform_placeholders, -}; +use crate::mysql::helper::{self, format_placeholder, transform_placeholders_with_count}; use crate::mysql::writer; use crate::mysql::writer::{create_mysql_column, handle_err}; use crate::query_handler::sql::ServerSqlQueryHandlerRef; @@ -192,28 +191,31 @@ impl MysqlInstanceShim { return Ok((vec![], vec![])); } - let (query, param_num) = replace_placeholders(raw_query); - let statement = validate_query(raw_query).await?; // We have to transform the placeholder, because DataFusion only parses placeholders // in the form of "$i", it can't process "?" right now. - let statement = transform_placeholders(statement); + let (statement, placeholder_count) = transform_placeholders_with_count(statement); + let param_num = placeholder_count + 1; let describe_result = self .do_describe(statement.clone(), query_ctx.clone()) .await?; let plan = describe_result.map(|DescribeResult { logical_plan }| logical_plan); - let params = if let Some(plan) = &plan { + let (params, can_cache_as_plan) = if let Some(plan) = &plan { let param_types = DfLogicalPlanner::get_inferred_parameter_types(plan) .context(InferParameterTypesSnafu)? .into_iter() .map(|(k, v)| (k, v.map(|v| ConcreteDataType::from_arrow_type(&v)))) .collect(); - prepared_params(¶m_types)? + + ( + prepared_params(¶m_types, param_num)?, + all_params_have_types(¶m_types, param_num), + ) } else { - dummy_params(param_num)? + (dummy_params(param_num)?, false) }; let columns = @@ -239,17 +241,20 @@ impl MysqlInstanceShim { .unwrap_or_default(); match plan { - Some(plan) if params.len() == param_num - 1 => { + Some(plan) if can_cache_as_plan => { self.save_plan(SqlPlan::Plan(plan, statement), stmt_key) .inspect_err(|e| { error!(e; "Failed to save prepared statement"); })?; } _ => { - self.save_plan(SqlPlan::Statement(statement, query), stmt_key) - .inspect_err(|e| { - error!(e; "Failed to save prepared statement"); - })?; + self.save_plan( + SqlPlan::Statement(statement, raw_query.to_string()), + stmt_key, + ) + .inspect_err(|e| { + error!(e; "Failed to save prepared statement"); + })?; } } @@ -312,7 +317,7 @@ impl MysqlInstanceShim { self.do_query(&query, query_ctx.clone()).await } } - SqlPlan::Statement(_stmt, query) => { + SqlPlan::Statement(stmt, query) => { let param_strs = match params { Params::ProtocolParams(params) => { params.iter().map(convert_param_value_to_string).collect() @@ -323,7 +328,7 @@ impl MysqlInstanceShim { "do_execute Replacing with Params: {:?}, Original Query: {}", param_strs, query ); - let query = replace_params(param_strs, query); + let query = replace_params(param_strs, stmt, query)?; debug!("Mysql execute replaced query: {}", query); self.do_query(&query, query_ctx.clone()).await } @@ -662,19 +667,133 @@ fn convert_param_value_to_string(param: &ParamValue) -> String { ValueInner::UInt(u) => u.to_string(), ValueInner::Double(u) => u.to_string(), ValueInner::NULL => "NULL".to_string(), - ValueInner::Bytes(b) => format!("'{}'", &String::from_utf8_lossy(b)), + // MySQL prepared fallback emits SQL text. Delegate bytes/string literal + // escaping to mysql_common. `false` means normal MySQL backslash escapes; + // if NO_BACKSLASH_ESCAPES is supported in this path later, wire the + // session SQL mode here. + ValueInner::Bytes(b) => MysqlValue::Bytes(b.to_vec()).as_sql(false), ValueInner::Date(_) => format!("'{}'", NaiveDate::from(param.value)), ValueInner::Datetime(_) => format!("'{}'", NaiveDateTime::from(param.value)), ValueInner::Time(_) => format_duration(Duration::from(param.value)), } } -fn replace_params(params: Vec, query: String) -> String { - let mut query = query; - for (index, param) in (1..).zip(params) { - query = query.replace(&format_placeholder(index), ¶m); +fn replace_params(params: Vec, stmt: Statement, mut query: String) -> Result { + let spans = helper::placeholder_spans(stmt); + ensure!( + spans.len() == params.len(), + error::InternalSnafu { + err_msg: format!( + "Prepared statement expected {} parameters but got {}", + spans.len(), + params.len() + ) + } + ); + + let mut replacements = Vec::with_capacity(spans.len()); + for span in spans { + let start = location_to_byte_offset(&query, span.start_line, span.start_column) + .ok_or_else(|| { + error::InternalSnafu { + err_msg: format!( + "Invalid placeholder start span: line {}, column {}", + span.start_line, span.start_column + ), + } + .build() + })?; + let end = + location_to_byte_offset(&query, span.end_line, span.end_column).ok_or_else(|| { + error::InternalSnafu { + err_msg: format!( + "Invalid placeholder end span: line {}, column {}", + span.end_line, span.end_column + ), + } + .build() + })?; + let param = span + .index + .checked_sub(1) + .and_then(|idx| params.get(idx)) + .ok_or_else(|| { + error::InternalSnafu { + err_msg: format!("Missing prepared statement parameter {}", span.index), + } + .build() + })?; + + ensure!( + start < end && end <= query.len(), + error::InternalSnafu { + err_msg: format!( + "Invalid placeholder byte span: {}..{} for query length {}", + start, + end, + query.len() + ) + } + ); + ensure!( + query.get(start..end) == Some("?"), + error::InternalSnafu { + err_msg: format!( + "Prepared statement placeholder span maps to {:?} instead of '?'", + query.get(start..end) + ) + } + ); + + replacements.push((start, end, param.clone())); } - query + + replacements.sort_unstable_by_key(|(start, _, _)| *start); + for windows in replacements.windows(2) { + ensure!( + windows[0].1 <= windows[1].0, + error::InternalSnafu { + err_msg: "Overlapping placeholder spans in prepared statement".to_string() + } + ); + } + + // All spans are computed against the original query. Apply replacements + // from right to left so changing one parameter's string length never shifts + // the byte offsets of placeholders that have not been replaced yet. + for (start, end, param) in replacements.into_iter().rev() { + query.replace_range(start..end, ¶m); + } + + Ok(query) +} + +fn location_to_byte_offset(query: &str, line: u64, column: u64) -> Option { + // sqlparser spans are 1-based line/column locations, and columns advance by + // Rust `char`s rather than bytes. Convert them to byte offsets before using + // `String::replace_range` on the original SQL text. + if line == 0 || column == 0 { + return None; + } + + let mut current_line = 1; + let mut current_column = 1; + for (index, ch) in query.char_indices() { + if current_line == line && current_column == column { + return Some(index); + } + + if ch == '\n' { + current_line += 1; + current_column = 1; + } else { + current_column += 1; + } + } + + // The exclusive end location of a trailing placeholder points just past + // the last character, for example the end span of `SELECT ?`. + (current_line == line && current_column == column).then_some(query.len()) } fn format_duration(duration: Duration) -> String { @@ -778,20 +897,33 @@ fn dummy_params(index: usize) -> Result> { } /// Parameters that the client must provide when executing the prepared statement. -fn prepared_params(param_types: &HashMap>) -> Result> { - let mut params = Vec::with_capacity(param_types.len()); +fn prepared_params( + param_types: &HashMap>, + param_num: usize, +) -> Result> { + let mut params = Vec::with_capacity(param_num - 1); // Placeholder index starts from 1 - for index in 1..=param_types.len() { - if let Some(Some(t)) = param_types.get(&format_placeholder(index)) { - let column = create_mysql_column(t, "")?; - params.push(column); - } + for i in 1..param_num { + let column = if let Some(Some(t)) = param_types.get(&format_placeholder(i)) { + create_mysql_column(t, "")? + } else { + create_mysql_column(&ConcreteDataType::null_datatype(), "")? + }; + params.push(column); } Ok(params) } +fn all_params_have_types( + param_types: &HashMap>, + param_num: usize, +) -> bool { + param_types.len() == param_num - 1 + && (1..param_num).all(|i| matches!(param_types.get(&format_placeholder(i)), Some(Some(_)))) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -852,6 +984,122 @@ mod tests { ) } + fn statement_with_transformed_placeholders(query: &str) -> Statement { + let mut statements = + ParserContext::create_with_dialect(query, &MySqlDialect {}, ParseOptions::default()) + .unwrap(); + assert_eq!(statements.len(), 1); + transform_placeholders_with_count(statements.remove(0)).0 + } + + #[test] + fn test_prepared_params_keep_unknown_type_placeholders() { + let mut param_types = HashMap::new(); + param_types.insert(format_placeholder(1), None); + param_types.insert( + format_placeholder(2), + Some(ConcreteDataType::int32_datatype()), + ); + + let params = prepared_params(¶m_types, 3).unwrap(); + assert_eq!(params.len(), 2); + assert!(!all_params_have_types(¶m_types, 3)); + } + + #[test] + fn test_replace_params_by_placeholder_span() { + let query = "SELECT ?, ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'$2 should stay'".to_string(), "'value'".to_string()]; + + assert_eq!( + "SELECT '$2 should stay', 'value'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT ?, ?, ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec![ + "'much longer than a placeholder'".to_string(), + "0".to_string(), + "'also much longer than a placeholder'".to_string(), + ]; + + assert_eq!( + "SELECT 'much longer than a placeholder', 0, 'also much longer than a placeholder'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT '$1', \"$2\", `$3`, ?, ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'1'".to_string(), "'2'".to_string()]; + + assert_eq!( + "SELECT '$1', \"$2\", `$3`, '1', '2'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT /* ? */ ? -- ?\n, ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'first'".to_string(), "'second'".to_string()]; + + assert_eq!( + "SELECT /* ? */ 'first' -- ?\n, 'second'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT '中文', ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'value'".to_string()]; + + assert_eq!( + "SELECT '中文', 'value'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT '中文',\n ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'value'".to_string()]; + + assert_eq!( + "SELECT '中文',\n 'value'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT 'x'\r\n, ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'crlf'".to_string()]; + + assert_eq!( + "SELECT 'x'\r\n, 'crlf'", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SELECT\t?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["NULL".to_string()]; + + assert_eq!("SELECT\tNULL", replace_params(params, stmt, query).unwrap()); + + let query = "SELECT CAST(? AS INT64), ? + (SELECT ?)".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["1".to_string(), "2".to_string(), "3".to_string()]; + + assert_eq!( + "SELECT CAST(1 AS INT64), 2 + (SELECT 3)", + replace_params(params, stmt, query).unwrap() + ); + + let query = "SET time_zone = ?".to_string(); + let stmt = statement_with_transformed_placeholders(&query); + let params = vec!["'UTC'".to_string()]; + + assert_eq!( + "SET time_zone = 'UTC'", + replace_params(params, stmt, query).unwrap() + ); + } + #[tokio::test] async fn test_prepare_federated_query() { let mut shim = create_shim(); diff --git a/src/servers/src/mysql/helper.rs b/src/servers/src/mysql/helper.rs index 2ee2421892..c4c072b007 100644 --- a/src/servers/src/mysql/helper.rs +++ b/src/servers/src/mysql/helper.rs @@ -23,6 +23,7 @@ use datatypes::prelude::ConcreteDataType; use datatypes::schema::ColumnSchema; use datatypes::types::TimestampType; use datatypes::value::{self, Value}; +#[cfg(test)] use itertools::Itertools; use opensrv_mysql::{ParamValue, ValueInner, to_naive_datetime}; use snafu::ResultExt; @@ -31,6 +32,17 @@ use sql::statements::statement::Statement; use crate::error::{self, Result}; +/// Location of a prepared-statement placeholder in the original SQL text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlaceholderSpan { + /// 1-based placeholder index. + pub(crate) index: usize, + pub(crate) start_line: u64, + pub(crate) start_column: u64, + pub(crate) end_line: u64, + pub(crate) end_column: u64, +} + /// Returns the placeholder string "$i". pub fn format_placeholder(i: usize) -> String { format!("${}", i) @@ -38,6 +50,7 @@ pub fn format_placeholder(i: usize) -> String { /// Replace all the "?" placeholder into "$i" in SQL, /// returns the new SQL and the last placeholder index. +#[cfg(test)] pub fn replace_placeholders(query: &str) -> (String, usize) { let query_parts = query.split('?').collect::>(); let parts_len = query_parts.len(); @@ -58,27 +71,51 @@ pub fn replace_placeholders(query: &str) -> (String, usize) { (query, index + 1) } -/// Transform all the "?" placeholder into "$i". -/// Only works for Insert,Query and Delete statements. -pub fn transform_placeholders(stmt: Statement) -> Statement { - match stmt { - Statement::Query(mut query) => { - visit_placeholders(&mut query.inner); - Statement::Query(query) - } - Statement::Insert(mut insert) => { - visit_placeholders(&mut insert.inner); - Statement::Insert(insert) - } - Statement::Delete(mut delete) => { - visit_placeholders(&mut delete.inner); - Statement::Delete(delete) - } - stmt => stmt, - } +/// Transform all the "?" placeholders into "$i" and return the number of +/// transformed placeholders. +pub fn transform_placeholders_with_count(mut stmt: Statement) -> (Statement, usize) { + let count = visit_placeholders(&mut stmt); + (stmt, count) } -fn visit_placeholders(v: &mut V) +/// Collect spans of "$i" placeholders in a statement. +pub(crate) fn placeholder_spans(mut stmt: Statement) -> Vec { + let mut spans = Vec::new(); + collect_placeholder_spans(&mut stmt, &mut spans); + spans +} + +fn collect_placeholder_spans(v: &mut V, spans: &mut Vec) +where + V: VisitMut, +{ + let _ = visit_expressions_mut(v, |expr| { + if let Expr::Value(ValueWithSpan { + value: ValueExpr::Placeholder(s), + span, + }) = expr + && let Some(index) = placeholder_index(s) + { + spans.push(PlaceholderSpan { + index, + start_line: span.start.line, + start_column: span.start.column, + end_line: span.end.line, + end_column: span.end.column, + }); + } + ControlFlow::<()>::Continue(()) + }); +} + +fn placeholder_index(s: &str) -> Option { + s.strip_prefix('$')? + .parse::() + .ok() + .filter(|i| *i > 0) +} + +fn visit_placeholders(v: &mut V) -> usize where V: VisitMut, { @@ -88,12 +125,14 @@ where value: ValueExpr::Placeholder(s), .. }) = expr + && s == "?" { *s = format_placeholder(index); index += 1; } ControlFlow::<()>::Continue(()) }); + index - 1 } /// Convert [`ParamValue`] into [`Value`] according to param type. @@ -340,33 +379,52 @@ mod tests { #[test] fn test_transform_placeholders() { let insert = parse_sql("insert into demo values(?,?,?)"); - let Statement::Insert(insert) = transform_placeholders(insert) else { + let (stmt, count) = transform_placeholders_with_count(insert); + let Statement::Insert(insert) = stmt else { unreachable!() }; assert_eq!( "INSERT INTO demo VALUES ($1, $2, $3)", insert.inner.to_string() ); + assert_eq!(3, count); let delete = parse_sql("delete from demo where host=? and idc=?"); - let Statement::Delete(delete) = transform_placeholders(delete) else { + let (stmt, count) = transform_placeholders_with_count(delete); + let Statement::Delete(delete) = stmt else { unreachable!() }; assert_eq!( "DELETE FROM demo WHERE host = $1 AND idc = $2", delete.inner.to_string() ); + assert_eq!(2, count); let select = parse_sql( "select * from demo where host=? and idc in (select idc from idcs where name=?) and cpu>?", ); - let Statement::Query(select) = transform_placeholders(select) else { + let (stmt, count) = transform_placeholders_with_count(select); + let Statement::Query(select) = stmt else { unreachable!() }; assert_eq!( "SELECT * FROM demo WHERE host = $1 AND idc IN (SELECT idc FROM idcs WHERE name = $2) AND cpu > $3", select.inner.to_string() ); + assert_eq!(3, count); + + let select = parse_sql("select '?', ?"); + let (stmt, count) = transform_placeholders_with_count(select); + let Statement::Query(select) = stmt else { + unreachable!() + }; + assert_eq!("SELECT '?', $1", select.inner.to_string()); + assert_eq!(1, count); + + let set = parse_sql("set time_zone = ?"); + let (stmt, count) = transform_placeholders_with_count(set); + assert_eq!("SET time_zone = $1", stmt.to_string()); + assert_eq!(1, count); } #[test] diff --git a/src/servers/tests/mysql/mysql_server_test.rs b/src/servers/tests/mysql/mysql_server_test.rs index 0694cc7746..888ac92fc3 100644 --- a/src/servers/tests/mysql/mysql_server_test.rs +++ b/src/servers/tests/mysql/mysql_server_test.rs @@ -548,6 +548,140 @@ async fn test_query_prepared() -> Result<()> { assert_eq!(rows.len(), 1, "LIMIT 1 should return 1 row"); } + // Untyped placeholders should still be advertised in the MySQL prepare + // response. This used to fail on the client side because the server + // reported 0 parameters for `SELECT ?`. + { + let stmt = connection.prep("SELECT ?").await.unwrap(); + assert_eq!(stmt.num_params(), 1); + + let row: Option = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"can't".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some("can't".to_string())); + + let stmt = connection.prep("SELECT ?").await.unwrap(); + let row: Option = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"a\\'b".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some("a\\'b".to_string())); + + let stmt = connection.prep("SELECT ?").await.unwrap(); + let row: Option> = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(vec![0xFF, 0xFE])]) + .await + .unwrap(); + assert_eq!(row, Some(vec![0xFF, 0xFE])); + + let stmt = connection.prep("SELECT ?").await.unwrap(); + let row: Option> = connection + .exec_first(stmt, vec![mysql_async::Value::NULL]) + .await + .unwrap(); + assert_eq!(row, Some(None)); + } + + // Values inserted into the SQL text must not be processed again while + // replacing later placeholders. + { + let stmt = connection.prep("SELECT ?, ?").await.unwrap(); + assert_eq!(stmt.num_params(), 2); + + let row: Option<(String, String)> = connection + .exec_first( + stmt, + vec![ + mysql_async::Value::Bytes(b"keep $2".to_vec()), + mysql_async::Value::Bytes(b"second".to_vec()), + ], + ) + .await + .unwrap(); + assert_eq!(row, Some(("keep $2".to_string(), "second".to_string()))); + } + + // Non-placeholder question marks inside string literals must not affect + // the advertised prepare parameter count. + { + let stmt = connection.prep("SELECT '?', ?").await.unwrap(); + assert_eq!(stmt.num_params(), 1); + + let row: Option<(String, String)> = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"actual".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some(("?".to_string(), "actual".to_string()))); + + let stmt = connection.prep("SELECT '$1', ?").await.unwrap(); + assert_eq!(stmt.num_params(), 1); + + let row: Option<(String, String)> = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"actual".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some(("$1".to_string(), "actual".to_string()))); + + let stmt = connection.prep("SELECT /* ? */ ? -- ?\n").await.unwrap(); + assert_eq!(stmt.num_params(), 1); + + let row: Option = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"commented".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some("commented".to_string())); + + let stmt = connection.prep("SELECT '中文', ?").await.unwrap(); + assert_eq!(stmt.num_params(), 1); + + let row: Option<(String, String)> = connection + .exec_first(stmt, vec![mysql_async::Value::Bytes(b"actual".to_vec())]) + .await + .unwrap(); + assert_eq!(row, Some(("中文".to_string(), "actual".to_string()))); + } + + // Also cover mixed known and unknown placeholders. The projection + // placeholder is untyped, while the WHERE placeholder is inferred from the + // column type. The prepare response must advertise both parameters. + { + let stmt = connection + .prep("SELECT ?, uint32s FROM all_datatypes WHERE uint32s >= ?") + .await + .unwrap(); + assert_eq!(stmt.num_params(), 2); + + let rows: Vec = connection + .exec( + stmt, + vec![ + mysql_async::Value::Bytes(b"unknown".to_vec()), + mysql_async::Value::UInt(0), + ], + ) + .await + .unwrap(); + assert!(!rows.is_empty()); + } + + // LIMIT placeholders used to be a common case where DataFusion did not + // infer a parameter type. The prepare response must still advertise the + // parameter and execution must substitute it correctly. + { + let stmt = connection + .prep("SELECT uint32s FROM all_datatypes ORDER BY uint32s LIMIT ?") + .await + .unwrap(); + assert_eq!(stmt.num_params(), 1); + + let rows: Vec = connection + .exec(stmt, vec![mysql_async::Value::UInt(1)]) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + } + Ok(()) } From e1e75b3ffe93cf6aa8745c25321eb1820cd9123e Mon Sep 17 00:00:00 2001 From: Yingwen Date: Mon, 25 May 2026 11:10:12 +0800 Subject: [PATCH 05/32] feat: implement a cache for the prefilter (#8102) * feat: cache parquet prefilter results Signed-off-by: evenyag * chore: set result cache size Signed-off-by: evenyag * refactor: rename is_stable to is_immutable and reject ScalarVariable Signed-off-by: evenyag * chore: typo Signed-off-by: evenyag * refactor: use capacity() for prefilter key memory accounting Signed-off-by: evenyag * feat: per filter cache Signed-off-by: evenyag * refactor: support other variants in MaybeFilter Signed-off-by: evenyag * refactor: split compute_projection_mask Signed-off-by: evenyag * refactor: build_prefilter_masks takes PrefilterEntry Signed-off-by: evenyag --------- Signed-off-by: evenyag --- config/config.md | 4 + config/datanode.example.toml | 10 + config/standalone.example.toml | 10 + src/cmd/src/datanode/objbench.rs | 2 + src/mito2/src/cache.rs | 300 ++++++++++- src/mito2/src/config.rs | 5 + src/mito2/src/sst/parquet/prefilter.rs | 699 +++++++++++++++++++------ src/mito2/src/sst/parquet/reader.rs | 117 ++++- src/mito2/src/worker.rs | 2 + tests-integration/tests/http.rs | 3 +- 10 files changed, 973 insertions(+), 179 deletions(-) diff --git a/config/config.md b/config/config.md index b1630d97ad..0fae0caaa4 100644 --- a/config/config.md +++ b/config/config.md @@ -155,6 +155,8 @@ | `region_engine.mito.vector_cache_size` | String | Auto | Cache size for vectors and arrow arrays. Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | | `region_engine.mito.page_cache_size` | String | Auto | Cache size for pages of SST row groups. Setting it to 0 to disable the cache.
If not set, it's default to 1/8 of OS memory. | | `region_engine.mito.selector_result_cache_size` | String | Auto | Cache size for time series selector (e.g. `last_value()`). Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | +| `region_engine.mito.range_result_cache_size` | String | Auto | Cache size for flat range scan results. Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | +| `region_engine.mito.prefilter_result_cache_size` | String | Auto | Cache size for prefilter results. Setting it to 0 to disable the cache.
If not set, it's default to 1/32 of OS memory with a max limitation of 128MB. | | `region_engine.mito.enable_write_cache` | Bool | `false` | Whether to enable the write cache, it's enabled by default when using object storage. It is recommended to enable it when using object storage for better performance. | | `region_engine.mito.write_cache_path` | String | `""` | File system path for write cache, defaults to `{data_home}`. | | `region_engine.mito.write_cache_size` | String | `5GiB` | Capacity for write cache. If your disk space is sufficient, it is recommended to set it larger. | @@ -543,6 +545,8 @@ | `region_engine.mito.vector_cache_size` | String | Auto | Cache size for vectors and arrow arrays. Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | | `region_engine.mito.page_cache_size` | String | Auto | Cache size for pages of SST row groups. Setting it to 0 to disable the cache.
If not set, it's default to 1/8 of OS memory. | | `region_engine.mito.selector_result_cache_size` | String | Auto | Cache size for time series selector (e.g. `last_value()`). Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | +| `region_engine.mito.range_result_cache_size` | String | Auto | Cache size for flat range scan results. Setting it to 0 to disable the cache.
If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. | +| `region_engine.mito.prefilter_result_cache_size` | String | Auto | Cache size for prefilter results. Setting it to 0 to disable the cache.
If not set, it's default to 1/32 of OS memory with a max limitation of 128MB. | | `region_engine.mito.enable_write_cache` | Bool | `false` | Whether to enable the write cache, it's enabled by default when using object storage. It is recommended to enable it when using object storage for better performance. | | `region_engine.mito.write_cache_path` | String | `""` | File system path for write cache, defaults to `{data_home}`. | | `region_engine.mito.write_cache_size` | String | `5GiB` | Capacity for write cache. If your disk space is sufficient, it is recommended to set it larger. | diff --git a/config/datanode.example.toml b/config/datanode.example.toml index 170045a090..d558918daf 100644 --- a/config/datanode.example.toml +++ b/config/datanode.example.toml @@ -480,6 +480,16 @@ auto_flush_interval = "1h" ## @toml2docs:none-default="Auto" #+ selector_result_cache_size = "512MB" +## Cache size for flat range scan results. Setting it to 0 to disable the cache. +## If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. +## @toml2docs:none-default="Auto" +#+ range_result_cache_size = "512MB" + +## Cache size for prefilter results. Setting it to 0 to disable the cache. +## If not set, it's default to 1/32 of OS memory with a max limitation of 128MB. +## @toml2docs:none-default="Auto" +#+ prefilter_result_cache_size = "128MB" + ## Whether to enable the write cache, it's enabled by default when using object storage. It is recommended to enable it when using object storage for better performance. enable_write_cache = false diff --git a/config/standalone.example.toml b/config/standalone.example.toml index 24249270b2..d5c42e744c 100644 --- a/config/standalone.example.toml +++ b/config/standalone.example.toml @@ -599,6 +599,16 @@ auto_flush_interval = "1h" ## @toml2docs:none-default="Auto" #+ selector_result_cache_size = "512MB" +## Cache size for flat range scan results. Setting it to 0 to disable the cache. +## If not set, it's default to 1/16 of OS memory with a max limitation of 512MB. +## @toml2docs:none-default="Auto" +#+ range_result_cache_size = "512MB" + +## Cache size for prefilter results. Setting it to 0 to disable the cache. +## If not set, it's default to 1/32 of OS memory with a max limitation of 128MB. +## @toml2docs:none-default="Auto" +#+ prefilter_result_cache_size = "128MB" + ## Whether to enable the write cache, it's enabled by default when using object storage. It is recommended to enable it when using object storage for better performance. enable_write_cache = false diff --git a/src/cmd/src/datanode/objbench.rs b/src/cmd/src/datanode/objbench.rs index a298430c83..65f194d19f 100644 --- a/src/cmd/src/datanode/objbench.rs +++ b/src/cmd/src/datanode/objbench.rs @@ -588,6 +588,8 @@ async fn build_cache_manager( .vector_cache_size(config.vector_cache_size.as_bytes()) .page_cache_size(config.page_cache_size.as_bytes()) .selector_result_cache_size(config.selector_result_cache_size.as_bytes()) + .range_result_cache_size(config.range_result_cache_size.as_bytes()) + .prefilter_result_cache_size(config.prefilter_result_cache_size.as_bytes()) .index_metadata_size(config.index.metadata_cache_size.as_bytes()) .index_content_size(config.index.content_cache_size.as_bytes()) .index_content_page_size(config.index.content_cache_page_size.as_bytes()) diff --git a/src/mito2/src/cache.rs b/src/mito2/src/cache.rs index c05db5b989..eee1cfae0a 100644 --- a/src/mito2/src/cache.rs +++ b/src/mito2/src/cache.rs @@ -30,6 +30,7 @@ use std::sync::Arc; use bytes::Bytes; use common_base::readable_size::ReadableSize; use common_telemetry::warn; +use datatypes::arrow::buffer::BooleanBuffer; use datatypes::arrow::record_batch::RecordBatch; use datatypes::value::Value; use datatypes::vectors::VectorRef; @@ -38,8 +39,10 @@ use index::result_cache::IndexResultCache; use moka::notification::RemovalCause; use moka::sync::Cache; use object_store::ObjectStore; +use parquet::arrow::arrow_reader::{RowSelection, RowSelector}; use parquet::file::metadata::{FileMetaData, PageIndexPolicy, ParquetMetaData}; use puffin::puffin_manager::cache::{PuffinMetadataCache, PuffinMetadataCacheRef}; +use smallvec::SmallVec; use snafu::{OptionExt, ResultExt}; use store_api::metadata::RegionMetadataRef; use store_api::storage::{ConcreteDataType, FileId, RegionId, TimeSeriesRowSelector}; @@ -74,6 +77,8 @@ const INDEX_TYPE: &str = "index"; const SELECTOR_RESULT_TYPE: &str = "selector_result"; /// Metrics type key for range scan result cache. const RANGE_RESULT_TYPE: &str = "range_result"; +/// Metrics type key for prefilter result cache. +const PREFILTER_RESULT_TYPE: &str = "prefilter_result"; const RANGE_RESULT_CONCAT_MEMORY_LIMIT: ReadableSize = ReadableSize::mb(512); const RANGE_RESULT_CONCAT_MEMORY_PERMIT: ReadableSize = ReadableSize::kb(1); @@ -274,6 +279,117 @@ fn strip_region_metadata_from_parquet(parquet_metadata: ParquetMetaData) -> Parq .build() } +fn removal_cause_str(cause: RemovalCause) -> &'static str { + match cause { + RemovalCause::Expired => "expired", + RemovalCause::Explicit => "explicit", + RemovalCause::Replaced => "replaced", + RemovalCause::Size => "size", + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PrefilterRowSelector { + row_count: usize, + skip: bool, +} + +// `parquet::arrow::arrow_reader::RowSelector` does not implement `Hash`, but +// prefilter cache keys must hash the upstream row-selection snapshot. Keep a +// local hashable mirror of the two fields that define selector semantics. +// TODO(yingwen): Remove this mirror if upstream `RowSelector` implements `Hash`. +impl From<&RowSelector> for PrefilterRowSelector { + fn from(selector: &RowSelector) -> Self { + Self { + row_count: selector.row_count, + skip: selector.skip, + } + } +} + +/// Key for a cached prefilter result. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PrefilterKey { + file_id: FileId, + row_group_idx: u32, + row_selection: Option>>, + schema_version: u64, + filter_exprs: SmallVec<[String; 1]>, + mem_usage: usize, +} + +impl PrefilterKey { + pub(crate) fn row_selection_snapshot( + row_selection: Option<&RowSelection>, + ) -> Option>> { + row_selection.map(|selection| { + Arc::new( + selection + .iter() + .map(PrefilterRowSelector::from) + .collect::>(), + ) + }) + } + + pub(crate) fn new( + file_id: FileId, + row_group_idx: u32, + row_selection: Option>>, + schema_version: u64, + filter_exprs: SmallVec<[String; 1]>, + ) -> Self { + let row_selection_bytes = row_selection + .as_ref() + .map(|selection| selection.len() * mem::size_of::()) + .unwrap_or(0); + let spilled_expr_bytes = if filter_exprs.spilled() { + filter_exprs.capacity() * mem::size_of::() + } else { + 0 + }; + let expr_bytes = filter_exprs.iter().map(|s| s.capacity()).sum::(); + + Self { + file_id, + row_group_idx, + row_selection, + schema_version, + filter_exprs, + mem_usage: mem::size_of::() + + row_selection_bytes + + spilled_expr_bytes + + expr_bytes, + } + } + + fn mem_usage(&self) -> usize { + self.mem_usage + } +} + +type PrefilterResultCache = Cache>; + +fn new_prefilter_result_cache(capacity: u64) -> PrefilterResultCache { + Cache::builder() + .max_capacity(capacity) + .weigher(prefilter_result_cache_weight) + .eviction_listener(|k, v, cause| { + let size = prefilter_result_cache_weight(&k, &v); + CACHE_BYTES + .with_label_values(&[PREFILTER_RESULT_TYPE]) + .sub(size.into()); + CACHE_EVICTION + .with_label_values(&[PREFILTER_RESULT_TYPE, removal_cause_str(cause)]) + .inc(); + }) + .build() +} + +fn prefilter_result_cache_weight(k: &PrefilterKey, v: &Arc) -> u32 { + (k.mem_usage() + mem::size_of::() + v.values().len()) as u32 +} + /// Cache strategies that may only enable a subset of caches. #[derive(Clone)] pub enum CacheStrategy { @@ -358,6 +474,23 @@ impl CacheStrategy { } } + /// Calls [CacheManager::get_prefilter_result()]. + /// It returns None if the strategy is [CacheStrategy::Compaction] or [CacheStrategy::Disabled]. + pub(crate) fn get_prefilter_result(&self, key: &PrefilterKey) -> Option> { + match self { + CacheStrategy::EnableAll(cache_manager) => cache_manager.get_prefilter_result(key), + CacheStrategy::Compaction(_) | CacheStrategy::Disabled => None, + } + } + + /// Calls [CacheManager::put_prefilter_result()]. + /// It does nothing if the strategy isn't [CacheStrategy::EnableAll]. + pub(crate) fn put_prefilter_result(&self, key: PrefilterKey, result: Arc) { + if let CacheStrategy::EnableAll(cache_manager) = self { + cache_manager.put_prefilter_result(key, result); + } + } + /// Calls [CacheManager::remove_parquet_meta_data()]. pub fn remove_parquet_meta_data(&self, file_id: RegionFileId) { match self { @@ -610,6 +743,8 @@ pub struct CacheManager { range_result_memory_limiter: Arc, /// Cache for index result. index_result_cache: Option, + /// Cache for prefilter result. + prefilter_result_cache: Option, } pub type CacheManagerRef = Arc; @@ -908,6 +1043,21 @@ impl CacheManager { pub(crate) fn index_result_cache(&self) -> Option<&IndexResultCache> { self.index_result_cache.as_ref() } + + pub(crate) fn get_prefilter_result(&self, key: &PrefilterKey) -> Option> { + self.prefilter_result_cache + .as_ref() + .and_then(|cache| update_hit_miss(cache.get(key), PREFILTER_RESULT_TYPE)) + } + + pub(crate) fn put_prefilter_result(&self, key: PrefilterKey, result: Arc) { + if let Some(cache) = &self.prefilter_result_cache { + CACHE_BYTES + .with_label_values(&[PREFILTER_RESULT_TYPE]) + .add(prefilter_result_cache_weight(&key, &result).into()); + cache.insert(key, result); + } + } } /// Increases selector cache miss metrics. @@ -930,6 +1080,7 @@ pub struct CacheManagerBuilder { index_content_size: u64, index_content_page_size: u64, index_result_cache_size: u64, + prefilter_result_cache_size: u64, puffin_metadata_size: u64, write_cache: Option, selector_result_cache_size: u64, @@ -985,6 +1136,12 @@ impl CacheManagerBuilder { self } + /// Sets cache size for prefilter result. + pub fn prefilter_result_cache_size(mut self, bytes: u64) -> Self { + self.prefilter_result_cache_size = bytes; + self + } + /// Sets cache size for puffin metadata. pub fn puffin_metadata_size(mut self, bytes: u64) -> Self { self.puffin_metadata_size = bytes; @@ -1005,15 +1162,6 @@ impl CacheManagerBuilder { /// Builds the [CacheManager]. pub fn build(self) -> CacheManager { - fn to_str(cause: RemovalCause) -> &'static str { - match cause { - RemovalCause::Expired => "expired", - RemovalCause::Explicit => "explicit", - RemovalCause::Replaced => "replaced", - RemovalCause::Size => "size", - } - } - let sst_meta_cache = (self.sst_meta_cache_size != 0).then(|| { Cache::builder() .max_capacity(self.sst_meta_cache_size) @@ -1024,7 +1172,7 @@ impl CacheManagerBuilder { .with_label_values(&[SST_META_TYPE]) .sub(size.into()); CACHE_EVICTION - .with_label_values(&[SST_META_TYPE, to_str(cause)]) + .with_label_values(&[SST_META_TYPE, removal_cause_str(cause)]) .inc(); }) .build() @@ -1039,7 +1187,7 @@ impl CacheManagerBuilder { .with_label_values(&[VECTOR_TYPE]) .sub(size.into()); CACHE_EVICTION - .with_label_values(&[VECTOR_TYPE, to_str(cause)]) + .with_label_values(&[VECTOR_TYPE, removal_cause_str(cause)]) .inc(); }) .build() @@ -1052,7 +1200,7 @@ impl CacheManagerBuilder { let size = page_cache_weight(&k, &v); CACHE_BYTES.with_label_values(&[PAGE_TYPE]).sub(size.into()); CACHE_EVICTION - .with_label_values(&[PAGE_TYPE, to_str(cause)]) + .with_label_values(&[PAGE_TYPE, removal_cause_str(cause)]) .inc(); }) .build() @@ -1073,6 +1221,8 @@ impl CacheManagerBuilder { .then(|| Arc::new(VectorIndexCache::new(self.index_content_size))); let index_result_cache = (self.index_result_cache_size != 0) .then(|| IndexResultCache::new(self.index_result_cache_size)); + let prefilter_result_cache = (self.prefilter_result_cache_size != 0) + .then(|| new_prefilter_result_cache(self.prefilter_result_cache_size)); let puffin_metadata_cache = PuffinMetadataCache::new(self.puffin_metadata_size, &CACHE_BYTES); let selector_result_cache = (self.selector_result_cache_size != 0).then(|| { @@ -1085,7 +1235,7 @@ impl CacheManagerBuilder { .with_label_values(&[SELECTOR_RESULT_TYPE]) .sub(size.into()); CACHE_EVICTION - .with_label_values(&[SELECTOR_RESULT_TYPE, to_str(cause)]) + .with_label_values(&[SELECTOR_RESULT_TYPE, removal_cause_str(cause)]) .inc(); }) .build() @@ -1100,7 +1250,7 @@ impl CacheManagerBuilder { .with_label_values(&[RANGE_RESULT_TYPE]) .sub(size.into()); CACHE_EVICTION - .with_label_values(&[RANGE_RESULT_TYPE, to_str(cause)]) + .with_label_values(&[RANGE_RESULT_TYPE, removal_cause_str(cause)]) .inc(); }) .build() @@ -1123,6 +1273,7 @@ impl CacheManagerBuilder { RANGE_RESULT_CONCAT_MEMORY_PERMIT.as_bytes() as usize, )), index_result_cache, + prefilter_result_cache, } } } @@ -1551,6 +1702,127 @@ mod tests { assert!(cache.get_selector_result(&key).is_some()); } + #[test] + fn test_prefilter_result_cache() { + let disabled = CacheManager::builder().build(); + let file_id = FileId::random(); + let key = PrefilterKey::new( + file_id, + 0, + None, + 1, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]), + ); + let selection = Arc::new(BooleanBuffer::new_set(3)); + + disabled.put_prefilter_result(key.clone(), selection.clone()); + assert!(disabled.get_prefilter_result(&key).is_none()); + + let cache = Arc::new( + CacheManager::builder() + .prefilter_result_cache_size(1000) + .build(), + ); + assert!(cache.get_prefilter_result(&key).is_none()); + cache.put_prefilter_result(key.clone(), selection.clone()); + assert_eq!( + cache.get_prefilter_result(&key).unwrap().as_ref(), + selection.as_ref() + ); + + let enable_all = CacheStrategy::EnableAll(cache.clone()); + assert!(enable_all.get_prefilter_result(&key).is_some()); + + let compaction = CacheStrategy::Compaction(cache.clone()); + assert!(compaction.get_prefilter_result(&key).is_none()); + compaction.put_prefilter_result(key.clone(), selection.clone()); + assert!(cache.get_prefilter_result(&key).is_some()); + + let disabled_strategy = CacheStrategy::Disabled; + assert!(disabled_strategy.get_prefilter_result(&key).is_none()); + disabled_strategy.put_prefilter_result(key.clone(), selection); + assert!(cache.get_prefilter_result(&key).is_some()); + } + + #[test] + fn test_prefilter_key_distinguishes_dimensions() { + let file_id = FileId::random(); + let row_selection = RowSelection::from(vec![RowSelector::skip(1), RowSelector::select(3)]); + let other_row_selection = + RowSelection::from(vec![RowSelector::skip(2), RowSelector::select(2)]); + let row_selection = PrefilterKey::row_selection_snapshot(Some(&row_selection)); + let other_row_selection = PrefilterKey::row_selection_snapshot(Some(&other_row_selection)); + let base = PrefilterKey::new( + file_id, + 0, + row_selection.clone(), + 1, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]), + ); + + assert_ne!( + base, + PrefilterKey::new( + FileId::random(), + 0, + row_selection.clone(), + 1, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]) + ) + ); + assert_ne!( + base, + PrefilterKey::new( + file_id, + 1, + row_selection.clone(), + 1, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]) + ) + ); + assert_ne!( + base, + PrefilterKey::new( + file_id, + 0, + other_row_selection, + 1, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]) + ) + ); + assert_ne!( + base, + PrefilterKey::new( + file_id, + 0, + row_selection.clone(), + 1, + SmallVec::from_vec(vec!["tag_0 IN ([b])".to_string()]) + ) + ); + assert_ne!( + base, + PrefilterKey::new( + file_id, + 0, + row_selection.clone(), + 2, + SmallVec::from_vec(vec!["tag_0 IN ([a])".to_string()]) + ) + ); + let pk_group = PrefilterKey::new( + file_id, + 0, + row_selection, + 1, + SmallVec::from_vec(vec![ + "tag_0 IN ([a])".to_string(), + "tag_1 IN ([x])".to_string(), + ]), + ); + assert_ne!(base, pk_group); + } + #[test] fn test_range_result_cache() { let cache = Arc::new( diff --git a/src/mito2/src/config.rs b/src/mito2/src/config.rs index 98e97fca85..3a85ff1c65 100644 --- a/src/mito2/src/config.rs +++ b/src/mito2/src/config.rs @@ -117,6 +117,8 @@ pub struct MitoConfig { pub selector_result_cache_size: ReadableSize, /// Cache size for flat range scan results. Setting it to 0 to disable the cache. pub range_result_cache_size: ReadableSize, + /// Cache size for prefilter results. Setting it to 0 to disable the cache. + pub prefilter_result_cache_size: ReadableSize, /// Whether to enable the write cache. pub enable_write_cache: bool, /// File system path for write cache dir's root, defaults to `{data_home}`. @@ -202,6 +204,7 @@ impl Default for MitoConfig { page_cache_size: ReadableSize::mb(512), selector_result_cache_size: ReadableSize::mb(512), range_result_cache_size: ReadableSize::mb(512), + prefilter_result_cache_size: ReadableSize::mb(128), enable_write_cache: false, write_cache_path: String::new(), write_cache_size: ReadableSize::gb(5), @@ -330,6 +333,8 @@ impl MitoConfig { self.page_cache_size = page_cache_size; self.selector_result_cache_size = mem_cache_size; self.range_result_cache_size = mem_cache_size; + // Use a smaller cache size because prefilter result usually should be small. + self.prefilter_result_cache_size = sst_meta_cache_size; self.index.adjust_buffer_and_cache_size(sys_memory); } diff --git a/src/mito2/src/sst/parquet/prefilter.rs b/src/mito2/src/sst/parquet/prefilter.rs index c98da1abac..7fb549e17d 100644 --- a/src/mito2/src/sst/parquet/prefilter.rs +++ b/src/mito2/src/sst/parquet/prefilter.rs @@ -26,17 +26,20 @@ use api::v1::SemanticType; use common_recordbatch::filter::SimpleFilterEvaluator; use datatypes::arrow::array::{Array, BinaryArray, BooleanArray, BooleanBufferBuilder}; use datatypes::arrow::buffer::BooleanBuffer; +use datatypes::arrow::datatypes::SchemaRef; use datatypes::arrow::record_batch::RecordBatch; use futures::StreamExt; use mito_codec::row_converter::{PrimaryKeyCodec, PrimaryKeyFilter}; use parquet::arrow::ProjectionMask; use parquet::arrow::arrow_reader::RowSelection; use parquet::schema::types::SchemaDescriptor; +use smallvec::{SmallVec, smallvec}; use snafu::{OptionExt, ResultExt}; use store_api::metadata::{RegionMetadata, RegionMetadataRef}; use store_api::storage::consts::PRIMARY_KEY_COLUMN_NAME; use table::predicate::Predicate; +use crate::cache::PrefilterKey; use crate::error::{ ComputeArrowSnafu, DecodeSnafu, EvalPartitionFilterSnafu, NewRecordBatchSnafu, RecordBatchSnafu, Result, UnexpectedSnafu, @@ -285,7 +288,6 @@ pub(crate) fn build_reader_filter_plan( expected_metadata: Option<&RegionMetadata>, pre_filter_mode: PreFilterMode, read_format: &FlatReadFormat, - parquet_schema: &SchemaDescriptor, codec: &Arc, ) -> ReaderFilterPlan { let Some(predicate) = predicate else { @@ -372,15 +374,27 @@ pub(crate) fn build_reader_filter_plan( } } + let pk_filter_expr_strs = (!pk_filter_contexts.is_empty()).then(|| { + let mut expr_strs = pk_filter_contexts + .iter() + .map(|filter_ctx| filter_ctx.expr_str().to_string()) + .collect::>(); + expr_strs.sort(); + SmallVec::from_vec(expr_strs) + }); let pk_filter_exprs = (!primary_key_filters.is_empty()).then_some(Arc::new(primary_key_filters)); + let schema_version = expected_metadata + .map(|metadata| metadata.schema_version) + .unwrap_or_else(|| read_format.metadata().schema_version); let prefilter_builder = PrefilterContextBuilder::new( read_format, codec, pk_filter_exprs, + pk_filter_expr_strs, prefilter_simple_filters.clone(), prefilter_physical_filters, - parquet_schema, + schema_version, ); if prefilter_builder.is_some() { @@ -402,8 +416,6 @@ pub(crate) fn build_reader_filter_plan( /// Context for prefiltering a row group. pub(crate) struct PrefilterContext { - /// Projection mask for reading prefilter columns. - projection: ProjectionMask, /// Optional PK filter for legacy primary-key-format parquet. pk_filter: Option>, /// Simple filters that can be evaluated directly from the prefilter batch. @@ -411,6 +423,12 @@ pub(crate) struct PrefilterContext { /// Physical filters that can be evaluated directly from the prefilter batch. /// Physical expressions are only applied in the prefilter phase. physical_filters: Vec, + /// Region schema version used in per-filter cache keys. + schema_version: u64, + /// Sorted expression strings for the encoded-PK filter group. + pk_filter_expr_strs: Option>, + /// Arrow schema used to build narrowed prefilter projections. + arrow_schema: SchemaRef, } /// Pre-built state for constructing [PrefilterContext] per row group. @@ -419,12 +437,14 @@ pub(crate) struct PrefilterContext { /// are computed once. A fresh [PrefilterContext] with its own mutable PK filter /// is created via [PrefilterContextBuilder::build()] for each row group. pub(crate) struct PrefilterContextBuilder { - projection: ProjectionMask, pk_filters: Option>>, + pk_filter_expr_strs: Option>, filters: Vec, physical_filters: Vec, codec: Arc, metadata: RegionMetadataRef, + schema_version: u64, + arrow_schema: SchemaRef, } impl PrefilterContextBuilder { @@ -438,9 +458,10 @@ impl PrefilterContextBuilder { read_format: &FlatReadFormat, codec: &Arc, primary_key_filters: Option>>, + primary_key_filter_expr_strs: Option>, filters: Vec, physical_filters: Vec, - parquet_schema: &SchemaDescriptor, + schema_version: u64, ) -> Option { let metadata = read_format.metadata(); let use_raw_tag_columns = read_format.batch_has_raw_pk_columns(); @@ -448,6 +469,10 @@ impl PrefilterContextBuilder { .then_some(primary_key_filters) .flatten() .filter(|filters| !filters.is_empty()); + let pk_filter_expr_strs = pk_filters + .is_some() + .then_some(primary_key_filter_expr_strs) + .flatten(); let mut prefilter_column_names = HashSet::new(); for filter_ctx in &filters { @@ -464,11 +489,8 @@ impl PrefilterContextBuilder { prefilter_column_names.insert(filter_ctx.column_name().to_string()); } - let (projection, prefilter_count) = compute_projection_mask( - &prefilter_column_names, - read_format.arrow_schema(), - parquet_schema, - ); + let prefilter_count = + compute_projection_count(&prefilter_column_names, read_format.arrow_schema()); if prefilter_count == 0 { return None; @@ -487,12 +509,14 @@ impl PrefilterContextBuilder { } Some(Self { - projection, pk_filters, + pk_filter_expr_strs, filters, physical_filters, codec: Arc::clone(codec), metadata: metadata.clone(), + schema_version, + arrow_schema: read_format.arrow_schema().clone(), }) } @@ -505,10 +529,12 @@ impl PrefilterContextBuilder { Box::new(CachedPrimaryKeyFilter::new(pk_filter)) as Box }); PrefilterContext { - projection: self.projection.clone(), pk_filter, filters: self.filters.clone(), physical_filters: self.physical_filters.clone(), + schema_version: self.schema_version, + pk_filter_expr_strs: self.pk_filter_expr_strs.clone(), + arrow_schema: self.arrow_schema.clone(), } } } @@ -532,18 +558,31 @@ fn compute_projection_mask( column_names: &HashSet, arrow_schema: &datatypes::arrow::datatypes::SchemaRef, parquet_schema: &SchemaDescriptor, -) -> (ProjectionMask, usize) { +) -> ProjectionMask { + ProjectionMask::roots( + parquet_schema, + projection_indices(column_names, arrow_schema), + ) +} + +fn compute_projection_count( + column_names: &HashSet, + arrow_schema: &datatypes::arrow::datatypes::SchemaRef, +) -> usize { + projection_indices(column_names, arrow_schema).len() +} + +fn projection_indices( + column_names: &HashSet, + arrow_schema: &datatypes::arrow::datatypes::SchemaRef, +) -> Vec { let mut projection_indices: Vec = column_names .iter() .filter_map(|name| arrow_schema.column_with_name(name).map(|(index, _)| index)) .collect(); projection_indices.sort_unstable(); projection_indices.dedup(); - let count = projection_indices.len(); - ( - ProjectionMask::roots(parquet_schema, projection_indices.iter().copied()), - count, - ) + projection_indices } fn should_use_prefilter( @@ -568,18 +607,121 @@ pub(crate) async fn execute_prefilter( reader_builder: &RowGroupReaderBuilder, build_ctx: &RowGroupBuildContext<'_>, ) -> Result { + let entries = build_prefilter_cache_entries(prefilter_ctx, reader_builder, build_ctx); + + if entries.is_empty() { + return execute_prefilter_by_reading_columns(prefilter_ctx, reader_builder, build_ctx) + .await; + } + + execute_prefilter_with_result_cache(prefilter_ctx, reader_builder, build_ctx, entries).await +} + +async fn execute_prefilter_with_result_cache( + prefilter_ctx: &mut PrefilterContext, + reader_builder: &RowGroupReaderBuilder, + build_ctx: &RowGroupBuildContext<'_>, + entries: Vec, +) -> Result { + let non_cacheable_physical = non_cacheable_physical_filters(prefilter_ctx); + let mut hit_mask: Option = None; + let mut misses = Vec::new(); + for entry in entries { + let Some(key) = &entry.key else { + misses.push(entry); + continue; + }; + + if let Some(mask) = reader_builder.cache_strategy().get_prefilter_result(key) { + hit_mask = Some(match hit_mask { + Some(hit_mask) => hit_mask.bitand(mask.as_ref()), + None => mask.as_ref().clone(), + }); + } else { + misses.push(entry); + } + } + + if misses.is_empty() && non_cacheable_physical.is_empty() { + let combined_mask = hit_mask.unwrap_or_else(|| BooleanBuffer::new_set(0)); + let refined_selection = + refined_selection_from_mask(&combined_mask, &build_ctx.row_selection); + let rows_before_filter = rows_before_filter(reader_builder, build_ctx); + let filtered_rows = rows_before_filter.saturating_sub(refined_selection.row_count()); + return Ok(PrefilterResult { + refined_selection, + filtered_rows, + }); + } + + let mut uncached_entries = misses; + uncached_entries.extend( + non_cacheable_physical + .iter() + .copied() + .map(|idx| PrefilterEntry::without_cache(PrefilterEntryKind::Physical(idx))), + ); + let (uncached_mask, read_rows) = + build_prefilter_masks(prefilter_ctx, reader_builder, build_ctx, &uncached_entries).await?; + + let final_mask = match (hit_mask, uncached_mask) { + (Some(hit_mask), Some(uncached_mask)) => hit_mask.bitand(&uncached_mask), + (Some(hit_mask), None) => hit_mask, + (None, Some(uncached_mask)) => uncached_mask, + (None, None) => BooleanBuffer::new_set(read_rows), + }; + debug_assert_eq!(final_mask.len(), read_rows); + let rows_selected = final_mask.count_set_bits(); + let filtered_rows = read_rows.saturating_sub(rows_selected); + let refined_selection = refined_selection_from_mask(&final_mask, &build_ctx.row_selection); + + Ok(PrefilterResult { + refined_selection, + filtered_rows, + }) +} + +fn non_cacheable_physical_filters(prefilter_ctx: &PrefilterContext) -> Vec { + prefilter_ctx + .physical_filters + .iter() + .enumerate() + .filter_map(|(idx, filter)| (!filter.is_immutable()).then_some(idx)) + .collect() +} + +async fn build_prefilter_masks( + prefilter_ctx: &mut PrefilterContext, + reader_builder: &RowGroupReaderBuilder, + build_ctx: &RowGroupBuildContext<'_>, + entries: &[PrefilterEntry], +) -> Result<(Option, usize)> { + let prefilter_column_names = prefilter_column_names_for_entries(prefilter_ctx, entries); + let parquet_schema = reader_builder + .parquet_metadata() + .file_metadata() + .schema_descr(); + let projection = compute_projection_mask( + &prefilter_column_names, + &prefilter_ctx.arrow_schema, + parquet_schema, + ); + let mut stream = reader_builder .build_with_projection( build_ctx.row_group_idx, build_ctx.row_selection.clone(), - prefilter_ctx.projection.clone(), + projection, build_ctx.fetch_metrics, ) .await?; - let mut filter_arrays = Vec::new(); + let mut cache_builders = entries + .iter() + .map(|entry| entry.key.is_some().then(|| BooleanBufferBuilder::new(0))) + .collect::>(); + let mut combined_builder = (!entries.is_empty()).then(|| BooleanBufferBuilder::new(0)); let mut rows_before_filter = 0usize; - let mut rows_selected = 0usize; while let Some(batch_result) = stream.next().await { let batch = batch_result?; @@ -589,30 +731,78 @@ pub(crate) async fn execute_prefilter( } rows_before_filter += num_rows; - let batch_mask = match apply_filters_to_batch( - &batch, - &mut prefilter_ctx.pk_filter, - &prefilter_ctx.filters, - &prefilter_ctx.physical_filters, - reader_builder.file_path(), - )? { - Some(mask) => mask, - None => BooleanBuffer::new_unset(num_rows), - }; - rows_selected += batch_mask.count_set_bits(); - filter_arrays.push(BooleanArray::from(batch_mask)); + let mut batch_mask = BooleanBuffer::new_set(num_rows); + for (idx, entry) in entries.iter().enumerate() { + let mask = eval_entry_mask( + &batch, + prefilter_ctx, + entry.kind, + reader_builder.file_path(), + )?; + batch_mask = batch_mask.bitand(&mask); + if let Some(Some(builder)) = cache_builders.get_mut(idx) { + builder.append_buffer(&mask); + } + } + if let Some(builder) = &mut combined_builder { + builder.append_buffer(&batch_mask); + } } - let filtered_rows = rows_before_filter.saturating_sub(rows_selected); - let refined_selection = if filter_arrays.is_empty() || rows_selected == 0 { - RowSelection::from(vec![]) - } else { - let prefilter_selection = RowSelection::from_filters(&filter_arrays); - match &build_ctx.row_selection { - Some(original) => original.and_then(&prefilter_selection), - None => prefilter_selection, + for (entry, builder) in entries.iter().zip(cache_builders) { + if let (Some(key), Some(mut builder)) = (&entry.key, builder) { + reader_builder + .cache_strategy() + .put_prefilter_result(key.clone(), Arc::new(builder.finish())); } - }; + } + + Ok(( + combined_builder.map(|mut builder| builder.finish()), + rows_before_filter, + )) +} + +fn prefilter_column_names_for_entries( + prefilter_ctx: &PrefilterContext, + entries: &[PrefilterEntry], +) -> HashSet { + let mut prefilter_column_names = HashSet::new(); + for entry in entries { + match entry.kind { + PrefilterEntryKind::Simple(idx) => { + if let MaybeFilter::Filter(filter) = prefilter_ctx.filters[idx].filter() { + prefilter_column_names.insert(filter.column_name().to_string()); + } + } + PrefilterEntryKind::Physical(idx) => { + prefilter_column_names.insert( + prefilter_ctx.physical_filters[idx] + .column_name() + .to_string(), + ); + } + PrefilterEntryKind::PkGroup => { + prefilter_column_names.insert(PRIMARY_KEY_COLUMN_NAME.to_string()); + } + } + } + prefilter_column_names +} + +async fn execute_prefilter_by_reading_columns( + prefilter_ctx: &mut PrefilterContext, + reader_builder: &RowGroupReaderBuilder, + build_ctx: &RowGroupBuildContext<'_>, +) -> Result { + let entries = all_prefilter_entries(prefilter_ctx); + let (mask, rows_before_filter) = + build_prefilter_masks(prefilter_ctx, reader_builder, build_ctx, &entries).await?; + + let final_mask = mask.unwrap_or_else(|| BooleanBuffer::new_set(rows_before_filter)); + let rows_selected = final_mask.count_set_bits(); + let filtered_rows = rows_before_filter.saturating_sub(rows_selected); + let refined_selection = refined_selection_from_mask(&final_mask, &build_ctx.row_selection); Ok(PrefilterResult { refined_selection, @@ -620,100 +810,243 @@ pub(crate) async fn execute_prefilter( }) } -fn apply_filters_to_batch( +fn all_prefilter_entries(prefilter_ctx: &PrefilterContext) -> Vec { + let mut entries = Vec::new(); + if prefilter_ctx.pk_filter.is_some() { + entries.push(PrefilterEntry::without_cache(PrefilterEntryKind::PkGroup)); + } + entries.extend( + prefilter_ctx + .filters + .iter() + .enumerate() + .map(|(idx, _)| PrefilterEntry::without_cache(PrefilterEntryKind::Simple(idx))), + ); + entries.extend( + prefilter_ctx + .physical_filters + .iter() + .enumerate() + .map(|(idx, _)| PrefilterEntry::without_cache(PrefilterEntryKind::Physical(idx))), + ); + entries +} + +#[derive(Clone, Copy)] +enum PrefilterEntryKind { + Simple(usize), + Physical(usize), + PkGroup, +} + +struct PrefilterEntry { + kind: PrefilterEntryKind, + key: Option, +} + +impl PrefilterEntry { + fn without_cache(kind: PrefilterEntryKind) -> Self { + Self { kind, key: None } + } +} + +fn build_prefilter_cache_entries( + prefilter_ctx: &PrefilterContext, + reader_builder: &RowGroupReaderBuilder, + build_ctx: &RowGroupBuildContext<'_>, +) -> Vec { + let row_selection = PrefilterKey::row_selection_snapshot(build_ctx.row_selection.as_ref()); + let file_id = reader_builder.file_handle().file_id().file_id(); + let row_group_idx = build_ctx.row_group_idx as u32; + let mut entries = Vec::new(); + + for (idx, filter_ctx) in prefilter_ctx.filters.iter().enumerate() { + entries.push(PrefilterEntry { + kind: PrefilterEntryKind::Simple(idx), + key: Some(PrefilterKey::new( + file_id, + row_group_idx, + row_selection.clone(), + prefilter_ctx.schema_version, + smallvec![filter_ctx.expr_str().to_string()], + )), + }); + } + + for (idx, filter_ctx) in prefilter_ctx.physical_filters.iter().enumerate() { + if !filter_ctx.is_immutable() { + continue; + } + entries.push(PrefilterEntry { + kind: PrefilterEntryKind::Physical(idx), + key: Some(PrefilterKey::new( + file_id, + row_group_idx, + row_selection.clone(), + prefilter_ctx.schema_version, + smallvec![filter_ctx.expr_str().to_string()], + )), + }); + } + + if prefilter_ctx.pk_filter.is_some() + && let Some(exprs) = &prefilter_ctx.pk_filter_expr_strs + { + entries.push(PrefilterEntry { + kind: PrefilterEntryKind::PkGroup, + key: Some(PrefilterKey::new( + file_id, + row_group_idx, + row_selection, + prefilter_ctx.schema_version, + exprs.clone(), + )), + }); + } + + entries +} + +fn rows_before_filter( + reader_builder: &RowGroupReaderBuilder, + build_ctx: &RowGroupBuildContext<'_>, +) -> usize { + build_ctx.row_selection.as_ref().map_or_else( + || { + reader_builder + .parquet_metadata() + .row_group(build_ctx.row_group_idx) + .num_rows() as usize + }, + RowSelection::row_count, + ) +} + +fn refined_selection_from_mask( + mask: &BooleanBuffer, + original_selection: &Option, +) -> RowSelection { + if mask.is_empty() || mask.count_set_bits() == 0 { + return RowSelection::from(vec![]); + } + + let prefilter_selection = RowSelection::from_filters(&[BooleanArray::from(mask.clone())]); + match original_selection { + Some(original) => original.and_then(&prefilter_selection), + None => prefilter_selection, + } +} + +fn eval_entry_mask( batch: &RecordBatch, - pk_filter: &mut Option>, - filters: &[SimpleFilterContext], - physical_filters: &[PhysicalFilterContext], + prefilter_ctx: &mut PrefilterContext, + kind: PrefilterEntryKind, file_path: &str, -) -> Result> { - let mut mask = BooleanBuffer::new_set(batch.num_rows()); - - if let Some(pk_filter) = pk_filter.as_mut() { - // Prefilter reads a reduced projection. For PK prefilter, the encoded - // primary key column is always appended as the last projected column, - // while `__sequence` and `__op_type` are not read. - let pk_column_index = batch.num_columns() - 1; - let matched_row_ranges = - matching_row_ranges_by_primary_key(batch, pk_column_index, pk_filter.as_mut())?; - let mut builder = BooleanBufferBuilder::new(batch.num_rows()); - builder.append_n(batch.num_rows(), false); - for range in matched_row_ranges { - for row in range { - builder.set_bit(row, true); - } +) -> Result { + match kind { + PrefilterEntryKind::Simple(idx) => { + eval_simple_filter_mask(batch, &prefilter_ctx.filters[idx], file_path) } - mask = mask.bitand(&builder.finish()); - } - - for filter_ctx in filters { - let filter = match filter_ctx.filter() { - MaybeFilter::Filter(filter) => filter, - MaybeFilter::Matched => continue, - MaybeFilter::Pruned => return Ok(None), - }; - - let (idx, _) = batch - .schema() - .column_with_name(filter.column_name()) - .with_context(|| UnexpectedSnafu { - reason: format!( - "Prefilter column '{}' (id {}) not found in batch for file {}", - filter.column_name(), - filter_ctx.column_id(), - file_path - ), - })?; - let column = batch.column(idx).clone(); - let result = filter.evaluate_array(&column).context(RecordBatchSnafu)?; - mask = mask.bitand(&result); - } - - for filter_ctx in physical_filters { - let filter = filter_ctx.filter(); - - let (idx, _) = batch - .schema() - .column_with_name(filter_ctx.column_name()) - .with_context(|| UnexpectedSnafu { - reason: format!( - "Prefilter physical column '{}' (id {}) not found in batch for file {}", - filter_ctx.column_name(), - filter_ctx.column_id(), - file_path - ), - })?; - let column = batch.column(idx).clone(); - - let record_batch = RecordBatch::try_new(filter_ctx.schema().clone(), vec![column]) - .context(NewRecordBatchSnafu)?; - let evaluated = filter - .evaluate(&record_batch) - .context(EvalPartitionFilterSnafu)?; - let array = evaluated - .into_array(record_batch.num_rows()) - .context(EvalPartitionFilterSnafu)?; - let boolean_array = - array - .as_any() - .downcast_ref::() - .context(UnexpectedSnafu { - reason: "Failed to downcast physical filter result to BooleanArray", - })?; - // Treat null results as false (filtered out); value bits are not guaranteed - // to be false for invalid entries. - let mut result = boolean_array.values().clone(); - if let Some(nulls) = boolean_array.nulls() { - result = result.bitand(nulls.inner()); + PrefilterEntryKind::Physical(idx) => { + eval_physical_filter_mask(batch, &prefilter_ctx.physical_filters[idx], file_path) + } + PrefilterEntryKind::PkGroup => { + let pk_filter = prefilter_ctx.pk_filter.as_mut().context(UnexpectedSnafu { + reason: "Missing primary key filter for prefilter cache entry", + })?; + eval_pk_group_mask(batch, pk_filter.as_mut()) } - mask = mask.bitand(&result); } +} - if mask.count_set_bits() == 0 { - Ok(None) - } else { - Ok(Some(mask)) +fn eval_pk_group_mask( + batch: &RecordBatch, + pk_filter: &mut dyn PrimaryKeyFilter, +) -> Result { + let (pk_column_index, _) = batch + .schema() + .column_with_name(PRIMARY_KEY_COLUMN_NAME) + .context(UnexpectedSnafu { + reason: "Primary key column not found in prefilter batch", + })?; + let matched_row_ranges = matching_row_ranges_by_primary_key(batch, pk_column_index, pk_filter)?; + let mut builder = BooleanBufferBuilder::new(batch.num_rows()); + builder.append_n(batch.num_rows(), false); + for range in matched_row_ranges { + for row in range { + builder.set_bit(row, true); + } } + Ok(builder.finish()) +} + +fn eval_simple_filter_mask( + batch: &RecordBatch, + filter_ctx: &SimpleFilterContext, + file_path: &str, +) -> Result { + let filter = match filter_ctx.filter() { + MaybeFilter::Filter(filter) => filter, + MaybeFilter::Matched => return Ok(BooleanBuffer::new_set(batch.num_rows())), + MaybeFilter::Pruned => return Ok(BooleanBuffer::new_unset(batch.num_rows())), + }; + + let (idx, _) = batch + .schema() + .column_with_name(filter.column_name()) + .with_context(|| UnexpectedSnafu { + reason: format!( + "Prefilter column '{}' (id {}) not found in batch for file {}", + filter.column_name(), + filter_ctx.column_id(), + file_path + ), + })?; + let column = batch.column(idx).clone(); + filter.evaluate_array(&column).context(RecordBatchSnafu) +} + +fn eval_physical_filter_mask( + batch: &RecordBatch, + filter_ctx: &PhysicalFilterContext, + file_path: &str, +) -> Result { + let filter = filter_ctx.filter(); + + let (idx, _) = batch + .schema() + .column_with_name(filter_ctx.column_name()) + .with_context(|| UnexpectedSnafu { + reason: format!( + "Prefilter physical column '{}' (id {}) not found in batch for file {}", + filter_ctx.column_name(), + filter_ctx.column_id(), + file_path + ), + })?; + let column = batch.column(idx).clone(); + + let record_batch = RecordBatch::try_new(filter_ctx.schema().clone(), vec![column]) + .context(NewRecordBatchSnafu)?; + let evaluated = filter + .evaluate(&record_batch) + .context(EvalPartitionFilterSnafu)?; + let array = evaluated + .into_array(record_batch.num_rows()) + .context(EvalPartitionFilterSnafu)?; + let boolean_array = array + .as_any() + .downcast_ref::() + .context(UnexpectedSnafu { + reason: "Failed to downcast physical filter result to BooleanArray", + })?; + // Treat null results as false (filtered out); value bits are not guaranteed + // to be false for invalid entries. + let mut result = boolean_array.values().clone(); + if let Some(nulls) = boolean_array.nulls() { + result = result.bitand(nulls.inner()); + } + Ok(result) } #[cfg(test)] @@ -728,7 +1061,6 @@ mod tests { }; use datatypes::arrow::datatypes::{Schema, UInt32Type}; use mito_codec::row_converter::{PrimaryKeyFilter, build_primary_key_codec}; - use parquet::arrow::ArrowSchemaConverter; use store_api::codec::PrimaryKeyEncoding; use super::*; @@ -800,12 +1132,6 @@ mod tests { .collect() } - fn parquet_schema(read_format: &FlatReadFormat) -> SchemaDescriptor { - ArrowSchemaConverter::new() - .convert(read_format.arrow_schema()) - .unwrap() - } - fn new_raw_batch(primary_keys: &[&[u8]], field_values: &[u64]) -> RecordBatch { assert_eq!(primary_keys.len(), field_values.len()); @@ -989,15 +1315,15 @@ mod tests { ) .unwrap(); let codec = build_primary_key_codec(metadata.as_ref()); - let parquet_schema = parquet_schema(&read_format); let builder = PrefilterContextBuilder::new( &read_format, &codec, None, + None, Vec::new(), Vec::new(), - &parquet_schema, + metadata.schema_version, ); assert!(builder.is_none()); } @@ -1091,7 +1417,6 @@ mod tests { true, ) .unwrap(); - let full_parquet_schema = parquet_schema(&full_read_format); let codec = build_primary_key_codec(metadata.as_ref()); let skip_fields_plan = build_reader_filter_plan( @@ -1102,7 +1427,6 @@ mod tests { None, PreFilterMode::SkipFields, &full_read_format, - &full_parquet_schema, &codec, ); assert!(skip_fields_plan.prefilter_builder.is_some()); @@ -1121,13 +1445,11 @@ mod tests { true, ) .unwrap(); - let projected_parquet_schema = parquet_schema(&projected_read_format); let pk_prefilter_plan = build_reader_filter_plan( Some(&Predicate::new(vec![col("tag_0").eq(lit("a"))])), None, PreFilterMode::All, &projected_read_format, - &projected_parquet_schema, &codec, ); assert!(pk_prefilter_plan.prefilter_builder.is_some()); @@ -1135,35 +1457,94 @@ mod tests { } #[test] - fn test_apply_filters_to_batch_uses_flat_tag_columns_directly() { + fn test_pk_filter_expr_strings_are_stable_under_expr_order() { + let metadata: RegionMetadataRef = Arc::new(sst_region_metadata_with_encoding( + PrimaryKeyEncoding::Sparse, + )); + let read_format = FlatReadFormat::new( + metadata.clone(), + ReadColumns::from_deduped_column_ids( + metadata.column_metadatas.iter().map(|c| c.column_id), + ), + None, + "test", + false, + ) + .unwrap(); + let codec = build_primary_key_codec(metadata.as_ref()); + + let expr_a = col("tag_0").eq(lit("a")); + let expr_b = col("tag_1").eq(lit("x")); + let plan_ab = build_reader_filter_plan( + Some(&Predicate::new(vec![expr_a.clone(), expr_b.clone()])), + None, + PreFilterMode::All, + &read_format, + &codec, + ); + let plan_b_a = build_reader_filter_plan( + Some(&Predicate::new(vec![expr_b, expr_a])), + None, + PreFilterMode::All, + &read_format, + &codec, + ); + + let exprs_ab = plan_ab.prefilter_builder.unwrap().pk_filter_expr_strs; + let exprs_b_a = plan_b_a.prefilter_builder.unwrap().pk_filter_expr_strs; + assert!(exprs_ab.is_some()); + assert_eq!(exprs_ab, exprs_b_a); + } + + #[test] + fn test_simple_and_physical_contexts_preserve_expr_strings() { + let metadata: RegionMetadataRef = Arc::new(sst_region_metadata()); + let read_format = FlatReadFormat::new( + metadata.clone(), + ReadColumns::from_deduped_column_ids( + metadata.column_metadatas.iter().map(|c| c.column_id), + ), + None, + "test", + true, + ) + .unwrap(); + + let simple_expr = col("tag_0").eq(lit("a")); + let simple = SimpleFilterContext::new_opt(&metadata, None, &simple_expr).unwrap(); + assert_eq!(simple.expr_str(), format!("{simple_expr:?}")); + + let physical_expr = col("field_0").in_list(vec![lit(1_u64), lit(2_u64)], false); + let physical = + PhysicalFilterContext::new_opt(&metadata, None, &read_format, &physical_expr).unwrap(); + assert_eq!(physical.expr_str(), format!("{physical_expr:?}")); + } + + #[test] + fn test_eval_simple_filter_mask_uses_flat_tag_columns_directly() { let metadata: RegionMetadataRef = Arc::new(sst_region_metadata()); let filters = new_simple_filter_contexts(&metadata, &[col("tag_0").eq(lit("a"))]); let batch = new_record_batch_with_custom_sequence(&["a", "x"], 0, 4, 1); - let mut no_pk_filter = None; - let mask = apply_filters_to_batch(&batch, &mut no_pk_filter, &filters, &[], "test") - .unwrap() - .unwrap(); + let mask = eval_simple_filter_mask(&batch, &filters[0], "test").unwrap(); assert_eq!(mask.count_set_bits(), 4); } #[test] - fn test_apply_filters_to_batch_errors_on_missing_selected_column() { + fn test_eval_simple_filter_mask_errors_on_missing_selected_column() { let metadata: RegionMetadataRef = Arc::new(sst_region_metadata()); let filters = new_simple_filter_contexts(&metadata, &[col("tag_0").eq(lit("a"))]); let pk = new_primary_key(&["a", "x"]); let batch = new_raw_batch(&[pk.as_slice()], &[10]); - let mut no_pk_filter = None; - let err = - apply_filters_to_batch(&batch, &mut no_pk_filter, &filters, &[], "test").unwrap_err(); + let err = eval_simple_filter_mask(&batch, &filters[0], "test").unwrap_err(); let err = err.to_string(); assert!(err.contains("Prefilter column")); assert!(err.contains("tag_0")); } #[test] - fn test_apply_filters_to_batch_evaluates_physical_filters() { + fn test_eval_physical_filter_mask_evaluates_physical_filters() { let metadata: RegionMetadataRef = Arc::new(sst_region_metadata_with_encoding(PrimaryKeyEncoding::Dense)); let read_format = FlatReadFormat::new( @@ -1181,16 +1562,12 @@ mod tests { let pk = new_primary_key(&["a", "x"]); let batch = new_raw_batch(&[pk.as_slice(), pk.as_slice(), pk.as_slice()], &[9, 10, 11]); - let mut no_pk_filter = None; - let mask = - apply_filters_to_batch(&batch, &mut no_pk_filter, &[], &physical_filters, "test") - .unwrap() - .unwrap(); + let mask = eval_physical_filter_mask(&batch, &physical_filters[0], "test").unwrap(); assert_eq!(mask.count_set_bits(), 1); } #[test] - fn test_apply_filters_to_batch_uses_last_projected_column_for_pk_prefilter() { + fn test_eval_pk_group_mask_finds_pk_column_by_name() { let metadata = Arc::new(sst_region_metadata()); let filters = Arc::new(new_test_filters(&[col("tag_0").eq(lit("a"))])); let mut pk_filter = Some(Box::new(CachedPrimaryKeyFilter::new( @@ -1208,9 +1585,7 @@ mod tests { &[10, 11, 12, 13], ); - let mask = apply_filters_to_batch(&batch, &mut pk_filter, &[], &[], "test") - .unwrap() - .unwrap(); + let mask = eval_pk_group_mask(&batch, pk_filter.as_mut().unwrap().as_mut()).unwrap(); assert_eq!(mask.count_set_bits(), 2); } diff --git a/src/mito2/src/sst/parquet/reader.rs b/src/mito2/src/sst/parquet/reader.rs index 0e1ce8d28b..5d812f6307 100644 --- a/src/mito2/src/sst/parquet/reader.rs +++ b/src/mito2/src/sst/parquet/reader.rs @@ -26,8 +26,9 @@ use api::v1::SemanticType; use common_recordbatch::filter::SimpleFilterEvaluator; use common_telemetry::{error, tracing, warn}; use datafusion::physical_plan::PhysicalExpr; -use datafusion_expr::Expr; +use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion}; use datafusion_expr::utils::expr_to_columns; +use datafusion_expr::{Expr, Volatility}; use datatypes::arrow::array::ArrayRef; use datatypes::arrow::datatypes::{Field, Schema as ArrowSchema, SchemaRef}; use datatypes::arrow::record_batch::RecordBatch; @@ -458,7 +459,6 @@ impl ParquetReaderBuilder { self.expected_metadata.as_deref(), self.pre_filter_mode, &read_format, - parquet_meta.file_metadata().schema_descr(), &codec, ); @@ -1862,6 +1862,8 @@ impl MaybeFilter { pub(crate) struct SimpleFilterContext { /// Filter to evaluate. filter: MaybeFilter, + /// Debug string of the original logical expression. + expr_str: String, /// Id of the column to evaluate. column_id: ColumnId, /// Semantic type of the column. @@ -1879,6 +1881,7 @@ impl SimpleFilterContext { expr: &Expr, ) -> Option { let filter = SimpleFilterEvaluator::try_new(expr)?; + let expr_str = format!("{expr:?}"); let (column_metadata, maybe_filter) = match expected_meta { Some(meta) => { // Gets the column metadata from the expected metadata. @@ -1924,6 +1927,7 @@ impl SimpleFilterContext { Some(Self { filter: maybe_filter, + expr_str, column_id: column_metadata.column_id, semantic_type: column_metadata.semantic_type, }) @@ -1934,6 +1938,11 @@ impl SimpleFilterContext { &self.filter } + /// Returns the original logical expression string. + pub(crate) fn expr_str(&self) -> &str { + &self.expr_str + } + /// Returns the column id. pub(crate) fn column_id(&self) -> ColumnId { self.column_id @@ -1950,6 +1959,8 @@ impl SimpleFilterContext { pub(crate) struct PhysicalFilterContext { /// Filter to evaluate. filter: Arc, + /// Debug string of the original logical expression. + expr_str: String, /// Id of the column to evaluate. column_id: ColumnId, /// Name of the column to evaluate. @@ -1958,6 +1969,8 @@ pub(crate) struct PhysicalFilterContext { semantic_type: SemanticType, /// Schema containing only the referenced column. schema: SchemaRef, + /// Whether the original logical expression is immutable across queries. + immutable: bool, } impl PhysicalFilterContext { @@ -1974,6 +1987,7 @@ impl PhysicalFilterContext { if !Self::is_prefilter_candidate(expr) { return None; } + let expr_str = format!("{expr:?}"); let column_name = Self::single_column_name(expr)?; let column_metadata = match expected_meta { Some(meta) => { @@ -1998,13 +2012,16 @@ impl PhysicalFilterContext { error!(e; "Unable to build physical filter for {expr}, schema: {schema:?}"); }) .ok()?; + let immutable = expr_is_immutable(expr); Some(Self { filter: physical_expr, + expr_str, column_id: column_metadata.column_id, column_name, semantic_type: column_metadata.semantic_type, schema, + immutable, }) } @@ -2035,6 +2052,11 @@ impl PhysicalFilterContext { &self.filter } + /// Returns the original logical expression string. + pub(crate) fn expr_str(&self) -> &str { + &self.expr_str + } + /// Returns the column id. pub(crate) fn column_id(&self) -> ColumnId { self.column_id @@ -2054,6 +2076,29 @@ impl PhysicalFilterContext { pub(crate) fn schema(&self) -> &SchemaRef { &self.schema } + + /// Returns true if the original logical expression is immutable across queries. + pub(crate) fn is_immutable(&self) -> bool { + self.immutable + } +} + +fn expr_is_immutable(expr: &Expr) -> bool { + let mut is_immutable = true; + let _ = expr.apply(|expr| match expr { + Expr::ScalarFunction(function) + if function.func.signature().volatility != Volatility::Immutable => + { + is_immutable = false; + Ok(TreeNodeRecursion::Stop) + } + Expr::ScalarVariable(_, _) => { + is_immutable = false; + Ok(TreeNodeRecursion::Stop) + } + _ => Ok(TreeNodeRecursion::Continue), + }); + is_immutable } /// Prune a column by its default value. @@ -2335,6 +2380,74 @@ mod tests { assert!(!selection.is_empty()); } + #[test] + fn test_expr_is_immutable_checks_scalar_function_volatility() { + #[derive(Debug, PartialEq, Eq, Hash)] + struct TestVolatilityUdf { + name: String, + signature: Signature, + } + + impl TestVolatilityUdf { + fn new(name: &str, volatility: Volatility) -> Self { + Self { + name: name.to_string(), + signature: Signature::variadic_any(volatility), + } + } + } + + impl ScalarUDFImpl for TestVolatilityUdf { + fn as_any(&self) -> &dyn Any { + self + } + + fn name(&self) -> &str { + &self.name + } + + fn signature(&self) -> &Signature { + &self.signature + } + + fn return_type(&self, _arg_types: &[DataType]) -> datafusion_common::Result { + Ok(DataType::Int64) + } + + fn invoke_with_args( + &self, + _args: ScalarFunctionArgs, + ) -> datafusion_common::Result { + Ok(ColumnarValue::Scalar(ScalarValue::Int64(Some(1)))) + } + } + + let expr = |name: &str, volatility| { + Expr::ScalarFunction(ScalarFunction::new_udf( + Arc::new(ScalarUDF::new_from_impl(TestVolatilityUdf::new( + name, volatility, + ))), + vec![], + )) + }; + + assert!(expr_is_immutable(&expr( + "immutable_udf", + Volatility::Immutable + ))); + assert!(!expr_is_immutable(&expr("stable_udf", Volatility::Stable))); + assert!(!expr_is_immutable(&expr( + "volatile_udf", + Volatility::Volatile + ))); + + let scalar_variable = Expr::ScalarVariable( + Arc::new(Field::new("@@version", DataType::Utf8, false)), + vec!["@@version".to_string()], + ); + assert!(!expr_is_immutable(&scalar_variable)); + } + #[tokio::test(flavor = "current_thread")] async fn test_has_row_level_selection() { let object_store = ObjectStore::new(Memory::default()).unwrap().finish(); diff --git a/src/mito2/src/worker.rs b/src/mito2/src/worker.rs index 6e9573a9bb..75279acf8b 100644 --- a/src/mito2/src/worker.rs +++ b/src/mito2/src/worker.rs @@ -208,6 +208,7 @@ impl WorkerGroup { .page_cache_size(config.page_cache_size.as_bytes()) .selector_result_cache_size(config.selector_result_cache_size.as_bytes()) .range_result_cache_size(config.range_result_cache_size.as_bytes()) + .prefilter_result_cache_size(config.prefilter_result_cache_size.as_bytes()) .index_metadata_size(config.index.metadata_cache_size.as_bytes()) .index_content_size(config.index.content_cache_size.as_bytes()) .index_content_page_size(config.index.content_cache_page_size.as_bytes()) @@ -423,6 +424,7 @@ impl WorkerGroup { .page_cache_size(config.page_cache_size.as_bytes()) .selector_result_cache_size(config.selector_result_cache_size.as_bytes()) .range_result_cache_size(config.range_result_cache_size.as_bytes()) + .prefilter_result_cache_size(config.prefilter_result_cache_size.as_bytes()) .write_cache(write_cache) .build(), ); diff --git a/tests-integration/tests/http.rs b/tests-integration/tests/http.rs index f17b78a7e5..7f411cdec2 100644 --- a/tests-integration/tests/http.rs +++ b/tests-integration/tests/http.rs @@ -1719,10 +1719,11 @@ fn drop_lines_with_inconsistent_results(input: String) -> String { "vector_cache_size =", "page_cache_size =", "selector_result_cache_size =", + "range_result_cache_size =", + "prefilter_result_cache_size =", "metadata_cache_size =", "content_cache_size =", "result_cache_size =", - "range_result_cache_size =", "name =", "recovery_parallelism =", "max_background_index_builds =", From 5401cc2e26fde5b8254ce9620954a89d7041e75e Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Sun, 24 May 2026 20:14:15 -0700 Subject: [PATCH 06/32] feat: update some interceptor to carry more information (#8090) * feat: provide query information for post_execute interceptor * test: update for tests-integration * feat: make interceptor available to prometheus serialization * feat: revert post_execute change * feat: add expr to pre_execute and remove serialization interceptor * chore: lint --- src/frontend/src/instance.rs | 28 +++++++++++++-------- src/servers/src/interceptor.rs | 42 ++++++++++++++++++++++++++++--- src/servers/tests/interceptor.rs | 9 ++++++- tests-integration/src/instance.rs | 4 +-- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/instance.rs b/src/frontend/src/instance.rs index 8cc6195e5f..e85bc28f9a 100644 --- a/src/frontend/src/instance.rs +++ b/src/frontend/src/instance.rs @@ -303,7 +303,7 @@ impl Instance { .await } _ => { - query_interceptor.pre_execute(&stmt, None, query_ctx.clone())?; + query_interceptor.pre_execute(Some(&stmt), None, query_ctx.clone())?; self.statement_executor .execute_sql(stmt, query_ctx) .await @@ -326,7 +326,7 @@ impl Instance { let QueryStatement::Sql(stmt) = stmt else { unreachable!() }; - query_interceptor.pre_execute(&stmt, Some(&plan), query_ctx.clone())?; + query_interceptor.pre_execute(Some(&stmt), Some(&plan), query_ctx.clone())?; self.statement_executor .exec_plan(plan, query_ctx.clone()) @@ -344,7 +344,11 @@ impl Instance { .statement_executor .plan_tql(tql.clone(), query_ctx) .await?; - query_interceptor.pre_execute(&Statement::Tql(tql), Some(&plan), query_ctx.clone())?; + query_interceptor.pre_execute( + Some(&Statement::Tql(tql)), + Some(&plan), + query_ctx.clone(), + )?; self.statement_executor .exec_plan(plan, query_ctx.clone()) .await @@ -649,9 +653,7 @@ impl Instance { let query_interceptor_opt = self.plugins.get::>(); let query_interceptor = query_interceptor_opt.as_ref(); - if let Some(ref s) = stmt { - query_interceptor.pre_execute(s, Some(&plan), query_ctx.clone())?; - } + query_interceptor.pre_execute(stmt.as_ref(), Some(&plan), query_ctx.clone())?; let query = stmt .as_ref() @@ -880,7 +882,11 @@ impl PrometheusHandler for Instance { .map_err(BoxedError::new) .context(ExecuteQuerySnafu)?; - interceptor.pre_execute(query, Some(&plan), query_ctx.clone())?; + let QueryStatement::Promql(eval_stmt, _) = &stmt else { + unreachable!("query is parsed from promql"); + }; + + interceptor.pre_execute(query, &eval_stmt.expr, Some(&plan), query_ctx.clone())?; // Take the EvalStmt from the original QueryStatement and use it to create the CatalogQueryStatement. let query_statement = if let QueryStatement::Promql(eval_stmt, alias) = stmt { @@ -892,7 +898,7 @@ impl PrometheusHandler for Instance { } .fail(); }; - let query = query_statement.to_string(); + let raw_query = query_statement.to_string(); let slow_query_timer = self .slow_query_options @@ -912,7 +918,7 @@ impl PrometheusHandler for Instance { let ticket = self.process_manager.register_query( query_ctx.current_catalog().to_string(), vec![query_ctx.current_schema()], - query, + raw_query, query_ctx.conn_info().to_string(), Some(query_ctx.process_id()), slow_query_timer, @@ -1394,11 +1400,11 @@ mod tests { fn pre_execute( &self, - statement: &Statement, + statement: Option<&Statement>, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> Result<()> { - let Statement::Insert(insert) = statement else { + let Some(Statement::Insert(insert)) = statement else { return Ok(()); }; if !insert.has_non_values_query_source() { diff --git a/src/servers/src/interceptor.rs b/src/servers/src/interceptor.rs index 7425c228f5..30bc5a2aa0 100644 --- a/src/servers/src/interceptor.rs +++ b/src/servers/src/interceptor.rs @@ -23,6 +23,7 @@ use common_error::ext::ErrorExt; use common_query::Output; use datafusion_expr::LogicalPlan; use log_query::LogQuery; +use promql_parser::parser::Expr; use query::parser::PromQuery; use session::context::QueryContextRef; use sql::statements::statement::Statement; @@ -58,7 +59,7 @@ pub trait SqlQueryInterceptor { /// Called before sql is actually executed. This hook is not called at the moment. fn pre_execute( &self, - _statement: &Statement, + _statement: Option<&Statement>, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> Result<(), Self::Error> { @@ -111,7 +112,7 @@ where fn pre_execute( &self, - statement: &Statement, + statement: Option<&Statement>, plan: Option<&LogicalPlan>, query_ctx: QueryContextRef, ) -> Result<(), Self::Error> { @@ -224,6 +225,7 @@ pub trait PromQueryInterceptor { fn pre_execute( &self, _query: &PromQuery, + _expr: &Expr, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> Result<(), Self::Error> { @@ -253,11 +255,45 @@ where fn pre_execute( &self, query: &PromQuery, + expr: &Expr, plan: Option<&LogicalPlan>, query_ctx: QueryContextRef, ) -> Result<(), Self::Error> { if let Some(this) = self { - this.pre_execute(query, plan, query_ctx) + this.pre_execute(query, expr, plan, query_ctx) + } else { + Ok(()) + } + } + + fn post_execute( + &self, + output: Output, + query_ctx: QueryContextRef, + ) -> Result { + if let Some(this) = self { + this.post_execute(output, query_ctx) + } else { + Ok(output) + } + } +} + +impl PromQueryInterceptor for Option<&PromQueryInterceptorRef> +where + E: ErrorExt, +{ + type Error = E; + + fn pre_execute( + &self, + query: &PromQuery, + expr: &Expr, + plan: Option<&LogicalPlan>, + query_ctx: QueryContextRef, + ) -> Result<(), Self::Error> { + if let Some(this) = self { + this.pre_execute(query, expr, plan, query_ctx) } else { Ok(()) } diff --git a/src/servers/tests/interceptor.rs b/src/servers/tests/interceptor.rs index 7712c90332..fd516a0dfe 100644 --- a/src/servers/tests/interceptor.rs +++ b/src/servers/tests/interceptor.rs @@ -90,6 +90,7 @@ impl PromQueryInterceptor for NoopInterceptor { fn pre_execute( &self, query: &PromQuery, + _expr: &promql_parser::parser::Expr, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> std::result::Result<(), Self::Error> { @@ -121,7 +122,13 @@ fn test_prom_interceptor() { ..Default::default() }; - let fail = PromQueryInterceptor::pre_execute(&di, &query, None, ctx.clone()); + let fail = PromQueryInterceptor::pre_execute( + &di, + &query, + &promql_parser::parser::parse(&query.query).unwrap(), + None, + ctx.clone(), + ); assert!(fail.is_err()); let output = Output::new_with_affected_rows(1); diff --git a/tests-integration/src/instance.rs b/tests-integration/src/instance.rs index b11d5c32c6..17435a3b7a 100644 --- a/tests-integration/src/instance.rs +++ b/tests-integration/src/instance.rs @@ -323,7 +323,7 @@ mod tests { fn pre_execute( &self, - _statement: &Statement, + _statement: Option<&Statement>, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> Result<()> { @@ -396,7 +396,7 @@ mod tests { fn pre_execute( &self, - _statement: &Statement, + _statement: Option<&Statement>, _plan: Option<&LogicalPlan>, _query_ctx: QueryContextRef, ) -> Result<()> { From 8f7951c5bd6e9ebf0dfdf7ff7fd78c298871b091 Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Mon, 25 May 2026 15:40:03 +0800 Subject: [PATCH 07/32] fix: close heartbeat streams on metasrv leader stepdown (#8156) * fix: reregister missing heartbeat pusher Signed-off-by: WenyXu * refactor: extract heartbeat session Signed-off-by: WenyXu * test: cover heartbeat session Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- src/meta-srv/src/handler.rs | 26 +- src/meta-srv/src/metasrv.rs | 5 - src/meta-srv/src/service/heartbeat.rs | 657 +++++++++++++++++++++++--- src/meta-srv/src/service/mailbox.rs | 3 - 4 files changed, 587 insertions(+), 104 deletions(-) diff --git a/src/meta-srv/src/handler.rs b/src/meta-srv/src/handler.rs index 4b05db4e4c..92916838d8 100644 --- a/src/meta-srv/src/handler.rs +++ b/src/meta-srv/src/handler.rs @@ -278,15 +278,6 @@ impl Pushers { async fn remove(&self, pusher_id: &str) -> Option { self.0.write().await.remove(pusher_id) } - - pub(crate) async fn clear(&self) -> Vec { - let mut pushers = self.0.write().await; - let keys = pushers.keys().cloned().collect::>(); - if !keys.is_empty() { - pushers.clear(); - } - keys - } } #[derive(Clone)] @@ -322,12 +313,19 @@ impl HeartbeatHandlerGroup { /// Deregisters the heartbeat response [`Pusher`] with the given key from the group. pub async fn deregister_push(&self, pusher_id: PusherId) { - info!("Pusher unregister: {}", pusher_id); if self.pushers.remove(&pusher_id.string_key()).await.is_some() { + info!("Pusher unregister: {}", pusher_id); METRIC_META_HEARTBEAT_CONNECTION_NUM.dec(); } } + #[cfg(test)] + /// Returns whether the group contains the heartbeat response [`Pusher`] with the given key. + pub async fn contains_pusher(&self, pusher_id: &PusherId) -> bool { + let pushers = self.pushers.0.read().await; + pushers.contains_key(&pusher_id.string_key()) + } + /// Returns the [`Pushers`] of the group. pub fn pushers(&self) -> Pushers { self.pushers.clone() @@ -550,14 +548,6 @@ impl Mailbox for HeartbeatMailbox { Ok(()) } - - async fn reset(&self) { - let keys = self.pushers.clear().await; - if !keys.is_empty() { - info!("Reset mailbox, deregister pushers: {:?}", keys); - METRIC_META_HEARTBEAT_CONNECTION_NUM.sub(keys.len() as i64); - } - } } /// The builder to build the group of heartbeat handlers. diff --git a/src/meta-srv/src/metasrv.rs b/src/meta-srv/src/metasrv.rs index df2a3a35b8..f7f5bbf77d 100644 --- a/src/meta-srv/src/metasrv.rs +++ b/src/meta-srv/src/metasrv.rs @@ -512,7 +512,6 @@ pub struct MetaStateHandler { greptimedb_telemetry_task: Arc, leader_cached_kv_backend: Arc, leadership_change_notifier: LeadershipChangeNotifier, - mailbox: MailboxRef, state: StateRef, } @@ -536,9 +535,6 @@ impl MetaStateHandler { pub async fn on_leader_stop(&self) { self.state.write().unwrap().next_state(become_follower()); - // Enforces the mailbox to clear all pushers. - // The remaining heartbeat connections will be closed by the remote peer or keep-alive detection. - self.mailbox.reset().await; self.leadership_change_notifier .notify_on_leader_stop() .await; @@ -667,7 +663,6 @@ impl Metasrv { state: self.state.clone(), leader_cached_kv_backend: leader_cached_kv_backend.clone(), leadership_change_notifier, - mailbox: self.mailbox.clone(), }; let _handle = common_runtime::spawn_global(async move { loop { diff --git a/src/meta-srv/src/service/heartbeat.rs b/src/meta-srv/src/service/heartbeat.rs index 238ed99df2..066d156047 100644 --- a/src/meta-srv/src/service/heartbeat.rs +++ b/src/meta-srv/src/service/heartbeat.rs @@ -20,10 +20,12 @@ use api::v1::meta::{ AskLeaderRequest, AskLeaderResponse, HeartbeatRequest, HeartbeatResponse, Peer, RequestHeader, ResponseHeader, Role, heartbeat_server, }; +use common_meta::election::LeaderChangeMessage; use common_telemetry::{debug, error, info, warn}; use futures::StreamExt; use once_cell::sync::OnceCell; use snafu::{OptionExt, ResultExt}; +use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; use tokio::sync::mpsc::Sender; use tokio_stream::wrappers::ReceiverStream; @@ -31,10 +33,282 @@ use tonic::{Request, Response, Status, Streaming}; use crate::error::{self, Result}; use crate::handler::{HeartbeatHandlerGroup, Pusher, PusherId}; -use crate::metasrv::{Context, Metasrv}; +use crate::metasrv::{Context, ElectionRef, Metasrv}; use crate::metrics::METRIC_META_HEARTBEAT_RECV; use crate::service::{GrpcResult, GrpcStream}; +type HeartbeatResponseResult = std::result::Result; + +#[async_trait::async_trait] +trait HeartbeatRequestStream { + async fn next(&mut self) -> Option>; +} + +struct TonicHeartbeatRequestStream { + inner: Streaming, +} + +impl TonicHeartbeatRequestStream { + fn new(inner: Streaming) -> Self { + Self { inner } + } +} + +#[async_trait::async_trait] +impl HeartbeatRequestStream for TonicHeartbeatRequestStream { + async fn next(&mut self) -> Option> { + self.inner.next().await + } +} + +enum LeaderStepDownEvent { + StepDown, + Closed, +} + +#[async_trait::async_trait] +trait LeaderStepDown { + async fn wait(&mut self) -> LeaderStepDownEvent; +} + +struct ElectionLeaderStepDown { + rx: tokio::sync::broadcast::Receiver, +} + +impl ElectionLeaderStepDown { + fn new(election: ElectionRef) -> Self { + Self { + rx: election.subscribe_leader_change(), + } + } +} + +#[async_trait::async_trait] +impl LeaderStepDown for ElectionLeaderStepDown { + async fn wait(&mut self) -> LeaderStepDownEvent { + loop { + match self.rx.recv().await { + Ok(LeaderChangeMessage::StepDown(_)) => return LeaderStepDownEvent::StepDown, + Ok(LeaderChangeMessage::Elected(_)) => {} + Err(RecvError::Lagged(skipped)) => { + warn!( + "Leader step-down watcher lagged, skipped {} leader change events", + skipped + ); + } + Err(RecvError::Closed) => return LeaderStepDownEvent::Closed, + } + } + } +} + +struct HeartbeatSession { + requests: R, + tx: Sender, + leader_step_down: Option, + handler_group: Arc, + ctx: Context, + sender_id: PusherId, +} + +impl HeartbeatSession +where + R: HeartbeatRequestStream, + L: LeaderStepDown, +{ + /// Initializes the heartbeat session by receiving the first request, + /// and returns `None` if the stream is closed or an error occurs. + async fn init( + mut requests: R, + tx: Sender, + leader_step_down: Option, + handler_group: Arc, + ctx: Context, + ) -> Option { + let msg = requests.next().await?; + + let req = match msg { + Ok(req) => req, + Err(err) => { + error!("Failed to receive the first heartbeat request, error: {err}"); + let _ = handle_request_stream_error(None, &tx, err).await; + return None; + } + }; + + let Some(header) = req.header.as_ref() else { + error!("Exit on malformed request: MissingRequestHeader"); + let _ = tx + .send(Err(error::MissingRequestHeaderSnafu {}.build().into())) + .await; + return None; + }; + + let sender_id = register_pusher(&handler_group, header, tx.clone()).await; + let mut session = Self { + requests, + tx, + leader_step_down, + handler_group, + ctx, + sender_id, + }; + + if session.handle_request(req, true).await { + Some(session) + } else { + session.cleanup().await; + None + } + } + + /// Runs the heartbeat session until the stream is closed or an error occurs. + async fn run(mut self) { + let mut leader_step_down = self.leader_step_down.take(); + + loop { + tokio::select! { + msg = self.requests.next() => { + let Some(msg) = msg else { + break; + }; + + if !self.handle_message(msg).await { + break; + } + } + event = wait_leader_step_down(leader_step_down.as_mut()), if leader_step_down.is_some() => { + match event { + LeaderStepDownEvent::StepDown => { + self.send_not_leader_error().await; + break; + } + LeaderStepDownEvent::Closed => { + warn!("Leader step-down watcher closed"); + self.send_election_unavailable_error().await; + break; + } + } + } + } + } + + self.cleanup().await; + } + + /// Handles the incoming message, and returns whether to continue the session. + async fn handle_message(&mut self, msg: std::result::Result) -> bool { + match msg { + Ok(req) => self.handle_request(req, false).await, + Err(err) => handle_request_stream_error(Some(self.sender_id), &self.tx, err).await, + } + } + + /// Handles the incoming heartbeat request, and returns whether to continue the session. + async fn handle_request(&mut self, req: HeartbeatRequest, is_handshake: bool) -> bool { + debug!("Receiving heartbeat request: {:?}", req); + + let sender_id = self.sender_id.to_string(); + METRIC_META_HEARTBEAT_RECV + .with_label_values(&[sender_id.as_str()]) + .inc(); + + let res = self + .handler_group + .handle(req, self.ctx.clone().with_handshake(is_handshake)) + .await + .inspect_err( + |e| warn!(e; "Failed to handle heartbeat request, sender: {}", self.sender_id), + ) + .map_err(|e| e.into()); + + let is_not_leader = res.as_ref().is_ok_and(|r| r.is_not_leader()); + + debug!("Sending heartbeat response: {:?}", res); + + if self.tx.send(res).await.is_err() { + info!( + "ReceiverStream was dropped; shutting down, sender: {}", + self.sender_id + ); + return false; + } + + if is_not_leader { + warn!( + "Quit because it is no longer the leader, sender: {}", + self.sender_id + ); + self.send_not_leader_error().await; + return false; + } + + true + } + + async fn send_not_leader_error(&mut self) { + let _ = self + .tx + .send(Err(Status::aborted(format!( + "The requested metasrv node is not leader, node addr: {}", + self.ctx.server_addr + )))) + .await; + } + + async fn send_election_unavailable_error(&mut self) { + let _ = self + .tx + .send(Err(Status::unavailable(format!( + "The requested metasrv node is shutting down, node addr: {}", + self.ctx.server_addr + )))) + .await; + } + + async fn cleanup(&self) { + info!("Heartbeat stream closed, sender: {}", self.sender_id); + let _ = self.handler_group.deregister_push(self.sender_id).await; + } +} + +async fn wait_leader_step_down(leader_step_down: Option<&mut L>) -> LeaderStepDownEvent +where + L: LeaderStepDown, +{ + match leader_step_down { + Some(leader_step_down) => leader_step_down.wait().await, + None => std::future::pending().await, + } +} + +/// Handles request stream error by logging and forwarding the error to the client if possible. +/// +/// Returns `false` if the stream should be terminated. +async fn handle_request_stream_error( + sender_id: Option, + tx: &Sender, + err: Status, +) -> bool { + if let Some(io_err) = error::match_for_io_error(&err) + && io_err.kind() == ErrorKind::BrokenPipe + { + error!("Client disconnected: broken pipe, sender: {:?}", sender_id); + return false; + } + error!(err; "Error while receiving heartbeat request, sender: {:?}", sender_id); + + if tx.send(Err(err)).await.is_err() { + info!( + "Failed to forward heartbeat request stream error; response stream was dropped, sender: {:?}", + sender_id + ); + return false; + } + + true +} + #[async_trait::async_trait] impl heartbeat_server::Heartbeat for Metasrv { type HeartbeatStream = GrpcStream; @@ -43,88 +317,26 @@ impl heartbeat_server::Heartbeat for Metasrv { &self, req: Request>, ) -> GrpcResult { - let mut in_stream = req.into_inner(); let (tx, rx) = mpsc::channel(128); let handler_group = self.handler_group().context(error::UnexpectedSnafu { violated: "expected heartbeat handlers", })?; let ctx = self.new_ctx(); + let requests = TonicHeartbeatRequestStream::new(req.into_inner()); let _handle = common_runtime::spawn_global(async move { - let mut pusher_id = None; - while let Some(msg) = in_stream.next().await { - let mut is_not_leader = false; - match msg { - Ok(req) => { - debug!("Receiving heartbeat request: {:?}", req); - - let Some(header) = req.header.as_ref() else { - error!("Exit on malformed request: MissingRequestHeader"); - let _ = tx - .send(Err(error::MissingRequestHeaderSnafu {}.build().into())) - .await; - break; - }; - - let is_handshake = pusher_id.is_none(); - if is_handshake { - pusher_id = - Some(register_pusher(&handler_group, header, tx.clone()).await); - } - if let Some(k) = &pusher_id { - METRIC_META_HEARTBEAT_RECV.with_label_values(&[&k.to_string()]); - } else { - METRIC_META_HEARTBEAT_RECV.with_label_values(&["none"]); - } - - let res = handler_group - .handle(req, ctx.clone().with_handshake(is_handshake)) - .await - .inspect_err(|e| warn!(e; "Failed to handle heartbeat request, pusher: {pusher_id:?}", )) - .map_err(|e| e.into()); - - is_not_leader = res.as_ref().is_ok_and(|r| r.is_not_leader()); - - debug!("Sending heartbeat response: {:?}", res); - - if tx.send(res).await.is_err() { - info!("ReceiverStream was dropped; shutting down"); - break; - } - } - Err(err) => { - if let Some(io_err) = error::match_for_io_error(&err) - && io_err.kind() == ErrorKind::BrokenPipe - { - // client disconnected in unexpected way - error!("Client disconnected: broken pipe"); - break; - } - error!(err; "Sending heartbeat response error"); - - if tx.send(Err(err)).await.is_err() { - info!("ReceiverStream was dropped; shutting down"); - break; - } - } - } - - if is_not_leader { - warn!("Quit because it is no longer the leader"); - let _ = tx - .send(Err(Status::aborted(format!( - "The requested metasrv node is not leader, node addr: {}", - ctx.server_addr - )))) - .await; - break; - } - } - - info!("Heartbeat stream closed: {pusher_id:?}"); - - if let Some(pusher_id) = pusher_id { - let _ = handler_group.deregister_push(pusher_id).await; + if let Some(session) = HeartbeatSession::init( + requests, + tx, + ctx.election + .as_ref() + .map(|r| ElectionLeaderStepDown::new(r.clone())), + handler_group, + ctx, + ) + .await + { + session.run().await; } }); @@ -192,6 +404,7 @@ async fn register_pusher( #[cfg(test)] mod tests { + use std::collections::VecDeque; use std::sync::Arc; use api::v1::meta::heartbeat_server::Heartbeat; @@ -199,12 +412,300 @@ mod tests { use common_meta::kv_backend::memory::MemoryKvBackend; use common_telemetry::tracing_context::W3cTrace; use servers::grpc::GrpcOptions; - use tonic::IntoRequest; + use tokio::sync::mpsc; + use tonic::{Code, IntoRequest}; - use super::get_node_id; + use super::*; + use crate::handler::test_utils::TestEnv; use crate::metasrv::MetasrvOptions; use crate::metasrv::builder::MetasrvBuilder; + struct MockHeartbeatRequestStream { + messages: VecDeque>, + pending_when_empty: bool, + } + + impl MockHeartbeatRequestStream { + fn new(messages: Vec>) -> Self { + Self { + messages: messages.into(), + pending_when_empty: false, + } + } + + fn pending_after(messages: Vec>) -> Self { + Self { + messages: messages.into(), + pending_when_empty: true, + } + } + } + + #[async_trait::async_trait] + impl HeartbeatRequestStream for MockHeartbeatRequestStream { + async fn next(&mut self) -> Option> { + if let Some(message) = self.messages.pop_front() { + return Some(message); + } + + if self.pending_when_empty { + std::future::pending().await + } else { + None + } + } + } + + struct MockLeaderStepDown { + event: Option, + } + + impl MockLeaderStepDown { + fn new(event: LeaderStepDownEvent) -> Self { + Self { event: Some(event) } + } + } + + #[async_trait::async_trait] + impl LeaderStepDown for MockLeaderStepDown { + async fn wait(&mut self) -> LeaderStepDownEvent { + self.event.take().unwrap() + } + } + + fn heartbeat_request(role: Role, member_id: u64) -> HeartbeatRequest { + HeartbeatRequest { + header: Some(RequestHeader { + role: role.into(), + member_id, + ..Default::default() + }), + ..Default::default() + } + } + + fn sender_id(role: Role, member_id: u64) -> PusherId { + PusherId::new(role, member_id) + } + + fn test_context() -> Context { + TestEnv::new().ctx() + } + + fn test_handler_group() -> Arc { + Arc::new(HeartbeatHandlerGroup::default()) + } + + async fn init_session( + requests: MockHeartbeatRequestStream, + tx: Sender, + leader_step_down: Option, + handler_group: Arc, + ) -> Option> + where + L: LeaderStepDown, + { + HeartbeatSession::init( + requests, + tx, + leader_step_down, + handler_group, + test_context(), + ) + .await + } + + async fn recv_response( + rx: &mut mpsc::Receiver, + ) -> HeartbeatResponseResult { + rx.recv().await.unwrap() + } + + #[tokio::test] + async fn test_heartbeat_session_init_returns_none_on_empty_stream() { + let (tx, _rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let requests = MockHeartbeatRequestStream::new(vec![]); + + let session = init_session( + requests, + tx, + None::, + handler_group.clone(), + ) + .await; + + assert!(session.is_none()); + assert!( + !handler_group + .contains_pusher(&sender_id(Role::Datanode, 42)) + .await + ); + } + + #[tokio::test] + async fn test_heartbeat_session_init_forwards_first_stream_error() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let requests = MockHeartbeatRequestStream::new(vec![Err(Status::internal("boom"))]); + + let session = init_session(requests, tx, None::, handler_group).await; + + assert!(session.is_none()); + let status = recv_response(&mut rx).await.unwrap_err(); + assert_eq!(Code::Internal, status.code()); + assert_eq!("boom", status.message()); + } + + #[tokio::test] + async fn test_heartbeat_session_init_sends_error_on_missing_header() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let requests = MockHeartbeatRequestStream::new(vec![Ok(HeartbeatRequest::default())]); + + let session = init_session( + requests, + tx, + None::, + handler_group.clone(), + ) + .await; + + assert!(session.is_none()); + assert!( + !handler_group + .contains_pusher(&sender_id(Role::Datanode, 42)) + .await + ); + + let status = recv_response(&mut rx).await.unwrap_err(); + assert_eq!(Code::InvalidArgument, status.code()); + } + + #[tokio::test] + async fn test_heartbeat_session_init_registers_sender() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let sender_id = sender_id(Role::Datanode, 42); + let requests = + MockHeartbeatRequestStream::new(vec![Ok(heartbeat_request(Role::Datanode, 42))]); + + let session = init_session( + requests, + tx, + None::, + handler_group.clone(), + ) + .await; + + assert!(session.is_some()); + assert!(handler_group.contains_pusher(&sender_id).await); + + let response = recv_response(&mut rx).await.unwrap(); + assert!(response.heartbeat_config.is_some()); + } + + #[tokio::test] + async fn test_heartbeat_session_run_deregisters_sender_on_stream_close() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let sender_id = sender_id(Role::Datanode, 42); + let requests = + MockHeartbeatRequestStream::new(vec![Ok(heartbeat_request(Role::Datanode, 42))]); + let session = init_session( + requests, + tx, + None::, + handler_group.clone(), + ) + .await + .unwrap(); + let _ = recv_response(&mut rx).await.unwrap(); + + session.run().await; + + assert!(!handler_group.contains_pusher(&sender_id).await); + } + + #[tokio::test] + async fn test_heartbeat_session_run_forwards_stream_error_after_init() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let sender_id = sender_id(Role::Datanode, 42); + let requests = MockHeartbeatRequestStream::new(vec![ + Ok(heartbeat_request(Role::Datanode, 42)), + Err(Status::unavailable("temporary")), + ]); + let session = init_session( + requests, + tx, + None::, + handler_group.clone(), + ) + .await + .unwrap(); + let _ = recv_response(&mut rx).await.unwrap(); + + session.run().await; + + let status = recv_response(&mut rx).await.unwrap_err(); + assert_eq!(Code::Unavailable, status.code()); + assert_eq!("temporary", status.message()); + assert!(!handler_group.contains_pusher(&sender_id).await); + } + + #[tokio::test] + async fn test_heartbeat_session_leader_step_down_sends_aborted_and_deregisters() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let sender_id = sender_id(Role::Datanode, 42); + let requests = MockHeartbeatRequestStream::pending_after(vec![Ok(heartbeat_request( + Role::Datanode, + 42, + ))]); + let session = init_session( + requests, + tx, + Some(MockLeaderStepDown::new(LeaderStepDownEvent::StepDown)), + handler_group.clone(), + ) + .await + .unwrap(); + let _ = recv_response(&mut rx).await.unwrap(); + + session.run().await; + + let status = recv_response(&mut rx).await.unwrap_err(); + assert_eq!(Code::Aborted, status.code()); + assert!(!handler_group.contains_pusher(&sender_id).await); + } + + #[tokio::test] + async fn test_heartbeat_session_leader_watcher_closed_sends_unavailable_and_deregisters() { + let (tx, mut rx) = mpsc::channel(8); + let handler_group = test_handler_group(); + let sender_id = sender_id(Role::Datanode, 42); + let requests = MockHeartbeatRequestStream::pending_after(vec![Ok(heartbeat_request( + Role::Datanode, + 42, + ))]); + let session = init_session( + requests, + tx, + Some(MockLeaderStepDown::new(LeaderStepDownEvent::Closed)), + handler_group.clone(), + ) + .await + .unwrap(); + let _ = recv_response(&mut rx).await.unwrap(); + + session.run().await; + + let status = recv_response(&mut rx).await.unwrap_err(); + assert_eq!(Code::Unavailable, status.code()); + assert!(!handler_group.contains_pusher(&sender_id).await); + } + #[tokio::test] async fn test_ask_leader() { let kv_backend = Arc::new(MemoryKvBackend::new()); diff --git a/src/meta-srv/src/service/mailbox.rs b/src/meta-srv/src/service/mailbox.rs index 8b37eeaad5..86b631998b 100644 --- a/src/meta-srv/src/service/mailbox.rs +++ b/src/meta-srv/src/service/mailbox.rs @@ -207,9 +207,6 @@ pub trait Mailbox: Send + Sync { async fn broadcast(&self, ch: &BroadcastChannel, msg: &MailboxMessage) -> Result<()>; async fn on_recv(&self, id: MessageId, maybe_msg: Result) -> Result<()>; - - /// Reset all pushers of the mailbox. - async fn reset(&self); } #[cfg(test)] From a25152664b552eeb052fbb23c5632e877b2ab096 Mon Sep 17 00:00:00 2001 From: Yingwen Date: Mon, 25 May 2026 15:40:48 +0800 Subject: [PATCH 08/32] fix: qualify HistogramFold schema (#8157) * test: add regression test for binary op on histogram_quantile (#8144) Signed-off-by: evenyag * fix: preserve column qualifiers in HistogramFold output schema (#8144) Signed-off-by: evenyag --------- Signed-off-by: evenyag --- .../src/extension_plan/histogram_fold.rs | 12 +-- src/query/src/promql/planner.rs | 44 ++++++++- .../histogram_quantile_binary_op.result | 90 +++++++++++++++++++ .../promql/histogram_quantile_binary_op.sql | 50 +++++++++++ 4 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 tests/cases/standalone/common/promql/histogram_quantile_binary_op.result create mode 100644 tests/cases/standalone/common/promql/histogram_quantile_binary_op.sql diff --git a/src/promql/src/extension_plan/histogram_fold.rs b/src/promql/src/extension_plan/histogram_fold.rs index 15dd5f7c8c..b663433859 100644 --- a/src/promql/src/extension_plan/histogram_fold.rs +++ b/src/promql/src/extension_plan/histogram_fold.rs @@ -322,16 +322,18 @@ impl HistogramFold { /// Transform the schema /// /// - `le` will be removed + /// + /// Column qualifiers are preserved so downstream plan nodes can keep + /// referencing the columns by their original qualified names. fn convert_schema( input_schema: &DFSchemaRef, le_column: &str, ) -> DataFusionResult { - let fields = input_schema.fields(); // safety: those fields are checked in `check_schema()` - let mut new_fields = Vec::with_capacity(fields.len() - 1); - for f in fields { - if f.name() != le_column { - new_fields.push((None, f.clone())); + let mut new_fields = Vec::with_capacity(input_schema.fields().len() - 1); + for (qualifier, field) in input_schema.iter() { + if field.name() != le_column { + new_fields.push((qualifier.cloned(), field.clone())); } } Ok(Arc::new(DFSchema::new_with_metadata( diff --git a/src/query/src/promql/planner.rs b/src/query/src/promql/planner.rs index 6b17b7837a..9b05632d59 100644 --- a/src/query/src/promql/planner.rs +++ b/src/query/src/promql/planner.rs @@ -4023,12 +4023,15 @@ impl PromPlanner { return Ok(plan); } + // Preserve column qualifiers so downstream plan nodes can keep referencing + // the columns by their original qualified names. let project_exprs = schema - .fields() .iter() - .filter(|field| field.name() != DATA_SCHEMA_TSID_COLUMN_NAME) - .map(|field| Ok(DfExpr::Column(Column::from_name(field.name().clone())))) - .collect::>>()?; + .filter(|(_, field)| field.name() != DATA_SCHEMA_TSID_COLUMN_NAME) + .map(|(qualifier, field)| { + DfExpr::Column(Column::new(qualifier.cloned(), field.name().clone())) + }) + .collect::>(); LogicalPlanBuilder::from(plan) .project(project_exprs) @@ -6005,6 +6008,39 @@ mod test { .unwrap(); } + #[tokio::test] + async fn test_histogram_quantile_binary_op() { + let mut eval_stmt = EvalStmt { + expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }), + start: UNIX_EPOCH, + end: UNIX_EPOCH + .checked_add(Duration::from_secs(100_000)) + .unwrap(), + interval: Duration::from_secs(5), + lookback_delta: Duration::from_secs(1), + }; + + // Arithmetic applied to a histogram_quantile() result. Regression for #8144: + // HistogramFold used to drop the input column qualifiers, so the binary-op + // projection failed to resolve the qualified tag column. + let case = r#"histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) + 0"#; + + let prom_expr = parser::parse(case).unwrap(); + eval_stmt.expr = prom_expr; + let table_provider = build_test_table_provider_with_fields( + &[( + DEFAULT_SCHEMA_NAME.to_string(), + "http_request_duration_seconds_bucket".to_string(), + )], + &["pod", "le"], + ) + .await; + // Should plan without a "No field named ..." error. + let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state()) + .await + .unwrap(); + } + #[tokio::test] async fn test_parse_and_operator() { let mut eval_stmt = EvalStmt { diff --git a/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result new file mode 100644 index 0000000000..601dad6219 --- /dev/null +++ b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result @@ -0,0 +1,90 @@ +-- Reproduce https://github.com/GreptimeTeam/greptimedb/issues/8144 +-- Binary comparison/arithmetic applied to a histogram_quantile() result. +create table http_request_duration_seconds_bucket ( + ts timestamp time index, + le string, + pod string, + val double, + primary key (pod, le), +); + +Affected Rows: 0 + +insert into http_request_duration_seconds_bucket values + (2900000, "0.01", "pod-a", 10), + (2900000, "0.05", "pod-a", 20), + (2900000, "0.1", "pod-a", 30), + (2900000, "+Inf", "pod-a", 40), + (3000000, "0.01", "pod-a", 20), + (3000000, "0.05", "pod-a", 50), + (3000000, "0.1", "pod-a", 80), + (3000000, "+Inf", "pod-a", 100), + (2900000, "0.01", "pod-b", 5), + (2900000, "0.05", "pod-b", 8), + (2900000, "0.1", "pod-b", 12), + (2900000, "+Inf", "pod-b", 15), + (3000000, "0.01", "pod-b", 10), + (3000000, "0.05", "pod-b", 25), + (3000000, "0.1", "pod-b", 45), + (3000000, "+Inf", "pod-b", 60); + +Affected Rows: 16 + +-- histogram_quantile alone +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))); + ++-------+---------------------+-----------------------------------------------+ +| pod | ts | sum(prom_rate(ts_range,val,ts,Int64(300000))) | ++-------+---------------------+-----------------------------------------------+ +| pod-a | 1970-01-01T00:50:00 | 0.05 | +| pod-b | 1970-01-01T00:50:00 | 0.062499999999999986 | ++-------+---------------------+-----------------------------------------------+ + +-- comparison filter +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= 0.02; + ++-------+---------------------+-----------------------------------------------+ +| pod | ts | sum(prom_rate(ts_range,val,ts,Int64(300000))) | ++-------+---------------------+-----------------------------------------------+ +| pod-a | 1970-01-01T00:50:00 | 0.05 | +| pod-b | 1970-01-01T00:50:00 | 0.062499999999999986 | ++-------+---------------------+-----------------------------------------------+ + +-- arithmetic +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) + 0; + ++-------+---------------------+------------------------------------------------------------+ +| pod | ts | sum(prom_rate(ts_range,val,ts,Int64(300000))) + Float64(0) | ++-------+---------------------+------------------------------------------------------------+ +| pod-a | 1970-01-01T00:50:00 | 0.05 | +| pod-b | 1970-01-01T00:50:00 | 0.062499999999999986 | ++-------+---------------------+------------------------------------------------------------+ + +-- bool modifier +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= bool 0.02; + ++-------+---------------------+----------------------------------------------------------------+ +| pod | ts | sum(prom_rate(ts_range,val,ts,Int64(300000))) >= Float64(0.02) | ++-------+---------------------+----------------------------------------------------------------+ +| pod-a | 1970-01-01T00:50:00 | 1.0 | +| pod-b | 1970-01-01T00:50:00 | 1.0 | ++-------+---------------------+----------------------------------------------------------------+ + +-- subquery +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') count_over_time((histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= 0.02)[10m:1m]); + ++---------------------+------------------------------------------------------------------------------+-------+ +| ts | prom_count_over_time(ts_range,sum(prom_rate(ts_range,val,ts,Int64(300000)))) | pod | ++---------------------+------------------------------------------------------------------------------+-------+ +| 1970-01-01T00:50:00 | 2.0 | pod-a | ++---------------------+------------------------------------------------------------------------------+-------+ + +drop table http_request_duration_seconds_bucket; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/promql/histogram_quantile_binary_op.sql b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.sql new file mode 100644 index 0000000000..d6e936eae5 --- /dev/null +++ b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.sql @@ -0,0 +1,50 @@ +-- Reproduce https://github.com/GreptimeTeam/greptimedb/issues/8144 +-- Binary comparison/arithmetic applied to a histogram_quantile() result. + +create table http_request_duration_seconds_bucket ( + ts timestamp time index, + le string, + pod string, + val double, + primary key (pod, le), +); + +insert into http_request_duration_seconds_bucket values + (2900000, "0.01", "pod-a", 10), + (2900000, "0.05", "pod-a", 20), + (2900000, "0.1", "pod-a", 30), + (2900000, "+Inf", "pod-a", 40), + (3000000, "0.01", "pod-a", 20), + (3000000, "0.05", "pod-a", 50), + (3000000, "0.1", "pod-a", 80), + (3000000, "+Inf", "pod-a", 100), + (2900000, "0.01", "pod-b", 5), + (2900000, "0.05", "pod-b", 8), + (2900000, "0.1", "pod-b", 12), + (2900000, "+Inf", "pod-b", 15), + (3000000, "0.01", "pod-b", 10), + (3000000, "0.05", "pod-b", 25), + (3000000, "0.1", "pod-b", 45), + (3000000, "+Inf", "pod-b", 60); + +-- histogram_quantile alone +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))); + +-- comparison filter +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= 0.02; + +-- arithmetic +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) + 0; + +-- bool modifier +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= bool 0.02; + +-- subquery +-- SQLNESS SORT_RESULT 3 1 +tql eval (3000, 3000, '1s') count_over_time((histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) >= 0.02)[10m:1m]); + +drop table http_request_duration_seconds_bucket; From eb264d9adfd4f8a2d0ec37758c3c8660bc3414b2 Mon Sep 17 00:00:00 2001 From: yihong Date: Mon, 25 May 2026 16:51:40 +0800 Subject: [PATCH 09/32] fix: faster jieba (#8158) * fix: faster jieba Signed-off-by: yihong0618 * fix: also update tantivy and the api Signed-off-by: yihong0618 * fix: better bench follow the copilot review Signed-off-by: yihong0618 * fix: apply comments Signed-off-by: yihong0618 --------- Signed-off-by: yihong0618 --- Cargo.lock | 213 +++++++++++++++------- src/index/Cargo.toml | 6 +- src/index/benches/tokenizer_bench.rs | 176 +++++++++++++++++- src/index/src/fulltext_index.rs | 2 +- src/index/src/fulltext_index/tokenizer.rs | 27 ++- 5 files changed, 342 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45b726ab82..a0494b4266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1321,9 +1321,9 @@ dependencies = [ [[package]] name = "bitpacking" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" dependencies = [ "crunchy", ] @@ -1832,7 +1832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d8d1efd5109b9c1cd3b7966bd071cdfb53bb6eb0b22a473a68c2f70a11a1eb" dependencies = [ "parse-zoneinfo", - "phf_codegen", + "phf_codegen 0.12.1", "phf_shared 0.12.1", "uncased", ] @@ -4380,6 +4380,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + [[package]] name = "datatypes" version = "1.1.0" @@ -5486,12 +5492,12 @@ dependencies = [ [[package]] name = "fs4" -version = "0.8.4" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", + "rustix 1.0.7", + "windows-sys 0.59.0", ] [[package]] @@ -6564,27 +6570,37 @@ checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" [[package]] name = "include-flate" -version = "0.3.0" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df49c16750695486c1f34de05da5b7438096156466e7f76c38fcdf285cf0113e" +checksum = "23e233413926ef735f7d87024466cfda5a4b87467730846bd82ea7d504121347" dependencies = [ "include-flate-codegen", - "lazy_static", - "libflate", + "include-flate-compress", ] [[package]] name = "include-flate-codegen" -version = "0.2.0" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c5b246c6261be723b85c61ecf87804e8ea4a35cb68be0ff282ed84b95ffe7d7" +checksum = "5e7148f24ef8922cc0e5574ebb908729ccdd3a110c440a45165733fedadd9969" dependencies = [ - "libflate", + "include-flate-compress", + "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "include-flate-compress" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74783a9ed407e844e99d5e7a57bd650acbfa124cf6e97ffd790ba59d8ab8e7ff" +dependencies = [ + "libflate", + "zstd", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -6918,25 +6934,25 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jieba-macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6105f38f083bb1a79ad523bd32fa0d8ffcb6abd2fc4da9da203c32bca5b6ace3" +checksum = "661344b2412fb00aee1841d2405c9a31f7c91cf6e578a8e953647c43dd1a8b0a" dependencies = [ - "phf_codegen", + "phf_codegen 0.13.1", ] [[package]] name = "jieba-rs" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47982a320106da83b0c5d6aec0fb83e109f0132b69670b063adaa6fa5b4f3f4a" +checksum = "d7ef90d6209fcff084a01b488c4199d882e3764b15ff0e7a6b5d7efaa46e1e4f" dependencies = [ "cedarwood", - "fxhash", "include-flate", "jieba-macros", - "phf 0.12.1", + "phf 0.13.1", "regex", + "rustc-hash 2.1.1", ] [[package]] @@ -7483,25 +7499,25 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libflate" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d9dfdc14ea4ef0900c1cddbc8dcd553fbaacd8a4a282cf4018ae9dd04fb21e" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" dependencies = [ "adler32", - "core2", "crc32fast", "dary_heap", "libflate_lz77", + "no_std_io2", ] [[package]] name = "libflate_lz77" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e0d73b369f386f1c44abd9c570d5318f55ccde816ff4b562fa452e5182863d" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" dependencies = [ - "core2", - "hashbrown 0.14.5", + "hashbrown 0.16.1", + "no_std_io2", "rle-decode-fast", ] @@ -7816,6 +7832,15 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -8434,7 +8459,7 @@ dependencies = [ "flate2", "io-enum", "libc", - "lru", + "lru 0.12.5", "mysql_common 0.34.1", "named_pipe", "pem", @@ -8497,7 +8522,7 @@ dependencies = [ "futures-sink", "futures-util", "keyed_priority_queue", - "lru", + "lru 0.12.5", "mysql_common 0.34.1", "pem", "percent-encoding", @@ -8695,6 +8720,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nohash" version = "0.2.0" @@ -9635,6 +9669,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -10122,6 +10165,15 @@ checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ "phf_macros", "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", "serde", ] @@ -10131,10 +10183,20 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbdcb6f01d193b17f0b9c3360fa7e0e620991b193ff08702f78b3ce365d7e61" dependencies = [ - "phf_generator", + "phf_generator 0.12.1", "phf_shared 0.12.1", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.12.1" @@ -10145,13 +10207,23 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" dependencies = [ - "phf_generator", + "phf_generator 0.12.1", "phf_shared 0.12.1", "proc-macro2", "quote", @@ -10178,6 +10250,15 @@ dependencies = [ "uncased", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -11415,16 +11496,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "rand_xorshift" version = "0.4.0" @@ -12961,9 +13032,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "sketches-ddsketch" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" +checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" dependencies = [ "serde", ] @@ -13864,9 +13935,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tantivy" -version = "0.24.2" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" +checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" dependencies = [ "aho-corasick", "arc-swap", @@ -13877,17 +13948,17 @@ dependencies = [ "census", "crc32fast", "crossbeam-channel", + "datasketches", "downcast-rs", "fastdivide", "fnv", "fs4", "htmlescape", - "hyperloglogplus", "itertools 0.14.0", "levenshtein_automata", "log", - "lru", - "lz4_flex 0.11.6", + "lru 0.16.4", + "lz4_flex 0.13.1", "measure_time", "memmap2", "once_cell", @@ -13910,6 +13981,7 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "time", + "typetag", "uuid", "winapi", "zstd", @@ -13917,18 +13989,18 @@ dependencies = [ [[package]] name = "tantivy-bitpacker" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" +checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4" dependencies = [ "bitpacking", ] [[package]] name = "tantivy-columnar" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" +checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc" dependencies = [ "downcast-rs", "fastdivide", @@ -13942,9 +14014,9 @@ dependencies = [ [[package]] name = "tantivy-common" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" +checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5" dependencies = [ "async-trait", "byteorder", @@ -13966,9 +14038,9 @@ dependencies = [ [[package]] name = "tantivy-jieba" -version = "0.16.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b08147cc130e323ecc522117927b198bec617fe1df562a0b6449905858d0363" +checksum = "3392170e86f1c387170aba7d171a466ffdc98a8b55b006e19ac64b123a7b690a" dependencies = [ "jieba-rs", "lazy_static", @@ -13977,20 +14049,22 @@ dependencies = [ [[package]] name = "tantivy-query-grammar" -version = "0.24.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" +checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" dependencies = [ + "fnv", "nom 7.1.3", + "ordered-float 5.3.0", "serde", "serde_json", ] [[package]] name = "tantivy-sstable" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" +checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606" dependencies = [ "futures-util", "itertools 0.14.0", @@ -14002,20 +14076,19 @@ dependencies = [ [[package]] name = "tantivy-stacker" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" +checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951" dependencies = [ "murmurhash32", - "rand_distr", "tantivy-common", ] [[package]] name = "tantivy-tokenizer-api" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" +checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98" dependencies = [ "serde", ] @@ -15018,9 +15091,9 @@ checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "typetag" -version = "0.2.20" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f22b40dd7bfe8c14230cf9702081366421890435b2d625fa92b4acc4c3de6f" +checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" dependencies = [ "erased-serde", "inventory", @@ -15031,9 +15104,9 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.20" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f5380909ffc31b4de4f4bdf96b877175a016aa2ca98cee39fcfd8c4d53d952" +checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", diff --git a/src/index/Cargo.toml b/src/index/Cargo.toml index 3b78f7d22f..167c1c0df1 100644 --- a/src/index/Cargo.toml +++ b/src/index/Cargo.toml @@ -26,7 +26,7 @@ fst.workspace = true futures.workspace = true greptime-proto.workspace = true itertools.workspace = true -jieba-rs = "0.8" +jieba-rs = "0.10" lazy_static.workspace = true mockall.workspace = true nalgebra.workspace = true @@ -40,8 +40,8 @@ serde.workspace = true serde_json.workspace = true snafu.workspace = true store-api.workspace = true -tantivy = { version = "0.24", features = ["zstd-compression"] } -tantivy-jieba = "0.16" +tantivy = { version = "0.26", features = ["zstd-compression"] } +tantivy-jieba = "0.20" tokio.workspace = true tokio-util.workspace = true usearch = { version = "2.21", default-features = false, features = ["fp16lib"], optional = true } diff --git a/src/index/benches/tokenizer_bench.rs b/src/index/benches/tokenizer_bench.rs index e365c884b2..f376fe57d7 100644 --- a/src/index/benches/tokenizer_bench.rs +++ b/src/index/benches/tokenizer_bench.rs @@ -12,8 +12,79 @@ // See the License for the specific language governing permissions and // limitations under the License. -use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use index::fulltext_index::tokenizer::{EnglishTokenizer, Tokenizer}; +use std::collections::HashMap; +use std::hint::black_box; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use futures::AsyncRead; +use index::fulltext_index::create::{FulltextIndexCreator, TantivyFulltextIndexCreator}; +use index::fulltext_index::tokenizer::{ChineseTokenizer, EnglishTokenizer, Tokenizer}; +use index::fulltext_index::{Analyzer, Config}; +use puffin::puffin_manager::{PuffinWriter, PutOptions}; + +const CHINESE_TOKENIZER_TEXTS: &[(&str, &str)] = &[ + ("short", "登录手机号。中国农业银行。"), + ( + "mixed_log", + "2025-08-01 21:09:28 用户登录失败 trace_id=abc_123 dynamic_key=mobile_login 中国农业银行接口返回超时。", + ), + ( + "product_search", + "哈基米哦南北绿豆,噢马自立曼波。装电视台,中国中央广播电视台。压不缩,笑不活。", + ), + ( + "long_news", + "中国农业银行发布公告称,手机银行登录服务完成升级。多个地区用户反馈查询速度提升,后台监控显示核心链路延迟下降,异常请求自动重试次数减少。系统继续保留 trace_id、request_id 和 dynamic_key 等字段用于排查问题。", + ), +]; + +const CHINESE_INDEX_DOCS: &[&str] = &[ + "登录手机号,中国农业银行手机银行接口返回成功。", + "用户登录失败,trace_id=abc_123,dynamic_key=mobile_login。", + "中国中央广播电视台发布新的节目预告。", + "装电视台的时候遇到压不缩的问题。", + "哈基米哦南北绿豆,噢马自立曼波。", + "后台监控显示核心链路延迟下降。", + "系统保留 request_id 用于排查问题。", + "中文全文索引需要兼顾召回率和 token 数量。", +]; + +struct NoopPuffinWriter; + +#[async_trait] +impl PuffinWriter for NoopPuffinWriter { + async fn put_blob( + &mut self, + _key: &str, + _raw_data: R, + _options: PutOptions, + _properties: HashMap, + ) -> puffin::error::Result + where + R: AsyncRead + Send, + { + unreachable!("tantivy fulltext benchmark only writes directory blobs") + } + + async fn put_dir( + &mut self, + _key: &str, + _dir: PathBuf, + _options: PutOptions, + _properties: HashMap, + ) -> puffin::error::Result { + Ok(0) + } + + fn set_footer_lz4_compressed(&mut self, _lz4_compressed: bool) {} + + async fn finish(self) -> puffin::error::Result { + Ok(0) + } +} fn bench_english_tokenizer(c: &mut Criterion) { let tokenizer = EnglishTokenizer; @@ -86,5 +157,104 @@ fn bench_english_tokenizer(c: &mut Criterion) { repeat_group.finish(); } -criterion_group!(benches, bench_english_tokenizer); +fn bench_chinese_tokenizer(c: &mut Criterion) { + let tokenizer = ChineseTokenizer; + let mut group = c.benchmark_group("chinese_tokenizer"); + + for (name, text) in CHINESE_TOKENIZER_TEXTS { + group.throughput(Throughput::Bytes(text.len() as u64)); + group.bench_with_input(BenchmarkId::new("tokenize", name), text, |b, text| { + b.iter(|| black_box(tokenizer.tokenize(black_box(text)))) + }); + } + + group.finish(); + + let mut repeat_group = c.benchmark_group("chinese_tokenizer_repeated"); + let sample_text = CHINESE_TOKENIZER_TEXTS + .iter() + .find(|(name, _)| *name == "mixed_log") + .map(|(_, text)| *text) + .expect("mixed_log sample must exist"); + + for repeat_count in [10, 100, 1000] { + repeat_group.bench_with_input( + BenchmarkId::new("repeated_tokenize", repeat_count), + &repeat_count, + |b, &repeat_count| { + b.iter(|| { + for _ in 0..repeat_count { + black_box(tokenizer.tokenize(black_box(sample_text))); + } + }) + }, + ); + } + + repeat_group.finish(); +} + +fn bench_tantivy_chinese_fulltext_index(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create Tokio runtime"); + let config = Config { + analyzer: Analyzer::Chinese, + case_sensitive: false, + }; + let mut group = c.benchmark_group("tantivy_chinese_fulltext_index"); + group.sample_size(10); + group.measurement_time(Duration::from_secs(10)); + + for doc_count in [32usize, 256usize] { + group.throughput(Throughput::Elements(doc_count as u64)); + group.bench_with_input( + BenchmarkId::new("build_commit", doc_count), + &doc_count, + |b, &doc_count| { + b.iter_batched( + tempfile::tempdir, + |dir| { + let dir = dir.expect("failed to create temp dir"); + runtime.block_on(async { + let mut creator = + TantivyFulltextIndexCreator::new(dir.path(), config, 64 << 20) + .await + .expect("failed to create tantivy fulltext index"); + for idx in 0..doc_count { + let text = CHINESE_INDEX_DOCS[idx % CHINESE_INDEX_DOCS.len()]; + creator + .push_text(black_box(text)) + .await + .expect("failed to push text"); + } + let mut puffin_writer = NoopPuffinWriter; + creator + .finish( + &mut puffin_writer, + "tantivy_chinese_fulltext_index", + PutOptions::default(), + ) + .await + .expect("failed to commit tantivy fulltext index"); + }); + // Return the temp dir so Criterion drops it after timing the routine. + dir + }, + BatchSize::SmallInput, + ) + }, + ); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_english_tokenizer, + bench_chinese_tokenizer, + bench_tantivy_chinese_fulltext_index +); criterion_main!(benches); diff --git a/src/index/src/fulltext_index.rs b/src/index/src/fulltext_index.rs index 8de28c0490..06a36f65a8 100644 --- a/src/index/src/fulltext_index.rs +++ b/src/index/src/fulltext_index.rs @@ -52,7 +52,7 @@ impl Config { fn build_tantivy_tokenizer(&self) -> TokenizerManager { let mut builder = match self.analyzer { Analyzer::English => TextAnalyzer::builder(SimpleTokenizer::default()).dynamic(), - Analyzer::Chinese => TextAnalyzer::builder(JiebaTokenizer {}).dynamic(), + Analyzer::Chinese => TextAnalyzer::builder(JiebaTokenizer::new()).dynamic(), }; if !self.case_sensitive { diff --git a/src/index/src/fulltext_index/tokenizer.rs b/src/index/src/fulltext_index/tokenizer.rs index 919c497317..3afc826e6f 100644 --- a/src/index/src/fulltext_index/tokenizer.rs +++ b/src/index/src/fulltext_index/tokenizer.rs @@ -98,7 +98,8 @@ impl Tokenizer for ChineseTokenizer { let mut tokens = JIEBA .cut_for_search(text, true) .into_iter() - .filter(|s| is_indexable_token(s)) + .map(|token| token.word) + .filter(|token| is_indexable_token(token)) .collect::>(); let english = EnglishTokenizer {}; @@ -336,10 +337,26 @@ mod tests { let text = "哈基米哦南北绿豆,噢马自立曼波。登录手机号。中国农业银行。装电视台,中国中央广播电视台。压不缩,笑不活。"; let default_tokens = tokenizer.tokenize(text); - let cut_hmm_false = JIEBA.cut(text, false); - let cut_hmm_true = JIEBA.cut(text, true); - let cut_for_search_hmm_false = JIEBA.cut_for_search(text, false); - let cut_for_search_hmm_true = JIEBA.cut_for_search(text, true); + let cut_hmm_false = JIEBA + .cut(text, false) + .into_iter() + .map(|token| token.word) + .collect::>(); + let cut_hmm_true = JIEBA + .cut(text, true) + .into_iter() + .map(|token| token.word) + .collect::>(); + let cut_for_search_hmm_false = JIEBA + .cut_for_search(text, false) + .into_iter() + .map(|token| token.word) + .collect::>(); + let cut_for_search_hmm_true = JIEBA + .cut_for_search(text, true) + .into_iter() + .map(|token| token.word) + .collect::>(); assert_eq!( default_tokens, From 8d3ebde6524ad140195cb51fd90e52ae129def9b Mon Sep 17 00:00:00 2001 From: fys <40801205+fengys1996@users.noreply.github.com> Date: Mon, 25 May 2026 19:45:42 +0800 Subject: [PATCH 10/32] fix(mito2): schema-safe skipping index pruning (#8122) * fix: schema-safe skipping index pruning * fix: cargo clippy * fix: sqlness test * remove default plan in BloomFilterIndexApplier * fix comment of plan_for_sst * add fast path for default_plan * minor refactor * some rename * fix: cr by ai --- .../src/sst/index/bloom_filter/applier.rs | 177 ++++++++++++++++-- .../sst/index/bloom_filter/applier/builder.rs | 25 ++- src/mito2/src/sst/parquet.rs | 23 ++- src/mito2/src/sst/parquet/reader.rs | 20 +- .../change_col_type_skipping_index.result | 47 +++++ .../alter/change_col_type_skipping_index.sql | 26 +++ 6 files changed, 286 insertions(+), 32 deletions(-) create mode 100644 tests/cases/standalone/common/alter/change_col_type_skipping_index.result create mode 100644 tests/cases/standalone/common/alter/change_col_type_skipping_index.sql diff --git a/src/mito2/src/sst/index/bloom_filter/applier.rs b/src/mito2/src/sst/index/bloom_filter/applier.rs index e36874e97d..b1f6032630 100644 --- a/src/mito2/src/sst/index/bloom_filter/applier.rs +++ b/src/mito2/src/sst/index/bloom_filter/applier.rs @@ -21,6 +21,7 @@ use std::time::Instant; use common_base::range_read::RangeReader; use common_telemetry::{tracing, warn}; +use datatypes::data_type::ConcreteDataType; use index::bloom_filter::applier::{BloomFilterApplier, InListPredicate}; use index::bloom_filter::reader::{ BloomFilterReadMetrics, BloomFilterReader, BloomFilterReaderImpl, @@ -30,6 +31,7 @@ use object_store::ObjectStore; use puffin::puffin_manager::cache::PuffinMetadataCacheRef; use puffin::puffin_manager::{PuffinManager, PuffinReader}; use snafu::ResultExt; +use store_api::metadata::RegionMetadataRef; use store_api::region_request::PathType; use store_api::storage::ColumnId; @@ -38,7 +40,6 @@ use crate::cache::file_cache::{FileCacheRef, FileType, IndexKey}; use crate::cache::index::bloom_filter_index::{ BloomFilterIndexCacheRef, CachedBloomFilterIndexBlobReader, Tag, }; -use crate::cache::index::result_cache::PredicateKey; use crate::error::{ ApplyBloomFilterIndexSnafu, Error, MetadataSnafu, PuffinBuildReaderSnafu, PuffinReadBlobSnafu, Result, @@ -133,10 +134,10 @@ pub struct BloomFilterIndexApplier { /// Bloom filter predicates. /// For each column, the value will be retained only if it contains __all__ predicates. - predicates: Arc>>, + default_predicates: Arc>>, - /// Predicate key. Used to identify the predicate and fetch result from cache. - predicate_key: PredicateKey, + /// Expected predicate column types from the latest region metadata. + expected_predicate_col_types: BTreeMap, } impl BloomFilterIndexApplier { @@ -149,8 +150,9 @@ impl BloomFilterIndexApplier { object_store: ObjectStore, puffin_manager_factory: PuffinManagerFactory, predicates: BTreeMap>, + expected_predicate_col_types: BTreeMap, ) -> Self { - let predicates = Arc::new(predicates); + let default_predicates = Arc::new(predicates); Self { table_dir, path_type, @@ -159,8 +161,8 @@ impl BloomFilterIndexApplier { puffin_manager_factory, puffin_metadata_cache: None, bloom_filter_index_cache: None, - predicate_key: PredicateKey::new_bloom(predicates.clone()), - predicates, + default_predicates, + expected_predicate_col_types, } } @@ -207,6 +209,7 @@ impl BloomFilterIndexApplier { &self, file_id: RegionIndexId, file_size_hint: Option, + predicates: &BTreeMap>, row_groups: impl Iterator, mut metrics: Option<&mut BloomFilterIndexApplyMetrics>, ) -> Result>)>> { @@ -230,7 +233,7 @@ impl BloomFilterIndexApplier { .map(|(i, range)| (*i, vec![range.clone()])) .collect::>(); - for (column_id, predicates) in self.predicates.iter() { + for (column_id, predicates) in predicates { let blob = match self .blob_reader(file_id, *column_id, file_size_hint, metrics.as_deref_mut()) .await? @@ -438,9 +441,46 @@ impl BloomFilterIndexApplier { Ok(()) } - /// Returns the predicate key. - pub fn predicate_key(&self) -> &PredicateKey { - &self.predicate_key + /// Returns compatible bloom filter predicates with the given SST metadata. + /// + /// Returns `None` when no compatible predicate remains for this SST. + pub fn compatible_predicate_for_sst( + &self, + sst_metadata: &RegionMetadataRef, + ) -> Option>>> { + let mut has_type_mismatch = false; + let mut compatible_col_ids = Vec::new(); + + for (col_id, expected) in &self.expected_predicate_col_types { + let Some(sst_col) = sst_metadata.column_by_id(*col_id) else { + has_type_mismatch = true; + continue; + }; + + if sst_col.column_schema.data_type != *expected { + has_type_mismatch = true; + continue; + } + + compatible_col_ids.push(*col_id); + } + + if compatible_col_ids.is_empty() { + return None; + } + + if !has_type_mismatch { + return Some(self.default_predicates.clone()); + } + + let mut compatible_predicates = BTreeMap::new(); + for col_id in compatible_col_ids { + if let Some(predicates) = self.default_predicates.get(&col_id) { + compatible_predicates.insert(col_id, predicates.clone()); + } + } + + Some(Arc::new(compatible_predicates)) } } @@ -456,9 +496,12 @@ fn is_blob_not_found(err: &Error) -> bool { #[cfg(test)] mod tests { + use std::collections::BTreeSet; use datafusion_expr::{Expr, col, lit}; use futures::future::BoxFuture; + use index::Bytes; + use object_store::services::Memory; use puffin::puffin_manager::PuffinWriter; use store_api::metadata::RegionMetadata; use store_api::storage::FileId; @@ -470,6 +513,113 @@ mod tests { mock_object_store, mock_region_metadata, new_batch, new_intm_mgr, }; + #[tokio::test] + async fn test_compatible_predicate_for_sst() { + let (_d, puffin_manager_factory) = + PuffinManagerFactory::new_for_test_async("test_plan_for_sst_basic_").await; + let object_store = ObjectStore::new(Memory::default()).unwrap().finish(); + let table_dir = "table_dir".to_string(); + + let predicates = BTreeMap::from_iter([( + 1, + vec![InListPredicate { + list: BTreeSet::from_iter([Bytes::from("foo")]), + }], + )]); + let expected_predicate_col_types = + BTreeMap::from_iter([(1, ConcreteDataType::string_datatype())]); + + let applier = BloomFilterIndexApplier::new( + table_dir, + PathType::Bare, + object_store, + puffin_manager_factory, + predicates, + expected_predicate_col_types, + ); + let predicates = applier.compatible_predicate_for_sst(&mock_region_metadata()); + assert!(predicates.is_some()); + } + + #[tokio::test] + async fn test_compatible_predicate_for_sst_type_mismatch() { + let (_d, puffin_manager_factory) = + PuffinManagerFactory::new_for_test_async("test_plan_for_sst_type_mismatch_").await; + let object_store = ObjectStore::new(Memory::default()).unwrap().finish(); + let table_dir = "table_dir".to_string(); + + let predicates = BTreeMap::from_iter([( + 1, + vec![InListPredicate { + list: BTreeSet::from_iter([Bytes::from("foo")]), + }], + )]); + let expected_predicate_col_types = + BTreeMap::from_iter([(1, ConcreteDataType::int64_datatype())]); + + let applier = BloomFilterIndexApplier::new( + table_dir, + PathType::Bare, + object_store, + puffin_manager_factory, + predicates, + expected_predicate_col_types, + ); + let predicates = applier.compatible_predicate_for_sst(&mock_region_metadata()); + assert!(predicates.is_none()); + } + + #[tokio::test] + async fn test_compatible_predicate_for_sst_partial_type_mismatch() { + let (_d, puffin_manager_factory) = + PuffinManagerFactory::new_for_test_async("test_plan_for_sst_partial_mismatch_").await; + let object_store = ObjectStore::new(Memory::default()).unwrap().finish(); + let table_dir = "table_dir".to_string(); + + // Column 1 (tag_str): expected string — matches SST (compatible). + // Column 3 (field_u64): expected int64 — SST has uint64 (mismatched). + let predicates = BTreeMap::from_iter([ + ( + 1, + vec![InListPredicate { + list: BTreeSet::from_iter([Bytes::from("foo")]), + }], + ), + ( + 3, + vec![InListPredicate { + list: BTreeSet::from_iter([Bytes::from("bar")]), + }], + ), + ]); + let expected_predicate_col_types = BTreeMap::from_iter([ + (1, ConcreteDataType::string_datatype()), + (3, ConcreteDataType::int64_datatype()), // intentional mismatch + ]); + + let applier = BloomFilterIndexApplier::new( + table_dir, + PathType::Bare, + object_store, + puffin_manager_factory, + predicates, + expected_predicate_col_types, + ); + let result = applier.compatible_predicate_for_sst(&mock_region_metadata()); + + // The subset containing only the compatible column must be returned. + let result = result.expect("expected Some with compatible subset"); + assert!( + result.contains_key(&1), + "compatible column 1 must be present" + ); + assert!( + !result.contains_key(&3), + "mismatched column 3 must be absent" + ); + assert_eq!(result.len(), 1, "only the compatible predicate must remain"); + } + #[allow(clippy::type_complexity)] fn tester( table_dir: String, @@ -496,8 +646,11 @@ mod tests { ); let applier = builder.build(&exprs).unwrap().unwrap(); + let predicates = applier + .compatible_predicate_for_sst(&Arc::new(metadata.clone())) + .unwrap(); applier - .apply(file_id, None, row_groups.into_iter(), None) + .apply(file_id, None, &predicates, row_groups.into_iter(), None) .await .unwrap() .into_iter() diff --git a/src/mito2/src/sst/index/bloom_filter/applier/builder.rs b/src/mito2/src/sst/index/bloom_filter/applier/builder.rs index a2930d4075..beb26d1bf7 100644 --- a/src/mito2/src/sst/index/bloom_filter/applier/builder.rs +++ b/src/mito2/src/sst/index/bloom_filter/applier/builder.rs @@ -101,12 +101,14 @@ impl<'a> BloomFilterIndexApplierBuilder<'a> { return Ok(None); } + let expected_predicate_column_types = self.expected_predicate_column_types(); let applier = BloomFilterIndexApplier::new( self.table_dir, self.path_type, self.object_store, self.puffin_manager_factory, self.predicates, + expected_predicate_column_types, ) .with_file_cache(self.file_cache) .with_puffin_metadata_cache(self.puffin_metadata_cache) @@ -137,6 +139,17 @@ impl<'a> BloomFilterIndexApplierBuilder<'a> { } } + /// Returns `(column_id, data_type)` pairs for predicate columns. + fn expected_predicate_column_types(&self) -> BTreeMap { + self.predicates + .keys() + .filter_map(|col_id| { + let col = self.metadata.column_by_id(*col_id)?; + Some((*col_id, col.column_schema.data_type.clone())) + }) + .collect() + } + /// Helper function to get the column id and type fn column_id_and_type( &self, @@ -404,7 +417,7 @@ mod tests { let result = builder.build(&exprs).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; assert_eq!(predicates.len(), 1); let column_predicates = predicates.get(&1).unwrap(); @@ -443,7 +456,7 @@ mod tests { let result = builder.build(&exprs).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; let column_predicates = predicates.get(&2).unwrap(); assert_eq!(column_predicates.len(), 1); assert_eq!(column_predicates[0].list.len(), 3); @@ -473,7 +486,7 @@ mod tests { let result = builder().build(&[expr]).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; let column_predicates = predicates.get(&1).unwrap(); assert_eq!(column_predicates.len(), 1); assert_eq!(column_predicates[0].list.len(), 4); @@ -537,7 +550,7 @@ mod tests { let result = builder.build(&exprs).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; assert_eq!(predicates.len(), 2); assert!(predicates.contains_key(&1)); assert!(predicates.contains_key(&2)); @@ -575,7 +588,7 @@ mod tests { let result = builder.build(&exprs).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; assert!(!predicates.contains_key(&1)); // Null equality should be ignored let column2_predicates = predicates.get(&2).unwrap(); assert_eq!(column2_predicates[0].list.len(), 2); @@ -644,7 +657,7 @@ mod tests { let result = builder.build(&exprs).unwrap(); assert!(result.is_some()); - let predicates = result.unwrap().predicates; + let predicates = result.unwrap().default_predicates; let column_predicates = predicates.get(&1).unwrap(); assert_eq!(column_predicates.len(), 2); } diff --git a/src/mito2/src/sst/parquet.rs b/src/mito2/src/sst/parquet.rs index a3fe32cbe3..48474d9fe4 100644 --- a/src/mito2/src/sst/parquet.rs +++ b/src/mito2/src/sst/parquet.rs @@ -138,6 +138,7 @@ mod tests { use super::*; use crate::access_layer::{FilePathProvider, Metrics, RegionFilePathFactory, WriteType}; + use crate::cache::index::result_cache::PredicateKey; use crate::cache::test_util::assert_parquet_metadata_equal; use crate::cache::{CacheManager, CacheStrategy, PageKey}; use crate::config::IndexConfig; @@ -985,11 +986,14 @@ mod tests { assert_eq!(metrics.filter_metrics.rg_minmax_filtered, 2); assert_eq!(metrics.filter_metrics.rg_bloom_filtered, 2); assert_eq!(metrics.filter_metrics.rows_bloom_filtered, 100); + let bloom_predicates = bloom_filter_applier + .as_ref() + .unwrap() + .compatible_predicate_for_sst(&metadata) + .unwrap(); + let bloom_predicate_key = PredicateKey::new_bloom(bloom_predicates); let cached = index_result_cache - .get( - bloom_filter_applier.unwrap().predicate_key(), - handle.file_id().file_id(), - ) + .get(&bloom_predicate_key, handle.file_id().file_id()) .unwrap(); assert!(cached.contains_row_group(2)); assert!(cached.contains_row_group(3)); @@ -1055,11 +1059,14 @@ mod tests { assert_eq!(metrics.filter_metrics.rg_minmax_filtered, 0); assert_eq!(metrics.filter_metrics.rg_bloom_filtered, 2); assert_eq!(metrics.filter_metrics.rows_bloom_filtered, 140); + let bloom_predicates = bloom_filter_applier + .as_ref() + .unwrap() + .compatible_predicate_for_sst(&metadata) + .unwrap(); + let bloom_predicate_key = PredicateKey::new_bloom(bloom_predicates); let cached = index_result_cache - .get( - bloom_filter_applier.unwrap().predicate_key(), - handle.file_id().file_id(), - ) + .get(&bloom_predicate_key, handle.file_id().file_id()) .unwrap(); assert!(cached.contains_row_group(0)); assert!(cached.contains_row_group(1)); diff --git a/src/mito2/src/sst/parquet/reader.rs b/src/mito2/src/sst/parquet/reader.rs index 5d812f6307..02db70fa88 100644 --- a/src/mito2/src/sst/parquet/reader.rs +++ b/src/mito2/src/sst/parquet/reader.rs @@ -741,6 +741,7 @@ impl ParquetReaderBuilder { } self.prune_row_groups_by_bloom_filter( + read_format.metadata(), row_group_size, parquet_meta, &mut output, @@ -935,6 +936,7 @@ impl ParquetReaderBuilder { async fn prune_row_groups_by_bloom_filter( &self, + sst_metadata: &RegionMetadataRef, row_group_size: usize, parquet_meta: &ParquetMetaData, output: &mut RowGroupSelection, @@ -953,12 +955,17 @@ impl ParquetReaderBuilder { &self.bloom_filter_index_appliers[..] }; for index_applier in appliers.iter().flatten() { - let predicate_key = index_applier.predicate_key(); + let Some(compatible_predicates) = + index_applier.compatible_predicate_for_sst(sst_metadata) + else { + continue; + }; + let predicate_key = PredicateKey::new_bloom(compatible_predicates.clone()); // Fast path: return early if the result is in the cache. - let cached = self - .cache_strategy - .index_result_cache() - .and_then(|cache| cache.get(predicate_key, self.file_handle.file_id().file_id())); + let cached = self.cache_strategy.index_result_cache().and_then(|cache| { + let file_id = self.file_handle.file_id().file_id(); + cache.get(&predicate_key, file_id) + }); if let Some(result) = cached.as_ref() && all_required_row_groups_searched(output, result) { @@ -986,6 +993,7 @@ impl ParquetReaderBuilder { .apply( self.file_handle.index_id(), Some(file_size_hint), + &compatible_predicates, rgs, metrics.bloom_filter_apply_metrics.as_mut(), ) @@ -1006,7 +1014,7 @@ impl ParquetReaderBuilder { } self.apply_index_result_and_update_cache( - predicate_key, + &predicate_key, self.file_handle.file_id().file_id(), selection, output, diff --git a/tests/cases/standalone/common/alter/change_col_type_skipping_index.result b/tests/cases/standalone/common/alter/change_col_type_skipping_index.result new file mode 100644 index 0000000000..e19cb455a5 --- /dev/null +++ b/tests/cases/standalone/common/alter/change_col_type_skipping_index.result @@ -0,0 +1,47 @@ +-- Regression test for skip index with column type change. +CREATE TABLE monitoring_data_skip ( + host STRING SKIPPING INDEX, + `region` STRING, + cpu_usage DOUBLE SKIPPING INDEX, + `timestamp` TIMESTAMP TIME INDEX +) WITH ('append_mode'='true'); + +Affected Rows: 0 + +INSERT INTO monitoring_data_skip (host, region, cpu_usage, `timestamp`) VALUES +('web-01', 'us-east', 12.5, '2026-05-06 10:00:00'), +('web-01', 'us-east', 15.2, '2026-05-06 10:01:00'), +('web-02', 'us-east', 23.7, '2026-05-06 10:01:00'), +('db-01', 'us-east', 45.0, '2026-05-06 10:02:00'), +('db-02', 'us-west', 82.2, '2026-05-06 10:02:00'), +('cache-01', 'eu-central', 55.4, '2026-05-06 10:02:00'), +('queue-01', 'ap-south', 99.1, '2026-05-06 10:02:00'); + +Affected Rows: 7 + +ADMIN FLUSH_TABLE('monitoring_data_skip'); + ++-------------------------------------------+ +| ADMIN FLUSH_TABLE('monitoring_data_skip') | ++-------------------------------------------+ +| 0 | ++-------------------------------------------+ + +ALTER TABLE monitoring_data_skip +MODIFY COLUMN cpu_usage STRING; + +Affected Rows: 0 + +SELECT host, region, cpu_usage, `timestamp` FROM monitoring_data_skip +WHERE cpu_usage = '23.7'; + ++--------+---------+-----------+---------------------+ +| host | region | cpu_usage | timestamp | ++--------+---------+-----------+---------------------+ +| web-02 | us-east | 23.7 | 2026-05-06T10:01:00 | ++--------+---------+-----------+---------------------+ + +DROP TABLE monitoring_data_skip; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/alter/change_col_type_skipping_index.sql b/tests/cases/standalone/common/alter/change_col_type_skipping_index.sql new file mode 100644 index 0000000000..8a7e3b431d --- /dev/null +++ b/tests/cases/standalone/common/alter/change_col_type_skipping_index.sql @@ -0,0 +1,26 @@ +-- Regression test for skip index with column type change. +CREATE TABLE monitoring_data_skip ( + host STRING SKIPPING INDEX, + `region` STRING, + cpu_usage DOUBLE SKIPPING INDEX, + `timestamp` TIMESTAMP TIME INDEX +) WITH ('append_mode'='true'); + +INSERT INTO monitoring_data_skip (host, region, cpu_usage, `timestamp`) VALUES +('web-01', 'us-east', 12.5, '2026-05-06 10:00:00'), +('web-01', 'us-east', 15.2, '2026-05-06 10:01:00'), +('web-02', 'us-east', 23.7, '2026-05-06 10:01:00'), +('db-01', 'us-east', 45.0, '2026-05-06 10:02:00'), +('db-02', 'us-west', 82.2, '2026-05-06 10:02:00'), +('cache-01', 'eu-central', 55.4, '2026-05-06 10:02:00'), +('queue-01', 'ap-south', 99.1, '2026-05-06 10:02:00'); + +ADMIN FLUSH_TABLE('monitoring_data_skip'); + +ALTER TABLE monitoring_data_skip +MODIFY COLUMN cpu_usage STRING; + +SELECT host, region, cpu_usage, `timestamp` FROM monitoring_data_skip +WHERE cpu_usage = '23.7'; + +DROP TABLE monitoring_data_skip; From fd53ebc8a3dbdad13b2d6b8eaacb6a6c9172533a Mon Sep 17 00:00:00 2001 From: Rogier Lommers Date: Mon, 25 May 2026 15:50:45 +0200 Subject: [PATCH 11/32] fix: update RPC bind address in README (#8168) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ed99fa306..df52769be4 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ docker run -p 127.0.0.1:4000-4003:4000-4003 \ --name greptime --rm \ greptime/greptimedb:latest standalone start \ --http-addr 0.0.0.0:4000 \ - --grpc-bind-addr 0.0.0.0:4001 \ + --rpc-bind-addr 0.0.0.0:4001 \ --mysql-addr 0.0.0.0:4002 \ --postgres-addr 0.0.0.0:4003 ``` From c84462bdc11bf137c3965d4c9fe68917a0bfd1ba Mon Sep 17 00:00:00 2001 From: jeremyhi Date: Mon, 25 May 2026 21:28:23 -0700 Subject: [PATCH 12/32] feat(cli): add export-v2 delete command (#8162) * feat(cli): add export-v2 celete command Signed-off-by: jeremyhi * fix: by AI comments Signed-off-by: jeremyhi * feat(cli): refine delete confirmation flag Signed-off-by: jeremyhi --------- Signed-off-by: jeremyhi --- src/cli/src/data/export_v2/command.rs | 297 +++++++++++++++++++++++++- src/cli/src/data/export_v2/error.rs | 10 + src/cli/src/data/snapshot_storage.rs | 124 +++++++++++ 3 files changed, 429 insertions(+), 2 deletions(-) diff --git a/src/cli/src/data/export_v2/command.rs b/src/cli/src/data/export_v2/command.rs index 3c069a72be..db0f576a4e 100644 --- a/src/cli/src/data/export_v2/command.rs +++ b/src/cli/src/data/export_v2/command.rs @@ -15,6 +15,7 @@ //! Export V2 CLI commands. use std::collections::HashSet; +use std::io::{self, Write}; use std::time::Duration; use async_trait::async_trait; @@ -28,7 +29,7 @@ use crate::Tool; use crate::common::ObjectStoreConfig; use crate::data::export_v2::coordinator::export_data; use crate::data::export_v2::error::{ - ChunkTimeWindowRequiresBoundsSnafu, DatabaseSnafu, EmptyResultSnafu, + ChunkTimeWindowRequiresBoundsSnafu, DatabaseSnafu, EmptyResultSnafu, IoSnafu, ManifestVersionMismatchSnafu, Result, ResumeConfigMismatchSnafu, SchemaOnlyArgsNotAllowedSnafu, SchemaOnlyModeMismatchSnafu, SnapshotVerifyFailedSnafu, UnexpectedValueTypeSnafu, }; @@ -38,7 +39,9 @@ use crate::data::export_v2::manifest::{ }; use crate::data::export_v2::schema::{DDL_DIR, SCHEMA_DIR, SCHEMAS_FILE}; use crate::data::path::{data_dir_for_schema_chunk, ddl_path_for_schema}; -use crate::data::snapshot_storage::{OpenDalStorage, SnapshotStorage, validate_uri}; +use crate::data::snapshot_storage::{ + OpenDalStorage, SnapshotStorage, validate_snapshot_uri, validate_uri, +}; use crate::data::sql::{escape_sql_identifier, escape_sql_literal}; use crate::database::{DatabaseClient, parse_proxy_opts}; @@ -51,6 +54,8 @@ pub enum ExportV2Command { List(ExportListCommand), /// Verify snapshot integrity. Verify(ExportVerifyCommand), + /// Delete a snapshot and all data under it. + Delete(ExportDeleteCommand), } impl ExportV2Command { @@ -59,6 +64,7 @@ impl ExportV2Command { ExportV2Command::Create(cmd) => cmd.build().await, ExportV2Command::List(cmd) => cmd.build().await, ExportV2Command::Verify(cmd) => cmd.build().await, + ExportV2Command::Delete(cmd) => cmd.build().await, } } } @@ -172,6 +178,75 @@ impl ExportVerify { } } +/// Delete a snapshot and all data under it. +#[derive(Debug, Parser)] +pub struct ExportDeleteCommand { + /// Snapshot storage location (e.g., s3://bucket/path, file:///tmp/backup). + #[clap(long)] + snapshot: String, + + /// Skip interactive confirmation. + #[clap(long = "no-confirm", alias = "yes")] + skip_confirmation: bool, + + /// Object store configuration for remote storage backends. + #[clap(flatten)] + storage: ObjectStoreConfig, +} + +impl ExportDeleteCommand { + pub async fn build(&self) -> std::result::Result, BoxedError> { + validate_snapshot_uri(&self.snapshot).map_err(BoxedError::new)?; + let storage = + OpenDalStorage::from_uri(&self.snapshot, &self.storage).map_err(BoxedError::new)?; + + Ok(Box::new(ExportDelete { + snapshot: self.snapshot.clone(), + skip_confirmation: self.skip_confirmation, + storage, + })) + } +} + +/// Export delete tool implementation. +pub struct ExportDelete { + snapshot: String, + skip_confirmation: bool, + storage: OpenDalStorage, +} + +#[async_trait] +impl Tool for ExportDelete { + async fn do_work(&self) -> std::result::Result<(), BoxedError> { + self.run().await.map_err(BoxedError::new) + } +} + +impl ExportDelete { + async fn run(&self) -> Result<()> { + self.run_with_confirmation(confirm_delete).await + } + + async fn run_with_confirmation(&self, confirm: F) -> Result<()> + where + F: FnOnce(&str) -> Result, + { + let manifest = self.storage.read_manifest().await?; + print_delete_summary(&self.snapshot, &manifest); + + if !self.skip_confirmation && !confirm(&self.snapshot)? { + println!("Deletion cancelled."); + return Ok(()); + } + + println!("Deleting snapshot..."); + self.storage.delete_snapshot().await?; + println!("Snapshot deleted successfully."); + + Ok(()) + } +} + /// Create a new snapshot. #[derive(Debug, Parser)] pub struct ExportCreateCommand { @@ -1239,6 +1314,79 @@ fn print_verify_report(snapshot: &str, report: &VerifyReport) { ); } +fn print_delete_summary(snapshot: &str, manifest: &Manifest) { + println!("Snapshot: {}", manifest.snapshot_id); + println!(" Location: {}", snapshot); + println!( + " Created: {} UTC", + manifest.created_at.format("%Y-%m-%d %H:%M:%S") + ); + println!(" Catalog: {}", manifest.catalog); + println!(" Schemas: {}", manifest.schemas.join(", ")); + println!(" Chunks: {}", format_delete_chunks(manifest)); +} + +fn format_delete_chunks(manifest: &Manifest) -> String { + if manifest.schema_only { + return "0 (schema-only)".to_string(); + } + + let summary = summarize_chunks(manifest); + if manifest.is_complete() { + format!("{} (all processed)", summary.total) + } else { + format!( + "{} ({} completed, {} skipped, {} pending, {} in_progress, {} failed)", + summary.total, + summary.completed, + summary.skipped, + summary.pending, + summary.in_progress, + summary.failed + ) + } +} + +fn confirm_delete(snapshot: &str) -> Result { + println!(); + println!( + "Warning: this removes the entire snapshot directory/prefix, not only files listed in manifest." + ); + println!("This will permanently delete all data under:"); + println!(" {}", display_snapshot_prefix(snapshot)); + print!("Type 'yes' to confirm deletion: "); + io::stdout().flush().map_err(|error| { + IoSnafu { + operation: "flushing delete confirmation prompt", + error, + } + .build() + })?; + + let mut input = String::new(); + io::stdin().read_line(&mut input).map_err(|error| { + IoSnafu { + operation: "reading delete confirmation", + error, + } + .build() + })?; + + Ok(delete_confirmation_matches(&input)) +} + +fn delete_confirmation_matches(input: &str) -> bool { + input.trim() == "yes" +} + +fn display_snapshot_prefix(snapshot: &str) -> String { + if snapshot.ends_with('/') { + snapshot.to_string() + } else { + format!("{}/", snapshot) + } +} + #[cfg(test)] mod tests { use chrono::TimeZone; @@ -1563,6 +1711,7 @@ mod tests { ); assert_eq!(snapshot_status(&complete), "complete"); assert_eq!(format_list_chunks(&complete), "2/2"); + assert_eq!(format_delete_chunks(&complete), "2 (all processed)"); let incomplete = test_manifest( chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), @@ -1571,6 +1720,150 @@ mod tests { ); assert_eq!(snapshot_status(&incomplete), "incomplete"); assert_eq!(format_list_chunks(&incomplete), "1/2"); + assert_eq!( + format_delete_chunks(&incomplete), + "2 (1 completed, 0 skipped, 1 pending, 0 in_progress, 0 failed)" + ); + } + + #[tokio::test] + async fn test_delete_build_rejects_bucket_root_uri() { + let cmd = ExportDeleteCommand::parse_from([ + "export-v2-delete", + "--snapshot", + "s3://bucket", + "--no-confirm", + ]); + + let error = cmd.build().await.err().unwrap().to_string(); + assert!(error.contains("non-empty path")); + } + + #[test] + fn test_delete_skip_confirmation_aliases() { + let no_confirm = ExportDeleteCommand::parse_from([ + "export-v2-delete", + "--snapshot", + "s3://bucket/snapshot", + "--no-confirm", + ]); + assert!(no_confirm.skip_confirmation); + + let yes = ExportDeleteCommand::parse_from([ + "export-v2-delete", + "--snapshot", + "s3://bucket/snapshot", + "--yes", + ]); + assert!(yes.skip_confirmation); + } + + #[tokio::test] + async fn test_delete_snapshot_with_no_confirm_removes_snapshot_contents() { + let parent = tempdir().unwrap(); + let snapshot = parent.path().join("snapshot"); + let sibling = parent.path().join("sibling"); + std::fs::create_dir_all(&snapshot).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(sibling.join("keep.txt"), b"keep").unwrap(); + write_root_manifest( + &snapshot, + test_manifest( + chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + true, + true, + ), + ); + write_snapshot_file(&snapshot, "schema/schemas.json", b"[]"); + + let uri = Url::from_directory_path(&snapshot).unwrap().to_string(); + let delete = ExportDelete { + snapshot: uri, + skip_confirmation: true, + storage: file_storage_for_dir(&snapshot), + }; + + delete + .run_with_confirmation(|_| unreachable!()) + .await + .unwrap(); + + assert!(!snapshot.join(MANIFEST_FILE).exists()); + assert!(!snapshot.join("schema/schemas.json").exists()); + assert!(sibling.join("keep.txt").exists()); + } + + #[tokio::test] + async fn test_delete_snapshot_requires_manifest() { + let dir = tempdir().unwrap(); + let uri = Url::from_directory_path(dir.path()).unwrap().to_string(); + let delete = ExportDelete { + snapshot: uri, + skip_confirmation: true, + storage: file_storage_for_dir(dir.path()), + }; + + let error = delete + .run_with_confirmation(|_| unreachable!()) + .await + .err() + .unwrap() + .to_string(); + + assert!(error.contains("Snapshot not found")); + assert!(dir.path().exists()); + } + + #[tokio::test] + async fn test_delete_snapshot_cancels_without_exact_confirmation() { + let dir = tempdir().unwrap(); + write_root_manifest( + dir.path(), + test_manifest( + chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + true, + true, + ), + ); + write_snapshot_file(dir.path(), "schema/schemas.json", b"[]"); + let uri = Url::from_directory_path(dir.path()).unwrap().to_string(); + let delete = ExportDelete { + snapshot: uri.clone(), + skip_confirmation: false, + storage: file_storage_for_dir(dir.path()), + }; + + delete + .run_with_confirmation(|snapshot| { + assert_eq!(snapshot, uri); + Ok(false) + }) + .await + .unwrap(); + + assert!(dir.path().join(MANIFEST_FILE).exists()); + assert!(dir.path().join("schema/schemas.json").exists()); + } + + #[test] + fn test_delete_confirmation_requires_exact_yes() { + assert!(delete_confirmation_matches("yes")); + assert!(delete_confirmation_matches(" yes\n")); + assert!(!delete_confirmation_matches("YES")); + assert!(!delete_confirmation_matches("y")); + assert!(!delete_confirmation_matches("yes please")); + } + + #[test] + fn test_display_snapshot_prefix_adds_trailing_slash() { + assert_eq!( + display_snapshot_prefix("s3://bucket/snapshot"), + "s3://bucket/snapshot/" + ); + assert_eq!( + display_snapshot_prefix("s3://bucket/snapshot/"), + "s3://bucket/snapshot/" + ); } #[tokio::test] diff --git a/src/cli/src/data/export_v2/error.rs b/src/cli/src/data/export_v2/error.rs index 8d9a53f186..e16e3a6176 100644 --- a/src/cli/src/data/export_v2/error.rs +++ b/src/cli/src/data/export_v2/error.rs @@ -71,6 +71,14 @@ pub enum Error { location: Location, }, + #[snafu(display("I/O error while {}: {}", operation, error))] + Io { + operation: &'static str, + error: std::io::Error, + #[snafu(implicit)] + location: Location, + }, + #[snafu(display( "Cannot resume snapshot with a different schema_only mode (existing: {}, requested: {}). Use --force to recreate.", existing_schema_only, @@ -223,6 +231,8 @@ impl ErrorExt for Error { | Error::UnexpectedValueType { .. } | Error::UrlParse { .. } => StatusCode::Internal, + Error::Io { .. } => StatusCode::External, + Error::Database { error, .. } => error.status_code(), Error::SnapshotNotFound { .. } => StatusCode::InvalidArguments, diff --git a/src/cli/src/data/snapshot_storage.rs b/src/cli/src/data/snapshot_storage.rs index da8fdf6ab1..93e211628a 100644 --- a/src/cli/src/data/snapshot_storage.rs +++ b/src/cli/src/data/snapshot_storage.rs @@ -18,6 +18,7 @@ //! to various storage backends (S3, OSS, GCS, Azure Blob, local filesystem). use std::collections::BTreeSet; +use std::path::Component; use async_trait::async_trait; use futures::TryStreamExt; @@ -131,6 +132,92 @@ pub fn validate_uri(uri: &str) -> Result { StorageScheme::from_uri(uri) } +/// Validates a URI for snapshot-scoped destructive operations. +/// +/// Unlike read-only parent scans, destructive commands must target a concrete +/// snapshot directory instead of a bucket/container root or filesystem root. +/// Remote storage buckets/containers already provide namespace isolation, so a +/// non-empty object prefix is enough; local filesystem paths require at least +/// two non-root path segments to avoid deleting broad system directories. +pub fn validate_snapshot_uri(uri: &str) -> Result { + let scheme = validate_uri(uri)?; + reject_query_or_fragment(uri)?; + match scheme { + StorageScheme::File => validate_file_snapshot_uri(uri)?, + StorageScheme::S3 | StorageScheme::Oss | StorageScheme::Gcs | StorageScheme::Azblob => { + extract_remote_location_with_root_policy(uri, false)?; + } + } + Ok(scheme) +} + +fn reject_query_or_fragment(uri: &str) -> Result<()> { + let url = Url::parse(uri).context(UrlParseSnafu)?; + if url.query().is_some() || url.fragment().is_some() { + return InvalidUriSnafu { + uri, + reason: "snapshot URI must not include query or fragment", + } + .fail(); + } + + Ok(()) +} + +fn validate_file_snapshot_uri(uri: &str) -> Result<()> { + if has_explicit_dot_segment(uri) { + return InvalidUriSnafu { + uri, + reason: "file snapshot URI must not contain '.' or '..' path segments", + } + .fail(); + } + + let path = extract_file_path_from_uri(uri)?; + let mut normal_component_count = 0; + + // This is only a path-shape guard for destructive operations. It does not + // resolve symlinks. Drive prefixes and root separators also do not count + // toward depth; delete still relies on the manifest check and explicit + // confirmation before removing the rooted storage prefix. + for component in std::path::Path::new(&path).components() { + match component { + Component::Normal(_) => normal_component_count += 1, + Component::CurDir | Component::ParentDir => { + return InvalidUriSnafu { + uri, + reason: "file snapshot URI must not contain '.' or '..' path segments", + } + .fail(); + } + Component::Prefix(_) | Component::RootDir => {} + } + } + + if normal_component_count < 2 { + return InvalidUriSnafu { + uri, + reason: "file snapshot URI must point to a directory at least two levels deep", + } + .fail(); + } + + Ok(()) +} + +fn has_explicit_dot_segment(uri: &str) -> bool { + // Defense in depth: catch dot segments at the raw URI level before + // `Url::to_file_path()` can normalize them away. The `Path::components()` + // check below still runs because URL decoding can reintroduce them. + let without_fragment = uri.split_once('#').map_or(uri, |(path, _)| path); + let path = without_fragment + .split_once('?') + .map_or(without_fragment, |(path, _)| path); + + path.split('/') + .any(|segment| segment == "." || segment == "..") +} + fn schema_index_path() -> String { format!("{}/{}", SCHEMA_DIR, SCHEMAS_FILE) } @@ -708,6 +795,43 @@ mod tests { assert!(OpenDalStorage::from_parent_uri("s3://bucket", &storage).is_ok()); } + #[test] + fn test_validate_snapshot_uri_rejects_dangerous_roots() { + assert!(validate_snapshot_uri("s3://bucket").is_err()); + assert!(validate_snapshot_uri("s3://bucket/").is_err()); + assert!(validate_snapshot_uri("oss://bucket").is_err()); + assert!(validate_snapshot_uri("gs://bucket").is_err()); + assert!(validate_snapshot_uri("azblob://container").is_err()); + assert!(validate_snapshot_uri("s3://bucket/snapshot?version=1").is_err()); + assert!(validate_snapshot_uri("file:///tmp/backup#fragment").is_err()); + assert!(validate_snapshot_uri("file:///").is_err()); + assert!(validate_snapshot_uri("file:///tmp").is_err()); + assert!(validate_snapshot_uri("file:///tmp/backup/.").is_err()); + assert!(validate_snapshot_uri("file:///tmp/backup/..").is_err()); + } + + #[test] + fn test_validate_snapshot_uri_accepts_snapshot_paths() { + assert_eq!( + validate_snapshot_uri("s3://bucket/snapshots/prod").unwrap(), + StorageScheme::S3 + ); + + let dir = tempdir().unwrap(); + let snapshot = dir.path().join("snapshot"); + std::fs::create_dir_all(&snapshot).unwrap(); + let uri = Url::from_directory_path(snapshot).unwrap().to_string(); + assert_eq!(validate_snapshot_uri(&uri).unwrap(), StorageScheme::File); + } + + #[cfg(windows)] + #[test] + fn test_validate_snapshot_uri_windows_drive_prefix_depth() { + assert!(validate_snapshot_uri("file:///C:/").is_err()); + assert!(validate_snapshot_uri("file:///C:/Users").is_err()); + assert!(validate_snapshot_uri("file:///C:/Users/snapshot").is_ok()); + } + #[cfg(not(windows))] #[test] fn test_extract_path_from_uri_unix_examples() { From 6193e8760b42fa94afbe84875f03314943d3eee1 Mon Sep 17 00:00:00 2001 From: Yingwen Date: Tue, 26 May 2026 15:03:31 +0800 Subject: [PATCH 13/32] feat: initial implementation for range cache with time filters (#8130) * feat: initial implementation for range cache time filters Signed-off-by: evenyag * refactor: tighten Lt implied time range bound Signed-off-by: evenyag * docs: tighten range cache key comment Signed-off-by: evenyag * fix: skip range cache unit asserts on empty implied range Signed-off-by: evenyag --------- Signed-off-by: evenyag --- src/mito2/src/read/range_cache.rs | 562 +++++++++++++++++++++++++++--- src/mito2/src/read/scan_region.rs | 111 +++--- src/mito2/src/read/scan_util.rs | 2 + src/table/src/predicate.rs | 9 +- 4 files changed, 590 insertions(+), 94 deletions(-) diff --git a/src/mito2/src/read/range_cache.rs b/src/mito2/src/read/range_cache.rs index 7d1010205d..af212ab23e 100644 --- a/src/mito2/src/read/range_cache.rs +++ b/src/mito2/src/read/range_cache.rs @@ -19,13 +19,20 @@ use std::sync::Arc; use async_stream::try_stream; use common_telemetry::warn; +use common_time::Timestamp; +use common_time::range::TimestampRange; +use common_time::timestamp::TimeUnit; +use datafusion_expr::expr::Expr; +use datafusion_expr::{Between, BinaryExpr, Operator}; use datatypes::arrow::compute::concat_batches; use datatypes::arrow::record_batch::RecordBatch; use datatypes::prelude::ConcreteDataType; +use datatypes::value::scalar_value_to_timestamp; use futures::TryStreamExt; use snafu::ResultExt; use store_api::region_engine::PartitionRange; use store_api::storage::{FileId, RegionId, TimeSeriesRowSelector}; +use table::predicate::is_string_timestamp_literal; use tokio::sync::{mpsc, oneshot}; use crate::cache::CacheStrategy; @@ -139,7 +146,6 @@ impl ScanRequestFingerprint { .unwrap_or(&[]) } - #[allow(dead_code)] pub(crate) fn without_time_filters(&self) -> Self { Self { inner: Arc::clone(&self.inner), @@ -266,6 +272,177 @@ pub(crate) fn collect_partition_range_row_groups( } } +/// Returns the timestamp range where all time-only predicates are guaranteed true. +/// +/// Returns `Some(min_to_max)` for empty input (vacuously true everywhere). +/// Returns `None` if any expression contains an unsupported shape: `OR`, `NOT`, +/// `IN`, non-literal RHS, unsupported operator, column-name mismatch, an `=` +/// literal that cannot be represented exactly in the column unit, or overflow +/// during bound adjustment. +/// +/// This is intentionally stricter than `extract_time_range_from_expr` in +/// `table::predicate`: lower bounds round up and upper bounds round down. If a +/// partition's file-time range is contained by the returned range, every row in +/// that partition satisfies the original time predicates. +/// +/// `IsNull`/`IsNotNull` on the time index are not routed into `time_filters` +/// today. If that changes, handle them here before stripping time filters from +/// the cache key. +pub(crate) fn implied_time_range_from_exprs( + ts_col_name: &str, + ts_col_unit: TimeUnit, + exprs: &[&Expr], +) -> Option { + let mut acc = TimestampRange::min_to_max(); + for expr in exprs { + let r = implied_time_range_from_expr(ts_col_name, ts_col_unit, expr)?; + acc = acc.and(&r); + } + Some(acc) +} + +fn implied_time_range_from_expr( + ts_col_name: &str, + ts_col_unit: TimeUnit, + expr: &Expr, +) -> Option { + match expr { + Expr::BinaryExpr(BinaryExpr { left, op, right }) => match op { + Operator::And => { + let l = implied_time_range_from_expr(ts_col_name, ts_col_unit, left)?; + let r = implied_time_range_from_expr(ts_col_name, ts_col_unit, right)?; + Some(l.and(&r)) + } + Operator::Eq | Operator::Lt | Operator::LtEq | Operator::Gt | Operator::GtEq => { + implied_from_cmp(ts_col_name, ts_col_unit, left, *op, right) + } + // `OR` would require a strict intersection over a union of half-planes + // (not the loose-span union provided by `TimestampRange::or`), so we + // refuse it. Any other operator is unsupported. + _ => None, + }, + Expr::Between(Between { + expr, + negated, + low, + high, + }) => { + if *negated { + return None; + } + implied_from_between(ts_col_name, ts_col_unit, expr, low, high) + } + // Includes `IsNull`, `IsNotNull`, `Not`, `InList`, function calls, etc. + _ => None, + } +} + +fn match_ts_column_literal<'a>( + ts_col_name: &str, + left: &'a Expr, + right: &'a Expr, +) -> Option<(Timestamp, bool)> { + let (col, scalar, reverse) = match (left, right) { + (Expr::Column(c), Expr::Literal(s, _)) => (c, s, false), + (Expr::Literal(s, _), Expr::Column(c)) => (c, s, true), + _ => return None, + }; + if col.name != ts_col_name { + return None; + } + // Reject string literals: their conversion needs a timezone we do not have, + // and the existing extractor in `table::predicate` rejects them too. + if is_string_timestamp_literal(scalar) { + return None; + } + scalar_value_to_timestamp(scalar, None).map(|t| (t, reverse)) +} + +fn implied_from_cmp( + ts_col_name: &str, + ts_col_unit: TimeUnit, + left: &Expr, + op: Operator, + right: &Expr, +) -> Option { + let (ts, reverse) = match_ts_column_literal(ts_col_name, left, right)?; + // Normalize to "column OP literal". + let op = if reverse { + match op { + Operator::Lt => Operator::Gt, + Operator::LtEq => Operator::GtEq, + Operator::Gt => Operator::Lt, + Operator::GtEq => Operator::LtEq, + Operator::Eq => Operator::Eq, + _ => return None, + } + } else { + op + }; + + match op { + Operator::GtEq => { + // ts >= L. Round the lower bound up in the column unit. + let b = ts.convert_to_ceil(ts_col_unit)?; + Some(TimestampRange::from_start(b)) + } + Operator::Gt => { + // ts > L. floor(L) + 1 is the tight lower bound in the column unit. + let v = ts.convert_to(ts_col_unit)?.value().checked_add(1)?; + Some(TimestampRange::from_start(Timestamp::new(v, ts_col_unit))) + } + Operator::LtEq => { + // ts <= L. Round the upper bound down in the column unit. + let b = ts.convert_to(ts_col_unit)?; + Some(TimestampRange::until_end(b, true)) + } + Operator::Lt => { + // ts < L. `ts < ceil(L)` is the tight bound: equal to `ts < L` when + // L is exactly representable, and `ts <= floor(L)` otherwise. + let b = ts.convert_to_ceil(ts_col_unit)?; + Some(TimestampRange::until_end(b, false)) + } + Operator::Eq => { + // ts = L. Only provable when L is exactly representable. + let f = ts.convert_to(ts_col_unit)?; + let c = ts.convert_to_ceil(ts_col_unit)?; + if f.value() != c.value() { + return None; + } + Some(TimestampRange::single(f)) + } + _ => None, + } +} + +fn implied_from_between( + ts_col_name: &str, + ts_col_unit: TimeUnit, + expr: &Expr, + low: &Expr, + high: &Expr, +) -> Option { + let Expr::Column(c) = expr else { + return None; + }; + if c.name != ts_col_name { + return None; + } + let (low_s, high_s) = match (low, high) { + (Expr::Literal(l, _), Expr::Literal(h, _)) => (l, h), + _ => return None, + }; + if is_string_timestamp_literal(low_s) || is_string_timestamp_literal(high_s) { + return None; + } + let low_ts = scalar_value_to_timestamp(low_s, None)?; + let high_ts = scalar_value_to_timestamp(high_s, None)?; + // BETWEEN low AND high is equivalent to ts >= low AND ts <= high. + let lo = low_ts.convert_to_ceil(ts_col_unit)?; + let hi = high_ts.convert_to(ts_col_unit)?; + Some(TimestampRange::new_inclusive(Some(lo), Some(hi))) +} + /// Builds a cache key for the given partition range if it is eligible for caching. pub(crate) fn build_range_cache_key( stream_ctx: &StreamContext, @@ -292,17 +469,36 @@ pub(crate) fn build_range_cache_key( return None; } - // TODO(yingwen): We used to call `fingerprint.without_time_filters()` when the query's - // `TimestampRange` fully covered the partition's `FileTimeRange`, so different queries that - // all enclosed the same partition could share a cache entry. The cover check turned out to - // be too coarse: it returned true in cases where the dropped time predicates would still - // have excluded rows, so the cache served results that should have been filtered. Reviving - // the optimization needs a per-predicate implication check that walks each time-only `Expr` - // (recursing through AND/OR/NOT) and proves the predicate is satisfied for every timestamp - // inside the partition's `FileTimeRange` — not the looser "does `extract_time_range_from_expr` - // return a range that covers the partition" used previously. Until then, always carry the - // full fingerprint so cache reuse stays correct. - let scan = fingerprint.clone(); + // If the implied range covers this partition's `FileTimeRange`, drop + // time-only predicates from the cache key so that queries with different + // but equally-covering time bounds share an entry. `None` means some + // time-only predicate had an unsupported shape (e.g. `OR`), so we keep + // them in the key. + let range_meta = &stream_ctx.ranges[part_range.identifier]; + let (file_min, file_max) = range_meta.time_range; + let covers = match &stream_ctx.scan_implied_time_range { + // An empty implied range can never cover a non-empty file range, so + // short-circuit. We also skip the unit asserts because + // `TimestampRange::empty()` uses `Timestamp::default()` (millisecond), + // which would falsely trip the asserts for non-ms time index columns. + Some(implied) if !implied.is_empty() => { + // The `contains` check is sound only when `file_min`/`file_max` + // share the implied range's unit (the time index column's unit). + // Mito stores time index values in that unit; assert to catch any + // future drift. + if let Some(ts) = implied.start().as_ref().or(implied.end().as_ref()) { + assert_eq!(file_min.unit(), ts.unit()); + assert_eq!(file_max.unit(), ts.unit()); + } + implied.contains(&file_min) && implied.contains(&file_max) + } + _ => false, + }; + let scan = if covers { + fingerprint.without_time_filters() + } else { + fingerprint.clone() + }; Some(RangeScanCacheKey { region_id: stream_ctx.input.region_metadata().region_id, @@ -722,11 +918,16 @@ mod tests { num_rows: 10, }; let partition_range = range_meta.new_partition_range(0); - let scan_fingerprint = crate::read::scan_region::build_scan_fingerprint(&input); + let (scan_fingerprint, scan_implied_time_range) = + match crate::read::scan_region::build_scan_fingerprint(&input) { + Some(b) => (Some(b.fingerprint), b.implied_time_range), + None => (None, None), + }; let stream_ctx = StreamContext { input, ranges: vec![range_meta], scan_fingerprint, + scan_implied_time_range, query_start: Instant::now(), }; @@ -770,57 +971,312 @@ mod tests { } #[tokio::test] - async fn preserves_time_filters_when_query_covers_partition_range() { - assert_range_cache_filters( - vec![ - col("ts").gt_eq(ts_lit(1000)), - col("ts").lt(ts_lit(2001)), - col("ts").is_not_null(), - col("k0").eq(lit("foo")), - ], - TimestampRange::with_unit(1000, 2002, TimeUnit::Millisecond), - ( - Timestamp::new_millisecond(1000), - Timestamp::new_millisecond(2000), - ), - vec![col("k0").eq(lit("foo")), col("ts").is_not_null()], - vec![col("ts").gt_eq(ts_lit(1000)), col("ts").lt(ts_lit(2001))], - ) - .await; + async fn range_cache_time_filter_key_cases() { + let partition = ( + Timestamp::new_millisecond(1000), + Timestamp::new_millisecond(2000), + ); + + struct Case { + filters: Vec, + query_time_range: Option, + expected_filters: Vec, + expected_time_filters: Vec, + } + + // Time filters are stripped only when their implied range fully covers + // the partition's file-time range. `is_not_null(ts)` stays in regular + // filters because it is not routed into `time_filters`. + for case in [ + Case { + filters: vec![ + col("ts").gt_eq(ts_lit(1000)), + col("ts").lt(ts_lit(2001)), + col("ts").is_not_null(), + col("k0").eq(lit("foo")), + ], + query_time_range: TimestampRange::with_unit(1000, 2002, TimeUnit::Millisecond), + expected_filters: vec![col("k0").eq(lit("foo")), col("ts").is_not_null()], + expected_time_filters: vec![], + }, + Case { + filters: vec![ + col("ts").gt_eq(ts_lit(500)), + col("ts").lt(ts_lit(3000)), + col("k0").eq(lit("foo")), + ], + query_time_range: TimestampRange::with_unit(500, 3000, TimeUnit::Millisecond), + expected_filters: vec![col("k0").eq(lit("foo"))], + expected_time_filters: vec![], + }, + Case { + filters: vec![ + col("ts").gt_eq(ts_lit(1000)), + col("ts").lt_eq(ts_lit(2000)), + col("k0").eq(lit("foo")), + ], + query_time_range: TimestampRange::with_unit(1000, 2001, TimeUnit::Millisecond), + expected_filters: vec![col("k0").eq(lit("foo"))], + expected_time_filters: vec![], + }, + Case { + filters: vec![ + col("ts").between(ts_lit(1000), ts_lit(2000)), + col("k0").eq(lit("foo")), + ], + query_time_range: TimestampRange::with_unit(1000, 2001, TimeUnit::Millisecond), + expected_filters: vec![col("k0").eq(lit("foo"))], + expected_time_filters: vec![], + }, + Case { + filters: vec![col("ts").gt_eq(ts_lit(1200)), col("k0").eq(lit("foo"))], + query_time_range: TimestampRange::with_unit(1200, 2001, TimeUnit::Millisecond), + expected_filters: vec![col("k0").eq(lit("foo"))], + expected_time_filters: vec![col("ts").gt_eq(ts_lit(1200))], + }, + Case { + filters: vec![ + col("ts").gt_eq(ts_lit(1500)), + col("ts").is_not_null(), + col("k0").eq(lit("foo")), + ], + query_time_range: None, + expected_filters: vec![col("k0").eq(lit("foo")), col("ts").is_not_null()], + expected_time_filters: vec![col("ts").gt_eq(ts_lit(1500))], + }, + ] { + assert_range_cache_filters( + case.filters, + case.query_time_range, + partition, + case.expected_filters, + case.expected_time_filters, + ) + .await; + } } #[tokio::test] - async fn preserves_time_filters_when_query_does_not_cover_partition_range() { - assert_range_cache_filters( - vec![col("ts").gt_eq(ts_lit(1000)), col("k0").eq(lit("foo"))], - TimestampRange::with_unit(1000, 1500, TimeUnit::Millisecond), - ( - Timestamp::new_millisecond(1000), - Timestamp::new_millisecond(2000), - ), - vec![col("k0").eq(lit("foo"))], - vec![col("ts").gt_eq(ts_lit(1000))], + async fn two_distinct_queries_share_cache_key_when_both_cover() { + let partition_range = ( + Timestamp::new_millisecond(1000), + Timestamp::new_millisecond(2000), + ); + + let (ctx_a, part_a) = new_stream_context( + vec![ + col("ts").gt_eq(ts_lit(500)), + col("ts").lt(ts_lit(3000)), + col("k0").eq(lit("foo")), + ], + TimestampRange::with_unit(500, 3000, TimeUnit::Millisecond), + partition_range, ) .await; + let (ctx_b, part_b) = new_stream_context( + vec![ + col("ts").gt_eq(ts_lit(100)), + col("ts").lt(ts_lit(5000)), + col("k0").eq(lit("foo")), + ], + TimestampRange::with_unit(100, 5000, TimeUnit::Millisecond), + partition_range, + ) + .await; + + let key_a = build_range_cache_key(&ctx_a, &part_a).unwrap(); + let key_b = build_range_cache_key(&ctx_b, &part_b).unwrap(); + assert_eq!(key_a.scan, key_b.scan); + assert!(key_a.scan.time_filters().is_empty()); } #[tokio::test] - async fn preserves_time_filters_when_query_has_no_time_range_limit() { - assert_range_cache_filters( - vec![ - col("ts").gt_eq(ts_lit(1000)), - col("ts").is_not_null(), - col("k0").eq(lit("foo")), - ], + async fn disables_optimization_on_or_clause() { + let partition_range = ( + Timestamp::new_millisecond(1000), + Timestamp::new_millisecond(2000), + ); + + let or_a = col("ts").gt_eq(ts_lit(1000)).or(col("ts").lt(ts_lit(500))); + let or_b = col("ts").gt_eq(ts_lit(900)).or(col("ts").lt(ts_lit(400))); + + let (ctx_a, part_a) = new_stream_context( + vec![or_a.clone(), col("k0").eq(lit("foo"))], None, - ( - Timestamp::new_millisecond(1000), - Timestamp::new_millisecond(2000), - ), - vec![col("k0").eq(lit("foo")), col("ts").is_not_null()], - vec![col("ts").gt_eq(ts_lit(1000))], + partition_range, ) .await; + let (ctx_b, part_b) = new_stream_context( + vec![or_b.clone(), col("k0").eq(lit("foo"))], + None, + partition_range, + ) + .await; + + assert!(ctx_a.scan_implied_time_range.is_none()); + let key_a = build_range_cache_key(&ctx_a, &part_a).unwrap(); + let key_b = build_range_cache_key(&ctx_b, &part_b).unwrap(); + assert_ne!(key_a.scan, key_b.scan); + assert_eq!( + key_a.scan.time_filters(), + normalized_exprs([or_a]).as_slice() + ); + } + + #[tokio::test] + async fn empty_implied_range_does_not_panic_on_non_ms_file_range() { + // Contradictory time predicates make the implied range empty. The + // empty range's sentinel timestamps use `Timestamp::default()` (ms), + // so without the `is_empty()` short-circuit the unit asserts would + // panic against a non-ms `range_meta.time_range`. + let partition = ( + Timestamp::new_millisecond(1000), + Timestamp::new_millisecond(2000), + ); + + let (mut ctx, part_range) = new_stream_context( + vec![col("ts").gt_eq(ts_lit(1500)), col("k0").eq(lit("foo"))], + TimestampRange::with_unit(1500, 3000, TimeUnit::Millisecond), + partition, + ) + .await; + + ctx.scan_implied_time_range = Some(TimestampRange::empty()); + ctx.ranges[0].time_range = ( + Timestamp::new(1_000_000_000, TimeUnit::Nanosecond), + Timestamp::new(2_000_000_000, TimeUnit::Nanosecond), + ); + + let key = build_range_cache_key(&ctx, &part_range).unwrap(); + // Empty implied range cannot cover, so time filters stay in the key. + assert!(!key.scan.time_filters().is_empty()); + } + + fn ms_ts(v: i64) -> Timestamp { + Timestamp::new_millisecond(v) + } + + fn implied_ms(expr: Expr) -> Option { + implied_time_range_from_exprs("ts", TimeUnit::Millisecond, &[&expr]) + } + + #[test] + fn implied_time_range_supported_exprs() { + for (expr, expected) in [ + ( + col("ts").gt_eq(ts_lit(1000)), + Some(TimestampRange::from_start(ms_ts(1000))), + ), + ( + col("ts").gt(ts_lit(1000)), + Some(TimestampRange::from_start(ms_ts(1001))), + ), + ( + col("ts").lt_eq(ts_lit(2000)), + Some(TimestampRange::until_end(ms_ts(2000), true)), + ), + ( + col("ts").lt(ts_lit(2000)), + Some(TimestampRange::until_end(ms_ts(2000), false)), + ), + ( + col("ts").eq(ts_lit(1500)), + Some(TimestampRange::single(ms_ts(1500))), + ), + ( + ts_lit(1000).lt_eq(col("ts")), + Some(TimestampRange::from_start(ms_ts(1000))), + ), + ( + col("ts").between(ts_lit(1000), ts_lit(2000)), + Some(TimestampRange::new_inclusive( + Some(ms_ts(1000)), + Some(ms_ts(2000)), + )), + ), + ( + col("ts") + .gt_eq(ts_lit(1000)) + .and(col("ts").lt(ts_lit(2000))), + TimestampRange::with_unit(1000, 2000, TimeUnit::Millisecond), + ), + ( + col("ts") + .gt_eq(ts_lit(1000)) + .and(col("ts").lt(ts_lit(5000))) + .and(col("ts").lt_eq(ts_lit(3000))), + TimestampRange::with_unit(1000, 3001, TimeUnit::Millisecond), + ), + ] { + assert_eq!(implied_ms(expr), expected); + } + + assert_eq!( + implied_time_range_from_exprs("ts", TimeUnit::Millisecond, &[]), + Some(TimestampRange::min_to_max()) + ); + } + + #[test] + fn implied_time_range_unsupported_exprs() { + let not_between = Expr::Between(Between { + expr: Box::new(col("ts")), + negated: true, + low: Box::new(ts_lit(1000)), + high: Box::new(ts_lit(2000)), + }); + + for expr in [ + not_between, + col("ts").gt_eq(ts_lit(1000)).or(col("ts").lt(ts_lit(500))), + Expr::Not(Box::new(col("ts").gt_eq(ts_lit(1000)))), + col("ts").in_list(vec![ts_lit(1000), ts_lit(2000)], false), + col("ts").gt_eq(col("other")), + col("other_ts").gt_eq(ts_lit(1000)), + ] { + assert!(implied_ms(expr).is_none()); + } + } + + #[test] + fn implied_time_range_unit_conversion() { + let second_1 = lit(ScalarValue::TimestampSecond(Some(1), None)); + let ns_1500 = lit(ScalarValue::TimestampNanosecond(Some(1_500_000_000), None)); + let ns_1500_5 = lit(ScalarValue::TimestampNanosecond(Some(1_500_500_000), None)); + + for (expr, expected) in [ + ( + col("ts").gt_eq(second_1.clone()), + Some(TimestampRange::from_start(ms_ts(1000))), + ), + ( + col("ts").lt_eq(second_1), + Some(TimestampRange::until_end(ms_ts(1000), true)), + ), + ( + col("ts").eq(ns_1500), + Some(TimestampRange::single(ms_ts(1500))), + ), + (col("ts").eq(ns_1500_5.clone()), None), + ( + col("ts").gt_eq(ns_1500_5.clone()), + Some(TimestampRange::from_start(ms_ts(1501))), + ), + ( + col("ts").lt_eq(ns_1500_5.clone()), + Some(TimestampRange::until_end(ms_ts(1500), true)), + ), + ( + col("ts").gt(ns_1500_5.clone()), + Some(TimestampRange::from_start(ms_ts(1501))), + ), + ( + col("ts").lt(ns_1500_5), + Some(TimestampRange::until_end(ms_ts(1501), false)), + ), + ] { + assert_eq!(implied_ms(expr), expected); + } } #[test] diff --git a/src/mito2/src/read/scan_region.rs b/src/mito2/src/read/scan_region.rs index f002c08bd7..59d83becec 100644 --- a/src/mito2/src/read/scan_region.rs +++ b/src/mito2/src/read/scan_region.rs @@ -57,7 +57,7 @@ use crate::metrics::READ_SST_COUNT; use crate::read::compat::{self, FlatCompatBatch}; use crate::read::flat_projection::FlatProjectionMapper; use crate::read::range::{FileRangeBuilder, MemRangeBuilder, RangeMeta, RowGroupIndex}; -use crate::read::range_cache::ScanRequestFingerprint; +use crate::read::range_cache::{ScanRequestFingerprint, implied_time_range_from_exprs}; use crate::read::read_columns::{ ReadColumns, merge, read_columns_from_predicate, read_columns_from_projection, }; @@ -1299,9 +1299,21 @@ fn pre_filter_mode(append_mode: bool, merge_mode: MergeMode) -> PreFilterMode { } } -/// Builds a [ScanRequestFingerprint] from a [ScanInput] if the scan is eligible +/// Output of [build_scan_fingerprint]: the cache fingerprint plus the derived +/// implied time range used to decide whether the cache key can drop the time +/// predicates for a given partition (see `build_range_cache_key`). +pub(crate) struct ScanFingerprintBundle { + pub(crate) fingerprint: ScanRequestFingerprint, + /// `Some(r)` = all time-only predicates are guaranteed true on `r` (in the + /// column's `TimeUnit`). + /// `None` = at least one time-only predicate could not be proven (e.g. + /// `OR`), so the cache-key optimization is disabled for this scan. + pub(crate) implied_time_range: Option, +} + +/// Builds a [ScanFingerprintBundle] from a [ScanInput] if the scan is eligible /// for partition range caching. -pub(crate) fn build_scan_fingerprint(input: &ScanInput) -> Option { +pub(crate) fn build_scan_fingerprint(input: &ScanInput) -> Option { let eligible = !input.compaction && !input.files.is_empty() && matches!(input.cache_strategy, CacheStrategy::EnableAll(_)); @@ -1334,7 +1346,7 @@ pub(crate) fn build_scan_fingerprint(input: &ScanInput) -> Option = Vec::new(); let mut has_tag_filter = false; let mut columns = HashSet::new(); @@ -1350,20 +1362,17 @@ pub(crate) fn build_scan_fingerprint(input: &ScanInput) -> Option false, }; - // TODO(yingwen): The split between `time_filters` and `filters` is currently inert - // because `build_range_cache_key()` always keeps both in the cache key. We used to - // strip `time_filters` when the query's `TimestampRange` covered the partition's - // `FileTimeRange`, but `extract_time_range_from_expr` is not precise enough to prove - // a time predicate is implied by that range (it can return a wider range than the - // predicate, and it does not analyze AND/OR shapes), which let the cache reuse rows - // that should have been filtered. Reviving the optimization needs a per-predicate - // implication check that walks each time-only `Expr` (recursing through AND/OR/NOT) - // and proves the predicate holds for every timestamp inside the partition's - // `FileTimeRange`; until then both buckets land in the fingerprint. + // Route time-only exprs that the legacy extractor recognizes into + // `time_only_exprs` so the implication walker + // (`implied_time_range_from_exprs`, called below) can attempt to drop + // them from the cache key when the partition's `FileTimeRange` is fully + // covered, then stringify them into the fingerprint's `time_filters` + // bucket. Time-only exprs that the extractor doesn't recognize stay in + // `filters` and never get stripped — conservatively correct. if is_time_only && extract_time_range_from_expr(&time_index_name, ts_col_unit, expr).is_some() { - time_filters.push(expr.to_string()); + time_only_exprs.push(expr); } else { filters.push(expr.to_string()); } @@ -1374,31 +1383,38 @@ pub(crate) fn build_scan_fingerprint(input: &ScanInput) -> Option = time_only_exprs.iter().map(|e| e.to_string()).collect(); + // Ensure the filters are sorted for consistent fingerprinting. filters.sort_unstable(); time_filters.sort_unstable(); let read_columns = input.read_cols.clone(); - Some( - crate::read::range_cache::ScanRequestFingerprintBuilder { - read_column_types: read_columns - .column_ids_iter() - .map(|id| { - metadata - .column_by_id(id) - .map(|col| col.column_schema.data_type.clone()) - }) - .collect(), - read_columns, - filters, - time_filters, - series_row_selector: input.series_row_selector, - append_mode: input.append_mode, - filter_deleted: input.filter_deleted, - merge_mode: input.merge_mode, - partition_expr_version: metadata.partition_expr_version, - } - .build(), - ) + let fingerprint = crate::read::range_cache::ScanRequestFingerprintBuilder { + read_column_types: read_columns + .column_ids_iter() + .map(|id| { + metadata + .column_by_id(id) + .map(|col| col.column_schema.data_type.clone()) + }) + .collect(), + read_columns, + filters, + time_filters, + series_row_selector: input.series_row_selector, + append_mode: input.append_mode, + filter_deleted: input.filter_deleted, + merge_mode: input.merge_mode, + partition_expr_version: metadata.partition_expr_version, + } + .build(); + + Some(ScanFingerprintBundle { + fingerprint, + implied_time_range, + }) } /// Context shared by different streams from a scanner. @@ -1412,6 +1428,13 @@ pub struct StreamContext { /// `None` when the scan is not eligible for caching. #[allow(dead_code)] pub(crate) scan_fingerprint: Option, + /// Implied range of every time-only predicate, in the time index column's + /// `TimeUnit`. Used by `build_range_cache_key` to decide whether the + /// partition's `FileTimeRange` is fully covered (allowing `time_filters` + /// to be stripped from the cache key). `None` when caching is ineligible + /// or when the implication walker bailed on an unsupported shape (e.g. + /// `OR`). + pub(crate) scan_implied_time_range: Option, // Metrics: /// The start time of the query. @@ -1424,12 +1447,16 @@ impl StreamContext { let query_start = input.query_start.unwrap_or_else(Instant::now); let ranges = RangeMeta::seq_scan_ranges(&input); READ_SST_COUNT.observe(input.num_files() as f64); - let scan_fingerprint = build_scan_fingerprint(&input); + let (scan_fingerprint, scan_implied_time_range) = match build_scan_fingerprint(&input) { + Some(b) => (Some(b.fingerprint), b.implied_time_range), + None => (None, None), + }; Self { input, ranges, scan_fingerprint, + scan_implied_time_range, query_start, } } @@ -1439,12 +1466,16 @@ impl StreamContext { let query_start = input.query_start.unwrap_or_else(Instant::now); let ranges = RangeMeta::unordered_scan_ranges(&input); READ_SST_COUNT.observe(input.num_files() as f64); - let scan_fingerprint = build_scan_fingerprint(&input); + let (scan_fingerprint, scan_implied_time_range) = match build_scan_fingerprint(&input) { + Some(b) => (Some(b.fingerprint), b.implied_time_range), + None => (None, None), + }; Self { input, ranges, scan_fingerprint, + scan_implied_time_range, query_start, } } @@ -1841,7 +1872,7 @@ mod tests { partition_expr_version: 0, } .build(); - assert_eq!(expected, fingerprint); + assert_eq!(expected, fingerprint.fingerprint); } #[tokio::test] @@ -1914,7 +1945,7 @@ mod tests { partition_expr_version: metadata.partition_expr_version, } .build(); - assert_eq!(expected, fingerprint); + assert_eq!(expected, fingerprint.fingerprint); assert_ne!(0, metadata.partition_expr_version); } diff --git a/src/mito2/src/read/scan_util.rs b/src/mito2/src/read/scan_util.rs index 5b7e46b0c1..1d97b2eb76 100644 --- a/src/mito2/src/read/scan_util.rs +++ b/src/mito2/src/read/scan_util.rs @@ -1375,6 +1375,7 @@ mod split_tests { input, ranges: vec![], scan_fingerprint: None, + scan_implied_time_range: None, query_start: std::time::Instant::now(), } } @@ -1755,6 +1756,7 @@ mod tests { input, ranges: Vec::new(), scan_fingerprint: None, + scan_implied_time_range: None, query_start: Instant::now(), }) } diff --git a/src/table/src/predicate.rs b/src/table/src/predicate.rs index e559e2c296..29b1043049 100644 --- a/src/table/src/predicate.rs +++ b/src/table/src/predicate.rs @@ -41,7 +41,7 @@ mod stats; /// In theory, it should be converted to a timestamp scalar value by `TypeConversionRule`. macro_rules! return_none_if_utf8 { ($lit: ident) => { - if matches!($lit, ScalarValue::Utf8(_)) { + if is_string_timestamp_literal($lit) { warn!( "Unexpected ScalarValue::Utf8 in time range predicate: {:?}. Maybe it's an implicit bug, please report it to https://github.com/GreptimeTeam/greptimedb/issues", $lit @@ -53,6 +53,13 @@ macro_rules! return_none_if_utf8 { }; } +pub fn is_string_timestamp_literal(scalar: &ScalarValue) -> bool { + matches!( + scalar, + ScalarValue::Utf8(_) | ScalarValue::LargeUtf8(_) | ScalarValue::Utf8View(_) + ) +} + /// Reference-counted pointer to a list of logical exprs and a list of dynamic filter physical exprs. #[derive(Debug, Clone, Default)] pub struct Predicate { From 44f1804b5ed2b122a57c8422770159947ef2794f Mon Sep 17 00:00:00 2001 From: discord9 Date: Tue, 26 May 2026 15:24:18 +0800 Subject: [PATCH 14/32] feat: add flow query-context plumbing for terminal watermarks (#8154) * feat: add flow checkpoint plumbing Signed-off-by: discord9 * fix: restore when fail Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 * chore: clean up some test Signed-off-by: discord9 * clippy Signed-off-by: discord9 * refactor: move more to pr3b Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 --------- Signed-off-by: discord9 --- src/catalog/src/table_source/dummy_catalog.rs | 31 +- src/datanode/src/region_server.rs | 17 +- src/flow/src/batching_mode.rs | 4 +- src/flow/src/batching_mode/checkpoint.rs | 23 + src/flow/src/batching_mode/engine.rs | 451 +++++++++++- src/flow/src/batching_mode/frontend_client.rs | 362 +++++++++- src/flow/src/batching_mode/state.rs | 37 +- src/flow/src/batching_mode/table_creator.rs | 381 ++++++++++ src/flow/src/batching_mode/task.rs | 652 ++++++------------ src/flow/src/batching_mode/task/test.rs | 337 +++++++++ src/frontend/src/instance/grpc.rs | 11 +- src/query/src/dummy_catalog.rs | 48 +- 12 files changed, 1847 insertions(+), 507 deletions(-) create mode 100644 src/flow/src/batching_mode/checkpoint.rs create mode 100644 src/flow/src/batching_mode/table_creator.rs create mode 100644 src/flow/src/batching_mode/task/test.rs diff --git a/src/catalog/src/table_source/dummy_catalog.rs b/src/catalog/src/table_source/dummy_catalog.rs index db49db0eed..20637c3a3a 100644 --- a/src/catalog/src/table_source/dummy_catalog.rs +++ b/src/catalog/src/table_source/dummy_catalog.rs @@ -22,6 +22,7 @@ use async_trait::async_trait; use common_catalog::format_full_table_name; use datafusion::catalog::{CatalogProvider, CatalogProviderList, SchemaProvider}; use datafusion::datasource::TableProvider; +use session::context::QueryContextRef; use snafu::OptionExt; use table::table::adapter::DfTableProviderAdapter; @@ -32,12 +33,27 @@ use crate::error::TableNotExistSnafu; #[derive(Clone)] pub struct DummyCatalogList { catalog_manager: CatalogManagerRef, + query_ctx: Option, } impl DummyCatalogList { - /// Creates a new catalog list with the given catalog manager. + /// Creates a new catalog list with the given catalog manager (no query context). pub fn new(catalog_manager: CatalogManagerRef) -> Self { - Self { catalog_manager } + Self { + catalog_manager, + query_ctx: None, + } + } + + /// Creates a new catalog list with the given catalog manager and query context. + pub fn new_with_query_ctx( + catalog_manager: CatalogManagerRef, + query_ctx: QueryContextRef, + ) -> Self { + Self { + catalog_manager, + query_ctx: Some(query_ctx), + } } } @@ -68,6 +84,7 @@ impl CatalogProviderList for DummyCatalogList { Some(Arc::new(DummyCatalogProvider { catalog_name: catalog_name.to_string(), catalog_manager: self.catalog_manager.clone(), + query_ctx: self.query_ctx.clone(), })) } } @@ -77,6 +94,7 @@ impl CatalogProviderList for DummyCatalogList { struct DummyCatalogProvider { catalog_name: String, catalog_manager: CatalogManagerRef, + query_ctx: Option, } impl CatalogProvider for DummyCatalogProvider { @@ -93,6 +111,7 @@ impl CatalogProvider for DummyCatalogProvider { catalog_name: self.catalog_name.clone(), schema_name: schema_name.to_string(), catalog_manager: self.catalog_manager.clone(), + query_ctx: self.query_ctx.clone(), })) } } @@ -111,6 +130,7 @@ struct DummySchemaProvider { catalog_name: String, schema_name: String, catalog_manager: CatalogManagerRef, + query_ctx: Option, } #[async_trait] @@ -126,7 +146,12 @@ impl SchemaProvider for DummySchemaProvider { async fn table(&self, name: &str) -> datafusion::error::Result>> { let table = self .catalog_manager - .table(&self.catalog_name, &self.schema_name, name, None) + .table( + &self.catalog_name, + &self.schema_name, + name, + self.query_ctx.as_deref(), + ) .await? .with_context(|| TableNotExistSnafu { table: format_full_table_name(&self.catalog_name, &self.schema_name, name), diff --git a/src/datanode/src/region_server.rs b/src/datanode/src/region_server.rs index aa2e627ca2..d5711e1761 100644 --- a/src/datanode/src/region_server.rs +++ b/src/datanode/src/region_server.rs @@ -314,6 +314,7 @@ impl RegionServer { let ctx = request.header.as_ref().map(|h| h.into()); let query_ctx = Arc::new(ctx.unwrap_or_else(|| QueryContextBuilder::default().build())); + let region_id = request.region_id; let injector_builder = NameAwareDataSourceInjectorBuilder::from_plan(&request.plan) .context(DataFusionSnafu)?; let mut injector = injector_builder @@ -326,7 +327,6 @@ impl RegionServer { .context(DataFusionSnafu)? .data; - let region_id = request.region_id; let stream = self .inner .handle_read(QueryRequest { plan, ..request }, query_ctx.clone()) @@ -837,14 +837,13 @@ fn wrap_flow_region_watermark_stream( region_id: RegionId, query_ctx: &QueryContextRef, ) -> SendableRecordBatchStream { - let Some(seq) = should_collect_region_watermark_from_extensions(&query_ctx.extensions()) - .then(|| query_ctx.get_snapshot(region_id.as_u64())) - .flatten() - else { - return stream; - }; - - Box::pin(RegionWatermarkStream::new(stream, region_id, seq)) + if should_collect_region_watermark_from_extensions(&query_ctx.extensions()) + && let Some(seq) = query_ctx.get_snapshot(region_id.as_u64()) + { + Box::pin(RegionWatermarkStream::new(stream, region_id, seq)) as SendableRecordBatchStream + } else { + stream + } } /// Wraps a region read stream so terminal metrics can carry the scan-open watermark. diff --git a/src/flow/src/batching_mode.rs b/src/flow/src/batching_mode.rs index 4162daa20c..47b3054f54 100644 --- a/src/flow/src/batching_mode.rs +++ b/src/flow/src/batching_mode.rs @@ -20,12 +20,14 @@ use common_grpc::channel_manager::ClientTlsOption; use serde::{Deserialize, Serialize}; use session::ReadPreference; +mod checkpoint; pub(crate) mod engine; pub(crate) mod frontend_client; mod state; +mod table_creator; mod task; mod time_window; -mod utils; +pub(crate) mod utils; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BatchingModeOptions { diff --git a/src/flow/src/batching_mode/checkpoint.rs b/src/flow/src/batching_mode/checkpoint.rs new file mode 100644 index 0000000000..c359360dc5 --- /dev/null +++ b/src/flow/src/batching_mode/checkpoint.rs @@ -0,0 +1,23 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CheckpointMode { + /// Full-snapshot reads over the source tables. + FullSnapshot, + /// Incremental reads driven by explicitly emitted incremental scan + /// extensions. + Incremental, +} diff --git a/src/flow/src/batching_mode/engine.rs b/src/flow/src/batching_mode/engine.rs index 054f5db9d6..f37e54d80b 100644 --- a/src/flow/src/batching_mode/engine.rs +++ b/src/flow/src/batching_mode/engine.rs @@ -59,8 +59,7 @@ use crate::{CreateFlowArgs, Error, FlowId, TableName}; /// /// TODO(discord9): determine how to configure refresh rate pub struct BatchingEngine { - tasks: RwLock>, - shutdown_txs: RwLock>>, + runtime: RwLock, /// frontend client for insert request pub(crate) frontend_client: Arc, flow_metadata_manager: FlowMetadataManagerRef, @@ -72,6 +71,51 @@ pub struct BatchingEngine { pub(crate) batch_opts: Arc, } +#[derive(Default)] +struct FlowRuntimeRegistry { + tasks: BTreeMap, + shutdown_txs: BTreeMap>, +} + +impl FlowRuntimeRegistry { + fn insert( + &mut self, + flow_id: FlowId, + task: BatchingTask, + shutdown_tx: oneshot::Sender<()>, + ) -> (Option, Option>) { + ( + self.tasks.insert(flow_id, task), + self.shutdown_txs.insert(flow_id, shutdown_tx), + ) + } + + fn remove(&mut self, flow_id: FlowId) -> Option<(BatchingTask, Option>)> { + let task = self.tasks.remove(&flow_id)?; + let shutdown_tx = self.shutdown_txs.remove(&flow_id); + Some((task, shutdown_tx)) + } + + fn remove_if_current( + &mut self, + flow_id: FlowId, + task: &BatchingTask, + ) -> (Option, Option>) { + if self + .tasks + .get(&flow_id) + .is_some_and(|current| Arc::ptr_eq(¤t.state, &task.state)) + { + let Some((removed_task, removed_shutdown_tx)) = self.remove(flow_id) else { + return (None, None); + }; + (Some(removed_task), removed_shutdown_tx) + } else { + (None, None) + } + } +} + impl BatchingEngine { pub fn new( frontend_client: Arc, @@ -82,8 +126,7 @@ impl BatchingEngine { batch_opts: BatchingModeOptions, ) -> Self { Self { - tasks: Default::default(), - shutdown_txs: Default::default(), + runtime: Default::default(), frontend_client, flow_metadata_manager, table_meta, @@ -95,8 +138,9 @@ impl BatchingEngine { /// Returns last execution timestamps (millisecond) for all batching flows. pub async fn get_last_exec_time_map(&self) -> BTreeMap { - let tasks = self.tasks.read().await; - tasks + let runtime = self.runtime.read().await; + runtime + .tasks .iter() .filter_map(|(flow_id, task)| { task.last_execution_time_millis() @@ -151,10 +195,17 @@ impl BatchingEngine { let group_by_table_name = Arc::new(group_by_table_name); + let tasks = self + .runtime + .read() + .await + .tasks + .values() + .cloned() + .collect::>(); let mut handles = Vec::new(); - let tasks = self.tasks.read().await; - for (_flow_id, task) in tasks.iter() { + for task in tasks { let src_table_names = &task.config.source_table_names; if src_table_names @@ -204,7 +255,6 @@ impl BatchingEngine { }); handles.push(handle); } - drop(tasks); for handle in handles { match handle.await { Err(e) => { @@ -274,9 +324,16 @@ impl BatchingEngine { let group_by_table_name = Arc::new(group_by_table_name); + let tasks = self + .runtime + .read() + .await + .tasks + .values() + .cloned() + .collect::>(); let mut handles = Vec::new(); - let tasks = self.tasks.read().await; - for (_flow_id, task) in tasks.iter() { + for task in tasks { let src_table_names = &task.config.source_table_names; if src_table_names @@ -327,8 +384,6 @@ impl BatchingEngine { } } } - drop(tasks); - Ok(()) } } @@ -390,7 +445,7 @@ impl BatchingEngine { // or replace logic { - let is_exist = self.tasks.read().await.contains_key(&flow_id); + let is_exist = self.runtime.read().await.tasks.contains_key(&flow_id); match (create_if_not_exists, or_replace, is_exist) { // if replace, ignore that old flow exists (_, true, true) => { @@ -521,17 +576,60 @@ impl BatchingEngine { // check execute once first to detect any error early task.check_or_create_sink_table(&engine, &frontend).await?; + let (start_tx, start_rx) = oneshot::channel(); + // TODO(discord9): use time wheel or what for better let handle = common_runtime::spawn_global(async move { - task_inner.start_executing_loop(engine, frontend).await; + if start_rx.await.is_ok() { + task_inner.start_executing_loop(engine, frontend).await; + } }); task.state.write().unwrap().task_handle = Some(handle); + let task_for_rollback = task.clone(); - // only replace here not earlier because we want the old one intact if something went wrong before this line - let replaced_old_task_opt = self.tasks.write().await.insert(flow_id, task); - drop(replaced_old_task_opt); + // Only replace here, not earlier, because we want the old one intact if + // something went wrong before this line. Keep the task and shutdown + // sender in one registry lock so create/remove can't observe one + // without the other. + let (replaced_old_task_opt, replaced_old_shutdown_tx) = { + let mut runtime = self.runtime.write().await; - self.shutdown_txs.write().await.insert(flow_id, tx); + let is_exist = runtime.tasks.contains_key(&flow_id); + match (create_if_not_exists, or_replace, is_exist) { + (_, true, true) => { + info!( + "Replacing flow with id={} after final registry check", + flow_id + ); + } + (false, false, true) => { + abort_flow_task(flow_id, Some(task), "unregistered"); + return FlowAlreadyExistSnafu { id: flow_id }.fail(); + } + (true, false, true) => { + info!( + "Flow with id={} already exists at final registry check, do nothing", + flow_id + ); + abort_flow_task(flow_id, Some(task), "unregistered"); + return Ok(None); + } + (_, _, false) => (), + } + + runtime.insert(flow_id, task, tx) + }; + + notify_flow_shutdown(flow_id, replaced_old_shutdown_tx, "replaced"); + abort_flow_task(flow_id, replaced_old_task_opt, "replaced"); + if start_tx.send(()).is_err() { + self.rollback_flow_runtime_if_current(flow_id, &task_for_rollback) + .await; + UnexpectedSnafu { + reason: format!("Failed to start flow {flow_id} due to task already dropped"), + } + .fail()?; + } Ok(Some(flow_id)) } @@ -662,21 +760,25 @@ impl BatchingEngine { } pub async fn remove_flow_inner(&self, flow_id: FlowId) -> Result<(), Error> { - if self.tasks.write().await.remove(&flow_id).is_none() { - warn!("Flow {flow_id} not found in tasks"); - FlowNotFoundSnafu { id: flow_id }.fail()?; - } - let Some(tx) = self.shutdown_txs.write().await.remove(&flow_id) else { + let (task, shutdown_tx) = { + let mut runtime = self.runtime.write().await; + let Some((task, shutdown_tx)) = runtime.remove(flow_id) else { + warn!("Flow {flow_id} not found in tasks"); + FlowNotFoundSnafu { id: flow_id }.fail()? + }; + (task, shutdown_tx) + }; + + let had_shutdown_tx = notify_flow_shutdown(flow_id, shutdown_tx, "removed"); + abort_flow_task(flow_id, Some(task), "removed"); + + if !had_shutdown_tx { UnexpectedSnafu { reason: format!("Can't found shutdown tx for flow {flow_id}"), } .fail()? - }; - if tx.send(()).is_err() { - warn!( - "Fail to shutdown flow {flow_id} due to receiver already dropped, maybe flow {flow_id} is already dropped?" - ) } + Ok(()) } @@ -688,7 +790,7 @@ impl BatchingEngine { // this is only useful for the case when we are flushing the flow right after inserting data into it // TODO(discord9): find a better way to ensure the data is ready, maybe inform flownode from frontend? tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let task = self.tasks.read().await.get(&flow_id).cloned(); + let task = self.runtime.read().await.tasks.get(&flow_id).cloned(); let task = task.with_context(|| FlowNotFoundSnafu { id: flow_id })?; let time_window_size = task @@ -713,7 +815,7 @@ impl BatchingEngine { ) .await?; - let affected_rows = res.map(|(r, _)| r).unwrap_or_default() as usize; + let affected_rows = res.map(|(r, _)| r).unwrap_or_default(); debug!( "Successfully flush flow {flow_id}, affected rows={}", affected_rows @@ -723,8 +825,46 @@ impl BatchingEngine { /// Determine if the batching mode flow task exists with given flow id pub async fn flow_exist_inner(&self, flow_id: FlowId) -> bool { - self.tasks.read().await.contains_key(&flow_id) + self.runtime.read().await.tasks.contains_key(&flow_id) } + + async fn rollback_flow_runtime_if_current(&self, flow_id: FlowId, task: &BatchingTask) { + let (removed_task, removed_shutdown_tx) = { + let mut runtime = self.runtime.write().await; + runtime.remove_if_current(flow_id, task) + }; + + notify_flow_shutdown(flow_id, removed_shutdown_tx, "rolled back"); + abort_flow_task(flow_id, removed_task, "rolled back"); + } +} + +fn notify_flow_shutdown(flow_id: FlowId, tx: Option>, action: &str) -> bool { + let Some(tx) = tx else { + return false; + }; + + if tx.send(()).is_err() { + warn!( + "Fail to shutdown {action} flow {flow_id} due to receiver already dropped, maybe flow {flow_id} is already dropped?" + ); + } + + true +} + +fn abort_flow_task(flow_id: FlowId, task: Option, action: &str) -> bool { + let Some(task) = task else { + return false; + }; + + if let Some(handle) = task.state.write().unwrap().task_handle.take() { + handle.abort(); + debug!("Aborted {action} flow task {flow_id}"); + return true; + } + + false } impl FlowEngine for BatchingEngine { @@ -741,7 +881,14 @@ impl FlowEngine for BatchingEngine { Ok(self.flow_exist_inner(flow_id).await) } async fn list_flows(&self) -> Result, Error> { - Ok(self.tasks.read().await.keys().cloned().collect::>()) + Ok(self + .runtime + .read() + .await + .tasks + .keys() + .cloned() + .collect::>()) } async fn handle_flow_inserts( &self, @@ -756,3 +903,241 @@ impl FlowEngine for BatchingEngine { self.handle_mark_dirty_time_window(req).await } } + +#[cfg(test)] +mod tests { + use catalog::memory::new_memory_catalog_manager; + use common_meta::key::TableMetadataManager; + use common_meta::key::flow::FlowMetadataManager; + use common_meta::kv_backend::memory::MemoryKvBackend; + use query::options::QueryOptions; + use session::context::QueryContext; + + use super::*; + use crate::test_utils::create_test_query_engine; + + struct DropNotify(Option>); + + impl Drop for DropNotify { + fn drop(&mut self) { + if let Some(tx) = self.0.take() { + let _ = tx.send(()); + } + } + } + + async fn new_test_engine() -> BatchingEngine { + let kv_backend = Arc::new(MemoryKvBackend::new()); + let table_meta = Arc::new(TableMetadataManager::new(kv_backend.clone())); + table_meta.init().await.unwrap(); + let flow_meta = Arc::new(FlowMetadataManager::new(kv_backend)); + let catalog_manager = new_memory_catalog_manager().unwrap(); + let query_engine = create_test_query_engine(); + let (frontend_client, _handler) = + FrontendClient::from_empty_grpc_handler(QueryOptions::default()); + + BatchingEngine::new( + Arc::new(frontend_client), + query_engine, + flow_meta, + table_meta, + catalog_manager, + BatchingModeOptions::default(), + ) + } + + async fn new_test_task(flow_id: FlowId) -> (BatchingTask, oneshot::Sender<()>) { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan = sql_to_df_plan( + ctx.clone(), + query_engine.clone(), + "SELECT number, ts FROM numbers_with_ts", + true, + ) + .await + .unwrap(); + let (tx, rx) = oneshot::channel(); + + let task = BatchingTask::try_new(TaskArgs { + flow_id, + query: "SELECT number, ts FROM numbers_with_ts", + plan, + time_window_expr: None, + expire_after: None, + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + "sink".to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + (task, tx) + } + + async fn install_abort_observed_handle(task: &BatchingTask) -> oneshot::Receiver<()> { + let (drop_tx, drop_rx) = oneshot::channel(); + let (entered_tx, entered_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let _guard = DropNotify(Some(drop_tx)); + let _ = entered_tx.send(()); + std::future::pending::<()>().await; + }); + task.state.write().unwrap().task_handle = Some(handle); + tokio::time::timeout(Duration::from_secs(1), entered_rx) + .await + .expect("test task handle should start") + .expect("test task handle should report start"); + drop_rx + } + + #[tokio::test] + async fn test_notify_flow_shutdown_sends_signal() { + let (tx, rx) = oneshot::channel(); + + assert!(notify_flow_shutdown(42, Some(tx), "test")); + + rx.await.expect("replaced flow should receive shutdown"); + } + + #[test] + fn test_notify_flow_shutdown_accepts_missing_sender() { + assert!(!notify_flow_shutdown(42, None, "test")); + } + + #[tokio::test] + async fn test_abort_flow_task_aborts_handle() { + let (task, _shutdown_tx) = new_test_task(42).await; + let drop_rx = install_abort_observed_handle(&task).await; + + assert!(abort_flow_task(42, Some(task), "test")); + + tokio::time::timeout(Duration::from_secs(1), drop_rx) + .await + .expect("aborted task should be dropped") + .expect("drop notifier should fire"); + } + + #[tokio::test] + async fn test_remove_flow_inner_aborts_registered_task() { + let engine = new_test_engine().await; + let (task, shutdown_tx) = new_test_task(42).await; + let drop_rx = install_abort_observed_handle(&task).await; + + engine.runtime.write().await.insert(42, task, shutdown_tx); + + engine.remove_flow_inner(42).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), drop_rx) + .await + .expect("removed task should be dropped") + .expect("drop notifier should fire"); + assert!(!engine.flow_exist_inner(42).await); + assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42)); + } + + #[tokio::test] + async fn test_or_replace_flow_runtime_replaces_old_handles_and_keeps_new_task() { + let engine = new_test_engine().await; + let (old_task, old_shutdown_tx) = new_test_task(42).await; + let old_task_identity = old_task.clone(); + let old_drop_rx = install_abort_observed_handle(&old_task).await; + let (new_task, new_shutdown_tx) = new_test_task(42).await; + let new_task_identity = new_task.clone(); + + engine + .runtime + .write() + .await + .insert(42, old_task, old_shutdown_tx); + let (replaced_old_task, replaced_old_shutdown_tx) = + engine + .runtime + .write() + .await + .insert(42, new_task, new_shutdown_tx); + + let replaced_old_task = replaced_old_task.expect("old task should be returned"); + assert!(Arc::ptr_eq( + &replaced_old_task.state, + &old_task_identity.state + )); + assert!(notify_flow_shutdown( + 42, + replaced_old_shutdown_tx, + "replaced" + )); + old_task_identity + .state + .write() + .unwrap() + .shutdown_rx + .try_recv() + .expect("old shutdown receiver should receive signal"); + assert!(abort_flow_task(42, Some(replaced_old_task), "replaced")); + + tokio::time::timeout(Duration::from_secs(1), old_drop_rx) + .await + .expect("replaced task should be dropped") + .expect("drop notifier should fire"); + + let runtime = engine.runtime.read().await; + assert_eq!(1, runtime.tasks.len()); + assert_eq!(1, runtime.shutdown_txs.len()); + let registered_task = runtime.tasks.get(&42).expect("new task should remain"); + assert!(Arc::ptr_eq( + ®istered_task.state, + &new_task_identity.state + )); + assert!(runtime.shutdown_txs.contains_key(&42)); + assert!(matches!( + new_task_identity + .state + .write() + .unwrap() + .shutdown_rx + .try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn test_rollback_flow_runtime_if_current_removes_matching_task_only() { + let engine = new_test_engine().await; + let (old_task, _old_shutdown_tx) = new_test_task(42).await; + let (current_task, current_shutdown_tx) = new_test_task(42).await; + let current_task_identity = current_task.clone(); + + engine + .runtime + .write() + .await + .insert(42, current_task, current_shutdown_tx); + + engine.rollback_flow_runtime_if_current(42, &old_task).await; + + let registered_task = engine.runtime.read().await.tasks.get(&42).cloned().unwrap(); + assert!(Arc::ptr_eq( + ®istered_task.state, + ¤t_task_identity.state + )); + assert!(engine.runtime.read().await.shutdown_txs.contains_key(&42)); + + engine + .rollback_flow_runtime_if_current(42, ¤t_task_identity) + .await; + assert!(!engine.flow_exist_inner(42).await); + assert!(!engine.runtime.read().await.shutdown_txs.contains_key(&42)); + } +} diff --git a/src/flow/src/batching_mode/frontend_client.rs b/src/flow/src/batching_mode/frontend_client.rs index 7382f214e5..8fbffc5a38 100644 --- a/src/flow/src/batching_mode/frontend_client.rs +++ b/src/flow/src/batching_mode/frontend_client.rs @@ -20,15 +20,17 @@ use std::sync::{Arc, Mutex, Weak}; use api::v1::greptime_request::Request; use api::v1::query_request::Query; use api::v1::{CreateTableExpr, QueryRequest}; -use client::{Client, Database}; +use client::{Client, Database, OutputWithMetrics}; use common_error::ext::BoxedError; use common_grpc::channel_manager::{ChannelConfig, ChannelManager, load_client_tls_config}; use common_meta::peer::{Peer, PeerDiscovery}; -use common_query::Output; +use common_query::{Output, OutputData}; +use common_recordbatch::adapter::{RecordBatchMetrics, RegionWatermarkEntry}; use common_telemetry::warn; use meta_client::client::MetaClient; use query::datafusion::QUERY_PARALLELISM_HINT; -use query::options::QueryOptions; +use query::metrics::terminal_recordbatch_metrics_from_plan; +use query::options::{FlowQueryExtensions, QueryOptions}; use rand::rng; use rand::seq::SliceRandom; use servers::query_handler::grpc::GrpcQueryHandler; @@ -196,9 +198,6 @@ impl DatabaseWithPeer { } impl FrontendClient { - // TODO: support more fine-grained load balancing strategies for frontend - // selection, such as AZ (availability zone) awareness, to prefer frontends - // in the same zone as the flownode and reduce cross-AZ latency. /// scan for available frontend from metadata pub(crate) async fn scan_for_frontend(&self) -> Result, Error> { let Self::Distributed { meta_client, .. } = self else { @@ -341,6 +340,78 @@ impl FrontendClient { } } + pub async fn query_with_terminal_metrics( + &self, + catalog: &str, + schema: &str, + request: QueryRequest, + extensions: &[(&str, &str)], + ) -> Result { + let flow_extensions = build_flow_extensions(extensions)?; + match self { + FrontendClient::Distributed { + query, batch_opts, .. + } => { + let query_parallelism = query.parallelism.to_string(); + let hints = vec![ + (QUERY_PARALLELISM_HINT, query_parallelism.as_str()), + (READ_PREFERENCE_HINT, batch_opts.read_preference.as_ref()), + ]; + let db = self.get_random_active_frontend(catalog, schema).await?; + db.database + .query_with_terminal_metrics_and_flow_extensions(request, &hints, extensions) + .await + .map_err(BoxedError::new) + .context(ExternalSnafu) + } + FrontendClient::Standalone { + database_client, + query, + } => { + let mut extensions_map = HashMap::from([( + QUERY_PARALLELISM_HINT.to_string(), + query.parallelism.to_string(), + )]); + for (key, value) in extensions { + extensions_map.insert((*key).to_string(), (*value).to_string()); + } + let ctx = QueryContextBuilder::default() + .current_catalog(catalog.to_string()) + .current_schema(schema.to_string()) + .extensions(extensions_map) + .build(); + let ctx = Arc::new(ctx); + let database_client = { + database_client + .handler + .lock() + .map_err(|e| { + UnexpectedSnafu { + reason: format!("Failed to lock database client: {e}"), + } + .build() + })? + .as_ref() + .context(UnexpectedSnafu { + reason: "Standalone's frontend instance is not set", + })? + .upgrade() + .context(UnexpectedSnafu { + reason: "Failed to upgrade database client", + })? + }; + database_client + .do_query(Request::Query(request), ctx.clone()) + .await + .map(|output| { + wrap_standalone_output_with_terminal_metrics(output, &flow_extensions, &ctx) + }) + .map_err(BoxedError::new) + .context(ExternalSnafu) + } + } + } + /// Handle a request to frontend pub(crate) async fn handle( &self, @@ -426,6 +497,64 @@ impl FrontendClient { } } +fn build_flow_extensions(extensions: &[(&str, &str)]) -> Result { + let flow_extensions = HashMap::from_iter( + extensions + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + FlowQueryExtensions::parse_flow_extensions(&flow_extensions) + .map_err(BoxedError::new) + .context(ExternalSnafu) + .map(|extensions| extensions.unwrap_or_default()) +} + +fn wrap_standalone_output_with_terminal_metrics( + output: Output, + flow_extensions: &FlowQueryExtensions, + query_ctx: &QueryContextRef, +) -> OutputWithMetrics { + let should_collect_region_watermark = flow_extensions.should_collect_region_watermark(); + let terminal_metrics = + if should_collect_region_watermark && !matches!(&output.data, OutputData::Stream(_)) { + output + .meta + .plan + .clone() + .and_then(terminal_recordbatch_metrics_from_plan) + .or_else(|| terminal_recordbatch_metrics_from_snapshots(query_ctx)) + } else { + None + }; + let result = OutputWithMetrics::from_output(output); + if let Some(metrics) = terminal_metrics { + result.metrics.update(Some(metrics)); + } + result +} + +fn terminal_recordbatch_metrics_from_snapshots( + query_ctx: &QueryContextRef, +) -> Option { + let mut region_watermarks = query_ctx + .snapshots() + .into_iter() + .map(|(region_id, watermark)| RegionWatermarkEntry { + region_id, + watermark: Some(watermark), + }) + .collect::>(); + if region_watermarks.is_empty() { + return None; + } + + region_watermarks.sort_by_key(|entry| entry.region_id); + Some(RecordBatchMetrics { + region_watermarks, + ..Default::default() + }) +} + /// Describe a peer of frontend #[derive(Debug, Default)] pub(crate) enum PeerDesc { @@ -450,9 +579,17 @@ impl std::fmt::Display for PeerDesc { #[cfg(test)] mod tests { + use std::pin::Pin; + use std::task::{Context, Poll}; use std::time::Duration; - use common_query::Output; + use common_query::{Output, OutputData}; + use common_recordbatch::adapter::RecordBatchMetrics; + use common_recordbatch::{OrderOption, RecordBatch, RecordBatchStream}; + use datatypes::prelude::{ConcreteDataType, VectorRef}; + use datatypes::schema::{ColumnSchema, Schema}; + use datatypes::vectors::Int32Vector; + use futures::StreamExt; use tokio::time::timeout; use super::*; @@ -460,6 +597,58 @@ mod tests { #[derive(Debug)] struct NoopHandler; + struct MockMetricsStream { + schema: datatypes::schema::SchemaRef, + batch: Option, + metrics: RecordBatchMetrics, + terminal_metrics_only: bool, + } + + impl futures::Stream for MockMetricsStream { + type Item = common_recordbatch::error::Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(self.batch.take().map(Ok)) + } + + fn size_hint(&self) -> (usize, Option) { + ( + usize::from(self.batch.is_some()), + Some(usize::from(self.batch.is_some())), + ) + } + } + + impl RecordBatchStream for MockMetricsStream { + fn name(&self) -> &str { + "MockMetricsStream" + } + + fn schema(&self) -> datatypes::schema::SchemaRef { + self.schema.clone() + } + + fn output_ordering(&self) -> Option<&[OrderOption]> { + None + } + + fn metrics(&self) -> Option { + if self.terminal_metrics_only && self.batch.is_some() { + return None; + } + Some(self.metrics.clone()) + } + } + + #[derive(Debug)] + struct MetricsHandler; + + #[derive(Debug)] + struct ExtensionAwareHandler; + + #[derive(Debug)] + struct SnapshotBindingHandler; + #[async_trait::async_trait] impl GrpcQueryHandlerWithBoxedError for NoopHandler { async fn do_query( @@ -471,6 +660,63 @@ mod tests { } } + #[async_trait::async_trait] + impl GrpcQueryHandlerWithBoxedError for MetricsHandler { + async fn do_query( + &self, + _query: Request, + _ctx: QueryContextRef, + ) -> std::result::Result { + let schema = Arc::new(Schema::new(vec![ColumnSchema::new( + "v", + ConcreteDataType::int32_datatype(), + false, + )])); + let batch = RecordBatch::new( + schema.clone(), + vec![Arc::new(Int32Vector::from_slice([1, 2])) as VectorRef], + ) + .unwrap(); + Ok(Output::new_with_stream(Box::pin(MockMetricsStream { + schema, + batch: Some(batch), + metrics: RecordBatchMetrics { + region_watermarks: vec![common_recordbatch::adapter::RegionWatermarkEntry { + region_id: 42, + watermark: Some(99), + }], + ..Default::default() + }, + terminal_metrics_only: true, + }))) + } + } + + #[async_trait::async_trait] + impl GrpcQueryHandlerWithBoxedError for ExtensionAwareHandler { + async fn do_query( + &self, + _query: Request, + ctx: QueryContextRef, + ) -> std::result::Result { + assert_eq!(ctx.extension("flow.return_region_seq"), Some("true")); + Ok(Output::new_with_affected_rows(1)) + } + } + + #[async_trait::async_trait] + impl GrpcQueryHandlerWithBoxedError for SnapshotBindingHandler { + async fn do_query( + &self, + _query: Request, + ctx: QueryContextRef, + ) -> std::result::Result { + assert_eq!(ctx.extension("flow.return_region_seq"), Some("true")); + ctx.set_snapshot(42, 99); + Ok(Output::new_with_affected_rows(1)) + } + } + #[tokio::test] async fn wait_initialized() { let (client, handler_mut) = @@ -516,4 +762,106 @@ mod tests { .is_ok() ); } + + #[tokio::test] + async fn test_query_with_terminal_metrics_tracks_watermark_in_standalone_mode() { + let handler: Arc = Arc::new(MetricsHandler); + let client = + FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + + let result = client + .query_with_terminal_metrics( + "greptime", + "public", + QueryRequest { + query: Some(Query::Sql("select 1".to_string())), + }, + &[], + ) + .await + .unwrap(); + + let terminal_metrics = result.metrics.clone(); + assert!(!result.metrics.is_ready()); + assert!(terminal_metrics.get().is_none()); + + let OutputData::Stream(mut stream) = result.output.data else { + panic!("expected stream output"); + }; + while stream.next().await.is_some() {} + + assert!(terminal_metrics.is_ready()); + assert_eq!( + terminal_metrics.region_watermark_map(), + Some(HashMap::from([(42_u64, 99_u64)])) + ); + } + + #[tokio::test] + async fn test_query_with_terminal_metrics_forwards_flow_extensions_in_standalone_mode() { + let handler: Arc = Arc::new(ExtensionAwareHandler); + let client = + FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + + let result = client + .query_with_terminal_metrics( + "greptime", + "public", + QueryRequest { + query: Some(Query::Sql("insert into t select 1".to_string())), + }, + &[("flow.return_region_seq", "true")], + ) + .await + .unwrap(); + + assert!(result.metrics.is_ready()); + assert!(result.region_watermark_map().is_none()); + } + + #[tokio::test] + async fn test_query_with_terminal_metrics_uses_standalone_snapshot_bounds() { + let handler: Arc = Arc::new(SnapshotBindingHandler); + let client = + FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + + let result = client + .query_with_terminal_metrics( + "greptime", + "public", + QueryRequest { + query: Some(Query::Sql("insert into t select * from src".to_string())), + }, + &[("flow.return_region_seq", "true")], + ) + .await + .unwrap(); + + assert!(result.metrics.is_ready()); + assert_eq!( + result.region_watermark_map(), + Some(HashMap::from([(42, 99)])) + ); + } + + #[tokio::test] + async fn test_query_with_terminal_metrics_rejects_invalid_flow_extensions() { + let handler: Arc = Arc::new(NoopHandler); + let client = + FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + + let err = client + .query_with_terminal_metrics( + "greptime", + "public", + QueryRequest { + query: Some(Query::Sql("select 1".to_string())), + }, + &[("flow.return_region_seq", "not-a-bool")], + ) + .await + .unwrap_err(); + + assert!(format!("{err:?}").contains("Invalid value for flow.return_region_seq")); + } } diff --git a/src/flow/src/batching_mode/state.rs b/src/flow/src/batching_mode/state.rs index d90023ae46..c470a2f2c1 100644 --- a/src/flow/src/batching_mode/state.rs +++ b/src/flow/src/batching_mode/state.rs @@ -13,6 +13,7 @@ // limitations under the License. //! Batching mode task state, which changes frequently +//! use std::collections::BTreeMap; use std::time::Duration; @@ -199,12 +200,42 @@ impl DirtyTimeWindows { } pub fn add_window(&mut self, start: Timestamp, end: Option) { - self.windows.insert(start, end); + self.add_or_merge_window(start, end); } pub fn add_windows(&mut self, time_ranges: Vec<(Timestamp, Timestamp)>) { for (start, end) in time_ranges { - self.windows.insert(start, Some(end)); + self.add_or_merge_window(start, Some(end)); + } + } + + /// Add all dirty markers from another dirty-window set. + pub fn add_dirty_windows(&mut self, dirty_windows: &DirtyTimeWindows) { + for (start, end) in &dirty_windows.windows { + self.add_or_merge_window(*start, *end); + } + } + + fn add_or_merge_window(&mut self, start: Timestamp, end: Option) { + self.windows + .entry(start) + .and_modify(|current_end| { + *current_end = Self::union_window_end(*current_end, end); + }) + .or_insert(end); + } + + fn union_window_end( + current_end: Option, + incoming_end: Option, + ) -> Option { + match (current_end, incoming_end) { + (Some(current), Some(incoming)) => Some(current.max(incoming)), + // `None` is a dirty marker without a known upper bound. When one + // side has a concrete end, keep it so merging a restored snapshot + // never shrinks an already-known dirty range with the same start. + (Some(end), None) | (None, Some(end)) => Some(end), + (None, None) => None, } } @@ -216,7 +247,7 @@ impl DirtyTimeWindows { /// Set windows to be dirty, only useful for full aggr without time window /// to mark some new data is inserted pub fn set_dirty(&mut self) { - self.windows.insert(Timestamp::new_second(0), None); + self.add_or_merge_window(Timestamp::new_second(0), None); } /// Number of dirty windows. diff --git a/src/flow/src/batching_mode/table_creator.rs b/src/flow/src/batching_mode/table_creator.rs new file mode 100644 index 0000000000..05da055a40 --- /dev/null +++ b/src/flow/src/batching_mode/table_creator.rs @@ -0,0 +1,381 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use api::v1::CreateTableExpr; +use datafusion_common::tree_node::TreeNode; +use datafusion_expr::LogicalPlan; +use datatypes::prelude::ConcreteDataType; +use datatypes::schema::ColumnSchema; +use operator::expr_helper::column_schemas_to_defs; +use snafu::ResultExt; + +use crate::Error; +use crate::adapter::{AUTO_CREATED_PLACEHOLDER_TS_COL, AUTO_CREATED_UPDATE_AT_TS_COL}; +use crate::batching_mode::utils::FindGroupByFinalName; +use crate::error::{ConvertColumnSchemaSnafu, DatafusionSnafu}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryType { + /// query is a tql query + Tql, + /// query is a sql query + Sql, +} + +// auto created table have a auto added column `update_at`, and optional have a `AUTO_CREATED_PLACEHOLDER_TS_COL` column for time index placeholder if no timestamp column is specified +// TODO(discord9): for now no default value is set for auto added column for compatibility reason with streaming mode, but this might change in favor of simpler code? +pub(super) fn create_table_with_expr( + plan: &LogicalPlan, + sink_table_name: &[String; 3], + query_type: &QueryType, +) -> Result { + let table_def = match query_type { + &QueryType::Sql => { + if let Some(def) = build_pk_from_aggr(plan)? { + def + } else { + build_by_sql_schema(plan)? + } + } + QueryType::Tql => { + // first try build from aggr, then from tql schema because tql query might not have aggr node + if let Some(table_def) = build_pk_from_aggr(plan)? { + table_def + } else { + build_by_tql_schema(plan)? + } + } + }; + let first_time_stamp = table_def.ts_col; + let primary_keys = table_def.pks; + + let mut column_schemas = Vec::new(); + for field in plan.schema().fields() { + let name = field.name(); + let ty = ConcreteDataType::from_arrow_type(field.data_type()); + let col_schema = if first_time_stamp == Some(name.clone()) { + ColumnSchema::new(name, ty, false).with_time_index(true) + } else { + ColumnSchema::new(name, ty, true) + }; + + match query_type { + QueryType::Sql => { + column_schemas.push(col_schema); + } + QueryType::Tql => { + // if is val column, need to rename as val DOUBLE NULL + // if is tag column, need to cast type as STRING NULL + let is_tag_column = primary_keys.contains(name); + let is_val_column = !is_tag_column && first_time_stamp.as_ref() != Some(name); + if is_val_column { + let col_schema = + ColumnSchema::new(name, ConcreteDataType::float64_datatype(), true); + column_schemas.push(col_schema); + } else if is_tag_column { + let col_schema = + ColumnSchema::new(name, ConcreteDataType::string_datatype(), true); + column_schemas.push(col_schema); + } else { + // time index column + column_schemas.push(col_schema); + } + } + } + } + + if query_type == &QueryType::Sql { + let update_at_schema = ColumnSchema::new( + AUTO_CREATED_UPDATE_AT_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + true, + ); + column_schemas.push(update_at_schema); + } + + let time_index = if let Some(time_index) = first_time_stamp { + time_index + } else { + column_schemas.push( + ColumnSchema::new( + AUTO_CREATED_PLACEHOLDER_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + ); + AUTO_CREATED_PLACEHOLDER_TS_COL.to_string() + }; + + let column_defs = + column_schemas_to_defs(column_schemas, &primary_keys).context(ConvertColumnSchemaSnafu)?; + Ok(CreateTableExpr { + catalog_name: sink_table_name[0].clone(), + schema_name: sink_table_name[1].clone(), + table_name: sink_table_name[2].clone(), + desc: "Auto created table by flow engine".to_string(), + column_defs, + time_index, + primary_keys, + create_if_not_exists: true, + table_options: Default::default(), + table_id: None, + engine: "mito".to_string(), + }) +} + +/// simply build by schema, return first timestamp column and no primary key +fn build_by_sql_schema(plan: &LogicalPlan) -> Result { + let first_time_stamp = plan.schema().fields().iter().find_map(|f| { + if ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() { + Some(f.name().clone()) + } else { + None + } + }); + Ok(TableDef { + ts_col: first_time_stamp, + pks: vec![], + }) +} + +/// Return first timestamp column found in output schema and all string columns +fn build_by_tql_schema(plan: &LogicalPlan) -> Result { + let first_time_stamp = plan.schema().fields().iter().find_map(|f| { + if ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() { + Some(f.name().clone()) + } else { + None + } + }); + let string_columns = plan + .schema() + .fields() + .iter() + .filter_map(|f| { + if ConcreteDataType::from_arrow_type(f.data_type()).is_string() { + Some(f.name().clone()) + } else { + None + } + }) + .collect::>(); + + Ok(TableDef { + ts_col: first_time_stamp, + pks: string_columns, + }) +} + +struct TableDef { + ts_col: Option, + pks: Vec, +} + +/// Return first timestamp column which is in group by clause and other columns which are also in group by clause +/// +/// # Returns +/// +/// * `Option` - first timestamp column which is in group by clause +/// * `Vec` - other columns which are also in group by clause +/// +/// if no aggregation found, return None +fn build_pk_from_aggr(plan: &LogicalPlan) -> Result, Error> { + let fields = plan.schema().fields(); + let mut pk_names = FindGroupByFinalName::default(); + + plan.visit(&mut pk_names) + .with_context(|_| DatafusionSnafu { + context: format!("Can't find aggr expr in plan {plan:?}"), + })?; + + // if no group by clause, return empty with first timestamp column found in output schema + let Some(pk_final_names) = pk_names.get_group_expr_names() else { + return Ok(None); + }; + if pk_final_names.is_empty() { + let first_ts_col = fields + .iter() + .find(|f| ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp()) + .map(|f| f.name().clone()); + return Ok(Some(TableDef { + ts_col: first_ts_col, + pks: vec![], + })); + } + + let all_pk_cols: Vec<_> = fields + .iter() + .filter(|f| pk_final_names.contains(f.name())) + .map(|f| f.name().clone()) + .collect(); + // Auto-created tables use the first timestamp column in the group-by keys + // as the time index. It is possible that timestamp columns appear only as + // aggregate outputs (for example `max(ts)`) and are not group-by keys; in + // that case `first_time_stamp` stays `None` and the caller falls back to a + // placeholder time index column. + let first_time_stamp = fields + .iter() + .find(|f| { + all_pk_cols.contains(&f.name().clone()) + && ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() + }) + .map(|f| f.name().clone()); + + let all_pk_cols: Vec<_> = all_pk_cols + .into_iter() + .filter(|col| first_time_stamp.as_ref() != Some(col)) + .collect(); + + Ok(Some(TableDef { + ts_col: first_time_stamp, + pks: all_pk_cols, + })) +} + +#[cfg(test)] +mod test { + use api::v1::column_def::try_as_column_schema; + use datatypes::prelude::ConcreteDataType; + use datatypes::schema::ColumnSchema; + use pretty_assertions::assert_eq; + use session::context::QueryContext; + + use super::*; + use crate::adapter::{AUTO_CREATED_PLACEHOLDER_TS_COL, AUTO_CREATED_UPDATE_AT_TS_COL}; + use crate::batching_mode::utils::sql_to_df_plan; + use crate::test_utils::create_test_query_engine; + + #[tokio::test] + async fn test_gen_create_table_sql() { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + struct TestCase { + sql: String, + sink_table_name: String, + column_schemas: Vec, + primary_keys: Vec, + time_index: String, + } + + let update_at_schema = ColumnSchema::new( + AUTO_CREATED_UPDATE_AT_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + true, + ); + + let ts_placeholder_schema = ColumnSchema::new( + AUTO_CREATED_PLACEHOLDER_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true); + + let testcases = vec![ + TestCase { + sql: "SELECT number, ts FROM numbers_with_ts".to_string(), + sink_table_name: "new_table".to_string(), + column_schemas: vec![ + ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), + ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + update_at_schema.clone(), + ], + primary_keys: vec![], + time_index: "ts".to_string(), + }, + TestCase { + sql: "SELECT number, max(ts) FROM numbers_with_ts GROUP BY number".to_string(), + sink_table_name: "new_table".to_string(), + column_schemas: vec![ + ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), + ColumnSchema::new( + "max(numbers_with_ts.ts)", + ConcreteDataType::timestamp_millisecond_datatype(), + true, + ), + update_at_schema.clone(), + ts_placeholder_schema.clone(), + ], + primary_keys: vec!["number".to_string()], + time_index: AUTO_CREATED_PLACEHOLDER_TS_COL.to_string(), + }, + TestCase { + sql: "SELECT max(number), ts FROM numbers_with_ts GROUP BY ts".to_string(), + sink_table_name: "new_table".to_string(), + column_schemas: vec![ + ColumnSchema::new( + "max(numbers_with_ts.number)", + ConcreteDataType::uint32_datatype(), + true, + ), + ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + update_at_schema.clone(), + ], + primary_keys: vec![], + time_index: "ts".to_string(), + }, + TestCase { + sql: "SELECT number, ts FROM numbers_with_ts GROUP BY ts, number".to_string(), + sink_table_name: "new_table".to_string(), + column_schemas: vec![ + ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), + ColumnSchema::new( + "ts", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + update_at_schema.clone(), + ], + primary_keys: vec!["number".to_string()], + time_index: "ts".to_string(), + }, + ]; + + for tc in testcases { + let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), &tc.sql, true) + .await + .unwrap(); + let expr = create_table_with_expr( + &plan, + &[ + "greptime".to_string(), + "public".to_string(), + tc.sink_table_name.clone(), + ], + &QueryType::Sql, + ) + .unwrap(); + // TODO(discord9): assert expr + let column_schemas = expr + .column_defs + .iter() + .map(|c| try_as_column_schema(c).unwrap()) + .collect::>(); + assert_eq!(tc.column_schemas, column_schemas, "{:?}", tc.sql); + assert_eq!(tc.primary_keys, expr.primary_keys, "{:?}", tc.sql); + assert_eq!(tc.time_index, expr.time_index, "{:?}", tc.sql); + } + } +} diff --git a/src/flow/src/batching_mode/task.rs b/src/flow/src/batching_mode/task.rs index 84c96cc7cd..51a417c0d1 100644 --- a/src/flow/src/batching_mode/task.rs +++ b/src/flow/src/batching_mode/task.rs @@ -28,13 +28,11 @@ use datafusion::sql::unparser::expr_to_sql; use datafusion_common::DFSchemaRef; use datafusion_common::tree_node::{Transformed, TreeNode}; use datafusion_expr::{DmlStatement, LogicalPlan, WriteOp}; -use datatypes::prelude::ConcreteDataType; -use datatypes::schema::{ColumnSchema, Schema}; -use operator::expr_helper::column_schemas_to_defs; +use datatypes::schema::Schema; use query::QueryEngineRef; use query::query_engine::DefaultSerializer; use session::context::QueryContextRef; -use snafu::{OptionExt, ResultExt, ensure}; +use snafu::{OptionExt, ResultExt}; use sql::parsers::utils::is_tql; use store_api::mito_engine_options::MERGE_MODE_KEY; use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan}; @@ -43,19 +41,19 @@ use tokio::sync::oneshot; use tokio::sync::oneshot::error::TryRecvError; use tokio::time::Instant; -use crate::adapter::{AUTO_CREATED_PLACEHOLDER_TS_COL, AUTO_CREATED_UPDATE_AT_TS_COL}; use crate::batching_mode::BatchingModeOptions; use crate::batching_mode::frontend_client::FrontendClient; -use crate::batching_mode::state::{FilterExprInfo, TaskState}; +use crate::batching_mode::state::{DirtyTimeWindows, FilterExprInfo, TaskState}; +use crate::batching_mode::table_creator::{QueryType, create_table_with_expr}; use crate::batching_mode::time_window::TimeWindowExpr; use crate::batching_mode::utils::{ - AddFilterRewriter, ColumnMatcherRewriter, FindGroupByFinalName, gen_plan_with_matching_schema, + AddFilterRewriter, ColumnMatcherRewriter, gen_plan_with_matching_schema, get_table_info_df_schema, sql_to_df_plan, }; use crate::df_optimizer::apply_df_optimizer; use crate::error::{ - ConvertColumnSchemaSnafu, DatafusionSnafu, ExternalSnafu, InvalidQuerySnafu, - SubstraitEncodeLogicalPlanSnafu, UnexpectedSnafu, + DatafusionSnafu, ExternalSnafu, InvalidQuerySnafu, SubstraitEncodeLogicalPlanSnafu, + UnexpectedSnafu, }; use crate::metrics::{ METRIC_FLOW_BATCHING_ENGINE_ERROR_CNT, METRIC_FLOW_BATCHING_ENGINE_QUERY_TIME, @@ -100,14 +98,6 @@ fn is_merge_mode_last_non_null(options: &HashMap) -> bool { .unwrap_or(false) } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum QueryType { - /// query is a tql query - Tql, - /// query is a sql query - Sql, -} - #[derive(Clone)] pub struct BatchingTask { pub config: Arc, @@ -132,7 +122,20 @@ pub struct TaskArgs<'a> { pub struct PlanInfo { pub plan: LogicalPlan, - pub filter: Option, + pub dirty_restore: DirtyRestore, +} + +pub enum DirtyRestore { + /// The query was scoped to dirty time ranges; restore those ranges if the + /// run fails. + Scoped(FilterExprInfo), + /// The query could not be scoped to dirty time ranges, so the dirty-window + /// state is only a dirty signal. Restore the consumed signal if the full + /// run fails. + /// + /// TODO(discord9): Full-query runs only need a dirty bool flag. Refactor + /// the unscoped path to stop reusing `DirtyTimeWindows` for this signal. + Unscoped(DirtyTimeWindows), } impl BatchingTask { @@ -210,7 +213,7 @@ impl BatchingTask { &self, engine: &QueryEngineRef, frontend_client: &Arc, - ) -> Result, Error> { + ) -> Result, Error> { if !self.is_table_exist(&self.config.sink_table_name).await? { let create_table = self.gen_create_table_expr(engine.clone()).await?; info!( @@ -241,11 +244,19 @@ impl BatchingTask { engine: &QueryEngineRef, frontend_client: &Arc, max_window_cnt: Option, - ) -> Result, Error> { + ) -> Result, Error> { if let Some(new_query) = self.gen_insert_plan(engine, max_window_cnt).await? { debug!("Generate new query: {}", new_query.plan); - self.execute_logical_plan(frontend_client, &new_query.plan) + match self + .execute_logical_plan(frontend_client, &new_query.plan) .await + { + Ok(result) => Ok(result), + Err(err) => { + self.handle_executed_query_failure(Some(&new_query)); + Err(err) + } + } } else { debug!("Generate no query"); Ok(None) @@ -278,57 +289,66 @@ impl BatchingTask { ) .await?; - let insert_into_info = if let Some(new_query) = new_query { - // first check if all columns in input query exists in sink table - // since insert into ref to names in record batch generate by given query - let table_columns = df_schema - .columns() - .into_iter() - .map(|c| c.name) - .collect::>(); - for column in new_query.plan.schema().columns() { - ensure!( - table_columns.contains(column.name()), - InvalidQuerySnafu { - reason: format!( - "Column {} not found in sink table with columns {:?}", - column, table_columns - ), - } - ); - } - - let table_provider = Arc::new(DfTableProviderAdapter::new(table)); - let table_source = Arc::new(DefaultTableSource::new(table_provider)); - - // update_at& time index placeholder (if exists) should have default value - let plan = LogicalPlan::Dml(DmlStatement::new( - datafusion_common::TableReference::Full { - catalog: self.config.sink_table_name[0].clone().into(), - schema: self.config.sink_table_name[1].clone().into(), - table: self.config.sink_table_name[2].clone().into(), - }, - table_source, - WriteOp::Insert(datafusion_expr::dml::InsertOp::Append), - Arc::new(new_query.plan), - )); - PlanInfo { - plan, - filter: new_query.filter, - } - } else { + let Some(new_query) = new_query else { return Ok(None); }; - let insert_into = insert_into_info - .plan - .recompute_schema() - .context(DatafusionSnafu { - context: "Failed to recompute schema", - })?; + + // first check if all columns in input query exists in sink table + // since insert into ref to names in record batch generate by given query + let table_columns = df_schema + .columns() + .into_iter() + .map(|c| c.name) + .collect::>(); + for column in new_query.plan.schema().columns() { + if !table_columns.contains(column.name()) { + self.restore_dirty_windows_after_failure(&new_query); + return InvalidQuerySnafu { + reason: format!( + "Column {} not found in sink table with columns {:?}", + column, table_columns + ), + } + .fail(); + } + } + + let table_provider = Arc::new(DfTableProviderAdapter::new(table)); + let table_source = Arc::new(DefaultTableSource::new(table_provider)); + + // update_at& time index placeholder (if exists) should have default value + let plan = LogicalPlan::Dml(DmlStatement::new( + datafusion_common::TableReference::Full { + catalog: self.config.sink_table_name[0].clone().into(), + schema: self.config.sink_table_name[1].clone().into(), + table: self.config.sink_table_name[2].clone().into(), + }, + table_source, + WriteOp::Insert(datafusion_expr::dml::InsertOp::Append), + Arc::new(new_query.plan.clone()), + )); + let insert_into_info = PlanInfo { + plan, + dirty_restore: new_query.dirty_restore, + }; + let insert_into = + match insert_into_info + .plan + .clone() + .recompute_schema() + .context(DatafusionSnafu { + context: "Failed to recompute schema", + }) { + Ok(insert_into) => insert_into, + Err(err) => { + self.restore_dirty_windows_after_failure(&insert_into_info); + return Err(err); + } + }; Ok(Some(PlanInfo { plan: insert_into, - filter: insert_into_info.filter, + dirty_restore: insert_into_info.dirty_restore, })) } @@ -349,7 +369,7 @@ impl BatchingTask { &self, frontend_client: &Arc, plan: &LogicalPlan, - ) -> Result, Error> { + ) -> Result, Error> { let instant = Instant::now(); let flow_id = self.config.flow_id; @@ -385,7 +405,6 @@ impl BatchingTask { .with_label_values(&[flow_id.to_string().as_str()]) .start_timer(); - // hack and special handling the insert logical plan let req = if let Some((insert_to, insert_plan)) = breakup_insert_plan(&plan, catalog, schema) { @@ -451,8 +470,46 @@ impl BatchingTask { .after_query_exec(elapsed, res.is_ok()); let res = res?; + Ok(Some((res as usize, elapsed))) + } - Ok(Some((res, elapsed))) + /// Restore dirty windows consumed by a failed query so they are retried on + /// the next execution. + /// + fn restore_dirty_windows_after_failure(&self, query: &PlanInfo) { + match &query.dirty_restore { + DirtyRestore::Scoped(filter) => self.restore_scoped_dirty_windows(filter), + DirtyRestore::Unscoped(dirty_windows) => self + .state + .write() + .unwrap() + .dirty_time_windows + .add_dirty_windows(dirty_windows), + } + } + + fn restore_scoped_dirty_windows(&self, filter: &FilterExprInfo) { + self.state + .write() + .unwrap() + .dirty_time_windows + .add_windows(filter.time_ranges.clone()); + } + + fn restore_scoped_dirty_windows_on_err( + &self, + filter: &FilterExprInfo, + result: Result, + ) -> Result { + result.inspect_err(|_| { + self.restore_scoped_dirty_windows(filter); + }) + } + + fn handle_executed_query_failure(&self, query: Option<&PlanInfo>) { + if let Some(query) = query { + self.restore_dirty_windows_after_failure(query); + } } /// start executing query in a loop, break when receive shutdown signal @@ -558,16 +615,13 @@ impl BatchingTask { } // TODO(discord9): this error should have better place to go, but for now just print error, also more context is needed Err(err) => { + self.handle_executed_query_failure(new_query.as_ref()); METRIC_FLOW_BATCHING_ENGINE_ERROR_CNT .with_label_values(&[&flow_id_str]) .inc(); match new_query { Some(query) => { common_telemetry::error!(err; "Failed to execute query for flow={} with query: {}", self.config.flow_id, query.plan); - // Re-add dirty windows back since query failed - self.state.write().unwrap().dirty_time_windows.add_windows( - query.filter.map(|f| f.time_ranges).unwrap_or_default(), - ); // TODO(discord9): add some backoff here? half the query time window or what // backoff meaning use smaller `max_window_cnt` for next query @@ -641,8 +695,13 @@ impl BatchingTask { self.config.flow_id ); // clean dirty time window too, this could be from create flow's check_execute - let is_dirty = !self.state.read().unwrap().dirty_time_windows.is_empty(); - self.state.write().unwrap().dirty_time_windows.clean(); + let (is_dirty, dirty_windows_to_restore) = { + let mut state = self.state.write().unwrap(); + let dirty_windows_to_restore = state.dirty_time_windows.clone(); + let is_dirty = !dirty_windows_to_restore.is_empty(); + state.dirty_time_windows.clean(); + (is_dirty, dirty_windows_to_restore) + }; if !is_dirty { // no dirty data, hence no need to update @@ -650,7 +709,7 @@ impl BatchingTask { return Ok(None); } - let plan = gen_plan_with_matching_schema( + let plan = match gen_plan_with_matching_schema( &self.config.query, query_ctx, engine, @@ -658,15 +717,35 @@ impl BatchingTask { primary_key_indices, allow_partial, ) - .await?; + .await + { + Ok(plan) => plan, + Err(err) => { + self.state + .write() + .unwrap() + .dirty_time_windows + .add_dirty_windows(&dirty_windows_to_restore); + return Err(err); + } + }; - return Ok(Some(PlanInfo { plan, filter: None })); + return Ok(Some(PlanInfo { + plan, + dirty_restore: DirtyRestore::Unscoped(dirty_windows_to_restore), + })); } _ => { - // clean for tql have no use for time window - self.state.write().unwrap().dirty_time_windows.clean(); + // Clean dirty windows for full-query/non-scoped paths, + // such as TQL, that cannot use a time-window filter. + let dirty_windows_to_restore = { + let mut state = self.state.write().unwrap(); + let dirty_windows_to_restore = state.dirty_time_windows.clone(); + state.dirty_time_windows.clean(); + dirty_windows_to_restore + }; - let plan = gen_plan_with_matching_schema( + let plan = match gen_plan_with_matching_schema( &self.config.query, query_ctx, engine, @@ -674,9 +753,23 @@ impl BatchingTask { primary_key_indices, allow_partial, ) - .await?; + .await + { + Ok(plan) => plan, + Err(err) => { + self.state + .write() + .unwrap() + .dirty_time_windows + .add_dirty_windows(&dirty_windows_to_restore); + return Err(err); + } + }; - return Ok(Some(PlanInfo { plan, filter: None })); + return Ok(Some(PlanInfo { + plan, + dirty_restore: DirtyRestore::Unscoped(dirty_windows_to_restore), + })); } }; @@ -721,25 +814,21 @@ impl BatchingTask { Some(self), )?; - debug!( - "Flow id={:?}, Generated filter expr: {:?}", - self.config.flow_id, - expr.as_ref() - .map( - |expr| expr_to_sql(&expr.expr).with_context(|_| DatafusionSnafu { - context: format!("Failed to generate filter expr from {expr:?}"), - }) - ) - .transpose()? - .map(|s| s.to_string()) - ); - let Some(expr) = expr else { // no new data, hence no need to update debug!("Flow id={:?}, no new data, not update", self.config.flow_id); return Ok(None); }; + let filter_sql = expr_to_sql(&expr.expr) + .map(|sql| sql.to_string()) + .unwrap_or_else(|err| format!("")); + + debug!( + "Flow id={:?}, Generated filter expr: {:?}", + self.config.flow_id, filter_sql + ); + let mut add_filter = AddFilterRewriter::new(expr.expr.clone()); let mut add_auto_column = ColumnMatcherRewriter::new( sink_table_schema.clone(), @@ -747,363 +836,34 @@ impl BatchingTask { allow_partial, ); - let plan = - sql_to_df_plan(query_ctx.clone(), engine.clone(), &self.config.query, false).await?; - let rewrite = plan - .clone() - .rewrite(&mut add_filter) - .and_then(|p| p.data.rewrite(&mut add_auto_column)) - .with_context(|_| DatafusionSnafu { - context: format!("Failed to rewrite plan:\n {}\n", plan), - })? - .data; + let plan = self.restore_scoped_dirty_windows_on_err( + &expr, + sql_to_df_plan(query_ctx.clone(), engine.clone(), &self.config.query, false).await, + )?; + let rewrite = self.restore_scoped_dirty_windows_on_err( + &expr, + plan.clone() + .rewrite(&mut add_filter) + .and_then(|p| p.data.rewrite(&mut add_auto_column)) + .with_context(|_| DatafusionSnafu { + context: format!("Failed to rewrite plan:\n {}\n", plan), + }) + .map(|rewrite| rewrite.data), + )?; // only apply optimize after complex rewrite is done - let new_plan = apply_df_optimizer(rewrite, &query_ctx).await?; + let new_plan = self.restore_scoped_dirty_windows_on_err( + &expr, + apply_df_optimizer(rewrite, &query_ctx).await, + )?; let info = PlanInfo { plan: new_plan.clone(), - filter: Some(expr), + dirty_restore: DirtyRestore::Scoped(expr), }; Ok(Some(info)) } } -// auto created table have a auto added column `update_at`, and optional have a `AUTO_CREATED_PLACEHOLDER_TS_COL` column for time index placeholder if no timestamp column is specified -// TODO(discord9): for now no default value is set for auto added column for compatibility reason with streaming mode, but this might change in favor of simpler code? -fn create_table_with_expr( - plan: &LogicalPlan, - sink_table_name: &[String; 3], - query_type: &QueryType, -) -> Result { - let table_def = match query_type { - &QueryType::Sql => { - if let Some(def) = build_pk_from_aggr(plan)? { - def - } else { - build_by_sql_schema(plan)? - } - } - QueryType::Tql => { - // first try build from aggr, then from tql schema because tql query might not have aggr node - if let Some(table_def) = build_pk_from_aggr(plan)? { - table_def - } else { - build_by_tql_schema(plan)? - } - } - }; - let first_time_stamp = table_def.ts_col; - let primary_keys = table_def.pks; - - let mut column_schemas = Vec::new(); - for field in plan.schema().fields() { - let name = field.name(); - let ty = ConcreteDataType::from_arrow_type(field.data_type()); - let col_schema = if first_time_stamp == Some(name.clone()) { - ColumnSchema::new(name, ty, false).with_time_index(true) - } else { - ColumnSchema::new(name, ty, true) - }; - - match query_type { - QueryType::Sql => { - column_schemas.push(col_schema); - } - QueryType::Tql => { - // if is val column, need to rename as val DOUBLE NULL - // if is tag column, need to cast type as STRING NULL - let is_tag_column = primary_keys.contains(name); - let is_val_column = !is_tag_column && first_time_stamp.as_ref() != Some(name); - if is_val_column { - let col_schema = - ColumnSchema::new(name, ConcreteDataType::float64_datatype(), true); - column_schemas.push(col_schema); - } else if is_tag_column { - let col_schema = - ColumnSchema::new(name, ConcreteDataType::string_datatype(), true); - column_schemas.push(col_schema); - } else { - // time index column - column_schemas.push(col_schema); - } - } - } - } - - if query_type == &QueryType::Sql { - let update_at_schema = ColumnSchema::new( - AUTO_CREATED_UPDATE_AT_TS_COL, - ConcreteDataType::timestamp_millisecond_datatype(), - true, - ); - column_schemas.push(update_at_schema); - } - - let time_index = if let Some(time_index) = first_time_stamp { - time_index - } else { - column_schemas.push( - ColumnSchema::new( - AUTO_CREATED_PLACEHOLDER_TS_COL, - ConcreteDataType::timestamp_millisecond_datatype(), - false, - ) - .with_time_index(true), - ); - AUTO_CREATED_PLACEHOLDER_TS_COL.to_string() - }; - - let column_defs = - column_schemas_to_defs(column_schemas, &primary_keys).context(ConvertColumnSchemaSnafu)?; - Ok(CreateTableExpr { - catalog_name: sink_table_name[0].clone(), - schema_name: sink_table_name[1].clone(), - table_name: sink_table_name[2].clone(), - desc: "Auto created table by flow engine".to_string(), - column_defs, - time_index, - primary_keys, - create_if_not_exists: true, - table_options: Default::default(), - table_id: None, - engine: "mito".to_string(), - }) -} - -/// simply build by schema, return first timestamp column and no primary key -fn build_by_sql_schema(plan: &LogicalPlan) -> Result { - let first_time_stamp = plan.schema().fields().iter().find_map(|f| { - if ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() { - Some(f.name().clone()) - } else { - None - } - }); - Ok(TableDef { - ts_col: first_time_stamp, - pks: vec![], - }) -} - -/// Return first timestamp column found in output schema and all string columns -fn build_by_tql_schema(plan: &LogicalPlan) -> Result { - let first_time_stamp = plan.schema().fields().iter().find_map(|f| { - if ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() { - Some(f.name().clone()) - } else { - None - } - }); - let string_columns = plan - .schema() - .fields() - .iter() - .filter_map(|f| { - if ConcreteDataType::from_arrow_type(f.data_type()).is_string() { - Some(f.name().clone()) - } else { - None - } - }) - .collect::>(); - - Ok(TableDef { - ts_col: first_time_stamp, - pks: string_columns, - }) -} - -struct TableDef { - ts_col: Option, - pks: Vec, -} - -/// Return first timestamp column which is in group by clause and other columns which are also in group by clause -/// -/// # Returns -/// -/// * `Option` - first timestamp column which is in group by clause -/// * `Vec` - other columns which are also in group by clause -/// -/// if no aggregation found, return None -fn build_pk_from_aggr(plan: &LogicalPlan) -> Result, Error> { - let fields = plan.schema().fields(); - let mut pk_names = FindGroupByFinalName::default(); - - plan.visit(&mut pk_names) - .with_context(|_| DatafusionSnafu { - context: format!("Can't find aggr expr in plan {plan:?}"), - })?; - - // if no group by clause, return empty with first timestamp column found in output schema - let Some(pk_final_names) = pk_names.get_group_expr_names() else { - return Ok(None); - }; - if pk_final_names.is_empty() { - let first_ts_col = fields - .iter() - .find(|f| ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp()) - .map(|f| f.name().clone()); - return Ok(Some(TableDef { - ts_col: first_ts_col, - pks: vec![], - })); - } - - let all_pk_cols: Vec<_> = fields - .iter() - .filter(|f| pk_final_names.contains(f.name())) - .map(|f| f.name().clone()) - .collect(); - // auto create table use first timestamp column in group by clause as time index - let first_time_stamp = fields - .iter() - .find(|f| { - all_pk_cols.contains(&f.name().clone()) - && ConcreteDataType::from_arrow_type(f.data_type()).is_timestamp() - }) - .map(|f| f.name().clone()); - - let all_pk_cols: Vec<_> = all_pk_cols - .into_iter() - .filter(|col| first_time_stamp.as_ref() != Some(col)) - .collect(); - - Ok(Some(TableDef { - ts_col: first_time_stamp, - pks: all_pk_cols, - })) -} - #[cfg(test)] -mod test { - use api::v1::column_def::try_as_column_schema; - use pretty_assertions::assert_eq; - use session::context::QueryContext; - - use super::*; - use crate::test_utils::create_test_query_engine; - - #[tokio::test] - async fn test_gen_create_table_sql() { - let query_engine = create_test_query_engine(); - let ctx = QueryContext::arc(); - struct TestCase { - sql: String, - sink_table_name: String, - column_schemas: Vec, - primary_keys: Vec, - time_index: String, - } - - let update_at_schema = ColumnSchema::new( - AUTO_CREATED_UPDATE_AT_TS_COL, - ConcreteDataType::timestamp_millisecond_datatype(), - true, - ); - - let ts_placeholder_schema = ColumnSchema::new( - AUTO_CREATED_PLACEHOLDER_TS_COL, - ConcreteDataType::timestamp_millisecond_datatype(), - false, - ) - .with_time_index(true); - - let testcases = vec![ - TestCase { - sql: "SELECT number, ts FROM numbers_with_ts".to_string(), - sink_table_name: "new_table".to_string(), - column_schemas: vec![ - ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), - ColumnSchema::new( - "ts", - ConcreteDataType::timestamp_millisecond_datatype(), - false, - ) - .with_time_index(true), - update_at_schema.clone(), - ], - primary_keys: vec![], - time_index: "ts".to_string(), - }, - TestCase { - sql: "SELECT number, max(ts) FROM numbers_with_ts GROUP BY number".to_string(), - sink_table_name: "new_table".to_string(), - column_schemas: vec![ - ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), - ColumnSchema::new( - "max(numbers_with_ts.ts)", - ConcreteDataType::timestamp_millisecond_datatype(), - true, - ), - update_at_schema.clone(), - ts_placeholder_schema.clone(), - ], - primary_keys: vec!["number".to_string()], - time_index: AUTO_CREATED_PLACEHOLDER_TS_COL.to_string(), - }, - TestCase { - sql: "SELECT max(number), ts FROM numbers_with_ts GROUP BY ts".to_string(), - sink_table_name: "new_table".to_string(), - column_schemas: vec![ - ColumnSchema::new( - "max(numbers_with_ts.number)", - ConcreteDataType::uint32_datatype(), - true, - ), - ColumnSchema::new( - "ts", - ConcreteDataType::timestamp_millisecond_datatype(), - false, - ) - .with_time_index(true), - update_at_schema.clone(), - ], - primary_keys: vec![], - time_index: "ts".to_string(), - }, - TestCase { - sql: "SELECT number, ts FROM numbers_with_ts GROUP BY ts, number".to_string(), - sink_table_name: "new_table".to_string(), - column_schemas: vec![ - ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), - ColumnSchema::new( - "ts", - ConcreteDataType::timestamp_millisecond_datatype(), - false, - ) - .with_time_index(true), - update_at_schema.clone(), - ], - primary_keys: vec!["number".to_string()], - time_index: "ts".to_string(), - }, - ]; - - for tc in testcases { - let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), &tc.sql, true) - .await - .unwrap(); - let expr = create_table_with_expr( - &plan, - &[ - "greptime".to_string(), - "public".to_string(), - tc.sink_table_name.clone(), - ], - &QueryType::Sql, - ) - .unwrap(); - // TODO(discord9): assert expr - let column_schemas = expr - .column_defs - .iter() - .map(|c| try_as_column_schema(c).unwrap()) - .collect::>(); - assert_eq!(tc.column_schemas, column_schemas, "{:?}", tc.sql); - assert_eq!(tc.primary_keys, expr.primary_keys, "{:?}", tc.sql); - assert_eq!(tc.time_index, expr.time_index, "{:?}", tc.sql); - } - } -} +mod test; diff --git a/src/flow/src/batching_mode/task/test.rs b/src/flow/src/batching_mode/task/test.rs new file mode 100644 index 0000000000..55a0a3057f --- /dev/null +++ b/src/flow/src/batching_mode/task/test.rs @@ -0,0 +1,337 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use catalog::RegisterTableRequest; +use catalog::memory::MemoryCatalogManager; +use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME}; +use common_recordbatch::RecordBatch; +use datatypes::data_type::ConcreteDataType as CDT; +use datatypes::schema::ColumnSchema; +use datatypes::vectors::{UInt32Vector, VectorRef}; +use pretty_assertions::assert_eq; +use session::context::QueryContext; +use table::test_util::MemTable; + +use super::*; +use crate::batching_mode::time_window::find_time_window_expr; +use crate::test_utils::create_test_query_engine; + +async fn new_test_task_and_plan_with_missing_sink() -> (BatchingTask, LogicalPlan) { + new_test_task_engine_and_plan_with_query( + "SELECT number, ts FROM numbers_with_ts", + "missing_sink", + ) + .await + .into_task_and_plan() +} + +struct TestTaskParts { + task: BatchingTask, + query_engine: QueryEngineRef, + plan: LogicalPlan, +} + +impl TestTaskParts { + fn into_task_and_plan(self) -> (BatchingTask, LogicalPlan) { + (self.task, self.plan) + } +} + +async fn new_test_task_engine_and_plan_with_query(query: &str, sink_table: &str) -> TestTaskParts { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan = sql_to_df_plan( + ctx.clone(), + query_engine.clone(), + "SELECT number, ts FROM numbers_with_ts", + true, + ) + .await + .unwrap(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + + let task = BatchingTask::try_new(TaskArgs { + flow_id: 1, + query, + plan: plan.clone(), + time_window_expr: None, + expire_after: None, + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + sink_table.to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + TestTaskParts { + task, + query_engine, + plan, + } +} + +async fn new_time_window_test_task_with_query(query: &str) -> TestTaskParts { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan_query = "SELECT number, date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window, number"; + let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), plan_query, true) + .await + .unwrap(); + let (column_name, time_window_expr, _, df_schema) = find_time_window_expr( + &plan, + query_engine.engine_state().catalog_manager().clone(), + ctx.clone(), + ) + .await + .unwrap(); + let time_window_expr = time_window_expr.map(|expr| { + TimeWindowExpr::from_expr( + &expr, + &column_name, + &df_schema, + &query_engine.engine_state().session_state(), + ) + .unwrap() + }); + let (_tx, rx) = tokio::sync::oneshot::channel(); + + let task = BatchingTask::try_new(TaskArgs { + flow_id: 1, + query, + plan: plan.clone(), + time_window_expr, + expire_after: None, + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + "missing_sink".to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + TestTaskParts { + task, + query_engine, + plan, + } +} + +fn register_number_only_sink(query_engine: &QueryEngineRef, table_name: &str) { + let schema = Arc::new(Schema::new(vec![ColumnSchema::new( + "number", + CDT::uint32_datatype(), + false, + )])); + let columns: Vec = vec![Arc::new(UInt32Vector::from_slice([1_u32]))]; + let recordbatch = RecordBatch::new(schema, columns).unwrap(); + let table = MemTable::table(table_name, recordbatch); + let request = RegisterTableRequest { + catalog: DEFAULT_CATALOG_NAME.to_string(), + schema: DEFAULT_SCHEMA_NAME.to_string(), + table_name: table_name.to_string(), + table_id: 9001, + table, + }; + let catalog_manager = query_engine.engine_state().catalog_manager(); + let memory_catalog = catalog_manager + .as_any() + .downcast_ref::() + .unwrap(); + memory_catalog.register_table_sync(request).unwrap(); +} + +fn dirty_marker() -> DirtyTimeWindows { + let mut dirty = DirtyTimeWindows::default(); + dirty.set_dirty(); + dirty +} + +fn dirty_range(start: i64, end: i64) -> DirtyTimeWindows { + let mut dirty = DirtyTimeWindows::default(); + dirty.add_window( + Timestamp::new_second(start), + Some(Timestamp::new_second(end)), + ); + dirty +} + +async fn assert_unscoped_failure_restore( + consumed_dirty_windows: DirtyTimeWindows, + current_dirty_windows: DirtyTimeWindows, + expected_len: usize, + expected_window_size_secs: u64, +) { + let (task, plan) = new_test_task_and_plan_with_missing_sink().await; + { + let mut state = task.state.write().unwrap(); + state.dirty_time_windows.clean(); + state + .dirty_time_windows + .add_dirty_windows(¤t_dirty_windows); + } + let unscoped_query = PlanInfo { + plan, + dirty_restore: DirtyRestore::Unscoped(consumed_dirty_windows), + }; + + task.handle_executed_query_failure(Some(&unscoped_query)); + + let state = task.state.read().unwrap(); + assert_eq!(state.dirty_time_windows.len(), expected_len); + assert_eq!( + state.dirty_time_windows.window_size(), + std::time::Duration::from_secs(expected_window_size_secs) + ); +} + +#[tokio::test] +async fn test_executed_query_failure_restores_scoped_dirty_windows_for_flush_path() { + let (task, plan) = new_test_task_and_plan_with_missing_sink().await; + { + let mut state = task.state.write().unwrap(); + state.dirty_time_windows.clean(); + } + let scoped_query = PlanInfo { + plan, + dirty_restore: DirtyRestore::Scoped(FilterExprInfo { + expr: datafusion_expr::lit(true), + col_name: "ts".to_string(), + time_ranges: vec![(Timestamp::new_second(10), Timestamp::new_second(20))], + window_size: chrono::Duration::seconds(10), + }), + }; + + task.handle_executed_query_failure(Some(&scoped_query)); + + let state = task.state.read().unwrap(); + assert_eq!(state.dirty_time_windows.len(), 1); +} + +#[tokio::test] +async fn test_unscoped_failure_restores_consumed_dirty_signal() { + assert_unscoped_failure_restore(dirty_marker(), DirtyTimeWindows::default(), 1, 0).await; + assert_unscoped_failure_restore(dirty_range(30, 40), dirty_range(10, 20), 2, 20).await; + assert_unscoped_failure_restore(dirty_range(30, 40), dirty_range(30, 50), 1, 20).await; +} + +#[tokio::test] +async fn test_unscoped_plan_generation_failure_restores_consumed_dirty_signal() { + let TestTaskParts { + task, query_engine, .. + } = new_test_task_engine_and_plan_with_query( + "SELECT missing_column FROM numbers_with_ts", + "missing_sink", + ) + .await; + task.state.write().unwrap().dirty_time_windows.set_dirty(); + let sink_schema = Arc::new(Schema::new(vec![ + ColumnSchema::new("number", CDT::uint32_datatype(), false), + ColumnSchema::new("ts", CDT::timestamp_millisecond_datatype(), false).with_time_index(true), + ])); + + let result = task + .gen_query_with_time_window(query_engine, &sink_schema, &[], false, None) + .await; + + assert!(result.is_err()); + let state = task.state.read().unwrap(); + assert_eq!(state.dirty_time_windows.len(), 1); + assert_eq!( + state.dirty_time_windows.window_size(), + std::time::Duration::from_secs(0) + ); +} + +#[tokio::test] +async fn test_scoped_plan_generation_failure_restores_consumed_dirty_windows() { + let TestTaskParts { + task, + query_engine, + .. + } = new_time_window_test_task_with_query( + "SELECT missing_column, date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window, missing_column", + ) + .await; + task.state + .write() + .unwrap() + .dirty_time_windows + .add_window(Timestamp::new_second(10), Some(Timestamp::new_second(15))); + let sink_schema = Arc::new(Schema::new(vec![ + ColumnSchema::new("number", CDT::uint32_datatype(), false), + ColumnSchema::new("time_window", CDT::timestamp_millisecond_datatype(), false) + .with_time_index(true), + ])); + + let result = task + .gen_query_with_time_window(query_engine, &sink_schema, &[], false, None) + .await; + + assert!(result.is_err()); + let state = task.state.read().unwrap(); + assert_eq!(state.dirty_time_windows.len(), 1); + assert_eq!( + state.dirty_time_windows.window_size(), + std::time::Duration::from_secs(5) + ); +} + +#[tokio::test] +async fn test_insert_plan_matching_failure_restores_consumed_dirty_marker() { + let sink_table = "partial_sink"; + let TestTaskParts { + task, query_engine, .. + } = new_test_task_engine_and_plan_with_query( + "SELECT number, ts FROM numbers_with_ts", + sink_table, + ) + .await; + register_number_only_sink(&query_engine, sink_table); + task.state.write().unwrap().dirty_time_windows.set_dirty(); + + let result = task.gen_insert_plan(&query_engine, None).await; + + assert!(result.is_err()); + let _err = match result { + Ok(_) => panic!("gen_insert_plan should fail with a sink column mismatch"), + Err(err) => err, + }; + let state = task.state.read().unwrap(); + assert_eq!(state.dirty_time_windows.len(), 1); + assert_eq!( + state.dirty_time_windows.window_size(), + std::time::Duration::from_secs(0) + ); +} diff --git a/src/frontend/src/instance/grpc.rs b/src/frontend/src/instance/grpc.rs index 8d18293cb8..0ca1a2cf20 100644 --- a/src/frontend/src/instance/grpc.rs +++ b/src/frontend/src/instance/grpc.rs @@ -121,8 +121,9 @@ impl GrpcQueryHandler for Instance { .context(PlanStatementSnafu)?; let dummy_catalog_list = - Arc::new(catalog::table_source::dummy_catalog::DummyCatalogList::new( + Arc::new(catalog::table_source::dummy_catalog::DummyCatalogList::new_with_query_ctx( self.catalog_manager().clone(), + ctx.clone(), )); let logical_plan = plan_decoder @@ -416,10 +417,12 @@ impl Instance { .new_plan_decoder() .context(PlanStatementSnafu)?; - let dummy_catalog_list = - Arc::new(catalog::table_source::dummy_catalog::DummyCatalogList::new( + let dummy_catalog_list = Arc::new( + catalog::table_source::dummy_catalog::DummyCatalogList::new_with_query_ctx( self.catalog_manager().clone(), - )); + ctx.clone(), + ), + ); // no optimize yet since we still need to add stuff let logical_plan = plan_decoder diff --git a/src/query/src/dummy_catalog.rs b/src/query/src/dummy_catalog.rs index 0ff985ea00..f08c6e6ec6 100644 --- a/src/query/src/dummy_catalog.rs +++ b/src/query/src/dummy_catalog.rs @@ -620,7 +620,10 @@ mod tests { use super::*; use crate::error::Error; - use crate::options::{FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_SINK_TABLE_ID}; + use crate::options::{ + FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_RETURN_REGION_SEQ, + FLOW_SINK_TABLE_ID, + }; fn test_region_id() -> RegionId { RegionId::new(1024, 1) @@ -651,6 +654,49 @@ mod tests { assert_eq!(request.sst_min_sequence, Some(7)); } + #[test] + fn test_terminal_watermark_context_source_and_sink_scan_semantics() { + let region_id = test_region_id(); + let query_ctx = QueryContextBuilder::default() + .extensions(HashMap::from([( + FLOW_RETURN_REGION_SEQ.to_string(), + "true".to_string(), + )])) + .build(); + + let request = scan_request_from_query_context(region_id, &query_ctx).unwrap(); + + assert!(request.snapshot_on_scan); + assert_eq!(request.memtable_min_sequence, None); + assert_eq!(request.memtable_max_sequence, None); + assert_eq!(request.sst_min_sequence, None); + + let query_ctx = QueryContextBuilder::default() + .extensions(HashMap::from([ + (FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string()), + ( + FLOW_SINK_TABLE_ID.to_string(), + region_id.table_id().to_string(), + ), + ])) + .snapshot_seqs(Arc::new(RwLock::new(HashMap::from([( + region_id.as_u64(), + 88_u64, + )])))) + .sst_min_sequences(Arc::new(RwLock::new(HashMap::from([( + region_id.as_u64(), + 77_u64, + )])))) + .build(); + + let request = scan_request_from_query_context(region_id, &query_ctx).unwrap(); + + assert!(!request.snapshot_on_scan); + assert_eq!(request.memtable_min_sequence, None); + assert_eq!(request.memtable_max_sequence, None); + assert_eq!(request.sst_min_sequence, None); + } + #[test] fn test_scan_request_from_incremental_context_uses_snapshot_bound_intent() { let region_id = test_region_id(); From a23ff4d589266a23c372de52cca48fa1bee40ee8 Mon Sep 17 00:00:00 2001 From: Yingwen Date: Tue, 26 May 2026 16:19:52 +0800 Subject: [PATCH 15/32] perf(mito): split record batches on equal timestamps (#8163) * perf(mito): split record batches on equal timestamps Signed-off-by: evenyag * test(mito): cover equal-timestamp runs at batch boundaries Signed-off-by: evenyag --------- Signed-off-by: evenyag --- src/mito2/src/read/scan_util.rs | 76 ++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/src/mito2/src/read/scan_util.rs b/src/mito2/src/read/scan_util.rs index 1d97b2eb76..4cf2179430 100644 --- a/src/mito2/src/read/scan_util.rs +++ b/src/mito2/src/read/scan_util.rs @@ -1662,7 +1662,7 @@ where } } -/// Splits the batch by timestamps. +/// Splits the batch so each sub-batch has strictly increasing timestamps. /// /// # Panics /// Panics if the timestamp array is invalid. @@ -1683,7 +1683,7 @@ pub(crate) fn split_record_batch(record_batch: RecordBatch, batches: &mut VecDeq offsets.push(0); let values = ts_values.values(); for (i, &value) in values.iter().take(batch_rows - 1).enumerate() { - if value > values[i + 1] { + if value >= values[i + 1] { offsets.push(i + 1); } } @@ -1951,4 +1951,76 @@ mod tests { compute_average_batch_size(std::iter::empty()) ); } + + /// Builds a flat-format record batch whose time index column holds `timestamps`. + fn flat_ts_batch(timestamps: &[i64]) -> RecordBatch { + use datatypes::arrow::array::{TimestampMillisecondArray, UInt8Array, UInt64Array}; + use datatypes::arrow::datatypes::{DataType, Field, Schema, TimeUnit}; + + let num_rows = timestamps.len(); + let schema = Arc::new(Schema::new(vec![ + Field::new( + "ts", + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + Field::new("pk", DataType::UInt64, false), + Field::new("seq", DataType::UInt64, false), + Field::new("op", DataType::UInt8, false), + ])); + RecordBatch::try_new( + schema, + vec![ + Arc::new(TimestampMillisecondArray::from(timestamps.to_vec())), + Arc::new(UInt64Array::from(vec![0u64; num_rows])), + Arc::new(UInt64Array::from(vec![0u64; num_rows])), + Arc::new(UInt8Array::from(vec![0u8; num_rows])), + ], + ) + .unwrap() + } + + /// Splits `timestamps` and returns the time index values of each sub-batch. + fn split_ts(timestamps: &[i64]) -> Vec> { + let mut batches = VecDeque::new(); + split_record_batch(flat_ts_batch(timestamps), &mut batches); + batches + .iter() + .map(|batch| { + let pos = time_index_column_index(batch.num_columns()); + let (values, _) = timestamp_array_to_primitive(batch.column(pos)).unwrap(); + values.values().to_vec() + }) + .collect() + } + + #[test] + fn test_split_record_batch_on_equal_timestamps() { + // Splits on both decreasing and equal timestamps. + assert_eq!( + split_ts(&[1, 2, 2, 3, 1]), + vec![vec![1, 2], vec![2, 3], vec![1]] + ); + // A run of equal timestamps yields single-row sub-batches. + assert_eq!(split_ts(&[5, 5, 5]), vec![vec![5], vec![5], vec![5]]); + // Equal-ts run at the leading edge of the batch. + assert_eq!(split_ts(&[5, 5, 1, 2]), vec![vec![5], vec![5], vec![1, 2]]); + // Equal-ts run at the trailing edge of the batch. + assert_eq!(split_ts(&[1, 2, 5, 5]), vec![vec![1, 2, 5], vec![5]]); + } + + #[test] + fn test_split_record_batch_on_decreasing_timestamps() { + assert_eq!(split_ts(&[1, 2, 3]), vec![vec![1, 2, 3]]); + assert_eq!(split_ts(&[1, 3, 2, 4]), vec![vec![1, 3], vec![2, 4]]); + } + + #[test] + fn test_split_record_batch_empty_and_single_row() { + let mut batches = VecDeque::new(); + split_record_batch(flat_ts_batch(&[]), &mut batches); + assert!(batches.is_empty()); + + assert_eq!(split_ts(&[42]), vec![vec![42]]); + } } From 0675cffe68f08c342cb0173e76bef154fe5d705e Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Tue, 26 May 2026 16:53:34 +0800 Subject: [PATCH 16/32] refactor: use structured pusher key (#8155) Signed-off-by: WenyXu --- src/meta-srv/src/handler.rs | 116 ++++++++++++++++++++---- src/meta-srv/src/procedure/test_util.rs | 2 +- src/meta-srv/src/service/mailbox.rs | 35 ++----- 3 files changed, 106 insertions(+), 47 deletions(-) diff --git a/src/meta-srv/src/handler.rs b/src/meta-srv/src/handler.rs index 92916838d8..9cfd4e6079 100644 --- a/src/meta-srv/src/handler.rs +++ b/src/meta-srv/src/handler.rs @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::cmp::Ordering; use std::collections::{BTreeMap, HashSet}; use std::fmt::{Debug, Display}; -use std::ops::Range; +use std::ops::Bound; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -136,6 +137,26 @@ pub struct PusherId { pub id: u64, } +impl PartialEq for PusherId { + fn eq(&self, other: &Self) -> bool { + self.role as i32 == other.role as i32 && self.id == other.id + } +} + +impl Eq for PusherId {} + +impl PartialOrd for PusherId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PusherId { + fn cmp(&self, other: &Self) -> Ordering { + (self.role as i32, self.id).cmp(&(other.role as i32, other.id)) + } +} + impl Debug for PusherId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}-{}", self.role, self.id) @@ -153,8 +174,11 @@ impl PusherId { Self { role, id } } - pub fn string_key(&self) -> String { - format!("{}-{}", self.role as i32, self.id) + fn role_range(role: Role) -> (Bound, Bound) { + ( + Bound::Included(Self::new(role, u64::MIN)), + Bound::Included(Self::new(role, u64::MAX)), + ) } } @@ -214,7 +238,7 @@ impl Pusher { /// The group of heartbeat pushers. #[derive(Clone, Default)] -pub struct Pushers(Arc>>); +pub struct Pushers(Arc>>); impl Pushers { async fn push( @@ -222,11 +246,12 @@ impl Pushers { pusher_id: PusherId, mailbox_message: MailboxMessage, ) -> Result { - let pusher_id = pusher_id.string_key(); let pushers = self.0.read().await; let pusher = pushers .get(&pusher_id) - .context(error::PusherNotFoundSnafu { pusher_id })?; + .with_context(|| error::PusherNotFoundSnafu { + pusher_id: pusher_id.to_string(), + })?; pusher .push(HeartbeatResponse { @@ -239,14 +264,10 @@ impl Pushers { Ok(pusher.deregister_signal_receiver.clone()) } - async fn broadcast( - &self, - range: Range, - mailbox_message: &MailboxMessage, - ) -> Result<()> { + async fn broadcast(&self, role: Role, mailbox_message: &MailboxMessage) -> Result<()> { let pushers = self.0.read().await; let pushers = pushers - .range(range) + .range(PusherId::role_range(role)) .map(|(_, value)| value) .collect::>(); let mut results = Vec::with_capacity(pushers.len()); @@ -271,12 +292,12 @@ impl Pushers { Ok(()) } - pub(crate) async fn insert(&self, pusher_id: String, pusher: Pusher) -> Option { + pub(crate) async fn insert(&self, pusher_id: PusherId, pusher: Pusher) -> Option { self.0.write().await.insert(pusher_id, pusher) } - async fn remove(&self, pusher_id: &str) -> Option { - self.0.write().await.remove(pusher_id) + async fn remove(&self, pusher_id: PusherId) -> Option { + self.0.write().await.remove(&pusher_id) } } @@ -308,12 +329,12 @@ impl HeartbeatHandlerGroup { pub async fn register_pusher(&self, pusher_id: PusherId, pusher: Pusher) { METRIC_META_HEARTBEAT_CONNECTION_NUM.inc(); info!("Pusher register: {}", pusher_id); - let _ = self.pushers.insert(pusher_id.string_key(), pusher).await; + let _ = self.pushers.insert(pusher_id, pusher).await; } /// Deregisters the heartbeat response [`Pusher`] with the given key from the group. pub async fn deregister_push(&self, pusher_id: PusherId) { - if self.pushers.remove(&pusher_id.string_key()).await.is_some() { + if self.pushers.remove(pusher_id).await.is_some() { info!("Pusher unregister: {}", pusher_id); METRIC_META_HEARTBEAT_CONNECTION_NUM.dec(); } @@ -323,7 +344,7 @@ impl HeartbeatHandlerGroup { /// Returns whether the group contains the heartbeat response [`Pusher`] with the given key. pub async fn contains_pusher(&self, pusher_id: &PusherId) -> bool { let pushers = self.pushers.0.read().await; - pushers.contains_key(&pusher_id.string_key()) + pushers.contains_key(pusher_id) } /// Returns the [`Pushers`] of the group. @@ -531,7 +552,7 @@ impl Mailbox for HeartbeatMailbox { } async fn broadcast(&self, ch: &BroadcastChannel, msg: &MailboxMessage) -> Result<()> { - self.pushers.broadcast(ch.pusher_range(), msg).await + self.pushers.broadcast(ch.role(), msg).await } async fn on_recv(&self, id: MessageId, maybe_msg: Result) -> Result<()> { @@ -861,6 +882,7 @@ impl HeartbeatHandlerGroupBuilderCustomizer for DefaultHeartbeatHandlerGroupBuil mod tests { use std::assert_matches; + use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; @@ -936,6 +958,62 @@ mod tests { (mailbox, receiver) } + #[test] + fn test_pusher_id_role_range() { + let mut pushers = BTreeMap::new(); + pushers.insert(PusherId::new(Role::Datanode, u64::MAX), "datanode"); + pushers.insert(PusherId::new(Role::Frontend, u64::MIN), "frontend-min"); + pushers.insert(PusherId::new(Role::Frontend, u64::MAX), "frontend-max"); + pushers.insert(PusherId::new(Role::Flownode, u64::MIN), "flownode"); + + let frontend_pushers = pushers + .range(PusherId::role_range(Role::Frontend)) + .map(|(_, value)| *value) + .collect::>(); + + assert_eq!(frontend_pushers, vec!["frontend-min", "frontend-max"]); + } + + #[tokio::test] + async fn test_pushers_broadcast_by_role() { + let pushers = Pushers::default(); + let (datanode_tx, mut datanode_rx) = mpsc::channel(1); + let (frontend_tx, mut frontend_rx) = mpsc::channel(1); + let (flownode_tx, mut flownode_rx) = mpsc::channel(1); + + pushers + .insert( + PusherId::new(Role::Datanode, u64::MAX), + Pusher::new(datanode_tx), + ) + .await; + pushers + .insert(PusherId::new(Role::Frontend, 1), Pusher::new(frontend_tx)) + .await; + pushers + .insert( + PusherId::new(Role::Flownode, u64::MIN), + Pusher::new(flownode_tx), + ) + .await; + + let msg = MailboxMessage { + id: 42, + subject: "broadcast-test".to_string(), + timestamp_millis: 123, + ..Default::default() + }; + + pushers.broadcast(Role::Frontend, &msg).await.unwrap(); + + let received = frontend_rx.recv().await.unwrap().unwrap(); + let mailbox_message = received.mailbox_message.unwrap(); + assert_eq!(mailbox_message.id, 0); + assert_eq!(mailbox_message.subject, "broadcast-test"); + assert!(datanode_rx.try_recv().is_err()); + assert!(flownode_rx.try_recv().is_err()); + } + #[test] fn test_handler_group_builder() { let group = HeartbeatHandlerGroupBuilder::new(Pushers::default()) diff --git a/src/meta-srv/src/procedure/test_util.rs b/src/meta-srv/src/procedure/test_util.rs index 5bf60fe32e..318a276676 100644 --- a/src/meta-srv/src/procedure/test_util.rs +++ b/src/meta-srv/src/procedure/test_util.rs @@ -66,7 +66,7 @@ impl MailboxContext { ) { let pusher_id = channel.pusher_id(); let pusher = Pusher::new(tx); - let _ = self.pushers.insert(pusher_id.string_key(), pusher).await; + let _ = self.pushers.insert(pusher_id, pusher).await; } pub fn mailbox(&self) -> &MailboxRef { diff --git a/src/meta-srv/src/service/mailbox.rs b/src/meta-srv/src/service/mailbox.rs index 86b631998b..f3fbdcbffc 100644 --- a/src/meta-srv/src/service/mailbox.rs +++ b/src/meta-srv/src/service/mailbox.rs @@ -13,7 +13,6 @@ // limitations under the License. use std::fmt::{Display, Formatter}; -use std::ops::Range; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -69,20 +68,11 @@ pub enum BroadcastChannel { } impl BroadcastChannel { - pub(crate) fn pusher_range(&self) -> Range { + pub(crate) fn role(&self) -> Role { match self { - BroadcastChannel::Datanode => Range { - start: format!("{}-", Role::Datanode as i32), - end: format!("{}-", Role::Frontend as i32), - }, - BroadcastChannel::Frontend => Range { - start: format!("{}-", Role::Frontend as i32), - end: format!("{}-", Role::Flownode as i32), - }, - BroadcastChannel::Flownode => Range { - start: format!("{}-", Role::Flownode as i32), - end: format!("{}-", Role::Flownode as i32 + 1), - }, + BroadcastChannel::Datanode => Role::Datanode, + BroadcastChannel::Frontend => Role::Frontend, + BroadcastChannel::Flownode => Role::Flownode, } } } @@ -219,19 +209,10 @@ mod tests { use super::*; #[test] - fn test_channel_pusher_range() { - assert_eq!( - BroadcastChannel::Datanode.pusher_range(), - ("0-".to_string().."1-".to_string()) - ); - assert_eq!( - BroadcastChannel::Frontend.pusher_range(), - ("1-".to_string().."2-".to_string()) - ); - assert_eq!( - BroadcastChannel::Flownode.pusher_range(), - ("2-".to_string().."3-".to_string()) - ); + fn test_broadcast_channel_role() { + assert_eq!(BroadcastChannel::Datanode.role(), Role::Datanode); + assert_eq!(BroadcastChannel::Frontend.role(), Role::Frontend); + assert_eq!(BroadcastChannel::Flownode.role(), Role::Flownode); } #[tokio::test] From 5943b4106789516a4f2beac74d7583a358a9f03e Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Tue, 26 May 2026 20:54:54 +0800 Subject: [PATCH 17/32] refactor: split repartition region descriptors (#8172) * refactor: split repartition region descriptors Signed-off-by: WenyXu * feat(meta-srv): support default source repartition planning Signed-off-by: WenyXu * feat(meta-srv): support default source repartition metadata Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- src/meta-srv/src/procedure/repartition.rs | 68 ++-- .../procedure/repartition/allocate_region.rs | 32 +- .../src/procedure/repartition/dispatch.rs | 8 +- .../src/procedure/repartition/group.rs | 12 +- .../group/apply_staging_manifest.rs | 4 +- .../repartition/group/enter_staging_region.rs | 12 +- .../repartition/group/remap_manifest.rs | 8 +- .../repartition/group/repartition_start.rs | 118 ++++-- .../update_metadata/apply_staging_region.rs | 97 ++++- .../update_metadata/exit_staging_region.rs | 62 ++- .../src/procedure/repartition/plan.rs | 360 ++++++++++++++++-- .../repartition/repartition_start.rs | 198 +++++++++- .../src/procedure/repartition/test_util.rs | 6 +- .../src/procedure/repartition/utils.rs | 100 +++-- 14 files changed, 894 insertions(+), 191 deletions(-) diff --git a/src/meta-srv/src/procedure/repartition.rs b/src/meta-srv/src/procedure/repartition.rs index f314a40080..be060b7424 100644 --- a/src/meta-srv/src/procedure/repartition.rs +++ b/src/meta-srv/src/procedure/repartition.rs @@ -853,7 +853,7 @@ mod tests { use crate::procedure::repartition::deallocate_region::DeallocateRegion; use crate::procedure::repartition::dispatch::Dispatch; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::repartition_end::RepartitionEnd; use crate::procedure::repartition::test_util::{ TestingEnv, assert_parent_state, current_parent_region_routes, extract_subprocedure_ids, @@ -864,16 +864,16 @@ mod tests { fn test_plan(table_id: TableId) -> RepartitionPlanEntry { RepartitionPlanEntry { group_id: uuid::Uuid::new_v4(), - source_regions: vec![RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }], + source_regions: vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + )], target_regions: vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 1), partition_expr: range_expr("x", 0, 50), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 3), partition_expr: range_expr("x", 50, 100), }, @@ -1209,16 +1209,16 @@ mod tests { ); let succeeded_plan = RepartitionPlanEntry { group_id: Uuid::new_v4(), - source_regions: vec![RegionDescriptor { - region_id: RegionId::new(table_id, 2), - partition_expr: range_expr("x", 100, 200), - }], + source_regions: vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 2), + range_expr("x", 100, 200), + )], target_regions: vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 2), partition_expr: range_expr("x", 100, 150), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 4), partition_expr: range_expr("x", 150, 200), }, @@ -1292,16 +1292,16 @@ mod tests { ); let succeeded_plan = RepartitionPlanEntry { group_id: Uuid::new_v4(), - source_regions: vec![RegionDescriptor { - region_id: RegionId::new(table_id, 2), - partition_expr: range_expr("x", 100, 200), - }], + source_regions: vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 2), + range_expr("x", 100, 200), + )], target_regions: vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 2), partition_expr: range_expr("x", 100, 150), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 4), partition_expr: range_expr("x", 150, 200), }, @@ -1567,16 +1567,16 @@ mod tests { let failed_merge_plan = RepartitionPlanEntry { group_id: Uuid::new_v4(), source_regions: vec![ - RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }, - RegionDescriptor { - region_id: RegionId::new(table_id, 2), - partition_expr: range_expr("x", 100, 200), - }, + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + ), + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 2), + range_expr("x", 100, 200), + ), ], - target_regions: vec![RegionDescriptor { + target_regions: vec![TargetRegionDescriptor { region_id: RegionId::new(table_id, 1), partition_expr: range_expr("x", 0, 200), }], @@ -1587,16 +1587,16 @@ mod tests { }; let succeeded_split_plan = RepartitionPlanEntry { group_id: Uuid::new_v4(), - source_regions: vec![RegionDescriptor { - region_id: RegionId::new(table_id, 3), - partition_expr: range_expr("x", 200, 300), - }], + source_regions: vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 3), + range_expr("x", 200, 300), + )], target_regions: vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 3), partition_expr: range_expr("x", 200, 250), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 4), partition_expr: range_expr("x", 250, 300), }, diff --git a/src/meta-srv/src/procedure/repartition/allocate_region.rs b/src/meta-srv/src/procedure/repartition/allocate_region.rs index a3293e8c3e..c49866ac8a 100644 --- a/src/meta-srv/src/procedure/repartition/allocate_region.rs +++ b/src/meta-srv/src/procedure/repartition/allocate_region.rs @@ -35,7 +35,7 @@ use tokio::time::Instant; use crate::error::{self, Result}; use crate::procedure::repartition::dispatch::Dispatch; use crate::procedure::repartition::plan::{ - AllocationPlanEntry, RegionDescriptor, RepartitionPlanEntry, + AllocationPlanEntry, RepartitionPlanEntry, TargetRegionDescriptor, convert_allocation_plan_to_repartition_plan, }; use crate::procedure::repartition::{Context, State}; @@ -324,7 +324,7 @@ impl AllocateRegion { /// Collects all regions that need to be allocated from the repartition plan entries. fn collect_allocate_regions( repartition_plan_entries: &[RepartitionPlanEntry], - ) -> Vec<&RegionDescriptor> { + ) -> Vec<&TargetRegionDescriptor> { repartition_plan_entries .iter() .flat_map(|p| p.allocate_regions()) @@ -333,7 +333,7 @@ impl AllocateRegion { /// Prepares region allocation data: region numbers and their partition expressions. fn prepare_region_allocation_data( - allocate_regions: &[&RegionDescriptor], + allocate_regions: &[&TargetRegionDescriptor], ) -> Result> { allocate_regions .iter() @@ -417,6 +417,7 @@ mod tests { use super::*; use crate::procedure::repartition::State; + use crate::procedure::repartition::plan::SourceRegionDescriptor; use crate::procedure::repartition::test_util::{ TestingEnv, current_parent_region_routes, new_parent_context, range_expr, test_region_wal_options, @@ -428,8 +429,21 @@ mod tests { col: &str, start: i64, end: i64, - ) -> RegionDescriptor { - RegionDescriptor { + ) -> SourceRegionDescriptor { + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, region_number), + range_expr(col, start, end), + ) + } + + fn create_target_region_descriptor( + table_id: TableId, + region_number: u32, + col: &str, + start: i64, + end: i64, + ) -> TargetRegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, region_number), partition_expr: range_expr(col, start, end), } @@ -700,10 +714,10 @@ mod tests { fn test_prepare_region_allocation_data() { let table_id = 1024; let regions = [ - create_region_descriptor(table_id, 10, "x", 0, 50), - create_region_descriptor(table_id, 11, "x", 50, 100), + create_target_region_descriptor(table_id, 10, "x", 0, 50), + create_target_region_descriptor(table_id, 11, "x", 50, 100), ]; - let region_refs: Vec<&RegionDescriptor> = regions.iter().collect(); + let region_refs: Vec<&TargetRegionDescriptor> = regions.iter().collect(); let result = AllocateRegion::prepare_region_allocation_data(®ion_refs).unwrap(); @@ -732,7 +746,7 @@ mod tests { ctx.persistent_ctx.plans = vec![RepartitionPlanEntry { group_id: Uuid::new_v4(), source_regions: vec![], - target_regions: vec![create_region_descriptor(table_id, 3, "x", 0, 100)], + target_regions: vec![create_target_region_descriptor(table_id, 3, "x", 0, 100)], allocated_region_ids: vec![RegionId::new(table_id, 3)], pending_deallocate_region_ids: vec![], transition_map: vec![], diff --git a/src/meta-srv/src/procedure/repartition/dispatch.rs b/src/meta-srv/src/procedure/repartition/dispatch.rs index 3a9f9376f1..4377123887 100644 --- a/src/meta-srv/src/procedure/repartition/dispatch.rs +++ b/src/meta-srv/src/procedure/repartition/dispatch.rs @@ -25,22 +25,22 @@ use store_api::storage::RegionId; use crate::error::Result; use crate::procedure::repartition::collect::{Collect, ProcedureMeta}; use crate::procedure::repartition::group::RepartitionGroupProcedure; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::{self, Context, State}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Dispatch; pub(crate) fn build_region_mapping( - source_regions: &[RegionDescriptor], - target_regions: &[RegionDescriptor], + source_regions: &[SourceRegionDescriptor], + target_regions: &[TargetRegionDescriptor], transition_map: &[Vec], ) -> HashMap> { transition_map .iter() .enumerate() .map(|(source_idx, indices)| { - let source_region = source_regions[source_idx].region_id; + let source_region = source_regions[source_idx].region_id(); let target_regions = indices .iter() .map(|&target_idx| target_regions[target_idx].region_id) diff --git a/src/meta-srv/src/procedure/repartition/group.rs b/src/meta-srv/src/procedure/repartition/group.rs index 12374e8ada..2dc1117467 100644 --- a/src/meta-srv/src/procedure/repartition/group.rs +++ b/src/meta-srv/src/procedure/repartition/group.rs @@ -49,7 +49,7 @@ use uuid::Uuid; use crate::error::{self, Result}; use crate::procedure::repartition::group::repartition_start::RepartitionStart; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::utils::get_datanode_table_value; use crate::procedure::repartition::{self}; use crate::service::mailbox::MailboxRef; @@ -330,9 +330,9 @@ pub struct PersistentContext { /// The schema name of the repartition group. pub schema_name: String, /// The source regions of the repartition group. - pub sources: Vec, + pub sources: Vec, /// The target regions of the repartition group. - pub targets: Vec, + pub targets: Vec, /// For each `source region`, the corresponding /// `target regions` that overlap with it. pub region_mapping: HashMap>, @@ -360,8 +360,8 @@ impl PersistentContext { table_id: TableId, catalog_name: String, schema_name: String, - sources: Vec, - targets: Vec, + sources: Vec, + targets: Vec, region_mapping: HashMap>, sync_region: bool, allocated_region_ids: Vec, @@ -392,7 +392,7 @@ impl PersistentContext { SchemaLock::read(&self.catalog_name, &self.schema_name).into(), ]); for source in &self.sources { - lock_keys.push(RegionLock::Write(source.region_id).into()); + lock_keys.push(RegionLock::Write(source.region_id()).into()); } lock_keys } diff --git a/src/meta-srv/src/procedure/repartition/group/apply_staging_manifest.rs b/src/meta-srv/src/procedure/repartition/group/apply_staging_manifest.rs index 43e5ee31d9..6148901ffa 100644 --- a/src/meta-srv/src/procedure/repartition/group/apply_staging_manifest.rs +++ b/src/meta-srv/src/procedure/repartition/group/apply_staging_manifest.rs @@ -37,7 +37,7 @@ use crate::procedure::repartition::group::utils::{ HandleMultipleResult, group_region_routes_by_peer, handle_multiple_results, }; use crate::procedure::repartition::group::{Context, State}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::TargetRegionDescriptor; use crate::service::mailbox::{Channel, MailboxRef}; #[derive(Debug, Serialize, Deserialize)] @@ -75,7 +75,7 @@ impl ApplyStagingManifest { fn build_apply_staging_manifest_instructions( staging_manifest_paths: &HashMap, target_routes: &[RegionRoute], - targets: &[RegionDescriptor], + targets: &[TargetRegionDescriptor], central_region_id: RegionId, ) -> Result { let target_partition_expr_by_region = targets diff --git a/src/meta-srv/src/procedure/repartition/group/enter_staging_region.rs b/src/meta-srv/src/procedure/repartition/group/enter_staging_region.rs index c1957031d5..d1be2ca9d0 100644 --- a/src/meta-srv/src/procedure/repartition/group/enter_staging_region.rs +++ b/src/meta-srv/src/procedure/repartition/group/enter_staging_region.rs @@ -38,7 +38,7 @@ use crate::procedure::repartition::group::utils::{ HandleMultipleResult, group_region_routes_by_peer, handle_multiple_results, }; use crate::procedure::repartition::group::{Context, GroupId, GroupPrepareResult, State}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::TargetRegionDescriptor; use crate::procedure::utils::{self, ErrorStrategy}; use crate::service::mailbox::{Channel, MailboxRef}; @@ -77,7 +77,7 @@ impl EnterStagingRegion { fn build_enter_staging_instructions( group_id: GroupId, prepare_result: &GroupPrepareResult, - targets: &[RegionDescriptor], + targets: &[TargetRegionDescriptor], pending_deallocate_region_ids: &[RegionId], ) -> Result>> { let target_partition_expr_by_region = targets @@ -454,7 +454,7 @@ mod tests { use crate::error::{self, Error}; use crate::procedure::repartition::group::GroupPrepareResult; use crate::procedure::repartition::group::enter_staging_region::EnterStagingRegion; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::TargetRegionDescriptor; use crate::procedure::repartition::test_util::{ TestingEnv, new_persistent_context, range_expr, }; @@ -720,13 +720,13 @@ mod tests { } } - fn test_targets() -> Vec { + fn test_targets() -> Vec { vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(1024, 1), partition_expr: range_expr("x", 0, 10), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(1024, 2), partition_expr: range_expr("x", 10, 20), }, diff --git a/src/meta-srv/src/procedure/repartition/group/remap_manifest.rs b/src/meta-srv/src/procedure/repartition/group/remap_manifest.rs index 1d6a75100e..d8259a354f 100644 --- a/src/meta-srv/src/procedure/repartition/group/remap_manifest.rs +++ b/src/meta-srv/src/procedure/repartition/group/remap_manifest.rs @@ -30,7 +30,7 @@ use crate::error::{self, Result}; use crate::handler::HeartbeatMailbox; use crate::procedure::repartition::group::apply_staging_manifest::ApplyStagingManifest; use crate::procedure::repartition::group::{Context, State}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::service::mailbox::{Channel, MailboxRef}; #[derive(Debug, Serialize, Deserialize)] @@ -98,8 +98,8 @@ impl State for RemapManifest { impl RemapManifest { fn build_remap_manifest_instructions( - source_regions: &[RegionDescriptor], - target_regions: &[RegionDescriptor], + source_regions: &[SourceRegionDescriptor], + target_regions: &[TargetRegionDescriptor], region_mapping: &HashMap>, central_region_id: RegionId, ) -> Result { @@ -117,7 +117,7 @@ impl RemapManifest { Ok(common_meta::instruction::RemapManifest { region_id: central_region_id, - input_regions: source_regions.iter().map(|r| r.region_id).collect(), + input_regions: source_regions.iter().map(|r| r.region_id()).collect(), region_mapping: region_mapping.clone(), new_partition_exprs, }) diff --git a/src/meta-srv/src/procedure/repartition/group/repartition_start.rs b/src/meta-srv/src/procedure/repartition/group/repartition_start.rs index 8b8b5208b4..8392890911 100644 --- a/src/meta-srv/src/procedure/repartition/group/repartition_start.rs +++ b/src/meta-srv/src/procedure/repartition/group/repartition_start.rs @@ -19,7 +19,7 @@ use common_meta::rpc::router::RegionRoute; use common_procedure::{Context as ProcedureContext, Status}; use common_telemetry::debug; use serde::{Deserialize, Serialize}; -use snafu::{OptionExt, ResultExt, ensure}; +use snafu::{OptionExt, ensure}; use crate::error::{self, Result}; use crate::procedure::repartition::group::sync_region::SyncRegion; @@ -27,21 +27,18 @@ use crate::procedure::repartition::group::update_metadata::UpdateMetadata; use crate::procedure::repartition::group::{ Context, GroupId, GroupPrepareResult, State, region_routes, }; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; #[derive(Debug, Serialize, Deserialize)] pub struct RepartitionStart; -/// Ensures that the partition expression of the region route matches the partition expression of the region descriptor. -fn ensure_region_route_expr_match( +/// Ensures that the partition expression of the source region route matches the source descriptor. +fn ensure_source_region_route_expr_match( region_route: &RegionRoute, - region_descriptor: &RegionDescriptor, + source: &SourceRegionDescriptor, ) -> Result { let actual = region_route.region.partition_expr(); - let expected = region_descriptor - .partition_expr - .as_json_str() - .context(error::SerializePartitionExprSnafu)?; + let expected = source.route_expr_for_rollback()?; ensure!( actual == expected, error::PartitionExprMismatchSnafu { @@ -60,8 +57,8 @@ impl RepartitionStart { fn ensure_route_present( group_id: GroupId, region_routes: &[RegionRoute], - sources: &[RegionDescriptor], - targets: &[RegionDescriptor], + sources: &[SourceRegionDescriptor], + targets: &[TargetRegionDescriptor], ) -> Result { ensure!( !sources.is_empty(), @@ -78,12 +75,12 @@ impl RepartitionStart { .iter() .map(|s| { region_routes_map - .get(&s.region_id) + .get(&s.region_id()) .context(error::RepartitionSourceRegionMissingSnafu { group_id, - region_id: s.region_id, + region_id: s.region_id(), }) - .and_then(|r| ensure_region_route_expr_match(r, s)) + .and_then(|r| ensure_source_region_route_expr_match(r, s)) }) .collect::>>()?; let target_region_routes = targets @@ -109,7 +106,7 @@ impl RepartitionStart { } ); } - let central_region = sources[0].region_id; + let central_region = sources[0].region_id(); let central_region_datanode = source_region_routes[0] .leader_peer .as_ref() @@ -216,16 +213,14 @@ mod tests { use crate::error::Error; use crate::procedure::repartition::group::repartition_start::RepartitionStart; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::test_util::range_expr; #[test] fn test_ensure_route_present_missing_source_region() { - let source_region = RegionDescriptor { - region_id: RegionId::new(1024, 1), - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = + SourceRegionDescriptor::partitioned(RegionId::new(1024, 1), range_expr("x", 0, 100)); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(1024, 2), partition_expr: range_expr("x", 0, 10), }; @@ -249,11 +244,9 @@ mod tests { #[test] fn test_ensure_route_present_partition_expr_mismatch() { - let source_region = RegionDescriptor { - region_id: RegionId::new(1024, 1), - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = + SourceRegionDescriptor::partitioned(RegionId::new(1024, 1), range_expr("x", 0, 100)); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(1024, 2), partition_expr: range_expr("x", 0, 10), }; @@ -277,12 +270,69 @@ mod tests { } #[test] - fn test_ensure_route_present_missing_target_region() { - let source_region = RegionDescriptor { + fn test_ensure_route_present_default_source_matches_empty_partition_expr() { + let source_region = SourceRegionDescriptor::Default { region_id: RegionId::new(1024, 1), - partition_expr: range_expr("x", 0, 100), }; - let target_region = RegionDescriptor { + let target_region = TargetRegionDescriptor { + region_id: RegionId::new(1024, 1), + partition_expr: range_expr("x", 0, 10), + }; + let region_routes = vec![RegionRoute { + region: Region { + id: RegionId::new(1024, 1), + partition_expr: String::new(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + ..Default::default() + }]; + + let result = RepartitionStart::ensure_route_present( + Uuid::new_v4(), + ®ion_routes, + &[source_region], + &[target_region], + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_ensure_route_present_default_source_rejects_non_empty_partition_expr() { + let source_region = SourceRegionDescriptor::Default { + region_id: RegionId::new(1024, 1), + }; + let target_region = TargetRegionDescriptor { + region_id: RegionId::new(1024, 1), + partition_expr: range_expr("x", 0, 10), + }; + let region_routes = vec![RegionRoute { + region: Region { + id: RegionId::new(1024, 1), + partition_expr: range_expr("x", 0, 100).as_json_str().unwrap(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + ..Default::default() + }]; + + let err = RepartitionStart::ensure_route_present( + Uuid::new_v4(), + ®ion_routes, + &[source_region], + &[target_region], + ) + .unwrap_err(); + + assert_matches!(err, Error::PartitionExprMismatch { .. }); + } + + #[test] + fn test_ensure_route_present_missing_target_region() { + let source_region = + SourceRegionDescriptor::partitioned(RegionId::new(1024, 1), range_expr("x", 0, 100)); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(1024, 2), partition_expr: range_expr("x", 0, 10), }; @@ -307,11 +357,9 @@ mod tests { #[test] fn test_ensure_route_present_legacy_partition_expr_source() { - let source_region = RegionDescriptor { - region_id: RegionId::new(1024, 1), - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = + SourceRegionDescriptor::partitioned(RegionId::new(1024, 1), range_expr("x", 0, 100)); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(1024, 2), partition_expr: range_expr("x", 0, 10), }; diff --git a/src/meta-srv/src/procedure/repartition/group/update_metadata/apply_staging_region.rs b/src/meta-srv/src/procedure/repartition/group/update_metadata/apply_staging_region.rs index ff01161ff5..13fc486467 100644 --- a/src/meta-srv/src/procedure/repartition/group/update_metadata/apply_staging_region.rs +++ b/src/meta-srv/src/procedure/repartition/group/update_metadata/apply_staging_region.rs @@ -22,7 +22,7 @@ use snafu::{OptionExt, ResultExt}; use crate::error::{self, Result}; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; use crate::procedure::repartition::group::{Context, GroupId, region_routes}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; impl UpdateMetadata { /// Applies the new partition expressions for staging regions. @@ -32,8 +32,8 @@ impl UpdateMetadata { /// - Source region not found. pub(crate) fn apply_staging_region_routes( group_id: GroupId, - sources: &[RegionDescriptor], - targets: &[RegionDescriptor], + sources: &[SourceRegionDescriptor], + targets: &[TargetRegionDescriptor], pending_deallocate_region_ids: &[store_api::storage::RegionId], current_region_routes: &[RegionRoute], ) -> Result> { @@ -61,15 +61,16 @@ impl UpdateMetadata { } for source in sources { - let region_route = region_routes_map.get_mut(&source.region_id).context( + let region_id = source.region_id(); + let region_route = region_routes_map.get_mut(®ion_id).context( error::RepartitionSourceRegionMissingSnafu { group_id, - region_id: source.region_id, + region_id, }, )?; // Set leader staging state for the source region route. region_route.set_leader_staging(); - if pending_deallocate_region_ids.contains(&source.region_id) { + if pending_deallocate_region_ids.contains(®ion_id) { // When a region is pending deallocation, it should ignore all writes. region_route.set_ignore_all_writes(); } @@ -130,7 +131,7 @@ mod tests { use uuid::Uuid; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::test_util::range_expr; #[test] @@ -166,11 +167,11 @@ mod tests { ..Default::default() }, ]; - let source_region = RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + ); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(table_id, 2), partition_expr: range_expr("x", 0, 10), }; @@ -196,6 +197,68 @@ mod tests { assert!(!new_region_routes[2].is_leader_staging()); } + #[test] + fn test_generate_region_routes_with_reused_default_source_region() { + let group_id = Uuid::new_v4(); + let table_id = 1024; + let default_region_id = RegionId::new(table_id, 1); + let region_routes = vec![ + RegionRoute { + region: Region { + id: default_region_id, + partition_expr: String::new(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + ..Default::default() + }, + RegionRoute { + region: Region { + id: RegionId::new(table_id, 2), + partition_expr: String::new(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + ..Default::default() + }, + ]; + let source_region = SourceRegionDescriptor::Default { + region_id: default_region_id, + }; + let reused_target_expr = range_expr("x", 0, 10); + let target_regions = vec![ + TargetRegionDescriptor { + region_id: default_region_id, + partition_expr: reused_target_expr.clone(), + }, + TargetRegionDescriptor { + region_id: RegionId::new(table_id, 2), + partition_expr: range_expr("x", 10, 20), + }, + ]; + + let new_region_routes = UpdateMetadata::apply_staging_region_routes( + group_id, + &[source_region], + &target_regions, + &[], + ®ion_routes, + ) + .unwrap(); + + assert_eq!( + new_region_routes[0].region.partition_expr, + reused_target_expr.as_json_str().unwrap() + ); + assert!(new_region_routes[0].is_leader_staging()); + assert!(!new_region_routes[0].is_ignore_all_writes()); + assert_eq!( + new_region_routes[1].region.partition_expr, + range_expr("x", 10, 20).as_json_str().unwrap() + ); + assert!(new_region_routes[1].is_leader_staging()); + } + #[test] fn test_generate_region_routes_mark_pending_deallocate_reject_all_writes() { let group_id = Uuid::new_v4(); @@ -221,11 +284,11 @@ mod tests { ..Default::default() }, ]; - let source_region = RegionDescriptor { - region_id: pending_deallocate_region_id, - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = SourceRegionDescriptor::partitioned( + pending_deallocate_region_id, + range_expr("x", 0, 100), + ); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(table_id, 2), partition_expr: range_expr("x", 0, 10), }; diff --git a/src/meta-srv/src/procedure/repartition/group/update_metadata/exit_staging_region.rs b/src/meta-srv/src/procedure/repartition/group/update_metadata/exit_staging_region.rs index 50864daa93..325f859a98 100644 --- a/src/meta-srv/src/procedure/repartition/group/update_metadata/exit_staging_region.rs +++ b/src/meta-srv/src/procedure/repartition/group/update_metadata/exit_staging_region.rs @@ -22,13 +22,13 @@ use snafu::{OptionExt, ResultExt}; use crate::error::{self, Result}; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; use crate::procedure::repartition::group::{Context, GroupId, region_routes}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; impl UpdateMetadata { pub(crate) fn exit_staging_region_routes( group_id: GroupId, - sources: &[RegionDescriptor], - targets: &[RegionDescriptor], + sources: &[SourceRegionDescriptor], + targets: &[TargetRegionDescriptor], current_region_routes: &[RegionRoute], ) -> Result> { let mut region_routes = current_region_routes.to_vec(); @@ -48,10 +48,11 @@ impl UpdateMetadata { } for source in sources { - let region_route = region_routes_map.get_mut(&source.region_id).context( + let region_id = source.region_id(); + let region_route = region_routes_map.get_mut(®ion_id).context( error::RepartitionSourceRegionMissingSnafu { group_id, - region_id: source.region_id, + region_id, }, )?; region_route.clear_leader_staging(); @@ -113,24 +114,25 @@ mod tests { use uuid::Uuid; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::test_util::range_expr; #[test] fn test_exit_staging_region_routes_keep_reject_all_writes() { let group_id = Uuid::new_v4(); let table_id = 1024; - let source_region = RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }; - let target_region = RegionDescriptor { + let source_region = SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + ); + let source_region_id = source_region.region_id(); + let target_region = TargetRegionDescriptor { region_id: RegionId::new(table_id, 2), partition_expr: range_expr("x", 0, 50), }; let mut source_route = RegionRoute { region: Region { - id: source_region.region_id, + id: source_region_id, partition_expr: range_expr("x", 0, 100).as_json_str().unwrap(), ..Default::default() }, @@ -165,4 +167,40 @@ mod tests { assert!(!new_region_routes[1].is_leader_staging()); assert!(new_region_routes[1].is_ignore_all_writes()); } + + #[test] + fn test_exit_staging_region_routes_with_reused_default_source_region() { + let group_id = Uuid::new_v4(); + let table_id = 1024; + let default_region_id = RegionId::new(table_id, 1); + let source_region = SourceRegionDescriptor::Default { + region_id: default_region_id, + }; + let target_region = TargetRegionDescriptor { + region_id: default_region_id, + partition_expr: range_expr("x", 0, 50), + }; + let target_expr = target_region.partition_expr.as_json_str().unwrap(); + let region_route = RegionRoute { + region: Region { + id: default_region_id, + partition_expr: target_expr.clone(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + leader_state: Some(LeaderState::Staging), + ..Default::default() + }; + + let new_region_routes = UpdateMetadata::exit_staging_region_routes( + group_id, + &[source_region], + &[target_region], + &[region_route], + ) + .unwrap(); + + assert!(!new_region_routes[0].is_leader_staging()); + assert_eq!(new_region_routes[0].region.partition_expr, target_expr); + } } diff --git a/src/meta-srv/src/procedure/repartition/plan.rs b/src/meta-srv/src/procedure/repartition/plan.rs index 063a64341b..1d11d7aa56 100644 --- a/src/meta-srv/src/procedure/repartition/plan.rs +++ b/src/meta-srv/src/procedure/repartition/plan.rs @@ -16,17 +16,137 @@ use std::cmp::Ordering; use common_meta::rpc::router::RegionRoute; use partition::expr::PartitionExpr; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use snafu::ResultExt; use store_api::storage::{RegionId, RegionNumber, TableId}; +use crate::error::{self, Result}; use crate::procedure::repartition::group::GroupId; -/// Metadata describing a region involved in the plan. +/// Metadata describing a source region involved in the plan. +/// +/// Source regions may represent either an existing partitioned region or the +/// default region of an unpartitioned table. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum SourceRegionDescriptor { + /// A regular partitioned source region. + Partitioned { + /// The region id of the source region. + region_id: RegionId, + /// The partition expression of the source region. + partition_expr: PartitionExpr, + }, + /// The default source region of an unpartitioned table. + Default { + /// The region id of the default source region. + region_id: RegionId, + }, +} + +impl<'de> Deserialize<'de> for SourceRegionDescriptor { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct PartitionedSourceRegionDescriptor { + region_id: RegionId, + partition_expr: PartitionExpr, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum SourceRegionDescriptorRepr { + Tagged(SourceRegionDescriptorTaggedRepr), + Legacy(PartitionedSourceRegionDescriptor), + } + + #[derive(Deserialize)] + enum SourceRegionDescriptorTaggedRepr { + Partitioned { + region_id: RegionId, + partition_expr: PartitionExpr, + }, + Default { + region_id: RegionId, + }, + } + + match SourceRegionDescriptorRepr::deserialize(deserializer)? { + SourceRegionDescriptorRepr::Tagged(SourceRegionDescriptorTaggedRepr::Partitioned { + region_id, + partition_expr, + }) => Ok(Self::Partitioned { + region_id, + partition_expr, + }), + SourceRegionDescriptorRepr::Tagged(SourceRegionDescriptorTaggedRepr::Default { + region_id, + }) => Ok(Self::Default { region_id }), + SourceRegionDescriptorRepr::Legacy(descriptor) => Ok(Self::Partitioned { + region_id: descriptor.region_id, + partition_expr: descriptor.partition_expr, + }), + } + } +} + +impl SourceRegionDescriptor { + /// Creates a partitioned source region descriptor. + pub fn partitioned(region_id: RegionId, partition_expr: PartitionExpr) -> Self { + Self::Partitioned { + region_id, + partition_expr, + } + } + + /// Returns the region id of this source descriptor. + pub fn region_id(&self) -> RegionId { + match self { + Self::Partitioned { region_id, .. } => *region_id, + Self::Default { region_id } => *region_id, + } + } + + /// Returns the partition expression if this source is partitioned. + pub fn partition_expr(&self) -> Option<&PartitionExpr> { + match self { + Self::Partitioned { partition_expr, .. } => Some(partition_expr), + Self::Default { .. } => None, + } + } + + /// Returns true if this source descriptor matches the route partition expression. + pub fn matches_route_expr(&self, route_expr: &str) -> Result { + match self { + Self::Partitioned { partition_expr, .. } => { + let expected = partition_expr + .as_json_str() + .context(error::SerializePartitionExprSnafu)?; + Ok(route_expr == expected) + } + Self::Default { .. } => Ok(route_expr.is_empty()), + } + } + + /// Returns the route partition expression to restore during rollback. + pub fn route_expr_for_rollback(&self) -> Result { + match self { + Self::Partitioned { partition_expr, .. } => partition_expr + .as_json_str() + .context(error::SerializePartitionExprSnafu), + Self::Default { .. } => Ok(String::new()), + } + } +} + +/// Metadata describing a target region involved in the plan. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegionDescriptor { - /// The region id of the region involved in the plan. +pub struct TargetRegionDescriptor { + /// The region id of the target region. pub region_id: RegionId, - /// The partition expression of the region. + /// The partition expression of the target region. pub partition_expr: PartitionExpr, } @@ -37,7 +157,7 @@ pub struct AllocationPlanEntry { /// The group id for this plan entry. pub group_id: GroupId, /// Source region descriptors involved in the plan. - pub source_regions: Vec, + pub source_regions: Vec, /// The target partition expressions for the new or changed regions. pub target_partition_exprs: Vec, /// For each `source_regions[k]`, the corresponding vector contains global @@ -52,9 +172,9 @@ pub struct RepartitionPlanEntry { /// The group id for this plan entry. pub group_id: GroupId, /// The source region descriptors involved in the plan. - pub source_regions: Vec, + pub source_regions: Vec, /// The target region descriptors involved in the plan. - pub target_regions: Vec, + pub target_regions: Vec, /// The region ids of the allocated regions. pub allocated_region_ids: Vec, /// The region ids of the regions that are pending deallocation. @@ -69,7 +189,7 @@ pub struct RepartitionPlanEntry { impl RepartitionPlanEntry { /// Returns the target regions that are newly allocated. - pub(crate) fn allocate_regions(&self) -> Vec<&RegionDescriptor> { + pub(crate) fn allocate_regions(&self) -> Vec<&TargetRegionDescriptor> { self.target_regions .iter() .filter(|r| self.allocated_region_ids.contains(&r.region_id)) @@ -111,7 +231,7 @@ pub fn convert_allocation_plan_to_repartition_plan( .iter() .skip(source_regions.len()) .map(|target_partition_expr| { - let desc = RegionDescriptor { + let desc = TargetRegionDescriptor { region_id: RegionId::new(table_id, *next_region_number), partition_expr: target_partition_expr.clone(), }; @@ -128,10 +248,12 @@ pub fn convert_allocation_plan_to_repartition_plan( let target_regions = source_regions .iter() .zip(target_partition_exprs.iter()) - .map(|(source_region, target_partition_expr)| RegionDescriptor { - region_id: source_region.region_id, - partition_expr: target_partition_expr.clone(), - }) + .map( + |(source_region, target_partition_expr)| TargetRegionDescriptor { + region_id: source_region.region_id(), + partition_expr: target_partition_expr.clone(), + }, + ) .chain(pending_allocate_target_partition_exprs) .collect::>(); @@ -149,10 +271,12 @@ pub fn convert_allocation_plan_to_repartition_plan( let target_regions = source_regions .iter() .zip(target_partition_exprs.iter()) - .map(|(source_region, target_partition_expr)| RegionDescriptor { - region_id: source_region.region_id, - partition_expr: target_partition_expr.clone(), - }) + .map( + |(source_region, target_partition_expr)| TargetRegionDescriptor { + region_id: source_region.region_id(), + partition_expr: target_partition_expr.clone(), + }, + ) .collect::>(); RepartitionPlanEntry { @@ -171,16 +295,18 @@ pub fn convert_allocation_plan_to_repartition_plan( .iter() .take(target_partition_exprs.len()) .zip(target_partition_exprs.iter()) - .map(|(source_region, target_partition_expr)| RegionDescriptor { - region_id: source_region.region_id, - partition_expr: target_partition_expr.clone(), - }) + .map( + |(source_region, target_partition_expr)| TargetRegionDescriptor { + region_id: source_region.region_id(), + partition_expr: target_partition_expr.clone(), + }, + ) .collect::>(); let pending_deallocate_region_ids = source_regions .iter() .skip(target_partition_exprs.len()) - .map(|source_region| source_region.region_id) + .map(|source_region| source_region.region_id()) .collect::>(); RepartitionPlanEntry { @@ -210,11 +336,140 @@ mod tests { col: &str, start: i64, end: i64, - ) -> RegionDescriptor { - RegionDescriptor { - region_id: RegionId::new(table_id, region_number), - partition_expr: range_expr(col, start, end), - } + ) -> SourceRegionDescriptor { + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, region_number), + range_expr(col, start, end), + ) + } + + #[test] + fn test_source_region_descriptor_deserializes_legacy_partitioned_shape() { + let table_id = 1024; + let region_id = RegionId::new(table_id, 1); + let partition_expr = range_expr("x", 0, 100); + let legacy_json = serde_json::json!({ + "region_id": region_id, + "partition_expr": partition_expr, + }); + + let descriptor: SourceRegionDescriptor = serde_json::from_value(legacy_json).unwrap(); + + assert_eq!( + descriptor, + SourceRegionDescriptor::partitioned(region_id, partition_expr) + ); + } + + #[test] + fn test_source_region_descriptor_rejects_legacy_default_shape() { + let region_id = RegionId::new(1024, 1); + let default_json = serde_json::json!({ + "region_id": region_id, + }); + + let err = serde_json::from_value::(default_json).unwrap_err(); + + assert!(err.to_string().contains("data did not match any variant")); + } + + #[test] + fn test_source_region_descriptor_roundtrip_tagged_partitioned_shape() { + let region_id = RegionId::new(1024, 1); + let partition_expr = range_expr("x", 0, 100); + let descriptor = SourceRegionDescriptor::partitioned(region_id, partition_expr.clone()); + + let value = serde_json::to_value(&descriptor).unwrap(); + let decoded = serde_json::from_value::(value.clone()).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "Partitioned": { + "region_id": region_id, + "partition_expr": partition_expr, + } + }) + ); + assert_eq!(decoded, descriptor); + } + + #[test] + fn test_source_region_descriptor_roundtrip_tagged_default_shape() { + let region_id = RegionId::new(1024, 1); + let descriptor = SourceRegionDescriptor::Default { region_id }; + + let value = serde_json::to_value(&descriptor).unwrap(); + let decoded = serde_json::from_value::(value.clone()).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "Default": { + "region_id": region_id, + } + }) + ); + assert_eq!(decoded, descriptor); + } + + #[test] + fn test_source_region_descriptor_rejects_invalid_partition_expr_shape() { + let region_id = RegionId::new(1024, 1); + let invalid_json = serde_json::json!({ + "region_id": region_id, + "partition_expr": 42, + }); + + let err = serde_json::from_value::(invalid_json).unwrap_err(); + + assert!(err.to_string().contains("data did not match any variant")); + } + + #[test] + fn test_repartition_plan_entry_deserializes_legacy_source_regions() { + let group_id = Uuid::new_v4(); + let table_id = 1024; + let source_region_id = RegionId::new(table_id, 1); + let target_region_id = RegionId::new(table_id, 2); + let source_partition_expr = range_expr("x", 0, 100); + let target_partition_expr = range_expr("x", 0, 50); + let legacy_json = serde_json::json!({ + "group_id": group_id, + "source_regions": [{ + "region_id": source_region_id, + "partition_expr": source_partition_expr, + }], + "target_regions": [{ + "region_id": target_region_id, + "partition_expr": target_partition_expr, + }], + "allocated_region_ids": [target_region_id], + "pending_deallocate_region_ids": [], + "transition_map": [[0]], + }); + + let plan: RepartitionPlanEntry = serde_json::from_value(legacy_json).unwrap(); + + assert_eq!(plan.group_id, group_id); + assert_eq!( + plan.source_regions, + vec![SourceRegionDescriptor::partitioned( + source_region_id, + source_partition_expr + )] + ); + assert_eq!( + plan.target_regions, + vec![TargetRegionDescriptor { + region_id: target_region_id, + partition_expr: target_partition_expr, + }] + ); + assert_eq!(plan.allocated_region_ids, vec![target_region_id]); + assert!(plan.pending_deallocate_region_ids.is_empty()); + assert_eq!(plan.transition_map, vec![vec![0]]); + assert!(plan.original_target_routes.is_empty()); } #[test] @@ -468,6 +723,55 @@ mod tests { assert_eq!(next_region_number, 6); } + #[test] + fn test_convert_plan_allocate_default_source_region() { + let group_id = Uuid::new_v4(); + let table_id = 1024; + let mut next_region_number = 5; + let source_regions = vec![SourceRegionDescriptor::Default { + region_id: RegionId::new(table_id, 1), + }]; + let target_partition_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + let allocation_plan = AllocationPlanEntry { + group_id, + source_regions: source_regions.clone(), + target_partition_exprs: target_partition_exprs.clone(), + transition_map: vec![vec![0, 1]], + }; + + let result = convert_allocation_plan_to_repartition_plan( + table_id, + &mut next_region_number, + &allocation_plan, + ); + + assert_eq!(result.source_regions, source_regions); + assert_eq!(result.target_regions.len(), 2); + assert_eq!( + result.target_regions[0].region_id, + RegionId::new(table_id, 1) + ); + assert_eq!( + result.target_regions[0].partition_expr, + target_partition_exprs[0] + ); + assert_eq!( + result.target_regions[1].region_id, + RegionId::new(table_id, 5) + ); + assert_eq!( + result.target_regions[1].partition_expr, + target_partition_exprs[1] + ); + assert_eq!( + result.allocated_region_ids, + vec![RegionId::new(table_id, 5)] + ); + assert!(result.pending_deallocate_region_ids.is_empty()); + assert_eq!(result.transition_map, vec![vec![0, 1]]); + assert_eq!(next_region_number, 6); + } + #[test] fn test_convert_plan_deallocate_to_single_region() { let group_id = Uuid::new_v4(); diff --git a/src/meta-srv/src/procedure/repartition/repartition_start.rs b/src/meta-srv/src/procedure/repartition/repartition_start.rs index 5c6bcfdb06..6e14e6a0e6 100644 --- a/src/meta-srv/src/procedure/repartition/repartition_start.rs +++ b/src/meta-srv/src/procedure/repartition/repartition_start.rs @@ -17,6 +17,7 @@ use std::any::Any; use common_meta::key::table_route::PhysicalTableRouteValue; use common_procedure::{Context as ProcedureContext, Status}; use common_telemetry::debug; +use partition::collider::Collider; use partition::expr::PartitionExpr; use partition::subtask::{self, RepartitionSubtask}; use serde::{Deserialize, Serialize}; @@ -26,7 +27,7 @@ use uuid::Uuid; use crate::error::{self, Result}; use crate::procedure::repartition::allocate_region::AllocateRegion; -use crate::procedure::repartition::plan::{AllocationPlanEntry, RegionDescriptor}; +use crate::procedure::repartition::plan::{AllocationPlanEntry, SourceRegionDescriptor}; use crate::procedure::repartition::repartition_end::RepartitionEnd; use crate::procedure::repartition::{Context, State}; @@ -107,8 +108,12 @@ impl RepartitionStart { from_exprs: &[PartitionExpr], to_exprs: &[PartitionExpr], ) -> Result> { - let subtasks = subtask::create_subtasks(from_exprs, to_exprs) - .context(error::RepartitionCreateSubtasksSnafu)?; + let subtasks = if from_exprs.is_empty() { + Self::default_source_subtasks(to_exprs)? + } else { + subtask::create_subtasks(from_exprs, to_exprs) + .context(error::RepartitionCreateSubtasksSnafu)? + }; if subtasks.is_empty() { return Ok(vec![]); } @@ -123,7 +128,7 @@ impl RepartitionStart { fn build_plan_entries( subtasks: Vec, - source_index: &[RegionDescriptor], + source_index: &[SourceRegionDescriptor], target_exprs: &[PartitionExpr], ) -> Vec { subtasks @@ -151,10 +156,32 @@ impl RepartitionStart { .collect::>() } + fn default_source_subtasks(to_exprs: &[PartitionExpr]) -> Result> { + ensure!( + !to_exprs.is_empty(), + error::UnexpectedSnafu { + violated: "Default source repartition expects non-empty target partition exprs", + } + ); + + Collider::new(to_exprs).context(error::RepartitionCreateSubtasksSnafu)?; + + let to_expr_indices = (0..to_exprs.len()).collect::>(); + Ok(vec![RepartitionSubtask { + from_expr_indices: vec![0], + to_expr_indices: to_expr_indices.clone(), + transition_map: vec![to_expr_indices], + }]) + } + fn source_region_descriptors( from_exprs: &[PartitionExpr], physical_route: &PhysicalTableRouteValue, - ) -> Result> { + ) -> Result> { + if from_exprs.is_empty() { + return Self::default_source_region_descriptors(physical_route); + } + let existing_regions = physical_route .region_routes .iter() @@ -178,13 +205,166 @@ impl RepartitionStart { debug!("Failed to find matching region for partition expression: {}, existing regions: {:?}", expr_json, existing_regions); })?; - Ok(RegionDescriptor { - region_id: matched_region_id, - partition_expr: expr.clone(), - }) + Ok(SourceRegionDescriptor::partitioned( + matched_region_id, + expr.clone(), + )) }) .collect::>>()?; Ok(descriptors) } + + fn default_source_region_descriptors( + physical_route: &PhysicalTableRouteValue, + ) -> Result> { + ensure!( + physical_route.region_routes.len() == 1, + error::UnexpectedSnafu { + violated: format!( + "Default source repartition expects exactly one source region, but got {}", + physical_route.region_routes.len() + ), + } + ); + + let source_region = &physical_route.region_routes[0].region; + ensure!( + source_region.partition_expr().is_empty(), + error::UnexpectedSnafu { + violated: format!( + "Default source repartition expects an empty partition expr, but got {}", + source_region.partition_expr() + ), + } + ); + + Ok(vec![SourceRegionDescriptor::Default { + region_id: source_region.id, + }]) + } +} + +#[cfg(test)] +mod tests { + use common_meta::key::table_route::PhysicalTableRouteValue; + use common_meta::peer::Peer; + use common_meta::rpc::router::{Region, RegionRoute}; + use datatypes::prelude::Value; + use partition::expr::{Operand, RestrictedOp}; + use store_api::storage::RegionId; + + use super::*; + use crate::procedure::repartition::test_util::{range_expr, test_region_route}; + + fn physical_route(region_routes: Vec) -> PhysicalTableRouteValue { + PhysicalTableRouteValue::new(region_routes) + } + + #[test] + fn test_build_plan_with_default_source_region() { + let table_id = 1024; + let physical_route = + physical_route(vec![test_region_route(RegionId::new(table_id, 1), "")]); + let to_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + + let plans = RepartitionStart::build_plan(&physical_route, &[], &to_exprs).unwrap(); + + assert_eq!(plans.len(), 1); + let plan = &plans[0]; + assert_eq!( + plan.source_regions, + vec![SourceRegionDescriptor::Default { + region_id: RegionId::new(table_id, 1) + }] + ); + assert_eq!(plan.target_partition_exprs, to_exprs); + assert_eq!(plan.transition_map, vec![vec![0, 1]]); + } + + #[test] + fn test_build_plan_with_default_source_rejects_non_empty_partition_expr() { + let table_id = 1024; + let physical_route = physical_route(vec![test_region_route( + RegionId::new(table_id, 1), + &range_expr("x", 0, 100).as_json_str().unwrap(), + )]); + let to_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + + let err = RepartitionStart::build_plan(&physical_route, &[], &to_exprs).unwrap_err(); + + assert!(err.to_string().contains("empty partition expr")); + } + + #[test] + fn test_build_plan_with_default_source_rejects_multiple_regions() { + let table_id = 1024; + let physical_route = physical_route(vec![ + test_region_route(RegionId::new(table_id, 1), ""), + test_region_route(RegionId::new(table_id, 2), ""), + ]); + let to_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + + let err = RepartitionStart::build_plan(&physical_route, &[], &to_exprs).unwrap_err(); + + assert!(err.to_string().contains("exactly one source region")); + } + + #[test] + fn test_build_plan_with_default_source_rejects_empty_targets() { + let table_id = 1024; + let physical_route = + physical_route(vec![test_region_route(RegionId::new(table_id, 1), "")]); + + let err = RepartitionStart::build_plan(&physical_route, &[], &[]).unwrap_err(); + + assert!(err.to_string().contains("non-empty target partition exprs")); + } + + #[test] + fn test_build_plan_with_default_source_rejects_invalid_targets() { + let table_id = 1024; + let physical_route = + physical_route(vec![test_region_route(RegionId::new(table_id, 1), "")]); + let invalid_to_expr = PartitionExpr::new( + Operand::Value(Value::Int64(1)), + RestrictedOp::Eq, + Operand::Value(Value::Int64(2)), + ); + + let err = + RepartitionStart::build_plan(&physical_route, &[], &[invalid_to_expr]).unwrap_err(); + + assert!( + err.to_string() + .contains("Failed to create repartition subtasks") + ); + } + + #[test] + fn test_build_plan_keeps_partitioned_source_matching() { + let table_id = 1024; + let from_exprs = vec![range_expr("x", 0, 100)]; + let to_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + let physical_route = physical_route(vec![RegionRoute { + region: Region { + id: RegionId::new(table_id, 1), + partition_expr: from_exprs[0].as_json_str().unwrap(), + ..Default::default() + }, + leader_peer: Some(Peer::empty(1)), + ..Default::default() + }]); + + let plans = RepartitionStart::build_plan(&physical_route, &from_exprs, &to_exprs).unwrap(); + + assert_eq!(plans.len(), 1); + assert_eq!( + plans[0].source_regions, + vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + from_exprs[0].clone() + )] + ); + } } diff --git a/src/meta-srv/src/procedure/repartition/test_util.rs b/src/meta-srv/src/procedure/repartition/test_util.rs index 83856a49e6..122f8e3953 100644 --- a/src/meta-srv/src/procedure/repartition/test_util.rs +++ b/src/meta-srv/src/procedure/repartition/test_util.rs @@ -42,7 +42,7 @@ use uuid::Uuid; use crate::cache_invalidator::MetasrvCacheInvalidator; use crate::metasrv::MetasrvInfo; use crate::procedure::repartition::group::{Context, PersistentContext, VolatileContext}; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::{ Context as ParentContext, PersistentContext as ParentPersistentContext, RepartitionProcedure, }; @@ -177,8 +177,8 @@ pub fn test_region_wal_options(region_numbers: &[RegionNumber]) -> HashMap, - targets: Vec, + sources: Vec, + targets: Vec, ) -> PersistentContext { PersistentContext { group_id: Uuid::new_v4(), diff --git a/src/meta-srv/src/procedure/repartition/utils.rs b/src/meta-srv/src/procedure/repartition/utils.rs index 6f274e9596..f255ca618f 100644 --- a/src/meta-srv/src/procedure/repartition/utils.rs +++ b/src/meta-srv/src/procedure/repartition/utils.rs @@ -23,7 +23,7 @@ use store_api::storage::{RegionId, RegionNumber, TableId}; use crate::error::{self, Result}; use crate::procedure::repartition::group::GroupId; -use crate::procedure::repartition::plan::RegionDescriptor; +use crate::procedure::repartition::plan::SourceRegionDescriptor; /// Returns the `datanode_table_value` /// @@ -138,21 +138,23 @@ pub fn merge_and_validate_region_wal_options( /// restored here. pub fn rollback_group_metadata_routes( group_id: GroupId, - source_regions: &[RegionDescriptor], + source_regions: &[SourceRegionDescriptor], original_target_routes: &[RegionRoute], allocated_region_ids: &[RegionId], pending_deallocate_region_ids: &[RegionId], region_routes_map: &mut HashMap, ) -> Result<()> { for source in source_regions { - let region_route = region_routes_map.get_mut(&source.region_id).context( + let region_id = source.region_id(); + let region_route = region_routes_map.get_mut(®ion_id).context( error::RepartitionSourceRegionMissingSnafu { group_id, - region_id: source.region_id, + region_id, }, )?; region_route.clear_leader_staging(); - if pending_deallocate_region_ids.contains(&source.region_id) { + region_route.region.partition_expr = source.route_expr_for_rollback()?; + if pending_deallocate_region_ids.contains(®ion_id) { region_route.clear_ignore_all_writes(); } } @@ -191,7 +193,7 @@ mod tests { use super::*; use crate::procedure::repartition::group::update_metadata::UpdateMetadata; - use crate::procedure::repartition::plan::RegionDescriptor; + use crate::procedure::repartition::plan::{SourceRegionDescriptor, TargetRegionDescriptor}; use crate::procedure::repartition::test_util::range_expr; /// Helper function to create a Kafka WAL option string from a topic name. @@ -242,7 +244,7 @@ mod tests { fn original_target_routes( region_routes: &[RegionRoute], - targets: &[RegionDescriptor], + targets: &[TargetRegionDescriptor], ) -> Vec { let target_ids = targets .iter() @@ -380,16 +382,16 @@ mod tests { ), new_staged_region_route(RegionId::new(table_id, 3), "", None, false), ]; - let sources = vec![RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }]; + let sources = vec![SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + )]; let targets = vec![ - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 1), partition_expr: range_expr("x", 0, 50), }, - RegionDescriptor { + TargetRegionDescriptor { region_id: RegionId::new(table_id, 3), partition_expr: range_expr("x", 50, 100), }, @@ -420,6 +422,60 @@ mod tests { assert_eq!(applied_region_routes, original_region_routes); } + #[test] + fn test_rollback_group_metadata_routes_default_source_restores_empty_expr() { + let group_id = Uuid::new_v4(); + let table_id = 1024; + let default_region_id = RegionId::new(table_id, 1); + let allocated_region_id = RegionId::new(table_id, 2); + let source_regions = vec![SourceRegionDescriptor::Default { + region_id: default_region_id, + }]; + let target_regions = vec![ + TargetRegionDescriptor { + region_id: default_region_id, + partition_expr: range_expr("x", 0, 50), + }, + TargetRegionDescriptor { + region_id: allocated_region_id, + partition_expr: range_expr("x", 50, 100), + }, + ]; + let current_region_routes = vec![ + new_staged_region_route(default_region_id, "", None, false), + new_staged_region_route(allocated_region_id, "", None, false), + ]; + let original_target_routes = vec![current_region_routes[0].clone()]; + let mut applied_region_routes = UpdateMetadata::apply_staging_region_routes( + group_id, + &source_regions, + &target_regions, + &[], + ¤t_region_routes, + ) + .unwrap(); + assert_eq!( + applied_region_routes[0].region.partition_expr, + range_expr("x", 0, 50).as_json_str().unwrap() + ); + + rollback_group_metadata_routes( + group_id, + &source_regions, + &original_target_routes, + &[allocated_region_id], + &[], + &mut applied_region_routes + .iter_mut() + .map(|route| (route.region.id, route)) + .collect(), + ) + .unwrap(); + + assert_eq!(applied_region_routes[0].region.partition_expr, ""); + assert!(!applied_region_routes[0].is_leader_staging()); + } + #[test] fn test_rollback_group_metadata_routes_merge_case_is_idempotent() { let group_id = Uuid::new_v4(); @@ -445,16 +501,16 @@ mod tests { ), ]; let sources = vec![ - RegionDescriptor { - region_id: RegionId::new(table_id, 1), - partition_expr: range_expr("x", 0, 100), - }, - RegionDescriptor { - region_id: RegionId::new(table_id, 2), - partition_expr: range_expr("x", 100, 200), - }, + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 1), + range_expr("x", 0, 100), + ), + SourceRegionDescriptor::partitioned( + RegionId::new(table_id, 2), + range_expr("x", 100, 200), + ), ]; - let targets = vec![RegionDescriptor { + let targets = vec![TargetRegionDescriptor { region_id: RegionId::new(table_id, 1), partition_expr: range_expr("x", 0, 200), }]; From f513b77cccb76751e3932694572481f83eba546e Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Tue, 26 May 2026 23:06:14 +0800 Subject: [PATCH 18/32] feat: support alter table partition syntax (#8177) * feat(sql): support alter table partition syntax Signed-off-by: WenyXu * feat: support repartition source proto Signed-off-by: WenyXu * chore: update greptime-proto Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/common/meta/src/ddl_manager.rs | 28 ++++-- src/operator/src/expr_helper.rs | 88 ++++++++++++++++-- src/operator/src/statement/ddl.rs | 133 ++++++++++++++++++--------- src/sql/src/parsers/alter_parser.rs | 112 +++++++++++++++++++++- src/sql/src/parsers/create_parser.rs | 8 +- src/sql/src/statements/alter.rs | 8 ++ 8 files changed, 316 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0494b4266..b801b342c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5826,7 +5826,7 @@ dependencies = [ [[package]] name = "greptime-proto" version = "0.1.0" -source = "git+https://github.com/GreptimeTeam/greptime-proto.git?rev=dfd2a6d7d3d9c718cb159fcf9abae144b74fc503#dfd2a6d7d3d9c718cb159fcf9abae144b74fc503" +source = "git+https://github.com/GreptimeTeam/greptime-proto.git?rev=7224c2ad6d11db612fbdb621c36135fc37ffce35#7224c2ad6d11db612fbdb621c36135fc37ffce35" dependencies = [ "prost 0.14.1", "prost-types 0.14.1", diff --git a/Cargo.toml b/Cargo.toml index eeddc7099f..32407f31cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,7 +158,7 @@ fs2 = "0.4" fst = "0.4.7" futures = "0.3" futures-util = "0.3" -greptime-proto = { git = "https://github.com/GreptimeTeam/greptime-proto.git", rev = "dfd2a6d7d3d9c718cb159fcf9abae144b74fc503" } +greptime-proto = { git = "https://github.com/GreptimeTeam/greptime-proto.git", rev = "7224c2ad6d11db612fbdb621c36135fc37ffce35" } hex = "0.4" http = "1" humantime = "2.1" diff --git a/src/common/meta/src/ddl_manager.rs b/src/common/meta/src/ddl_manager.rs index 52af4a36af..550887e315 100644 --- a/src/common/meta/src/ddl_manager.rs +++ b/src/common/meta/src/ddl_manager.rs @@ -15,8 +15,9 @@ use std::sync::Arc; use std::time::Duration; -use api::v1::Repartition; use api::v1::alter_table_expr::Kind; +use api::v1::repartition::Source; +use api::v1::{PartitionExprs, Repartition}; use common_error::ext::BoxedError; use common_procedure::{ BoxedProcedure, BoxedProcedureLoader, Output, ProcedureId, ProcedureManagerRef, @@ -48,7 +49,7 @@ use crate::error::{ self, CreateRepartitionProcedureSnafu, EmptyDdlTasksSnafu, ProcedureOutputSnafu, RegisterProcedureLoaderSnafu, RegisterRepartitionProcedureLoaderSnafu, Result, SubmitProcedureSnafu, TableInfoNotFoundSnafu, TableNotFoundSnafu, TableRouteNotFoundSnafu, - UnexpectedLogicalRouteTableSnafu, WaitProcedureSnafu, + UnexpectedLogicalRouteTableSnafu, UnexpectedSnafu, WaitProcedureSnafu, }; use crate::key::table_info::TableInfoValue; use crate::key::table_name::TableNameKey; @@ -280,15 +281,30 @@ impl DdlManager { &self, table_id: TableId, table_name: TableName, - Repartition { - from_partition_exprs, - into_partition_exprs, - }: Repartition, + repartition: Repartition, wait: bool, timeout: Duration, ) -> Result<(ProcedureId, Option)> { let context = self.create_context(); + let into_partition_exprs = repartition.into_partition_exprs; + let source = repartition.source; + + let from_partition_exprs = match source { + Some(Source::PartitionExprs(PartitionExprs { exprs })) => exprs, + Some(Source::Unpartitioned(_)) => { + return UnexpectedSnafu { + err_msg: "Unpartitioned repartition source is not supported yet".to_string(), + } + .fail(); + } + None => { + // Reads the deprecated field for backward compatibility with old persisted DDL tasks. + #[allow(deprecated)] + repartition.from_partition_exprs + } + }; + let procedure = self .repartition_procedure_factory .create( diff --git a/src/operator/src/expr_helper.rs b/src/operator/src/expr_helper.rs index 378122030c..af6e7d1032 100644 --- a/src/operator/src/expr_helper.rs +++ b/src/operator/src/expr_helper.rs @@ -689,11 +689,17 @@ pub struct RepartitionRequest { pub catalog_name: String, pub schema_name: String, pub table_name: String, - pub from_exprs: Vec, + pub source: RepartitionSource, pub into_exprs: Vec, pub options: OptionMap, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepartitionSource { + Partitions { from_exprs: Vec }, + Unpartitioned { partition_columns: Vec }, +} + pub(crate) fn to_repartition_request( alter_table: AlterTable, query_ctx: &QueryContextRef, @@ -708,19 +714,37 @@ pub(crate) fn to_repartition_request( .map_err(BoxedError::new) .context(ExternalSnafu)?; - let AlterTableOperation::Repartition { operation } = alter_operation else { - return InvalidSqlSnafu { - err_msg: "expected REPARTITION operation", + let (source, into_exprs) = match alter_operation { + AlterTableOperation::Repartition { operation } => ( + RepartitionSource::Partitions { + from_exprs: operation.from_exprs, + }, + operation.into_exprs, + ), + AlterTableOperation::Partition { partitions } => ( + RepartitionSource::Unpartitioned { + partition_columns: partitions + .column_list + .into_iter() + .map(|ident| ident.value) + .collect(), + }, + partitions.exprs, + ), + _ => { + return InvalidSqlSnafu { + err_msg: "expected REPARTITION or PARTITION operation", + } + .fail(); } - .fail(); }; Ok(RepartitionRequest { catalog_name, schema_name, table_name, - from_exprs: operation.from_exprs, - into_exprs: operation.into_exprs, + source, + into_exprs, options, }) } @@ -814,6 +838,12 @@ pub(crate) fn to_alter_table_expr( } .fail(); } + AlterTableOperation::Partition { .. } => { + return NotSupportedSnafu { + feat: "ALTER TABLE ... PARTITION ON COLUMNS", + } + .fail(); + } AlterTableOperation::SetIndex { options } => { let option = match options { sql::statements::alter::SetIndexOperation::Fulltext { @@ -1687,9 +1717,11 @@ ALTER TABLE metrics REPARTITION ( assert_eq!("greptime", request.catalog_name); assert_eq!("public", request.schema_name); assert_eq!("metrics", request.table_name); + let RepartitionSource::Partitions { from_exprs } = request.source else { + unreachable!() + }; assert_eq!( - request - .from_exprs + from_exprs .into_iter() .map(|x| x.to_string()) .collect::>(), @@ -1708,6 +1740,44 @@ ALTER TABLE metrics REPARTITION ( ); } + #[test] + fn test_to_repartition_request_with_unpartitioned_source() { + let sql = r#" +ALTER TABLE metrics PARTITION ON COLUMNS (device_id, area) ( + device_id < 100 AND area < 'South', + device_id < 100 AND area >= 'South' +);"#; + let stmt = + ParserContext::create_with_dialect(sql, &GreptimeDbDialect {}, ParseOptions::default()) + .unwrap() + .pop() + .unwrap(); + + let Statement::AlterTable(alter_table) = stmt else { + unreachable!() + }; + + let request = to_repartition_request(alter_table, &QueryContext::arc()).unwrap(); + assert_eq!("greptime", request.catalog_name); + assert_eq!("public", request.schema_name); + assert_eq!("metrics", request.table_name); + let RepartitionSource::Unpartitioned { partition_columns } = request.source else { + unreachable!() + }; + assert_eq!(partition_columns, vec!["device_id", "area"]); + assert_eq!( + request + .into_exprs + .into_iter() + .map(|x| x.to_string()) + .collect::>(), + vec![ + "device_id < 100 AND area < 'South'".to_string(), + "device_id < 100 AND area >= 'South'".to_string() + ] + ); + } + fn new_test_table_names() -> Vec { vec![ TableName { diff --git a/src/operator/src/statement/ddl.rs b/src/operator/src/statement/ddl.rs index 08d3206548..1fab624f46 100644 --- a/src/operator/src/statement/ddl.rs +++ b/src/operator/src/statement/ddl.rs @@ -19,9 +19,10 @@ use std::time::Duration; use api::helper::ColumnDataTypeWrapper; use api::v1::alter_table_expr::Kind; use api::v1::meta::CreateFlowTask as PbCreateFlowTask; +use api::v1::repartition::Source; use api::v1::{ AlterDatabaseExpr, AlterTableExpr, CreateFlowExpr, CreateTableExpr, CreateViewExpr, - Repartition, column_def, + PartitionExprs, Repartition, UnpartitionedSource, column_def, }; #[cfg(feature = "enterprise")] use api::v1::{ @@ -102,7 +103,7 @@ use crate::error::{ TableMetadataManagerSnafu, TableNotFoundSnafu, UnrecognizedTableOptionSnafu, ViewAlreadyExistsSnafu, }; -use crate::expr_helper::{self, RepartitionRequest}; +use crate::expr_helper::{self, RepartitionRequest, RepartitionSource}; use crate::statement::StatementExecutor; use crate::statement::show::create_partitions_stmt; use crate::utils::{to_meta_query_context, to_meta_query_context_with_origin_frontend}; @@ -1408,7 +1409,7 @@ impl StatementExecutor { ) -> Result { if matches!( alter_table.alter_operation(), - AlterTableOperation::Repartition { .. } + AlterTableOperation::Repartition { .. } | AlterTableOperation::Partition { .. } ) { let request = expr_helper::to_repartition_request(alter_table, &query_context)?; return self.repartition_table(request, &query_context).await; @@ -1468,32 +1469,59 @@ impl StatementExecutor { ); let table_info = table.table_info(); - // Get partition column names from the table metadata. let existing_partition_columns = table_info.meta.partition_columns().collect::>(); - // Repartition requires the table to have partition columns. - ensure!( - !existing_partition_columns.is_empty(), - InvalidPartitionRuleSnafu { - reason: format!( - "table {} does not have partition columns, cannot repartition", - table_ref - ) + let partition_columns = match &request.source { + RepartitionSource::Partitions { .. } => { + ensure!( + !existing_partition_columns.is_empty(), + InvalidPartitionRuleSnafu { + reason: format!( + "table {} does not have partition columns, cannot repartition", + table_ref + ) + } + ); + existing_partition_columns } - ); + RepartitionSource::Unpartitioned { partition_columns } => { + ensure!( + !partition_columns.is_empty(), + InvalidPartitionRuleSnafu { + reason: "PARTITION ON COLUMNS requires at least one partition column" + } + ); + ensure!( + existing_partition_columns.is_empty(), + InvalidPartitionRuleSnafu { + reason: format!("table {} already has partition columns", table_ref) + } + ); + let column_schemas = table_info.meta.schema.column_schemas(); + partition_columns + .iter() + .map(|column_name| { + column_schemas + .iter() + .find(|column| &column.name == column_name) + .with_context(|| ColumnNotFoundSnafu { msg: column_name }) + }) + .collect::>>()? + } + }; - // Repartition operations involving columns outside the existing partition columns are not supported. - // This restriction ensures repartition only applies to current partition columns. - let column_name_and_type = existing_partition_columns + let column_name_and_type = partition_columns .iter() .map(|column| (&column.name, column.data_type.clone())) .collect(); let timezone = query_context.timezone(); // Convert SQL Exprs to PartitionExprs. - let from_partition_exprs = request - .from_exprs - .iter() - .map(|expr| convert_one_expr(expr, &column_name_and_type, &timezone)) - .collect::>>()?; + let from_partition_exprs = match &request.source { + RepartitionSource::Partitions { from_exprs } => from_exprs + .iter() + .map(|expr| convert_one_expr(expr, &column_name_and_type, &timezone)) + .collect::>>()?, + RepartitionSource::Unpartitioned { .. } => vec![], + }; let mut into_partition_exprs = request .into_exprs @@ -1503,7 +1531,8 @@ impl StatementExecutor { // `MERGE PARTITION` (and some `REPARTITION`) generates a single `OR` expression from // multiple source partitions; try to simplify it for better readability and stability. - if from_partition_exprs.len() > 1 + if matches!(&request.source, RepartitionSource::Partitions { .. }) + && from_partition_exprs.len() > 1 && into_partition_exprs.len() == 1 && let Some(expr) = into_partition_exprs.pop() { @@ -1530,34 +1559,36 @@ impl StatementExecutor { // Validate that from_partition_exprs are a subset of existing partition exprs. // We compare PartitionExpr directly since it implements Eq. - for from_expr in &from_partition_exprs { - ensure!( - existing_partition_exprs.contains(from_expr), - InvalidPartitionRuleSnafu { - reason: format!( - "partition expression '{}' does not exist in table {}", - from_expr, table_ref - ) - } - ); + if matches!(&request.source, RepartitionSource::Partitions { .. }) { + for from_expr in &from_partition_exprs { + ensure!( + existing_partition_exprs.contains(from_expr), + InvalidPartitionRuleSnafu { + reason: format!( + "partition expression '{}' does not exist in table {}", + from_expr, table_ref + ) + } + ); + } } // Build the new partition expressions: // new_exprs = existing_exprs - from_exprs + into_exprs - let new_partition_exprs: Vec = existing_partition_exprs - .into_iter() - .filter(|expr| !from_partition_exprs.contains(expr)) - .chain(into_partition_exprs.clone().into_iter()) - .collect(); + let new_partition_exprs: Vec = match &request.source { + RepartitionSource::Partitions { .. } => existing_partition_exprs + .into_iter() + .filter(|expr| !from_partition_exprs.contains(expr)) + .chain(into_partition_exprs.clone().into_iter()) + .collect(), + RepartitionSource::Unpartitioned { .. } => into_partition_exprs.clone(), + }; let new_partition_exprs_len = new_partition_exprs.len(); let from_partition_exprs_len = from_partition_exprs.len(); // Validate the new partition expressions using MultiDimPartitionRule and PartitionChecker. let _ = MultiDimPartitionRule::try_new( - existing_partition_columns - .iter() - .map(|c| c.name.clone()) - .collect(), + partition_columns.iter().map(|c| c.name.clone()).collect(), vec![], new_partition_exprs, true, @@ -1574,16 +1605,28 @@ impl StatementExecutor { }; let from_partition_exprs_json = serialize_exprs(from_partition_exprs)?; let into_partition_exprs_json = serialize_exprs(into_partition_exprs)?; + let source = match &request.source { + RepartitionSource::Partitions { .. } => Source::PartitionExprs(PartitionExprs { + exprs: from_partition_exprs_json, + }), + RepartitionSource::Unpartitioned { partition_columns } => { + Source::Unpartitioned(UnpartitionedSource { + partition_columns: partition_columns.clone(), + }) + } + }; + let repartition = Repartition { + into_partition_exprs: into_partition_exprs_json, + source: Some(source), + ..Default::default() + }; let mut req = SubmitDdlTaskRequest::new( to_meta_query_context(query_context.clone()), DdlTask::new_alter_table(AlterTableExpr { catalog_name: request.catalog_name.clone(), schema_name: request.schema_name.clone(), table_name: request.table_name.clone(), - kind: Some(Kind::Repartition(Repartition { - from_partition_exprs: from_partition_exprs_json, - into_partition_exprs: into_partition_exprs_json, - })), + kind: Some(Kind::Repartition(repartition)), }), ); req.wait = ddl_options.wait; diff --git a/src/sql/src/parsers/alter_parser.rs b/src/sql/src/parsers/alter_parser.rs index e5e1575a20..4aa571005a 100644 --- a/src/sql/src/parsers/alter_parser.rs +++ b/src/sql/src/parsers/alter_parser.rs @@ -134,6 +134,7 @@ impl ParserContext<'_> { self.parse_alter_table_merge_partition()? } else { match w.keyword { + Keyword::PARTITION => self.parse_alter_table_partition()?, Keyword::ADD => self.parse_alter_table_add()?, Keyword::DROP => { let _ = self.parser.next_token(); @@ -174,7 +175,7 @@ impl ParserContext<'_> { AlterTableOperation::SetTableOptions { options } } _ => self.expected( - "ADD or DROP or MODIFY or RENAME or SET or REPARTITION or SPLIT or MERGE after ALTER TABLE", + "ADD or DROP or MODIFY or RENAME or SET or UNSET or REPARTITION or SPLIT or MERGE or PARTITION after ALTER TABLE", self.parser.peek_token(), )?, } @@ -218,6 +219,19 @@ impl ParserContext<'_> { }) } + fn parse_alter_table_partition(&mut self) -> Result { + let _ = self.parser.next_token(); + let partitions = self.parse_partition_on_columns()?; + if partitions.exprs.is_empty() { + return Err(ParserError::ParserError( + "PARTITION ON COLUMNS requires at least one partition expression".to_string(), + )) + .context(error::SyntaxSnafu); + } + + Ok(AlterTableOperation::Partition { partitions }) + } + fn parse_alter_table_split_partition(&mut self) -> Result { let _ = self.parser.next_token(); self.parser @@ -976,6 +990,100 @@ ALTER TABLE t REPARTITION ( } } + #[test] + fn test_parse_alter_table_partition_on_columns() { + let sql = r#" +ALTER TABLE sensor_readings PARTITION ON COLUMNS (device_id, area) ( + device_id < 100 AND area < 'South', + device_id < 100 AND area >= 'South', + device_id >= 100 AND area <= 'East', + device_id >= 100 AND area > 'East' +);"#; + let mut result = + ParserContext::create_with_dialect(sql, &GreptimeDbDialect {}, ParseOptions::default()) + .unwrap(); + assert_eq!(1, result.len()); + + let statement = result.remove(0); + assert_matches!(statement, Statement::AlterTable { .. }); + if let Statement::AlterTable(alter_table) = statement { + assert_matches!( + alter_table.alter_operation(), + AlterTableOperation::Partition { .. } + ); + + if let AlterTableOperation::Partition { partitions } = alter_table.alter_operation() { + assert_eq!(partitions.column_list.len(), 2); + assert_eq!(partitions.column_list[0].value, "device_id"); + assert_eq!(partitions.column_list[1].value, "area"); + assert_eq!(partitions.exprs.len(), 4); + assert_eq!( + partitions.exprs[0].to_string(), + "device_id < 100 AND area < 'South'" + ); + assert_eq!( + partitions.exprs[3].to_string(), + "device_id >= 100 AND area > 'East'" + ); + } + } + } + + #[test] + fn test_parse_alter_table_partition_on_columns_with_options() { + let sql = r#" +ALTER TABLE sensor_readings PARTITION ON COLUMNS (device_id) ( + device_id < 100, + device_id >= 100 +) WITH ( + TIMEOUT = '5m', + WAIT = false +);"#; + let mut result = + ParserContext::create_with_dialect(sql, &GreptimeDbDialect {}, ParseOptions::default()) + .unwrap(); + assert_eq!(1, result.len()); + + let statement = result.remove(0); + assert_matches!(statement, Statement::AlterTable { .. }); + if let Statement::AlterTable(alter_table) = statement { + assert_matches!( + alter_table.alter_operation(), + AlterTableOperation::Partition { .. } + ); + let options = alter_table.options().to_str_map(); + assert_eq!(options.get("timeout").unwrap(), &"5m"); + assert_eq!(options.get("wait").unwrap(), &"false"); + assert_eq!(options.len(), 2); + } + } + + #[test] + fn test_parse_alter_table_partition_on_columns_empty_columns() { + let sql = r#" +ALTER TABLE sensor_readings PARTITION ON COLUMNS () ( + device_id < 100 +);"#; + let result = + ParserContext::create_with_dialect(sql, &GreptimeDbDialect {}, ParseOptions::default()); + + assert!(result.is_err()); + } + + #[test] + fn test_parse_alter_table_partition_on_columns_empty_exprs() { + let sql = r#" +ALTER TABLE sensor_readings PARTITION ON COLUMNS (device_id) ();"#; + let result = + ParserContext::create_with_dialect(sql, &GreptimeDbDialect {}, ParseOptions::default()) + .unwrap_err(); + + assert_eq!( + result.output_msg(), + "Invalid SQL syntax: sql parser error: PARTITION ON COLUMNS requires at least one partition expression" + ); + } + #[test] fn test_parse_alter_table_split_partition() { let sql = r#" @@ -1274,7 +1382,7 @@ ALTER TABLE metrics REPARTITION let err = result.output_msg(); assert_eq!( err, - "Invalid SQL syntax: sql parser error: Expected ADD or DROP or MODIFY or RENAME or SET or REPARTITION or SPLIT or MERGE after ALTER TABLE, found: table_t" + "Invalid SQL syntax: sql parser error: Expected ADD or DROP or MODIFY or RENAME or SET or UNSET or REPARTITION or SPLIT or MERGE or PARTITION after ALTER TABLE, found: table_t" ); let sql = "ALTER TABLE test_table RENAME table_t"; diff --git a/src/sql/src/parsers/create_parser.rs b/src/sql/src/parsers/create_parser.rs index a82590c603..b2614bb8d5 100644 --- a/src/sql/src/parsers/create_parser.rs +++ b/src/sql/src/parsers/create_parser.rs @@ -502,6 +502,12 @@ impl<'a> ParserContext<'a> { if !self.parser.parse_keyword(Keyword::PARTITION) { return Ok(None); } + + self.parse_partition_on_columns().map(Some) + } + + /// Parses the "ON COLUMNS (...) (...)" part after "PARTITION". + pub(crate) fn parse_partition_on_columns(&mut self) -> Result { self.parser .expect_keywords(&[Keyword::ON, Keyword::COLUMNS]) .context(error::UnexpectedSnafu { @@ -520,7 +526,7 @@ impl<'a> ParserContext<'a> { let exprs = self.parse_comma_separated(Self::parse_partition_entry)?; - Ok(Some(Partitions { column_list, exprs })) + Ok(Partitions { column_list, exprs }) } fn parse_partition_entry(&mut self) -> Result { diff --git a/src/sql/src/statements/alter.rs b/src/sql/src/statements/alter.rs index ab35e5bd34..72182a4b60 100644 --- a/src/sql/src/statements/alter.rs +++ b/src/sql/src/statements/alter.rs @@ -26,6 +26,7 @@ use sqlparser::ast::{ColumnDef, DataType, Expr, Ident, ObjectName, TableConstrai use sqlparser_derive::{Visit, VisitMut}; use crate::statements::OptionMap; +use crate::statements::create::Partitions; #[derive(Debug, Clone, PartialEq, Eq, Visit, VisitMut, Serialize)] pub struct AlterTable { @@ -119,6 +120,10 @@ pub enum AlterTableOperation { Repartition { operation: RepartitionOperation, }, + /// `PARTITION ON COLUMNS (...) (...)` + Partition { + partitions: Partitions, + }, } #[derive(Debug, Clone, PartialEq, Eq, Visit, VisitMut, Serialize)] @@ -248,6 +253,9 @@ impl Display for AlterTableOperation { AlterTableOperation::Repartition { operation } => { write!(f, "REPARTITION {operation}") } + AlterTableOperation::Partition { partitions } => { + write!(f, "{partitions}") + } AlterTableOperation::SetIndex { options } => match options { SetIndexOperation::Fulltext { column_name, From 407d048136f21892c4f5005efaab13c5fa39b115 Mon Sep 17 00:00:00 2001 From: dennis zhuang Date: Tue, 26 May 2026 17:20:47 -0700 Subject: [PATCH 19/32] feat: update project status and architecture (#8182) * feat: update readme Signed-off-by: Dennis Zhuang * fix: image link Signed-off-by: Dennis Zhuang * chore: style Signed-off-by: Dennis Zhuang * feat: adds agent onboarding Signed-off-by: Dennis Zhuang * fix: address by CR Signed-off-by: Dennis Zhuang * fix: link Signed-off-by: Dennis Zhuang --------- Signed-off-by: Dennis Zhuang --- README.md | 125 +++++++++++++++++++++++++----------------- docs/architecture.png | Bin 176869 -> 432832 bytes docs/overview.png | Bin 0 -> 241691 bytes 3 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 docs/overview.png diff --git a/README.md b/README.md index df52769be4..127dd1ba85 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

One database for metrics, logs, and traces
replacing Prometheus, Loki, and Elasticsearch

-> The unified OpenTelemetry backend — with SQL + PromQL on object storage. +> The unified OpenTelemetry backend — with SQL + PromQL on object storage.

@@ -30,11 +30,11 @@ replacing Prometheus, Loki, and Elasticsearch

GitHub Actions - -Codecov + +Codecov - -License + +License
@@ -51,7 +51,8 @@ replacing Prometheus, Loki, and Elasticsearch
- [Introduction](#introduction) -- [⭐ Key Features](#features) +- [Overview](#overview) +- [Features](#features) - [How GreptimeDB Compares](#how-greptimedb-compares) - [Architecture](#architecture) - [Try GreptimeDB](#try-greptimedb) @@ -69,37 +70,47 @@ replacing Prometheus, Loki, and Elasticsearch **GreptimeDB** is an open-source observability database built for [Observability 2.0](https://docs.greptime.com/user-guide/concepts/observability-2/) — treating metrics, logs, and traces as one unified data model (wide events) instead of three separate pillars. -Use it as the single OpenTelemetry backend — replacing Prometheus, Loki, and Elasticsearch with one database built on object storage. Query with SQL and PromQL, scale without pain, cut costs up to 50x. +Use it as the single OpenTelemetry backend — replacing Prometheus, Loki, and Elasticsearch with one database built on object storage. Query with SQL and PromQL, scale without pain, cut costs up to 50×. + +## Overview + +A quick overview of what GreptimeDB ingests, how it connects to other systems, and what its distributed engine lets you do. + +

+ + GreptimeDB Overview + +

## Features -| Feature | Description | -| --------- | ----------- | -| Drop-in replacement | [PromQL](https://docs.greptime.com/user-guide/query-data/promql/), [Prometheus remote write](https://docs.greptime.com/user-guide/ingest-data/for-observability/prometheus/), [Jaeger](https://docs.greptime.com/user-guide/query-data/jaeger/), and [OpenTelemetry](https://docs.greptime.com/user-guide/ingest-data/for-observability/opentelemetry/) native. Use as your single backend for all three signals, or migrate one at a time.| -| 50x lower cost | Object storage (S3, GCS, Azure Blob etc.) as [primary storage](https://docs.greptime.com/user-guide/deployments-administration/configuration/#storage-options). Compute-storage separation scales without pain.| -| SQL + PromQL | Monitor with [PromQL](https://docs.greptime.com/user-guide/query-data/promql), analyze with [SQL](https://docs.greptime.com/user-guide/query-data/sql). One database replaces Prometheus + your data warehouse.| -| Sub-second at PB-EB scale | Columnar engine with [fulltext, inverted, and skipping indexes](https://docs.greptime.com/user-guide/manage-data/data-index). Written in Rust.| +| Feature | Description | +|---------|-------------| +| **Observability 2.0 native** | Logs, metrics, and traces in one engine with [SQL + PromQL](https://docs.greptime.com/user-guide/query-data/overview/). Native [OpenTelemetry](https://docs.greptime.com/user-guide/ingest-data/for-observability/opentelemetry/), [Prometheus remote write](https://docs.greptime.com/user-guide/ingest-data/for-observability/prometheus/), and [Jaeger](https://docs.greptime.com/user-guide/query-data/jaeger/). Migrate one signal at a time, or use as a single backend. | +| **Elastic compute-storage separation** | Scale reads independently with horizontal replicas. Serve high-concurrency workloads from dashboards, alerting, and AI agents — without resharding or data migration. | +| **Sub-second on PB–EB-scale data** | Columnar engine with [fulltext, inverted, and skipping indexes](https://docs.greptime.com/user-guide/manage-data/data-index). Written in Rust. Designed for high-concurrency point queries, not just analytical scans. | +| **50× lower cost** | Object storage (S3, GCS, Azure Blob) as [primary storage](https://docs.greptime.com/user-guide/deployments-administration/configuration/#storage-options), with a tiered cache (memory + local disk) to keep writes and queries fast. | - ✅ **Perfect for:** - * Replacing Prometheus + Loki + Elasticsearch with one database +**Perfect for:** + * Replacing Prometheus + Loki + Elasticsearch with a single observability backend * Scaling past Prometheus — high cardinality, long-term storage, no Thanos/Mimir overhead - * Cutting observability costs with object storage (up to 50x savings on traces, 30% on logs) - * AI/LLM observability — store and analyze high-volume conversation data, agent traces, and token metrics via [OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) + * AI/agent workloads — store GenAI telemetry ([OTel GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)), and serve high-concurrency reads from SRE/developer agents via horizontal read replicas + * Cutting observability costs with object storage (up to 50× savings on traces, 30% on logs) * Edge-to-cloud observability with unified APIs on resource-constrained devices -> **Why Observability 2.0?** The three-pillar model (separate databases for metrics, logs, traces) creates data silos and operational complexity. GreptimeDB treats all observability data as timestamped wide events in a single columnar engine — enabling cross-signal SQL JOINs, eliminating redundant infrastructure, and naturally supporting emerging workloads like AI agent observability. Read more: [Observability 2.0 and the Database for It](https://greptime.com/blogs/2025-04-25-greptimedb-observability2-new-database). +> **Why Observability 2.0?** Three separate databases for metrics, logs, and traces means three storage layers, three query languages, and three sets of dashboards. GreptimeDB stores all three as timestamped wide events in one columnar engine — JOIN across signals in SQL, run one stack instead of three, and ingest AI agent telemetry the same way. Read more: [Observability 2.0 and the Database for It](https://greptime.com/blogs/2025-04-25-greptimedb-observability2-new-database). Learn more in [Why GreptimeDB](https://docs.greptime.com/user-guide/concepts/why-greptimedb). ## How GreptimeDB Compares -| Feature | GreptimeDB | Prometheus / Thanos / Mimir | Grafana Loki | Elasticsearch | +| Capability | GreptimeDB | Prometheus / Thanos / Mimir | Grafana Loki | Elasticsearch | |---|---|---|---|---| | Data types | Metrics, logs, traces | Metrics only | Logs only | Logs, traces | | Query language | SQL + PromQL | PromQL | LogQL | Query DSL | | Storage | Native object storage (S3, etc.) | Local disk + object storage (Thanos/Mimir) | Object storage (chunks) | Local disk | | Scaling | Compute-storage separation, stateless nodes | Federation / Thanos / Mimir — multi-component, ops heavy | Stateless + object storage | Shard-based, ops heavy | -| Cost efficiency | Up to 50x lower storage | High at scale | Moderate | High (inverted index overhead) | +| Cost efficiency | Up to 50× lower storage cost | High at scale | Moderate | High (inverted index overhead) | | OpenTelemetry | Native (metrics + logs + traces) | Partial (metrics only) | Partial (logs only) | Via instrumentation | **Benchmarks:** @@ -110,19 +121,26 @@ Learn more in [Why GreptimeDB](https://docs.greptime.com/user-guide/concepts/why ## Architecture GreptimeDB can run in two modes: -* **Standalone Mode** - Single binary for development and small deployments -* **Distributed Mode** - Separate components for production scale: - - Frontend: Query processing and protocol handling - - Datanode: Data storage and retrieval - - Metasrv: Metadata management and coordination - -Read the [architecture](https://docs.greptime.com/contributor-guide/overview/#architecture) document. [DeepWiki](https://deepwiki.com/GreptimeTeam/greptimedb/1-overview) provides an in-depth look at GreptimeDB: - GreptimeDB System Overview +* **Standalone** — single binary for development and small deployments. +* **Distributed** — four components, each independently scalable: + - **Frontend** — protocol entry (OTel, Prometheus, MySQL/PostgreSQL, gRPC, ingestion APIs for Elasticsearch/InfluxDB/Loki) and the distributed query engine. Stateless, scales horizontally. + - **Datanode** — region engine with WAL, memtable, SST, cache, compaction, and indexes. Persists data to object storage. Elastic. + - **Metasrv** — metadata, routing, repartitioning, autopilot, and security. Backed by a pluggable KV layer (etcd or RDS). + - **Flownode** (optional) — continuous flow computation (streaming and materialized views). + +For deeper coverage, see the [architecture doc](https://docs.greptime.com/contributor-guide/overview/#architecture) or [DeepWiki](https://deepwiki.com/GreptimeTeam/greptimedb/1-overview). + + + GreptimeDB System Overview + ## Try GreptimeDB -```shell -docker pull greptime/greptimedb +**For AI agents** — paste this prompt into your agent: + +```text +Read https://docs.greptime.com/SKILL.md and follow the instructions +to deploy, configure, ingest, and query GreptimeDB. ``` ```shell @@ -153,20 +171,30 @@ Read more in the [full Install Guide](https://docs.greptime.com/getting-started/ ## Build From Source **Prerequisites:** -* [Rust toolchain](https://www.rust-lang.org/tools/install) (nightly) +* [Rust toolchain](https://www.rust-lang.org/tools/install) — nightly, pinned by [`rust-toolchain.toml`](https://github.com/GreptimeTeam/greptimedb/blob/main/rust-toolchain.toml) * [Protobuf compiler](https://grpc.io/docs/protoc-installation/) (>= 3.15) -* C/C++ building essentials, including `gcc`/`g++`/`autoconf` and glibc library (eg. `libc6-dev` on Ubuntu and `glibc-devel` on Fedora) -* Python toolchain (optional): Required only if using some test scripts. +* C/C++ building essentials: `gcc` / `g++` / `autoconf` and the glibc dev package (`libc6-dev` on Ubuntu, `glibc-devel` on Fedora) +* Python toolchain (optional, only for some test scripts) -**Build and Run:** +**Build and run:** ```bash -make -cargo run -- standalone start +make # build greptime binary +cargo run -- standalone start # start in standalone mode ``` +**Common dev commands:** +```bash +make fmt # format Rust code +make clippy # lint (fails on warnings) +make test # unit + integration tests (uses cargo-nextest) +make sqlness-test # SQL regression tests +``` + +See the [Contribution Guidelines](CONTRIBUTING.md) for the full developer workflow. + ## Tools & Extensions -- **Kubernetes**: [GreptimeDB Operator](https://github.com/GrepTimeTeam/greptimedb-operator) +- **Kubernetes**: [GreptimeDB Operator](https://github.com/GreptimeTeam/greptimedb-operator) - **Helm Charts**: [Greptime Helm Charts](https://github.com/GreptimeTeam/helm-charts) - **Dashboard**: [Web UI](https://github.com/GreptimeTeam/dashboard) - **gRPC Ingester**: [Go](https://github.com/GreptimeTeam/greptimedb-ingester-go), [Java](https://github.com/GreptimeTeam/greptimedb-ingester-java), [C++](https://github.com/GreptimeTeam/greptimedb-ingester-cpp), [Erlang](https://github.com/GreptimeTeam/greptimedb-ingester-erl), [Rust](https://github.com/GreptimeTeam/greptimedb-ingester-rust), [.NET](https://github.com/GreptimeTeam/greptimedb-ingester-dotnet) @@ -175,18 +203,11 @@ cargo run -- standalone start ## Project Status -> **Status:** [v1.0 GA](https://github.com/GreptimeTeam/greptimedb/releases/tag/v1.0.0) — generally available and production-ready! 🎉 +GreptimeDB is at [v1.0 GA](https://github.com/GreptimeTeam/greptimedb/releases/tag/v1.0.0) with stable APIs and regular releases. It runs in production at scale — [OceanBase Cloud](https://greptime.com/blogs/2025-07-22-user-case-obcloud-log-management-greptimedb) operates 80+ GreptimeDB clusters managing 300 TB of logs, cutting log storage cost by 60% after migrating from Grafana Loki. See more in [case studies](https://greptime.com/blogs/?category=Use%20Case). -- Deployed in production handling billions of data points daily -- Stable APIs, actively maintained, with regular releases ([version info](https://docs.greptime.com/nightly/reference/about-greptimedb-version)) +Read the [v1.0 highlights](https://greptime.com/blogs/2025-11-05-greptimedb-v1-highlights) and [2026 roadmap](https://greptime.com/blogs/2026-02-11-greptimedb-roadmap-2026), or browse the [version reference](https://docs.greptime.com/nightly/reference/about-greptimedb-version). -GreptimeDB v1.0 marks a major milestone — stable APIs, production readiness, and proven performance at scale. - -**Learn more:** [v1.0 highlights](https://greptime.com/blogs/2025-11-05-greptimedb-v1-highlights) and [2026 roadmap](https://greptime.com/blogs/2026-02-11-greptimedb-roadmap-2026). - -For production use, we recommend v1.0 or later. - -If you find this project useful, a ⭐ would mean a lot to us! +If GreptimeDB is useful to you, please star the repo. [![Star History Chart](https://api.star-history.com/svg?repos=GreptimeTeam/GreptimeDB&type=Date)](https://www.star-history.com/#GreptimeTeam/GreptimeDB&Date) @@ -216,15 +237,19 @@ We offer enterprise add-ons, services, training, and consulting. ## Contributing -- Read our [Contribution Guidelines](https://github.com/GreptimeTeam/greptimedb/blob/main/CONTRIBUTING.md). +- Read our [Contribution Guidelines](CONTRIBUTING.md). - Explore [Internal Concepts](https://docs.greptime.com/contributor-guide/overview.html) and [DeepWiki](https://deepwiki.com/GreptimeTeam/greptimedb). - Pick up a [good first issue](https://github.com/GreptimeTeam/greptimedb/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and join the #contributors [Slack](https://greptime.com/slack) channel. ## Acknowledgement -Special thanks to all contributors! See [AUTHORS.md](https://github.com/GreptimeTeam/greptimedb/blob/main/AUTHOR.md). +Special thanks to all contributors! See [AUTHOR.md](AUTHOR.md). - Uses [Apache Arrow™](https://arrow.apache.org/) (memory model) - [Apache Parquet™](https://parquet.apache.org/) (file storage) -- [Apache DataFusion™](https://arrow.apache.org/datafusion/) (query engine) +- [Apache DataFusion™](https://datafusion.apache.org/) (query engine) - [Apache OpenDAL™](https://opendal.apache.org/) (data access abstraction) + +--- + +*All trademarks, logos, and brand names referenced in this README and in the Overview diagram are the property of their respective owners. Their use is for identification purposes only and does not imply endorsement or affiliation.* diff --git a/docs/architecture.png b/docs/architecture.png index 992b6c856d7a564ee7ca79a290000400007e0aa2..697292ef2f9bf3cc3848eaf8015b231fed876d3c 100644 GIT binary patch literal 432832 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

UZJy$TqGB{$ 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%0Ap8nk{Kv?YWie`isu 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�|MPs#U!K%PJN#Wqm>3syqJDmT#aHG5uEau&Fh_bS%rIPwt-F<)*rAG_*c? z=N=LMp_Y?hK=SP*^gi?v17}flTBofxHZ;s+D=o4f7d6D>% zB5f9!=}c2_t=P%kyO^)>+p7~tA-_n6j-Ock^@^%nJKZ*%%!E=&!b*n&-zkq+(^u?I zH%T!xOm467%D*d6sgpN{*RsybT^(qyOS}3Wb+VRYq+n%#xIga!7@wAw7Bg`91hi*l zM3BCOb==X^G``)z+S&%BSC``JXZh(hP1Sd3oV?6NJmZ$iV4R9E)?#l$Ez9^swojww z7iwUojFz+tEF<&iF-+D`dpnwca5r6&ugGu<{Wi9ibR;mb+v7@OG%IMOG zrVHH17#)|R=KY1L+!mRax@7nZ(oOO|KUFf^G|CmaMMd}dD-B`oPI-mldmLRxN}Z&1P3 zCgbA$GX!LU>=)wpF*0+pgkb=EHoQF7Zu~>!47vrw7W7hW=1p%3d?gMc^U2Okru?G} z@(pACz=2Fct&_=AT~mX{G?EgDXbp3$BH7aVd9`tw>?f*X0r+5MdVK#+ffjr$?LUXCxEchXZ5WjjkYIl#Gvev>71kvD^E z`mw@C&}l)S>uK&seMbJOMFC|)m+36S(?R;w(k<_moVih*chu2^y

oh{*Zvt8j6 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

R116o|2kM=$LSCjA?s(Eg?E3*L~@e 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≤YM|}2bdnj7nG}8YOIZbGN9U$< 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 literal 0 HcmV?d00001 From 67887d3ec683d69ad1fa46feafbcbc135516a235 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:36:42 +0800 Subject: [PATCH 20/32] ci: add PostgreSQL and MySQL dependency setup steps to sqlness job (#8185) * Migrate sqlness CI dependencies to services * Clarify sqlness kafka readiness wait * Normalize sqlness MySQL job name * Refactor sqlness CI to reuse setup actions * Describe reusable sqlness setup actions * Clarify MySQL setup action usage * Update sqlness workflow setup steps * Decouple sqlness docker setup from cluster actions * Align postgres docker setup with dependency pulls * Revert nonessential setup action edits * Revert setup action files to main state --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/develop.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index d0d2804c6a..65546dcc25 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -669,18 +669,28 @@ jobs: - name: "Basic" opts: "" kafka: false + postgres: false + mysql: false - name: "Remote WAL" opts: "-w kafka -k 127.0.0.1:9092" kafka: true + postgres: false + mysql: false - name: "PostgreSQL KvBackend" - opts: "--setup-pg" + opts: "--setup-pg postgresql://greptimedb:admin@127.0.0.1:5432/postgres" kafka: false - - name: "MySQL Kvbackend" - opts: "--setup-mysql" + postgres: true + mysql: false + - name: "MySQL KvBackend" + opts: "--setup-mysql mysql://greptimedb:admin@127.0.0.1:3306/mysql" kafka: false + postgres: false + mysql: true - name: "Flat format" opts: "--enable-flat-format" kafka: false + postgres: false + mysql: false timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -688,9 +698,19 @@ jobs: persist-credentials: false - if: matrix.mode.kafka - name: Setup kafka server + name: Setup Kafka working-directory: tests-integration/fixtures - run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait kafka + run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait kafka + + - if: matrix.mode.postgres + name: Setup PostgreSQL + working-directory: tests-integration/fixtures + run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait postgres + + - if: matrix.mode.mysql + name: Setup MySQL + working-directory: tests-integration/fixtures + run: ../../.github/scripts/pull-test-deps-images.sh && docker compose up -d --wait mysql - name: Download pre-built binaries uses: actions/download-artifact@v4 From 9487e2c3caed8f20076b8bd6e50ca457f9f77608 Mon Sep 17 00:00:00 2001 From: Yingwen Date: Wed, 27 May 2026 15:10:24 +0800 Subject: [PATCH 21/32] fix: divide series for subquery output (#8173) * fix: divide series for subquery output Signed-off-by: evenyag * fix: propagate time index lookup error in prom_call_manipulate Signed-off-by: evenyag --------- Signed-off-by: evenyag --- src/query/src/promql/planner.rs | 84 +++++++++++++++++-- .../common/promql/encode_substrait.result | 36 ++++---- .../histogram_quantile_binary_op.result | 3 +- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/query/src/promql/planner.rs b/src/query/src/promql/planner.rs index 9b05632d59..0dacc136e8 100644 --- a/src/query/src/promql/planner.rs +++ b/src/query/src/promql/planner.rs @@ -312,17 +312,71 @@ impl PromPlanner { let range_ms = range.as_millis() as _; self.ctx.range = Some(range_ms); + let time_index_column = + self.ctx + .time_index_column + .clone() + .with_context(|| TimeIndexNotFoundSnafu { + table: self.ctx.table_name.clone().unwrap_or_default(), + })?; + + // `RangeManipulate` assumes each input batch holds exactly one series + // (it takes tag column values from row 0 and applies them to every + // output row). The inner expression may emit batches that mix series, + // so sort by series key + time index and split into per-series batches + // with a `SeriesDivide` first. + let input_schema = input.schema(); + let input_has_tsid = input_schema.fields().iter().any(|field| { + field.name() == DATA_SCHEMA_TSID_COLUMN_NAME + && field.data_type() == &ArrowDataType::UInt64 + }); + let (series_key_columns, mut sort_exprs) = if input_has_tsid { + ( + vec![DATA_SCHEMA_TSID_COLUMN_NAME.to_string()], + vec![ + DfExpr::Column(Column::from_name(DATA_SCHEMA_TSID_COLUMN_NAME)) + .sort(true, true), + ], + ) + } else { + // Only use tag columns that survive in the inner plan's schema — + // `ctx.tag_columns` can drift from the actual output. + let key_columns: Vec = self + .ctx + .tag_columns + .iter() + .filter(|name| input_schema.has_column_with_unqualified_name(name)) + .cloned() + .collect(); + let sort = key_columns + .iter() + .map(|name| DfExpr::Column(Column::from_name(name)).sort(true, true)) + .collect::>(); + (key_columns, sort) + }; + sort_exprs.push(DfExpr::Column(Column::from_name(&time_index_column)).sort(true, true)); + + let sort_plan = LogicalPlanBuilder::from(input) + .sort(sort_exprs) + .context(DataFusionPlanningSnafu)? + .build() + .context(DataFusionPlanningSnafu)?; + let divide_plan = LogicalPlan::Extension(Extension { + node: Arc::new(SeriesDivide::new( + series_key_columns, + time_index_column.clone(), + sort_plan, + )), + }); + let manipulate = RangeManipulate::new( self.ctx.start, self.ctx.end, self.ctx.interval, range_ms, - self.ctx - .time_index_column - .clone() - .expect("time index should be set in `setup_context`"), + time_index_column, self.ctx.field_columns.clone(), - input, + divide_plan, ) .context(DataFusionPlanningSnafu)?; @@ -5926,6 +5980,26 @@ mod test { indie_query_plan_compare(query, expected).await; } + /// The outer `PromRangeManipulate` from a subquery must be preceded by + /// `Sort` + `PromSeriesDivide`. + #[tokio::test] + async fn count_over_time_subquery() { + let query = "count_over_time(some_metric[10m:1m])"; + let expected = String::from( + "Filter: prom_count_over_time(timestamp_range,field_0) IS NOT NULL [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\ + \n Projection: some_metric.timestamp, prom_count_over_time(timestamp_range, field_0) AS prom_count_over_time(timestamp_range,field_0), some_metric.tag_0 [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\ + \n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[600000], time index=[timestamp], values=[\"field_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]\ + \n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n PromInstantManipulate: range=[-540000..100000000], lookback=[1000], interval=[60000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n Filter: some_metric.timestamp >= TimestampMillisecond(-540999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\ + \n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]", + ); + indie_query_plan_compare(query, expected).await; + } + #[tokio::test] async fn test_hash_join() { let mut eval_stmt = EvalStmt { diff --git a/tests/cases/standalone/common/promql/encode_substrait.result b/tests/cases/standalone/common/promql/encode_substrait.result index a154d9e5a2..deb72317e9 100644 --- a/tests/cases/standalone/common/promql/encode_substrait.result +++ b/tests/cases/standalone/common/promql/encode_substrait.result @@ -16,24 +16,26 @@ tql explain (0, 100, '1s') tag_a="ffa", }[1h])[12h:1h]; -+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| plan_type | plan | -+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| logical_plan | MergeScan [is_placeholder=false, remote_input=[ | -| | PromRangeManipulate: req range=[0..100000], interval=[1000], eval range=[43200000], time index=[ts], values=["prom_increase(ts_range,val,ts,Int64(3600000))"] | -| | Filter: prom_increase(ts_range,val,ts,Int64(3600000)) IS NOT NULL | -| | Projection: count_total.ts, prom_increase(ts_range, val, count_total.ts, Int64(3600000)) AS prom_increase(ts_range,val,ts,Int64(3600000)), count_total.tag_a, count_total.tag_b | -| | PromRangeManipulate: req range=[-39600000..100000], interval=[3600000], eval range=[3600000], time index=[ts], values=["val"] | -| | PromSeriesNormalize: offset=[0], time index=[ts], filter NaN: [true] | -| | PromSeriesDivide: tags=["tag_a", "tag_b"] | -| | Sort: count_total.tag_a ASC NULLS FIRST, count_total.tag_b ASC NULLS FIRST, count_total.ts ASC NULLS FIRST | -| | Filter: count_total.tag_a = Utf8("ffa") AND count_total.ts >= TimestampMillisecond(-43199999, None) AND count_total.ts <= TimestampMillisecond(100000, None) | -| | TableScan: count_total | -| | ]] | -| physical_plan | CooperativeExec | ++---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| plan_type | plan | ++---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| logical_plan | MergeScan [is_placeholder=false, remote_input=[ | +| | PromRangeManipulate: req range=[0..100000], interval=[1000], eval range=[43200000], time index=[ts], values=["prom_increase(ts_range,val,ts,Int64(3600000))"] | +| | PromSeriesDivide: tags=["tag_a", "tag_b"] | +| | Sort: count_total.tag_a ASC NULLS FIRST, count_total.tag_b ASC NULLS FIRST, count_total.ts ASC NULLS FIRST | +| | Filter: prom_increase(ts_range,val,ts,Int64(3600000)) IS NOT NULL | +| | Projection: count_total.ts, prom_increase(ts_range, val, count_total.ts, Int64(3600000)) AS prom_increase(ts_range,val,ts,Int64(3600000)), count_total.tag_a, count_total.tag_b | +| | PromRangeManipulate: req range=[-39600000..100000], interval=[3600000], eval range=[3600000], time index=[ts], values=["val"] | +| | PromSeriesNormalize: offset=[0], time index=[ts], filter NaN: [true] | +| | PromSeriesDivide: tags=["tag_a", "tag_b"] | +| | Sort: count_total.tag_a ASC NULLS FIRST, count_total.tag_b ASC NULLS FIRST, count_total.ts ASC NULLS FIRST | +| | Filter: count_total.tag_a = Utf8("ffa") AND count_total.ts >= TimestampMillisecond(-43199999, None) AND count_total.ts <= TimestampMillisecond(100000, None) | +| | TableScan: count_total | +| | ]] | +| physical_plan | CooperativeExec | | | MergeScanExec: REDACTED -| | | -+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| | | ++---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ tql eval (0, 100, '1s') increase(count_total{ diff --git a/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result index 601dad6219..df21957356 100644 --- a/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result +++ b/tests/cases/standalone/common/promql/histogram_quantile_binary_op.result @@ -81,7 +81,8 @@ tql eval (3000, 3000, '1s') count_over_time((histogram_quantile(0.5, sum by (le, +---------------------+------------------------------------------------------------------------------+-------+ | ts | prom_count_over_time(ts_range,sum(prom_rate(ts_range,val,ts,Int64(300000)))) | pod | +---------------------+------------------------------------------------------------------------------+-------+ -| 1970-01-01T00:50:00 | 2.0 | pod-a | +| 1970-01-01T00:50:00 | 1.0 | pod-a | +| 1970-01-01T00:50:00 | 1.0 | pod-b | +---------------------+------------------------------------------------------------------------------+-------+ drop table http_request_duration_seconds_bucket; From bf7e3551fe60ea10d135ecb01493091ab473ab86 Mon Sep 17 00:00:00 2001 From: LFC <990479+MichaelScofield@users.noreply.github.com> Date: Wed, 27 May 2026 16:34:06 +0800 Subject: [PATCH 22/32] test: add jsonbench tests (#8165) Signed-off-by: luofucong --- .../src/scalars/json/json_get_rewriter.rs | 8 +- src/datatypes/src/types/json_type.rs | 2 +- src/datatypes/src/vectors/json/array.rs | 124 +++++++++++- src/mito2/src/read/scan_region.rs | 12 +- src/query/src/datafusion/json_expr_planner.rs | 5 +- tests-integration/tests/jsonbench.rs | 77 +++----- .../standalone/common/types/json/json2.result | 38 +++- .../standalone/common/types/json/json2.sql | 4 + .../common/types/json/jsonbench.result | 180 ++++++++++++++++++ .../common/types/json/jsonbench.sql | 92 +++++++++ 10 files changed, 481 insertions(+), 61 deletions(-) create mode 100644 tests/cases/standalone/common/types/json/jsonbench.result create mode 100644 tests/cases/standalone/common/types/json/jsonbench.sql diff --git a/src/common/function/src/scalars/json/json_get_rewriter.rs b/src/common/function/src/scalars/json/json_get_rewriter.rs index 137b307412..0143ee05d5 100644 --- a/src/common/function/src/scalars/json/json_get_rewriter.rs +++ b/src/common/function/src/scalars/json/json_get_rewriter.rs @@ -59,7 +59,10 @@ impl FunctionRewrite for JsonGetRewriter { // json_get(column, path, ) // ) fn inject_type_from_cast_expr(cast: Cast) -> Result> { - let Cast { expr, data_type } = cast; + let Cast { + expr, + mut data_type, + } = cast; let mut json_get = match *expr { Expr::ScalarFunction(f) @@ -75,6 +78,9 @@ fn inject_type_from_cast_expr(cast: Cast) -> Result> { } }; + if data_type.is_string() { + data_type = DataType::Utf8View; + } let with_type = ScalarValue::try_new_null(&data_type).map(|x| Expr::Literal(x, None))?; json_get.args.push(with_type); Ok(Transformed::yes(Expr::ScalarFunction(json_get))) diff --git a/src/datatypes/src/types/json_type.rs b/src/datatypes/src/types/json_type.rs index 362357c5e6..e8d06543ed 100644 --- a/src/datatypes/src/types/json_type.rs +++ b/src/datatypes/src/types/json_type.rs @@ -128,7 +128,7 @@ impl JsonNativeType { JsonNumberType::I64 => ArrowDataType::Int64, JsonNumberType::F64 => ArrowDataType::Float64, }, - JsonNativeType::String => ArrowDataType::Utf8, + JsonNativeType::String => ArrowDataType::Utf8View, JsonNativeType::Array(array) => { ArrowDataType::List(Arc::new(Field::new("item", array.as_arrow_type(), true))) } diff --git a/src/datatypes/src/vectors/json/array.rs b/src/datatypes/src/vectors/json/array.rs index 75779821c5..b3bd24cd98 100644 --- a/src/datatypes/src/vectors/json/array.rs +++ b/src/datatypes/src/vectors/json/array.rs @@ -17,16 +17,24 @@ use std::sync::Arc; use arrow::compute; use arrow::util::display::{ArrayFormatter, FormatOptions}; +use arrow_array::builder::{ + ArrayBuilder, BooleanBuilder, Float64Builder, Int64Builder, NullBuilder, StringViewBuilder, + make_builder, +}; use arrow_array::cast::AsArray; use arrow_array::types::{Float64Type, Int64Type, UInt64Type}; use arrow_array::{Array, ArrayRef, GenericListArray, ListArray, StructArray, new_null_array}; use arrow_schema::{DataType, FieldRef}; +use common_telemetry::debug; use serde_json::Value; use snafu::{OptionExt, ResultExt}; -use crate::arrow_array::{StringArray, binary_array_value, string_array_value}; +use crate::arrow_array::{ + MutableBinaryArray, StringViewArray, binary_array_value, string_array_value, +}; use crate::error::{ - AlignJsonArraySnafu, ArrowComputeSnafu, DeserializeSnafu, InvalidJsonSnafu, Result, + AlignJsonArraySnafu, ArrowComputeSnafu, CastTypeSnafu, DeserializeSnafu, InvalidJsonSnafu, + Result, SerializeSnafu, }; pub struct JsonArray<'a> { @@ -101,6 +109,12 @@ impl JsonArray<'_> { return Ok(self.inner.clone()); } + debug!( + "Try aligning JSON array {} to data type {}", + self.inner.data_type(), + expect + ); + let struct_array = self.inner.as_struct_opt().context(AlignJsonArraySnafu { reason: "expect struct array", })?; @@ -178,11 +192,23 @@ impl JsonArray<'_> { } fn try_cast(&self, to_type: &DataType) -> Result { - if compute::can_cast_types(self.inner.data_type(), to_type) { + let from_type = self.inner.data_type(); + if from_type == to_type { + return Ok(self.inner.clone()); + } + + if from_type.is_binary() && !to_type.is_binary() { + return self.decode_variant(to_type); + } + + if !from_type.is_binary() && to_type.is_binary() { + return self.encode_variant(); + } + + if compute::can_cast_types(from_type, to_type) { return compute::cast(&self.inner, to_type).context(ArrowComputeSnafu); } - // TODO(LFC): Cast according to `to_type` instead of formatting to String here. let formatter = ArrayFormatter::try_new(&self.inner, &FormatOptions::default()) .context(ArrowComputeSnafu)?; let values = (0..self.inner.len()) @@ -192,7 +218,91 @@ impl JsonArray<'_> { .then(|| formatter.value(i).to_string()) }) .collect::>(); - Ok(Arc::new(StringArray::from(values))) + Ok(Arc::new(StringViewArray::from(values))) + } + + fn encode_variant(&self) -> Result { + let len = self.inner.len(); + let mut encoded = Vec::with_capacity(len); + let mut total_bytes = 0; + + for i in 0..len { + let value = self.try_get_value(i)?; + if value.is_null() { + encoded.push(None); + } else { + let bytes = serde_json::to_vec(&value).context(SerializeSnafu)?; + total_bytes += bytes.len(); + encoded.push(Some(bytes)); + } + } + + let mut builder = MutableBinaryArray::with_capacity(len, total_bytes); + for value in encoded { + builder.append_option(value); + } + Ok(Arc::new(builder.finish())) + } + + fn decode_variant(&self, to_type: &DataType) -> Result { + fn downcast_builder<'a, T: ArrayBuilder>( + builder: &'a mut dyn ArrayBuilder, + to_type: &DataType, + ) -> Result<&'a mut T> { + builder + .as_any_mut() + .downcast_mut::() + .with_context(|| CastTypeSnafu { + msg: format!("Expect ArrayBuilder is of type {to_type}"), + }) + } + + let mut builder = make_builder(to_type, self.inner.len()); + if to_type.is_null() { + downcast_builder::(builder.as_mut(), to_type)? + .append_nulls(self.inner.len()); + } else { + match to_type { + DataType::Boolean => { + let b = downcast_builder::(builder.as_mut(), to_type)?; + for i in 0..self.inner.len() { + b.append_option(self.try_get_value(i)?.as_bool()); + } + } + DataType::Int64 => { + let b = downcast_builder::(builder.as_mut(), to_type)?; + for i in 0..self.inner.len() { + b.append_option(self.try_get_value(i)?.as_i64()); + } + } + DataType::Float64 => { + let b = downcast_builder::(builder.as_mut(), to_type)?; + for i in 0..self.inner.len() { + b.append_option(self.try_get_value(i)?.as_f64()); + } + } + DataType::Utf8View => { + let b = downcast_builder::(builder.as_mut(), to_type)?; + for i in 0..self.inner.len() { + let v = self.try_get_value(i)?; + if v.is_null() { + b.append_null(); + } else if let Some(s) = v.as_str() { + b.append_value(s); + } else { + b.append_value(v.to_string()); + } + } + } + _ => { + return CastTypeSnafu { + msg: format!("Cannot cast JSON value to {to_type}"), + } + .fail(); + } + } + } + Ok(builder.finish()) } } @@ -231,7 +341,9 @@ impl<'a> From<&'a ArrayRef> for JsonArray<'a> { #[cfg(test)] mod test { use arrow_array::types::Int64Type; - use arrow_array::{BinaryArray, BooleanArray, Float64Array, Int32Array, Int64Array, ListArray}; + use arrow_array::{ + BinaryArray, BooleanArray, Float64Array, Int32Array, Int64Array, ListArray, StringArray, + }; use arrow_schema::{Field, Fields}; use serde_json::json; diff --git a/src/mito2/src/read/scan_region.rs b/src/mito2/src/read/scan_region.rs index 59d83becec..ea779c145f 100644 --- a/src/mito2/src/read/scan_region.rs +++ b/src/mito2/src/read/scan_region.rs @@ -33,6 +33,7 @@ use datafusion_expr::Expr; use datafusion_expr::utils::expr_to_columns; use datatypes::schema::ext::ArrowSchemaExt; use futures::StreamExt; +use itertools::Itertools; use partition::expr::PartitionExpr; use smallvec::SmallVec; use snafu::ResultExt; @@ -436,7 +437,16 @@ impl ScanRegion { .schema .arrow_schema() .has_json_extension_field() - .then_some(&self.request.json_type_hint); + .then_some(&self.request.json_type_hint) + .inspect(|json_type_hint| { + debug!( + "Concretized JSON type: {{{}}}", + json_type_hint + .iter() + .map(|(k, v)| format!("{}: {}", k, v)) + .join(", ") + ); + }); let mapper = FlatProjectionMapper::new_with_read_columns( &self.version.metadata, projection, diff --git a/src/query/src/datafusion/json_expr_planner.rs b/src/query/src/datafusion/json_expr_planner.rs index 4546c0a84d..786561db2b 100644 --- a/src/query/src/datafusion/json_expr_planner.rs +++ b/src/query/src/datafusion/json_expr_planner.rs @@ -115,11 +115,14 @@ fn extract_untyped_json_get(expr: &mut Expr) -> Option<&mut ScalarFunction> { } } -fn push_json_get_type_arg(mut expr: Expr, data_type: DataType) -> Result> { +fn push_json_get_type_arg(mut expr: Expr, mut data_type: DataType) -> Result> { let Some(json_get) = extract_untyped_json_get(&mut expr) else { return Ok(Either::Left(expr)); }; + if data_type.is_string() { + data_type = DataType::Utf8View; + } let with_type = ScalarValue::try_new_null(&data_type).map(|x| Expr::Literal(x, None))?; json_get.args.push(with_type); diff --git a/tests-integration/tests/jsonbench.rs b/tests-integration/tests/jsonbench.rs index 55cfcd53f0..9e8cabf3e0 100644 --- a/tests-integration/tests/jsonbench.rs +++ b/tests-integration/tests/jsonbench.rs @@ -25,8 +25,6 @@ use servers::server::ServerHandlers; use tests_integration::standalone::GreptimeDbStandaloneBuilder; use tests_integration::test_util::execute_sql_and_expect; -// TODO(LFC): Unignore the test when JSON2 is ready. -#[ignore] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_load_jsonbench_data_by_pipeline() -> io::Result<()> { common_telemetry::init_default_ut_logging(); @@ -123,8 +121,6 @@ transform: assert!(response.starts_with(pattern)); } -// TODO(LFC): Unignore the test when JSON2 is ready. -#[ignore] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_load_jsonbench_data_by_sql() -> io::Result<()> { common_telemetry::init_default_ut_logging(); @@ -153,16 +149,10 @@ async fn query_data(frontend: &Arc) -> io::Result<()> { +----------+"#; execute_sql_and_expect(frontend, sql, expected).await; - let sql = "SELECT * FROM bluesky ORDER BY time_us"; - let expected = fs::read_to_string(find_workspace_path( - "tests-integration/resources/jsonbench-select-all.txt", - ))?; - execute_sql_and_expect(frontend, sql, &expected).await; - // query 1: let sql = " SELECT - json_get_string(data, '$.commit.collection') AS event, count() AS count + data.commit.collection AS event, count() AS count FROM bluesky GROUP BY event ORDER BY count DESC, event ASC"; @@ -180,13 +170,12 @@ ORDER BY count DESC, event ASC"; // query 2: let sql = " SELECT - json_get_string(data, '$.commit.collection') AS event, + data.commit.collection AS event, count() AS count, - count(DISTINCT json_get_string(data, '$.did')) AS users + count(DISTINCT data.did) AS users FROM bluesky WHERE - (json_get_string(data, '$.kind') = 'commit') AND - (json_get_string(data, '$.commit.operation') = 'create') + data.kind = 'commit' AND data.commit.operation = 'create' GROUP BY event ORDER BY count DESC, event ASC"; let expected = r#" @@ -203,15 +192,14 @@ ORDER BY count DESC, event ASC"; // query 3: let sql = " SELECT - json_get_string(data, '$.commit.collection') AS event, - date_part('hour', to_timestamp_micros(json_get_int(data, '$.time_us'))) as hour_of_day, + data.commit.collection AS event, + date_part('hour', to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) as hour_of_day, count() AS count FROM bluesky WHERE - (json_get_string(data, '$.kind') = 'commit') AND - (json_get_string(data, '$.commit.operation') = 'create') AND - json_get_string(data, '$.commit.collection') IN - ('app.bsky.feed.post', 'app.bsky.feed.repost', 'app.bsky.feed.like') + data.kind = 'commit' AND + data.commit.operation = 'create' AND + data.commit.collection in ('app.bsky.feed.post', 'app.bsky.feed.repost', 'app.bsky.feed.like') GROUP BY event, hour_of_day ORDER BY hour_of_day, event"; let expected = r#" @@ -227,13 +215,13 @@ ORDER BY hour_of_day, event"; // query 4: let sql = " SELECT - json_get_string(data, '$.did') as user_id, - min(to_timestamp_micros(json_get_int(data, '$.time_us'))) AS first_post_ts + data.did::String as user_id, + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) AS first_post_ts FROM bluesky WHERE - (json_get_string(data, '$.kind') = 'commit') AND - (json_get_string(data, '$.commit.operation') = 'create') AND - (json_get_string(data, '$.commit.collection') = 'app.bsky.feed.post') + data.kind = 'commit' AND + data.commit.operation = 'create' AND + data.commit.collection = 'app.bsky.feed.post' GROUP BY user_id ORDER BY first_post_ts ASC, user_id DESC LIMIT 3"; @@ -250,17 +238,17 @@ LIMIT 3"; // query 5: let sql = " SELECT - json_get_string(data, '$.did') as user_id, + data.did::String as user_id, date_part( 'epoch', - max(to_timestamp_micros(json_get_int(data, '$.time_us'))) - - min(to_timestamp_micros(json_get_int(data, '$.time_us'))) + max(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) - + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) ) AS activity_span FROM bluesky WHERE - (json_get_string(data, '$.kind') = 'commit') AND - (json_get_string(data, '$.commit.operation') = 'create') AND - (json_get_string(data, '$.commit.collection') = 'app.bsky.feed.post') + data.kind = 'commit' AND + data.commit.operation = 'create' AND + data.commit.collection = 'app.bsky.feed.post' GROUP BY user_id ORDER BY activity_span DESC, user_id DESC LIMIT 3"; @@ -304,30 +292,21 @@ async fn insert_data_by_sql(frontend: &Arc) -> io::Result<()> { async fn desc_table(frontend: &Arc) { let sql = "DESC TABLE bluesky"; let expected = r#" -+---------+------------------------------------------------------------------------------------------------------------------------------------------------+-----+------+---------+---------------+ -| Column | Type | Key | Null | Default | Semantic Type | -+---------+------------------------------------------------------------------------------------------------------------------------------------------------+-----+------+---------+---------------+ -| data | Json<{"_raw":"","commit.collection":"","commit.operation":"","did":"","kind":"","time_us":""}> | | YES | | FIELD | -| time_us | TimestampMicrosecond | PRI | NO | | TIMESTAMP | -+---------+------------------------------------------------------------------------------------------------------------------------------------------------+-----+------+---------+---------------+"#; ++---------+----------------------+-----+------+---------+---------------+ +| Column | Type | Key | Null | Default | Semantic Type | ++---------+----------------------+-----+------+---------+---------------+ +| data | Json2{} | | YES | | FIELD | +| time_us | TimestampMicrosecond | PRI | NO | | TIMESTAMP | ++---------+----------------------+-----+------+---------+---------------+"#; execute_sql_and_expect(frontend, sql, expected).await; } async fn create_table(frontend: &Arc) { let sql = r#" CREATE TABLE bluesky ( - "data" JSON ( - format = "partial", - fields = Struct< - kind String, - "commit.operation" String, - "commit.collection" String, - did String, - time_us Bigint - >, - ), + "data" JSON2, time_us TimestampMicrosecond TIME INDEX, -) +) WITH ('append_mode' = 'true', 'sst_format' = 'flat') "#; execute_sql_and_expect(frontend, sql, "Affected Rows: 0").await; } diff --git a/tests/cases/standalone/common/types/json/json2.result b/tests/cases/standalone/common/types/json/json2.result index fdae802f3b..71e119307c 100644 --- a/tests/cases/standalone/common/types/json/json2.result +++ b/tests/cases/standalone/common/types/json/json2.result @@ -111,12 +111,29 @@ select j.a.b from json2_table order by ts; | -4 | | | | | -| "s7" | +| s7 | | 8 | | | | 10 | +-------------------------------------+ +select j.a, j.a.x from json2_table order by ts; + ++-----------------------------------+-------------------------------------+ +| json_get(json2_table.j,Utf8("a")) | json_get(json2_table.j,Utf8("a.x")) | ++-----------------------------------+-------------------------------------+ +| {"b":1} | | +| {"b":-2} | | +| {"b":3} | | +| {"b":-4} | | +| {"b":null} | | +| | | +| {"b":"s7"} | | +| {"b":8} | | +| {"b":null,"x":true} | true | +| {"b":10,"x":null} | null | ++-----------------------------------+-------------------------------------+ + select j.c, j.y from json2_table order by ts; +-----------------------------------+-----------------------------------+ @@ -129,11 +146,28 @@ select j.c, j.y from json2_table order by ts; | s5 | | | s6 | | | [1] | | -| "s8" | | +| s8 | | | s9 | | | | false | +-----------------------------------+-----------------------------------+ +select j.a.b + 1 from json2_table order by ts; + ++------------------------------------------------------------+ +| json_get(json2_table.j,Utf8("a.b"),Int64(NULL)) + Int64(1) | ++------------------------------------------------------------+ +| 2 | +| -1 | +| 4 | +| -3 | +| | +| | +| | +| 9 | +| | +| 11 | ++------------------------------------------------------------+ + select j.d from json2_table order by ts; +-----------------------------------+ diff --git a/tests/cases/standalone/common/types/json/json2.sql b/tests/cases/standalone/common/types/json/json2.sql index 57e113f8be..8dd6789bce 100644 --- a/tests/cases/standalone/common/types/json/json2.sql +++ b/tests/cases/standalone/common/types/json/json2.sql @@ -42,8 +42,12 @@ explain select j.a.x::bool from json2_table; select j.a.b from json2_table order by ts; +select j.a, j.a.x from json2_table order by ts; + select j.c, j.y from json2_table order by ts; +select j.a.b + 1 from json2_table order by ts; + select j.d from json2_table order by ts; drop table json2_table; diff --git a/tests/cases/standalone/common/types/json/jsonbench.result b/tests/cases/standalone/common/types/json/jsonbench.result new file mode 100644 index 0000000000..bc039e0b08 --- /dev/null +++ b/tests/cases/standalone/common/types/json/jsonbench.result @@ -0,0 +1,180 @@ +CREATE TABLE bluesky ( + `data` JSON2, + time_us TimestampMicrosecond TIME INDEX +) WITH ('append_mode' = 'true', 'sst_format' = 'flat'); + +Affected Rows: 0 + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349000167, + '{"did":"did:plc:yj3sjq3blzpynh27cumnp5ks","time_us":1732206349000167,"kind":"commit","commit":{"rev":"3lbhtytnn2k2f","operation":"create","collection":"app.bsky.feed.post","rkey":"3lbhtyteurk2y","record":{"$type":"app.bsky.feed.post","createdAt":"2024-11-21T16:09:27.095Z","langs":["en"],"reply":{"parent":{"cid":"bafyreibfglofvqou2yiqvwzk4rcgkhhxrbunyemshdjledgwymimqkg24e","uri":"at://did:plc:6tr6tuzlx2db3rduzr2d6r24/app.bsky.feed.post/3lbhqo2rtys2z"},"root":{"cid":"bafyreibfglofvqou2yiqvwzk4rcgkhhxrbunyemshdjledgwymimqkg24e","uri":"at://did:plc:6tr6tuzlx2db3rduzr2d6r24/app.bsky.feed.post/3lbhqo2rtys2z"}},"text":"aaaaah.  LIght shines in a corner of WTF...."},"cid":"bafyreidblutgvj75o4q4akzyyejedjj6l3it6hgqwee6jpwv2wqph5fsgm"}}'); + +Affected Rows: 1 + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349000644, + '{"did":"did:plc:3i4xf2v4wcnyktgv6satke64","time_us":1732206349000644,"kind":"commit","commit":{"rev":"3lbhuvzds6d2a","operation":"create","collection":"app.bsky.feed.like","rkey":"3lbhuvzdked2a","record":{"$type":"app.bsky.feed.like","createdAt":"2024-11-21T16:25:46.221Z","subject":{"cid":"bafyreidjvrcmckkm765mct5fph36x7kupkfo35rjklbf2k76xkzwyiauge","uri":"at://did:plc:azrv4rcbws6kmcga4fsbphg2/app.bsky.feed.post/3lbgjdpbiec2l"}},"cid":"bafyreia5l5vrkh5oj4cjyhcqby2dprhyvcyofo2q5562tijlae2pzih23m"}}'); + +Affected Rows: 1 + +ADMIN flush_table('bluesky'); + ++------------------------------+ +| ADMIN flush_table('bluesky') | ++------------------------------+ +| 0 | ++------------------------------+ + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001108, + '{"did":"did:plc:gccfnqqizz4urhchsaie6jft","time_us":1732206349001108,"kind":"commit","commit":{"rev":"3lbhuvze3gi2u","operation":"create","collection":"app.bsky.graph.follow","rkey":"3lbhuvzdtmi2u","record":{"$type":"app.bsky.graph.follow","createdAt":"2024-11-21T16:27:40.923Z","subject":"did:plc:r7cdh4sgzqbfdc6wcdxxti7c"},"cid":"bafyreiew2p6cgirfaj45qoenm4fgumib7xoloclrap3jgkz5es7g7kby3i"}}'); + +Affected Rows: 1 + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001372, + '{"did":"did:plc:msxqf3twq7abtdw7dbfskphk","time_us":1732206349001372,"kind":"commit","commit":{"rev":"3lbhueija5p22","operation":"create","collection":"app.bsky.feed.like","rkey":"3lbhueiizcx22","record":{"$type":"app.bsky.feed.like","createdAt":"2024-11-21T16:15:58.232Z","subject":{"cid":"bafyreiavpshyqzrlo5m7fqodjhs6jevweqnif4phasiwimv4a7mnsqi2fe","uri":"at://did:plc:fusulxqc52zbrc75fi6xrcof/app.bsky.feed.post/3lbhskq5zn22f"}},"cid":"bafyreidjix4dauj2afjlbzmhj3a7gwftcevvmmy6edww6vrjdbst26rkby"}}'); + +Affected Rows: 1 + +ADMIN flush_table('bluesky'); + ++------------------------------+ +| ADMIN flush_table('bluesky') | ++------------------------------+ +| 0 | ++------------------------------+ + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001905, + '{"did":"did:plc:l5o3qjrmfztir54cpwlv2eme","time_us":1732206349001905,"kind":"commit","commit":{"rev":"3lbhtytohxc2o","operation":"create","collection":"app.bsky.feed.post","rkey":"3lbhtytjqzk2q","record":{"$type":"app.bsky.feed.post","createdAt":"2024-11-21T16:09:27.254Z","langs":["en"],"reply":{"parent":{"cid":"bafyreih35fe2jj3gchmgk4amold4l6sfxd2sby5wrg3jrws5fkdypxrbg4","uri":"at://did:plc:6wx2gg5yqgvmlu35r6y3bk6d/app.bsky.feed.post/3lbhtj2eb4s2o"},"root":{"cid":"bafyreifipyt3vctd4ptuoicvio7rbr5xvjv4afwuggnd2prnmn55mu6luu","uri":"at://did:plc:474ldquxwzrlcvjhhbbk2wte/app.bsky.feed.post/3lbhdzrynik27"}},"text":"okay i take mine back because I hadn’t heard this one yet^^"},"cid":"bafyreigzdsdne3z2xxcakgisieyj7y47hj6eg7lj6v4q25ah5q2qotu5ku"}}'); + +Affected Rows: 1 + +ADMIN compact_table('bluesky', 'swcs', '86400'); + ++-------------------------------------------------+ +| ADMIN compact_table('bluesky', 'swcs', '86400') | ++-------------------------------------------------+ +| 0 | ++-------------------------------------------------+ + +SELECT count(*) FROM bluesky; + ++----------+ +| count(*) | ++----------+ +| 5 | ++----------+ + +-- Query 1: +SELECT data.commit.collection AS event, + count() AS count +FROM bluesky +GROUP BY event +ORDER BY count DESC, event ASC; + ++-----------------------+-------+ +| event | count | ++-----------------------+-------+ +| app.bsky.feed.like | 2 | +| app.bsky.feed.post | 2 | +| app.bsky.graph.follow | 1 | ++-----------------------+-------+ + +-- Query 2: +SELECT data.commit.collection AS event, + count() AS count, + count(DISTINCT data.did) AS users +FROM bluesky +WHERE data.kind = 'commit' AND data.commit.operation = 'create' +GROUP BY event +ORDER BY count DESC, event ASC; + ++-----------------------+-------+-------+ +| event | count | users | ++-----------------------+-------+-------+ +| app.bsky.feed.like | 2 | 2 | +| app.bsky.feed.post | 2 | 2 | +| app.bsky.graph.follow | 1 | 1 | ++-----------------------+-------+-------+ + +-- Query 3: +SELECT data.commit.collection AS event, + date_part('hour', to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) as hour_of_day, + count() AS count +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection in ('app.bsky.feed.post', 'app.bsky.feed.repost', 'app.bsky.feed.like') +GROUP BY event, hour_of_day +ORDER BY hour_of_day, event; + ++--------------------+-------------+-------+ +| event | hour_of_day | count | ++--------------------+-------------+-------+ +| app.bsky.feed.like | 16 | 2 | +| app.bsky.feed.post | 16 | 2 | ++--------------------+-------------+-------+ + +-- Query 4: +SELECT data.did::String as user_id, + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) AS first_post_ts +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection = 'app.bsky.feed.post' +GROUP BY user_id +ORDER BY first_post_ts ASC, user_id DESC +LIMIT 3; + ++----------------------------------+----------------------------+ +| user_id | first_post_ts | ++----------------------------------+----------------------------+ +| did:plc:yj3sjq3blzpynh27cumnp5ks | 2024-11-21T16:25:49.000167 | +| did:plc:l5o3qjrmfztir54cpwlv2eme | 2024-11-21T16:25:49.001905 | ++----------------------------------+----------------------------+ + +-- Query 5: +SELECT data.did::String as user_id, + date_part( + 'epoch', + max(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) - + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) + ) AS activity_span +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection = 'app.bsky.feed.post' +GROUP BY user_id +ORDER BY activity_span DESC, user_id DESC +LIMIT 3; + ++----------------------------------+---------------+ +| user_id | activity_span | ++----------------------------------+---------------+ +| did:plc:yj3sjq3blzpynh27cumnp5ks | 0.0 | +| did:plc:l5o3qjrmfztir54cpwlv2eme | 0.0 | ++----------------------------------+---------------+ + +-- SQLNESS REPLACE (peers.*) REDACTED +EXPLAIN +SELECT date_part('hour', to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) as hour_of_day +FROM bluesky; + ++---------------+-------------------------------------------------------------------------------------------------------------------------------+ +| plan_type | plan | ++---------------+-------------------------------------------------------------------------------------------------------------------------------+ +| logical_plan | MergeScan [is_placeholder=false, remote_input=[ | +| | Projection: date_part(Utf8("hour"), to_timestamp_micros(json_get(bluesky.data, Utf8("time_us"), Int64(NULL)))) AS hour_of_day | +| | TableScan: bluesky | +| | ]] | +| physical_plan | CooperativeExec | +| | MergeScanExec: REDACTED +| | | ++---------------+-------------------------------------------------------------------------------------------------------------------------------+ + +DROP TABLE bluesky; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/types/json/jsonbench.sql b/tests/cases/standalone/common/types/json/jsonbench.sql new file mode 100644 index 0000000000..8d25605ded --- /dev/null +++ b/tests/cases/standalone/common/types/json/jsonbench.sql @@ -0,0 +1,92 @@ +CREATE TABLE bluesky ( + `data` JSON2, + time_us TimestampMicrosecond TIME INDEX +) WITH ('append_mode' = 'true', 'sst_format' = 'flat'); + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349000167, + '{"did":"did:plc:yj3sjq3blzpynh27cumnp5ks","time_us":1732206349000167,"kind":"commit","commit":{"rev":"3lbhtytnn2k2f","operation":"create","collection":"app.bsky.feed.post","rkey":"3lbhtyteurk2y","record":{"$type":"app.bsky.feed.post","createdAt":"2024-11-21T16:09:27.095Z","langs":["en"],"reply":{"parent":{"cid":"bafyreibfglofvqou2yiqvwzk4rcgkhhxrbunyemshdjledgwymimqkg24e","uri":"at://did:plc:6tr6tuzlx2db3rduzr2d6r24/app.bsky.feed.post/3lbhqo2rtys2z"},"root":{"cid":"bafyreibfglofvqou2yiqvwzk4rcgkhhxrbunyemshdjledgwymimqkg24e","uri":"at://did:plc:6tr6tuzlx2db3rduzr2d6r24/app.bsky.feed.post/3lbhqo2rtys2z"}},"text":"aaaaah.  LIght shines in a corner of WTF...."},"cid":"bafyreidblutgvj75o4q4akzyyejedjj6l3it6hgqwee6jpwv2wqph5fsgm"}}'); + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349000644, + '{"did":"did:plc:3i4xf2v4wcnyktgv6satke64","time_us":1732206349000644,"kind":"commit","commit":{"rev":"3lbhuvzds6d2a","operation":"create","collection":"app.bsky.feed.like","rkey":"3lbhuvzdked2a","record":{"$type":"app.bsky.feed.like","createdAt":"2024-11-21T16:25:46.221Z","subject":{"cid":"bafyreidjvrcmckkm765mct5fph36x7kupkfo35rjklbf2k76xkzwyiauge","uri":"at://did:plc:azrv4rcbws6kmcga4fsbphg2/app.bsky.feed.post/3lbgjdpbiec2l"}},"cid":"bafyreia5l5vrkh5oj4cjyhcqby2dprhyvcyofo2q5562tijlae2pzih23m"}}'); + +ADMIN flush_table('bluesky'); + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001108, + '{"did":"did:plc:gccfnqqizz4urhchsaie6jft","time_us":1732206349001108,"kind":"commit","commit":{"rev":"3lbhuvze3gi2u","operation":"create","collection":"app.bsky.graph.follow","rkey":"3lbhuvzdtmi2u","record":{"$type":"app.bsky.graph.follow","createdAt":"2024-11-21T16:27:40.923Z","subject":"did:plc:r7cdh4sgzqbfdc6wcdxxti7c"},"cid":"bafyreiew2p6cgirfaj45qoenm4fgumib7xoloclrap3jgkz5es7g7kby3i"}}'); + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001372, + '{"did":"did:plc:msxqf3twq7abtdw7dbfskphk","time_us":1732206349001372,"kind":"commit","commit":{"rev":"3lbhueija5p22","operation":"create","collection":"app.bsky.feed.like","rkey":"3lbhueiizcx22","record":{"$type":"app.bsky.feed.like","createdAt":"2024-11-21T16:15:58.232Z","subject":{"cid":"bafyreiavpshyqzrlo5m7fqodjhs6jevweqnif4phasiwimv4a7mnsqi2fe","uri":"at://did:plc:fusulxqc52zbrc75fi6xrcof/app.bsky.feed.post/3lbhskq5zn22f"}},"cid":"bafyreidjix4dauj2afjlbzmhj3a7gwftcevvmmy6edww6vrjdbst26rkby"}}'); + +ADMIN flush_table('bluesky'); + +INSERT INTO bluesky (time_us, data) +VALUES (1732206349001905, + '{"did":"did:plc:l5o3qjrmfztir54cpwlv2eme","time_us":1732206349001905,"kind":"commit","commit":{"rev":"3lbhtytohxc2o","operation":"create","collection":"app.bsky.feed.post","rkey":"3lbhtytjqzk2q","record":{"$type":"app.bsky.feed.post","createdAt":"2024-11-21T16:09:27.254Z","langs":["en"],"reply":{"parent":{"cid":"bafyreih35fe2jj3gchmgk4amold4l6sfxd2sby5wrg3jrws5fkdypxrbg4","uri":"at://did:plc:6wx2gg5yqgvmlu35r6y3bk6d/app.bsky.feed.post/3lbhtj2eb4s2o"},"root":{"cid":"bafyreifipyt3vctd4ptuoicvio7rbr5xvjv4afwuggnd2prnmn55mu6luu","uri":"at://did:plc:474ldquxwzrlcvjhhbbk2wte/app.bsky.feed.post/3lbhdzrynik27"}},"text":"okay i take mine back because I hadn’t heard this one yet^^"},"cid":"bafyreigzdsdne3z2xxcakgisieyj7y47hj6eg7lj6v4q25ah5q2qotu5ku"}}'); + +ADMIN compact_table('bluesky', 'swcs', '86400'); + +SELECT count(*) FROM bluesky; + +-- Query 1: +SELECT data.commit.collection AS event, + count() AS count +FROM bluesky +GROUP BY event +ORDER BY count DESC, event ASC; + +-- Query 2: +SELECT data.commit.collection AS event, + count() AS count, + count(DISTINCT data.did) AS users +FROM bluesky +WHERE data.kind = 'commit' AND data.commit.operation = 'create' +GROUP BY event +ORDER BY count DESC, event ASC; + +-- Query 3: +SELECT data.commit.collection AS event, + date_part('hour', to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) as hour_of_day, + count() AS count +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection in ('app.bsky.feed.post', 'app.bsky.feed.repost', 'app.bsky.feed.like') +GROUP BY event, hour_of_day +ORDER BY hour_of_day, event; + +-- Query 4: +SELECT data.did::String as user_id, + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) AS first_post_ts +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection = 'app.bsky.feed.post' +GROUP BY user_id +ORDER BY first_post_ts ASC, user_id DESC +LIMIT 3; + +-- Query 5: +SELECT data.did::String as user_id, + date_part( + 'epoch', + max(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) - + min(to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) + ) AS activity_span +FROM bluesky +WHERE data.kind = 'commit' + AND data.commit.operation = 'create' + AND data.commit.collection = 'app.bsky.feed.post' +GROUP BY user_id +ORDER BY activity_span DESC, user_id DESC +LIMIT 3; + +-- SQLNESS REPLACE (peers.*) REDACTED +EXPLAIN +SELECT date_part('hour', to_timestamp_micros(arrow_cast(data.time_us, 'Int64'))) as hour_of_day +FROM bluesky; + +DROP TABLE bluesky; From 91ac84019b23a513076d2c93302e82640f4cd860 Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Thu, 28 May 2026 11:31:55 +0800 Subject: [PATCH 23/32] feat(meta-srv): support repartition for unpartitioned tables (#8186) * feat(meta-srv): update repartition partition metadata Signed-off-by: WenyXu * feat(meta-srv): connect repartition source metadata Signed-off-by: WenyXu * test(meta-srv): cover unpartitioned repartition rollback Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu * feat: connect unpartitioned repartition SQL path Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- src/common/meta/src/ddl_manager.rs | 34 +- src/meta-srv/src/procedure/repartition.rs | 479 ++++++++++++++++-- .../repartition/repartition_start.rs | 367 +++++++++++++- .../repartition/update_partition_metadata.rs | 251 +++++++++ src/standalone/src/procedure.rs | 4 +- tests-integration/tests/repartition.rs | 286 +++++++++++ 6 files changed, 1344 insertions(+), 77 deletions(-) create mode 100644 src/meta-srv/src/procedure/repartition/update_partition_metadata.rs diff --git a/src/common/meta/src/ddl_manager.rs b/src/common/meta/src/ddl_manager.rs index 550887e315..8dceeb2e5a 100644 --- a/src/common/meta/src/ddl_manager.rs +++ b/src/common/meta/src/ddl_manager.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::Duration; use api::v1::alter_table_expr::Kind; -use api::v1::repartition::Source; +use api::v1::repartition::Source as PbRepartitionSource; use api::v1::{PartitionExprs, Repartition}; use common_error::ext::BoxedError; use common_procedure::{ @@ -49,7 +49,7 @@ use crate::error::{ self, CreateRepartitionProcedureSnafu, EmptyDdlTasksSnafu, ProcedureOutputSnafu, RegisterProcedureLoaderSnafu, RegisterRepartitionProcedureLoaderSnafu, Result, SubmitProcedureSnafu, TableInfoNotFoundSnafu, TableNotFoundSnafu, TableRouteNotFoundSnafu, - UnexpectedLogicalRouteTableSnafu, UnexpectedSnafu, WaitProcedureSnafu, + UnexpectedLogicalRouteTableSnafu, WaitProcedureSnafu, }; use crate::key::table_info::TableInfoValue; use crate::key::table_name::TableNameKey; @@ -152,13 +152,18 @@ macro_rules! procedure_loader { pub type RepartitionProcedureFactoryRef = Arc; +pub enum RepartitionSource { + Partitioned { exprs: Vec }, + Unpartitioned { partition_columns: Vec }, +} + pub trait RepartitionProcedureFactory: Send + Sync { fn create( &self, ddl_ctx: &DdlContext, table_name: TableName, table_id: TableId, - from_exprs: Vec, + source: RepartitionSource, to_exprs: Vec, timeout: Option, ) -> std::result::Result; @@ -290,18 +295,19 @@ impl DdlManager { let into_partition_exprs = repartition.into_partition_exprs; let source = repartition.source; - let from_partition_exprs = match source { - Some(Source::PartitionExprs(PartitionExprs { exprs })) => exprs, - Some(Source::Unpartitioned(_)) => { - return UnexpectedSnafu { - err_msg: "Unpartitioned repartition source is not supported yet".to_string(), - } - .fail(); + let source = match source { + Some(PbRepartitionSource::PartitionExprs(PartitionExprs { exprs })) => { + RepartitionSource::Partitioned { exprs } } + Some(PbRepartitionSource::Unpartitioned(source)) => RepartitionSource::Unpartitioned { + partition_columns: source.partition_columns, + }, None => { // Reads the deprecated field for backward compatibility with old persisted DDL tasks. #[allow(deprecated)] - repartition.from_partition_exprs + RepartitionSource::Partitioned { + exprs: repartition.from_partition_exprs, + } } }; @@ -311,7 +317,7 @@ impl DdlManager { &context, table_name, table_id, - from_partition_exprs, + source, into_partition_exprs, Some(timeout), ) @@ -1124,7 +1130,7 @@ mod tests { use crate::ddl::table_meta::TableMetadataAllocator; use crate::ddl::truncate_table::TruncateTableProcedure; use crate::ddl::{DdlContext, NoopRegionFailureDetectorControl}; - use crate::ddl_manager::RepartitionProcedureFactory; + use crate::ddl_manager::{RepartitionProcedureFactory, RepartitionSource}; use crate::key::TableMetadataManager; use crate::key::flow::FlowMetadataManager; use crate::kv_backend::memory::MemoryKvBackend; @@ -1162,7 +1168,7 @@ mod tests { _ddl_ctx: &DdlContext, _table_name: TableName, _table_id: TableId, - _from_exprs: Vec, + _source: RepartitionSource, _to_exprs: Vec, _timeout: Option, ) -> std::result::Result { diff --git a/src/meta-srv/src/procedure/repartition.rs b/src/meta-srv/src/procedure/repartition.rs index be060b7424..c1819cb364 100644 --- a/src/meta-srv/src/procedure/repartition.rs +++ b/src/meta-srv/src/procedure/repartition.rs @@ -20,6 +20,7 @@ pub mod group; pub mod plan; pub mod repartition_end; pub mod repartition_start; +pub mod update_partition_metadata; pub mod utils; use std::any::Any; @@ -32,7 +33,7 @@ use common_meta::cache_invalidator::CacheInvalidatorRef; use common_meta::ddl::DdlContext; use common_meta::ddl::allocator::region_routes::RegionRoutesAllocatorRef; use common_meta::ddl::allocator::wal_options::WalOptionsAllocatorRef; -use common_meta::ddl_manager::RepartitionProcedureFactory; +use common_meta::ddl_manager::{RepartitionProcedureFactory, RepartitionSource}; use common_meta::instruction::CacheIdent; use common_meta::key::datanode_table::RegionInfo; use common_meta::key::table_info::TableInfoValue; @@ -62,7 +63,8 @@ use crate::procedure::repartition::group::{ Context as RepartitionGroupContext, RepartitionGroupProcedure, region_routes, }; use crate::procedure::repartition::plan::RepartitionPlanEntry; -use crate::procedure::repartition::repartition_start::RepartitionStart; +use crate::procedure::repartition::repartition_start::{RepartitionFrom, RepartitionStart}; +use crate::procedure::repartition::update_partition_metadata::PartitionMetadataUpdate; use crate::procedure::repartition::utils::{ get_datanode_table_value, rollback_group_metadata_routes, }; @@ -93,6 +95,9 @@ pub struct PersistentContext { /// The timeout for repartition operations. #[serde(with = "humantime_serde", default = "default_timeout")] pub timeout: Duration, + #[serde(default)] + /// Records table-level partition metadata added by this repartition. + pub partition_metadata_update: Option, } fn default_timeout() -> Duration { @@ -121,6 +126,7 @@ impl PersistentContext { failed_procedures: vec![], unknown_procedures: vec![], timeout: timeout.unwrap_or_else(default_timeout), + partition_metadata_update: None, } } @@ -317,7 +323,9 @@ impl Context { /// /// Abort: /// - Table info not found. - pub async fn get_table_info_value(&self) -> Result { + pub async fn get_raw_table_info_value( + &self, + ) -> Result> { let table_id = self.persistent_ctx.table_id; let table_info_value = self .table_metadata_manager @@ -328,11 +336,36 @@ impl Context { .with_context(|_| error::RetryLaterWithSourceSnafu { reason: format!("Failed to get table info for table: {}", table_id), })? - .context(error::TableInfoNotFoundSnafu { table_id })? - .into_inner(); + .context(error::TableInfoNotFoundSnafu { table_id })?; + Ok(table_info_value) } + pub async fn get_table_info_value(&self) -> Result { + let table_info_value = self.get_raw_table_info_value().await?.into_inner(); + Ok(table_info_value) + } + + /// Updates the table info. + pub async fn update_table_info( + &self, + current_table_info_value: &DeserializedValueWithBytes, + new_table_info_value: TableInfoValue, + ) -> Result<()> { + let table_id = self.persistent_ctx.table_id; + self.table_metadata_manager + .update_table_info( + current_table_info_value, + None, + new_table_info_value.table_info, + ) + .await + .map_err(BoxedError::new) + .with_context(|_| error::RetryLaterWithSourceSnafu { + reason: format!("Failed to update table info for table: {}", table_id), + }) + } + /// Updates the table route. /// /// Retry: @@ -469,12 +502,8 @@ struct RepartitionDataOwned { impl RepartitionProcedure { const TYPE_NAME: &'static str = "metasrv-procedure::Repartition"; - pub fn new( - from_exprs: Vec, - to_exprs: Vec, - context: Context, - ) -> Self { - let state = Box::new(RepartitionStart::new(from_exprs, to_exprs)); + pub fn new(from: RepartitionFrom, to_exprs: Vec, context: Context) -> Self { + let state = Box::new(RepartitionStart::new(from, to_exprs)); Self { state, context } } @@ -492,24 +521,24 @@ impl RepartitionProcedure { Ok(Self { state, context }) } - /// Returns whether parent rollback should remove this repartition's allocated regions. + /// Returns whether parent rollback should run. /// - /// This uses an "after AllocateRegion" semantic: once execution reaches - /// `AllocateRegion` or any later state, rollback must try to remove this round's - /// `allocated_region_ids` from table-route metadata when they exist. - /// - /// State flow: - /// `RepartitionStart -> AllocateRegion -> Dispatch -> Collect -> DeallocateRegion -> RepartitionEnd` - /// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - /// rollback allocated regions in metadata + /// This uses an "after repartition metadata update" semantic: once execution + /// reaches `UpdatePartitionMetadata` or any later rollback-active state, + /// rollback must try to clean metadata written by the repartition procedure. /// /// Notes: - /// - `RepartitionStart`: no-op, because allocation has not happened yet. - /// - `AllocateRegion` / `Dispatch` / `Collect` rollback-active. + /// - `RepartitionStart`: no-op, because no metadata has been updated yet. + /// - `UpdatePartitionMetadata`: rollback table partition metadata. + /// - `AllocateRegion` / `Dispatch` / `Collect`: rollback table partition metadata + /// and allocated region metadata. /// - `DeallocateRegion`: is not rollback-active. /// - `RepartitionEnd`: no-op. - fn should_rollback_allocated_regions(&self) -> bool { - self.state.as_any().is::() + fn should_rollback(&self) -> bool { + self.state + .as_any() + .is::() + || self.state.as_any().is::() || self.state.as_any().is::() || self.state.as_any().is::() } @@ -526,7 +555,7 @@ impl RepartitionProcedure { /// Returns allocated region ids that parent rollback should remove. /// - /// Rollback uses an "after AllocateRegion" semantic: + /// Rollback uses an "after region allocation" semantic: /// - in `AllocateRegion` and `Dispatch`, all allocated regions belong to the /// current repartition attempt and must be cleaned up. /// - in `Collect`, only the plans referenced by failed or unknown @@ -586,8 +615,47 @@ impl RepartitionProcedure { Ok(()) } + async fn rollback_partition_metadata(&mut self) -> Result<()> { + let Some(update) = self + .context + .persistent_ctx + .partition_metadata_update + .as_ref() + else { + return Ok(()); + }; + if update.partition_key_indices.is_empty() { + return Ok(()); + } + + let table_info_value = self.context.get_raw_table_info_value().await?; + let mut new_partition_key_indices = table_info_value + .table_info + .meta + .partition_key_indices + .clone(); + new_partition_key_indices.retain(|idx| !update.partition_key_indices.contains(idx)); + if new_partition_key_indices == table_info_value.table_info.meta.partition_key_indices { + return Ok(()); + } + + let mut new_table_info = table_info_value.table_info.clone(); + new_table_info.meta.partition_key_indices = new_partition_key_indices; + self.context + .update_table_info(&table_info_value, table_info_value.update(new_table_info)) + .await?; + + // Do not invalidate the table cache here. The table routes may still + // contain partition expressions until `rollback_inner` rolls them back. + // Exposing cleared partition columns with partitioned routes can build + // an inconsistent partition rule. The cache is invalidated once after + // both partition metadata and routes are rolled back. + + Ok(()) + } + async fn rollback_inner(&mut self, procedure_ctx: &ProcedureContext) -> Result<()> { - if !self.should_rollback_allocated_regions() { + if !self.should_rollback() { return Ok(()); } @@ -596,6 +664,8 @@ impl RepartitionProcedure { let table_lock = TableLock::Write(table_id).into(); let _guard = procedure_ctx.provider.acquire_lock(&table_lock).await; + + self.rollback_partition_metadata().await?; let table_route_value = self.context.get_table_route_value().await?; let original_region_routes = region_routes(table_id, table_route_value.get_inner_ref())?; let mut current_region_routes = original_region_routes.clone(); @@ -738,20 +808,28 @@ impl RepartitionProcedureFactory for DefaultRepartitionProcedureFactory { ddl_ctx: &DdlContext, table_name: TableName, table_id: TableId, - from_exprs: Vec, + source: RepartitionSource, to_exprs: Vec, timeout: Option, ) -> std::result::Result { let persistent_ctx = PersistentContext::new(table_name, table_id, timeout); - let from_exprs = from_exprs - .iter() - .map(|e| { - PartitionExpr::from_json_str(e) - .context(error::DeserializePartitionExprSnafu)? - .context(error::EmptyPartitionExprSnafu) - }) - .collect::>>() - .map_err(BoxedError::new)?; + let from = match source { + RepartitionSource::Partitioned { exprs } => { + let exprs = exprs + .iter() + .map(|e| { + PartitionExpr::from_json_str(e) + .context(error::DeserializePartitionExprSnafu)? + .context(error::EmptyPartitionExprSnafu) + }) + .collect::>>() + .map_err(BoxedError::new)?; + RepartitionFrom::Partitioned { exprs } + } + RepartitionSource::Unpartitioned { partition_columns } => { + RepartitionFrom::Unpartitioned { partition_columns } + } + }; let to_exprs = to_exprs .iter() .map(|e| { @@ -763,7 +841,7 @@ impl RepartitionProcedureFactory for DefaultRepartitionProcedureFactory { .map_err(BoxedError::new)?; let procedure = RepartitionProcedure::new( - from_exprs, + from, to_exprs, Context::new( ddl_ctx, @@ -860,6 +938,9 @@ mod tests { new_parent_context, procedure_context_with_receivers, procedure_state_receiver, range_expr, test_region_route, test_region_wal_options, }; + use crate::procedure::repartition::update_partition_metadata::{ + PartitionMetadataUpdate, UpdatePartitionMetadata, + }; fn test_plan(table_id: TableId) -> RepartitionPlanEntry { RepartitionPlanEntry { @@ -927,6 +1008,15 @@ mod tests { .unwrap() } + async fn table_partition_key_indices(ctx: &Context) -> Vec { + ctx.get_table_info_value() + .await + .unwrap() + .table_info + .meta + .partition_key_indices + } + fn test_procedure(state: Box, context: Context) -> RepartitionProcedure { RepartitionProcedure { state, context } } @@ -965,34 +1055,43 @@ mod tests { } #[test] - fn test_should_rollback_allocated_regions() { + fn test_should_rollback_after_metadata_update() { let env = TestingEnv::new(); let table_id = 1024; let procedure = test_procedure( - Box::new(RepartitionStart::new(vec![], vec![])), + Box::new(RepartitionStart::new( + RepartitionFrom::Partitioned { exprs: vec![] }, + vec![], + )), test_context(&env, table_id), ); - assert!(!procedure.should_rollback_allocated_regions()); + assert!(!procedure.should_rollback()); + + let procedure = test_procedure( + Box::new(UpdatePartitionMetadata::new(vec![])), + test_context(&env, table_id), + ); + assert!(procedure.should_rollback()); let procedure = test_procedure( Box::new(AllocateRegion::new(vec![])), test_context(&env, table_id), ); - assert!(procedure.should_rollback_allocated_regions()); + assert!(procedure.should_rollback()); let procedure = test_procedure(Box::new(Dispatch), test_context(&env, table_id)); - assert!(procedure.should_rollback_allocated_regions()); + assert!(procedure.should_rollback()); let procedure = test_procedure(Box::new(Collect::new(vec![])), test_context(&env, table_id)); - assert!(procedure.should_rollback_allocated_regions()); + assert!(procedure.should_rollback()); let procedure = test_procedure(Box::new(DeallocateRegion), test_context(&env, table_id)); - assert!(!procedure.should_rollback_allocated_regions()); + assert!(!procedure.should_rollback()); let procedure = test_procedure(Box::new(RepartitionEnd), test_context(&env, table_id)); - assert!(!procedure.should_rollback_allocated_regions()); + assert!(!procedure.should_rollback()); } #[test] @@ -1048,6 +1147,68 @@ mod tests { ); } + #[test] + fn test_persistent_context_partition_metadata_update_serde_default() { + let json = r#"{ + "catalog_name":"test_catalog", + "schema_name":"test_schema", + "table_name":"test_table", + "table_id":1024, + "plans":[], + "timeout":"120s" + }"#; + + let persistent_ctx: PersistentContext = serde_json::from_str(json).unwrap(); + + assert!(persistent_ctx.partition_metadata_update.is_none()); + } + + #[tokio::test] + async fn test_repartition_rollback_removes_partition_metadata_indices() { + let env = TestingEnv::new(); + let table_id = 1024; + let node_manager = Arc::new(MockDatanodeManager::new(UnexpectedErrorDatanodeHandler)); + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route(RegionId::new(table_id, 1), "")], + test_region_wal_options(&[1]), + ) + .await; + + let mut context = new_parent_context(&env, node_manager, table_id); + let current = context.get_raw_table_info_value().await.unwrap(); + let mut table_info = current.table_info.clone(); + table_info.meta.partition_key_indices = vec![0, 1]; + context + .update_table_info(¤t, current.update(table_info)) + .await + .unwrap(); + context.persistent_ctx.partition_metadata_update = Some(PartitionMetadataUpdate { + partition_key_indices: vec![0], + }); + let mut procedure = RepartitionProcedure { + state: Box::new(UpdatePartitionMetadata::new(vec![])), + context, + }; + + procedure + .rollback(&TestingEnv::procedure_context()) + .await + .unwrap(); + + assert_eq!( + procedure + .context + .get_table_info_value() + .await + .unwrap() + .table_info + .meta + .partition_key_indices, + vec![1] + ); + } + #[tokio::test] async fn test_repartition_rollback_removes_allocated_routes_from_dispatch() { let env = TestingEnv::new(); @@ -1708,7 +1869,9 @@ mod tests { let context = new_parent_context(&env, node_manager, table_id); let mut procedure = RepartitionProcedure::new( - vec![range_expr("x", 0, 100)], + RepartitionFrom::Partitioned { + exprs: vec![range_expr("x", 0, 100)], + }, vec![range_expr("x", 0, 50), range_expr("x", 50, 100)], context, ); @@ -1810,6 +1973,226 @@ mod tests { ); } + #[tokio::test] + async fn test_repartition_procedure_flow_unpartitioned_failed_and_full_rollback() { + let env = TestingEnv::new(); + let table_id = 1024; + let node_manager = Arc::new(MockDatanodeManager::new(NaiveDatanodeHandler)); + + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route(RegionId::new(table_id, 1), "")], + test_region_wal_options(&[1]), + ) + .await; + + let context = new_parent_context(&env, node_manager, table_id); + let to_exprs = vec![range_expr("col1", 0, 50), range_expr("col1", 50, 100)]; + let mut procedure = RepartitionProcedure::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col1".to_string()], + }, + to_exprs.clone(), + context, + ); + + let start_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + assert!(start_status.need_persist()); + assert_parent_state::(&procedure); + assert_eq!( + procedure + .context + .persistent_ctx + .partition_metadata_update + .as_ref() + .unwrap() + .partition_key_indices, + vec![0] + ); + + let update_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + assert!(update_status.need_persist()); + assert_parent_state::(&procedure); + assert_eq!( + table_partition_key_indices(&procedure.context).await, + vec![0] + ); + + let build_allocate_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + assert!(build_allocate_status.need_persist()); + assert_parent_state::(&procedure); + assert_eq!(procedure.context.persistent_ctx.plans.len(), 1); + let plan = &procedure.context.persistent_ctx.plans[0]; + assert_eq!( + plan.source_regions, + vec![SourceRegionDescriptor::Default { + region_id: RegionId::new(table_id, 1) + }] + ); + assert_eq!(plan.target_regions.len(), 2); + assert_eq!(plan.target_regions[0].region_id, RegionId::new(table_id, 1)); + assert_eq!(plan.target_regions[0].partition_expr, to_exprs[0]); + assert_eq!( + plan.allocated_region_ids, + vec![plan.target_regions[1].region_id] + ); + assert!(plan.pending_deallocate_region_ids.is_empty()); + assert_eq!(plan.transition_map, vec![vec![0, 1]]); + let target_regions = plan.target_regions.clone(); + + let execute_allocate_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + assert!(execute_allocate_status.need_persist()); + assert_parent_state::(&procedure); + let region_routes = current_parent_region_routes(&procedure.context).await; + assert_eq!(region_routes.len(), 2); + assert_eq!( + region_route_by_id(®ion_routes, target_regions[0].region_id) + .region + .partition_expr(), + "" + ); + assert_eq!( + region_route_by_id(®ion_routes, target_regions[1].region_id) + .region + .partition_expr(), + to_exprs[1].as_json_str().unwrap() + ); + + let dispatch_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + let subprocedure_ids = extract_subprocedure_ids(dispatch_status); + assert_eq!(subprocedure_ids.len(), 1); + assert_parent_state::(&procedure); + + let failed_state = ProcedureState::failed(Arc::new(ProcedureError::external( + MockError::new(StatusCode::Internal), + ))); + let collect_ctx = procedure_context_with_receivers(HashMap::from([( + subprocedure_ids[0], + procedure_state_receiver(failed_state), + )])); + let err = procedure.execute(&collect_ctx).await.unwrap_err(); + assert!(!err.is_retry_later()); + assert_parent_state::(&procedure); + + procedure + .rollback(&TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!( + table_partition_key_indices(&procedure.context) + .await + .is_empty() + ); + assert_eq!( + current_parent_region_routes(&procedure.context).await, + vec![test_region_route(RegionId::new(table_id, 1), "")] + ); + } + + #[tokio::test] + async fn test_repartition_procedure_flow_unpartitioned_rollback_is_idempotent() { + let env = TestingEnv::new(); + let table_id = 1024; + let node_manager = Arc::new(MockDatanodeManager::new(NaiveDatanodeHandler)); + + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route(RegionId::new(table_id, 1), "")], + test_region_wal_options(&[1]), + ) + .await; + + let context = new_parent_context(&env, node_manager, table_id); + let mut procedure = RepartitionProcedure::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col1".to_string()], + }, + vec![range_expr("col1", 0, 50), range_expr("col1", 50, 100)], + context, + ); + + procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + assert_eq!( + table_partition_key_indices(&procedure.context).await, + vec![0] + ); + assert_eq!( + current_parent_region_routes(&procedure.context).await.len(), + 2 + ); + + let dispatch_status = procedure + .execute(&TestingEnv::procedure_context()) + .await + .unwrap(); + let subprocedure_ids = extract_subprocedure_ids(dispatch_status); + assert_eq!(subprocedure_ids.len(), 1); + assert_parent_state::(&procedure); + + let failed_state = ProcedureState::failed(Arc::new(ProcedureError::external( + MockError::new(StatusCode::Internal), + ))); + let collect_ctx = procedure_context_with_receivers(HashMap::from([( + subprocedure_ids[0], + procedure_state_receiver(failed_state), + )])); + let err = procedure.execute(&collect_ctx).await.unwrap_err(); + assert!(!err.is_retry_later()); + + procedure + .rollback(&TestingEnv::procedure_context()) + .await + .unwrap(); + let once_indices = table_partition_key_indices(&procedure.context).await; + let once_routes = current_parent_region_routes(&procedure.context).await; + + procedure + .rollback(&TestingEnv::procedure_context()) + .await + .unwrap(); + let twice_indices = table_partition_key_indices(&procedure.context).await; + let twice_routes = current_parent_region_routes(&procedure.context).await; + + assert_eq!(once_indices, twice_indices); + assert_eq!(once_routes, twice_routes); + assert!(twice_indices.is_empty()); + assert_eq!( + twice_routes, + vec![test_region_route(RegionId::new(table_id, 1), "")] + ); + } + #[tokio::test] async fn test_repartition_procedure_flow_split_allocate_retryable_then_resume() { common_telemetry::init_default_ut_logging(); @@ -1852,7 +2235,9 @@ mod tests { let context = new_parent_context(&env, node_manager, table_id); let mut procedure = RepartitionProcedure::new( - vec![range_expr("x", 0, 100)], + RepartitionFrom::Partitioned { + exprs: vec![range_expr("x", 0, 100)], + }, vec![range_expr("x", 0, 50), range_expr("x", 50, 100)], context, ); diff --git a/src/meta-srv/src/procedure/repartition/repartition_start.rs b/src/meta-srv/src/procedure/repartition/repartition_start.rs index 6e14e6a0e6..b6f0ec9c0a 100644 --- a/src/meta-srv/src/procedure/repartition/repartition_start.rs +++ b/src/meta-srv/src/procedure/repartition/repartition_start.rs @@ -20,7 +20,7 @@ use common_telemetry::debug; use partition::collider::Collider; use partition::expr::PartitionExpr; use partition::subtask::{self, RepartitionSubtask}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use snafu::{OptionExt, ResultExt, ensure}; use tokio::time::Instant; use uuid::Uuid; @@ -29,20 +29,57 @@ use crate::error::{self, Result}; use crate::procedure::repartition::allocate_region::AllocateRegion; use crate::procedure::repartition::plan::{AllocationPlanEntry, SourceRegionDescriptor}; use crate::procedure::repartition::repartition_end::RepartitionEnd; +use crate::procedure::repartition::update_partition_metadata::{ + PartitionMetadataUpdate, UpdatePartitionMetadata, +}; use crate::procedure::repartition::{Context, State}; +#[derive(Debug, Clone, Serialize)] +pub enum RepartitionFrom { + Partitioned { exprs: Vec }, + Unpartitioned { partition_columns: Vec }, +} + +impl<'de> Deserialize<'de> for RepartitionFrom { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + enum CurrentRepartitionFrom { + Partitioned { exprs: Vec }, + Unpartitioned { partition_columns: Vec }, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum RepartitionFromRepr { + Current(CurrentRepartitionFrom), + Legacy(Vec), + } + + match RepartitionFromRepr::deserialize(deserializer)? { + RepartitionFromRepr::Current(CurrentRepartitionFrom::Partitioned { exprs }) => { + Ok(Self::Partitioned { exprs }) + } + RepartitionFromRepr::Current(CurrentRepartitionFrom::Unpartitioned { + partition_columns, + }) => Ok(Self::Unpartitioned { partition_columns }), + RepartitionFromRepr::Legacy(exprs) => Ok(Self::Partitioned { exprs }), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepartitionStart { - from_exprs: Vec, + #[serde(alias = "from_exprs")] + from: RepartitionFrom, to_exprs: Vec, } impl RepartitionStart { - pub fn new(from_exprs: Vec, to_exprs: Vec) -> Self { - Self { - from_exprs, - to_exprs, - } + pub fn new(from: RepartitionFrom, to_exprs: Vec) -> Self { + Self { from, to_exprs } } } @@ -54,6 +91,13 @@ impl State for RepartitionStart { ctx: &mut Context, _: &ProcedureContext, ) -> Result<(Box, Status)> { + ensure!( + !self.to_exprs.is_empty(), + error::InvalidArgumentsSnafu { + err_msg: "Repartition expects non-empty target partition expressions".to_string(), + } + ); + let timer = Instant::now(); let (physical_table_id, table_route) = ctx .table_metadata_manager @@ -72,7 +116,8 @@ impl State for RepartitionStart { } ); - let plans = Self::build_plan(&table_route, &self.from_exprs, &self.to_exprs)?; + let from_exprs = self.prepare_from(ctx).await?; + let plans = Self::build_plan(&table_route, from_exprs, &self.to_exprs)?; let plan_count = plans.len(); let total_source_regions: usize = plans.iter().map(|p| p.source_regions.len()).sum(); let total_target_regions: usize = @@ -91,10 +136,17 @@ impl State for RepartitionStart { return Ok((Box::new(RepartitionEnd), Status::done())); } - Ok(( - Box::new(AllocateRegion::new(plans)), - Status::executing(false), - )) + if ctx.persistent_ctx.partition_metadata_update.is_some() { + Ok(( + Box::new(UpdatePartitionMetadata::new(plans)), + Status::executing(true), + )) + } else { + Ok(( + Box::new(AllocateRegion::new(plans)), + Status::executing(false), + )) + } } fn as_any(&self) -> &dyn Any { @@ -103,6 +155,65 @@ impl State for RepartitionStart { } impl RepartitionStart { + async fn prepare_from<'a>(&'a self, ctx: &mut Context) -> Result<&'a [PartitionExpr]> { + match &self.from { + RepartitionFrom::Partitioned { exprs } => Ok(exprs), + RepartitionFrom::Unpartitioned { partition_columns } => { + Self::prepare_unpartitioned(ctx, partition_columns).await?; + Ok(&[]) + } + } + } + + async fn prepare_unpartitioned(ctx: &mut Context, partition_columns: &[String]) -> Result<()> { + if ctx.persistent_ctx.partition_metadata_update.is_some() { + return Ok(()); + } + + ensure!( + !partition_columns.is_empty(), + error::InvalidArgumentsSnafu { + err_msg: "Unpartitioned repartition expects non-empty partition columns" + .to_string(), + } + ); + + let table_info_value = ctx.get_table_info_value().await?; + ensure!( + table_info_value + .table_info + .meta + .partition_key_indices + .is_empty(), + error::InvalidArgumentsSnafu { + err_msg: format!( + "Unpartitioned repartition expects an unpartitioned table, but table {} has partition key indices: {:?}", + ctx.persistent_ctx.table_id, + table_info_value.table_info.meta.partition_key_indices + ), + } + ); + + let schema = &table_info_value.table_info.meta.schema; + let partition_key_indices = partition_columns + .iter() + .map(|column_name| { + schema.column_index_by_name(column_name).with_context(|| { + error::InvalidArgumentsSnafu { + err_msg: format!( + "Partition column {} not found in table {}", + column_name, ctx.persistent_ctx.table_id + ), + } + }) + }) + .collect::>>()?; + ctx.persistent_ctx.partition_metadata_update = + Some(PartitionMetadataUpdate::new(partition_key_indices)); + + Ok(()) + } + pub(crate) fn build_plan( physical_route: &PhysicalTableRouteValue, from_exprs: &[PartitionExpr], @@ -227,7 +338,6 @@ impl RepartitionStart { ), } ); - let source_region = &physical_route.region_routes[0].region; ensure!( source_region.partition_expr().is_empty(), @@ -247,20 +357,37 @@ impl RepartitionStart { #[cfg(test)] mod tests { + use std::sync::Arc; + + use common_meta::ddl::test_util::datanode_handler::NaiveDatanodeHandler; use common_meta::key::table_route::PhysicalTableRouteValue; use common_meta::peer::Peer; use common_meta::rpc::router::{Region, RegionRoute}; + use common_meta::test_util::MockDatanodeManager; use datatypes::prelude::Value; use partition::expr::{Operand, RestrictedOp}; use store_api::storage::RegionId; use super::*; - use crate::procedure::repartition::test_util::{range_expr, test_region_route}; + use crate::procedure::repartition::test_util::{ + TestingEnv, new_parent_context, range_expr, test_region_route, test_region_wal_options, + }; fn physical_route(region_routes: Vec) -> PhysicalTableRouteValue { PhysicalTableRouteValue::new(region_routes) } + async fn new_test_context(env: &TestingEnv, table_id: u32) -> Context { + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route(RegionId::new(table_id, 1), "")], + test_region_wal_options(&[1]), + ) + .await; + let node_manager = Arc::new(MockDatanodeManager::new(NaiveDatanodeHandler)); + new_parent_context(env, node_manager, table_id) + } + #[test] fn test_build_plan_with_default_source_region() { let table_id = 1024; @@ -367,4 +494,216 @@ mod tests { )] ); } + + #[test] + fn test_repartition_start_deserializes_legacy_from_exprs() { + let from_exprs = vec![range_expr("x", 0, 100)]; + let to_exprs = vec![range_expr("x", 0, 50), range_expr("x", 50, 100)]; + let json = serde_json::json!({ + "from_exprs": from_exprs, + "to_exprs": to_exprs, + }) + .to_string(); + + let state: RepartitionStart = serde_json::from_str(&json).unwrap(); + + let RepartitionFrom::Partitioned { exprs } = state.from else { + panic!("expected partition source"); + }; + assert_eq!(exprs, vec![range_expr("x", 0, 100)]); + } + + #[test] + fn test_repartition_start_deserializes_current_from() { + let state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col1".to_string()], + }, + vec![range_expr("col1", 0, 50)], + ); + let json = serde_json::to_string(&state).unwrap(); + + let state: RepartitionStart = serde_json::from_str(&json).unwrap(); + + let RepartitionFrom::Unpartitioned { partition_columns } = state.from else { + panic!("expected unpartitioned source"); + }; + assert_eq!(partition_columns, vec!["col1"]); + } + + #[tokio::test] + async fn test_partitioned_source_does_not_initialize_partition_metadata_update() { + let env = TestingEnv::new(); + let table_id = 1024; + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route( + RegionId::new(table_id, 1), + &range_expr("x", 0, 100).as_json_str().unwrap(), + )], + test_region_wal_options(&[1]), + ) + .await; + let node_manager = Arc::new(MockDatanodeManager::new(NaiveDatanodeHandler)); + let mut ctx = new_parent_context(&env, node_manager, table_id); + let mut state = RepartitionStart::new( + RepartitionFrom::Partitioned { + exprs: vec![range_expr("x", 0, 100)], + }, + vec![range_expr("x", 0, 50), range_expr("x", 50, 100)], + ); + + let (next, status) = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!(!status.need_persist()); + assert!(next.as_any().is::()); + assert!(ctx.persistent_ctx.partition_metadata_update.is_none()); + } + + #[tokio::test] + async fn test_unpartitioned_source_initializes_partition_metadata_update() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col2".to_string(), "col1".to_string()], + }, + vec![range_expr("col2", 0, 50), range_expr("col2", 50, 100)], + ); + + let (next, status) = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!(status.need_persist()); + assert!(next.as_any().is::()); + assert_eq!( + ctx.persistent_ctx + .partition_metadata_update + .as_ref() + .unwrap() + .partition_key_indices, + vec![2, 0] + ); + } + + #[tokio::test] + async fn test_unpartitioned_source_rejects_existing_partition_metadata() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let current = ctx.get_raw_table_info_value().await.unwrap(); + let mut table_info = current.table_info.clone(); + table_info.meta.partition_key_indices = vec![0]; + ctx.update_table_info(¤t, current.update(table_info)) + .await + .unwrap(); + let mut state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col1".to_string()], + }, + vec![range_expr("col1", 0, 50)], + ); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("expects an unpartitioned table")); + assert!(ctx.persistent_ctx.partition_metadata_update.is_none()); + } + + #[tokio::test] + async fn test_repartition_start_rejects_empty_target_partition_exprs() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = + RepartitionStart::new(RepartitionFrom::Partitioned { exprs: vec![] }, vec![]); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!( + err.to_string() + .contains("non-empty target partition expressions") + ); + } + + #[tokio::test] + async fn test_unpartitioned_source_rejects_empty_target_partition_exprs() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["col1".to_string()], + }, + vec![], + ); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!( + err.to_string() + .contains("non-empty target partition expressions") + ); + assert!(ctx.persistent_ctx.partition_metadata_update.is_none()); + } + + #[tokio::test] + async fn test_unpartitioned_source_rejects_empty_partition_columns() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec![], + }, + vec![range_expr("col1", 0, 50)], + ); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("non-empty partition columns")); + assert!(ctx.persistent_ctx.partition_metadata_update.is_none()); + } + + #[tokio::test] + async fn test_unpartitioned_source_rejects_missing_partition_column() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = RepartitionStart::new( + RepartitionFrom::Unpartitioned { + partition_columns: vec!["missing_col".to_string()], + }, + vec![range_expr("col1", 0, 50)], + ); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!( + err.to_string() + .contains("Partition column missing_col not found") + ); + assert!(ctx.persistent_ctx.partition_metadata_update.is_none()); + } } diff --git a/src/meta-srv/src/procedure/repartition/update_partition_metadata.rs b/src/meta-srv/src/procedure/repartition/update_partition_metadata.rs new file mode 100644 index 0000000000..cc9ca1c9bb --- /dev/null +++ b/src/meta-srv/src/procedure/repartition/update_partition_metadata.rs @@ -0,0 +1,251 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::any::Any; + +use common_meta::lock_key::TableLock; +use common_procedure::{Context as ProcedureContext, Status}; +use serde::{Deserialize, Serialize}; +use snafu::ensure; + +use crate::error::{self, Result}; +use crate::procedure::repartition::allocate_region::AllocateRegion; +use crate::procedure::repartition::plan::AllocationPlanEntry; +use crate::procedure::repartition::{Context, State}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PartitionMetadataUpdate { + pub partition_key_indices: Vec, +} + +impl PartitionMetadataUpdate { + pub fn new(partition_key_indices: Vec) -> Self { + Self { + partition_key_indices, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePartitionMetadata { + plan_entries: Vec, +} + +impl UpdatePartitionMetadata { + pub fn new(plan_entries: Vec) -> Self { + Self { plan_entries } + } +} + +#[async_trait::async_trait] +#[typetag::serde] +impl State for UpdatePartitionMetadata { + async fn next( + &mut self, + ctx: &mut Context, + procedure_ctx: &ProcedureContext, + ) -> Result<(Box, Status)> { + let Some(update) = ctx.persistent_ctx.partition_metadata_update.as_ref() else { + return Ok(( + Box::new(AllocateRegion::new(self.plan_entries.clone())), + Status::executing(false), + )); + }; + let partition_key_indices = update.partition_key_indices.clone(); + ensure!( + !partition_key_indices.is_empty(), + error::InvalidArgumentsSnafu { + err_msg: + "Repartition partition metadata update expects non-empty partition key indices" + .to_string(), + } + ); + + let table_id = ctx.persistent_ctx.table_id; + let table_lock = TableLock::Write(table_id).into(); + let _guard = procedure_ctx.provider.acquire_lock(&table_lock).await; + let table_info_value = ctx.get_raw_table_info_value().await?; + let current_partition_key_indices = &table_info_value.table_info.meta.partition_key_indices; + if current_partition_key_indices == &partition_key_indices { + return Ok(( + Box::new(AllocateRegion::new(self.plan_entries.clone())), + Status::executing(true), + )); + } + ensure!( + current_partition_key_indices.is_empty(), + error::InvalidArgumentsSnafu { + err_msg: format!( + "Repartition partition metadata update expects an unpartitioned table, but table {} has partition key indices: {:?}", + table_id, current_partition_key_indices + ), + } + ); + + let mut new_table_info = table_info_value.table_info.clone(); + new_table_info.meta.partition_key_indices = partition_key_indices; + ctx.update_table_info(&table_info_value, table_info_value.update(new_table_info)) + .await?; + // We don't invalidate cache here because the subsequent AllocateRegion step + // will update the table route and invalidate the cache accordingly. + + Ok(( + Box::new(AllocateRegion::new(self.plan_entries.clone())), + Status::executing(true), + )) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use common_meta::ddl::test_util::datanode_handler::NaiveDatanodeHandler; + use common_meta::test_util::MockDatanodeManager; + use store_api::storage::{RegionId, TableId}; + + use super::*; + use crate::procedure::repartition::test_util::{ + TestingEnv, new_parent_context, range_expr, test_region_route, test_region_wal_options, + }; + + async fn new_test_context(env: &TestingEnv, table_id: TableId) -> Context { + env.create_physical_table_metadata_for_repartition( + table_id, + vec![test_region_route(RegionId::new(table_id, 1), "")], + test_region_wal_options(&[1]), + ) + .await; + let node_manager = Arc::new(MockDatanodeManager::new(NaiveDatanodeHandler)); + let mut ctx = new_parent_context(env, node_manager, table_id); + ctx.persistent_ctx.partition_metadata_update = Some(PartitionMetadataUpdate::new(vec![0])); + ctx + } + + async fn set_partition_key_indices(ctx: &Context, partition_key_indices: Vec) { + let current = ctx.get_raw_table_info_value().await.unwrap(); + let mut table_info = current.table_info.clone(); + table_info.meta.partition_key_indices = partition_key_indices; + ctx.update_table_info(¤t, current.update(table_info)) + .await + .unwrap(); + } + + async fn partition_key_indices(ctx: &Context) -> Vec { + ctx.get_table_info_value() + .await + .unwrap() + .table_info + .meta + .partition_key_indices + } + + #[tokio::test] + async fn test_update_partition_metadata_applies_to_unpartitioned_table() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let mut state = UpdatePartitionMetadata::new(vec![]); + + let (next, status) = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!(status.need_persist()); + assert!(next.as_any().is::()); + assert_eq!(partition_key_indices(&ctx).await, vec![0]); + } + + #[tokio::test] + async fn test_update_partition_metadata_replay_is_noop() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + set_partition_key_indices(&ctx, vec![0]).await; + let mut state = UpdatePartitionMetadata::new(vec![]); + + let (next, status) = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!(status.need_persist()); + assert!(next.as_any().is::()); + assert_eq!(partition_key_indices(&ctx).await, vec![0]); + } + + #[tokio::test] + async fn test_update_partition_metadata_rejects_empty_partition_key_indices() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + ctx.persistent_ctx.partition_metadata_update = Some(PartitionMetadataUpdate::new(vec![])); + let mut state = UpdatePartitionMetadata::new(vec![]); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("non-empty partition key indices")); + assert!(partition_key_indices(&ctx).await.is_empty()); + } + + #[tokio::test] + async fn test_update_partition_metadata_rejects_other_partition_keys() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + set_partition_key_indices(&ctx, vec![1]).await; + let mut state = UpdatePartitionMetadata::new(vec![]); + + let err = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap_err(); + + assert!(err.to_string().contains("expects an unpartitioned table")); + assert_eq!(partition_key_indices(&ctx).await, vec![1]); + } + + #[tokio::test] + async fn test_update_partition_metadata_preserves_plan_entries() { + let env = TestingEnv::new(); + let table_id = 1024; + let mut ctx = new_test_context(&env, table_id).await; + let plan_entries = vec![crate::procedure::repartition::plan::AllocationPlanEntry { + group_id: uuid::Uuid::new_v4(), + source_regions: vec![ + crate::procedure::repartition::plan::SourceRegionDescriptor::Default { + region_id: RegionId::new(table_id, 1), + }, + ], + target_partition_exprs: vec![range_expr("x", 0, 10)], + transition_map: vec![vec![0]], + }]; + let mut state = UpdatePartitionMetadata::new(plan_entries); + + let (next, _) = state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + assert!(next.as_any().is::()); + } +} diff --git a/src/standalone/src/procedure.rs b/src/standalone/src/procedure.rs index 144a56be44..853221e698 100644 --- a/src/standalone/src/procedure.rs +++ b/src/standalone/src/procedure.rs @@ -17,7 +17,7 @@ use std::time::Duration; use common_error::ext::BoxedError; use common_meta::ddl::DdlContext; -use common_meta::ddl_manager::RepartitionProcedureFactory; +use common_meta::ddl_manager::{RepartitionProcedureFactory, RepartitionSource}; use common_meta::key::runtime_switch::RuntimeSwitchManager; use common_meta::kv_backend::KvBackendRef; use common_meta::state_store::KvStateStore; @@ -66,7 +66,7 @@ impl RepartitionProcedureFactory for StandaloneRepartitionProcedureFactory { _ddl_ctx: &DdlContext, _table_name: TableName, _table_id: TableId, - _from_exprs: Vec, + _source: RepartitionSource, _to_exprs: Vec, _timeout: Option, ) -> std::result::Result { diff --git a/tests-integration/tests/repartition.rs b/tests-integration/tests/repartition.rs index 50893cc7a6..ef59d1b910 100644 --- a/tests-integration/tests/repartition.rs +++ b/tests-integration/tests/repartition.rs @@ -55,6 +55,24 @@ macro_rules! repartition_tests { } } + #[tokio::test(flavor = "multi_thread")] + async fn [< test_partition_unpartitioned_mito >]() { + let store_type = tests_integration::test_util::StorageType::$service; + if store_type.test_on() { + common_telemetry::init_default_ut_logging(); + $crate::repartition::test_partition_unpartitioned_mito(store_type).await; + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn [< test_partition_unpartitioned_metric >]() { + let store_type = tests_integration::test_util::StorageType::$service; + if store_type.test_on() { + common_telemetry::init_default_ut_logging(); + $crate::repartition::test_partition_unpartitioned_metric(store_type).await; + } + } + #[tokio::test(flavor = "multi_thread")] async fn [< test_repartition_metric >]() { let store_type = tests_integration::test_util::StorageType::$service; @@ -78,6 +96,274 @@ macro_rules! repartition_tests { }; } +pub async fn test_partition_unpartitioned_mito(store_type: StorageType) { + info!( + "test_partition_unpartitioned_mito: store_type: {:?}", + store_type + ); + let cluster_name = "test_partition_unpartitioned_mito"; + let (store_config, _guard) = get_test_store_config(&store_type); + let datanodes = 3u64; + let mut builder = GreptimeDbClusterBuilder::new(cluster_name).await; + if matches!(store_type, StorageType::File) { + let home_dir = create_temp_dir("test_partition_unpartitioned_mito_data_home"); + builder = builder.with_shared_home_dir(Arc::new(home_dir)); + } + + let cluster = builder + .with_datanodes(datanodes as u32) + .with_store_config(store_config) + .with_datanode_wal_config(DatanodeWalConfig::Noop) + .build(true) + .await; + + let query_ctx = QueryContext::arc(); + let instance = cluster.fe_instance(); + + let sql = r#" + CREATE TABLE `partition_unpartitioned_mito_table`( + `id` INT, + `city` STRING, + `ts` TIMESTAMP TIME INDEX, + PRIMARY KEY(`id`, `city`) + ) ENGINE = mito; + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let sql = r#" + INSERT INTO `partition_unpartitioned_mito_table` VALUES + (1, 'New York', '2022-01-01 00:00:00'), + (10, 'Paris', '2022-01-01 00:00:00'), + (20, 'Beijing', '2022-01-01 00:00:00'); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let sql = r#" + ALTER TABLE `partition_unpartitioned_mito_table` PARTITION ON COLUMNS (`id`) ( + `id` < 10, + `id` >= 10 AND `id` < 20, + `id` >= 20 + ); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + // Wait for cache invalidation. + tokio::time::sleep(Duration::from_millis(500)).await; + + let result = run_sql( + instance, + "SELECT * FROM `partition_unpartitioned_mito_table` ORDER BY `id`", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected = "\ ++----+----------+---------------------+ +| id | city | ts | ++----+----------+---------------------+ +| 1 | New York | 2022-01-01T00:00:00 | +| 10 | Paris | 2022-01-01T00:00:00 | +| 20 | Beijing | 2022-01-01T00:00:00 | ++----+----------+---------------------+"; + check_output_stream(result.data, expected).await; + + let result = run_sql( + instance, + "\ +SELECT partition_expression, partition_description \ +FROM information_schema.partitions \ +WHERE table_name = 'partition_unpartitioned_mito_table' \ +ORDER BY partition_ordinal_position;", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected_partitions = r#"+----------------------+-----------------------+ +| partition_expression | partition_description | ++----------------------+-----------------------+ +| id | id < 10 | +| id | id >= 10 AND id < 20 | +| id | id >= 20 | ++----------------------+-----------------------+"#; + check_output_stream(result.data, expected_partitions).await; + + let sql = r#" + INSERT INTO `partition_unpartitioned_mito_table` VALUES + (5, 'London', '2022-01-02 00:00:00'), + (15, 'Tokyo', '2022-01-02 00:00:00'), + (25, 'Shanghai', '2022-01-02 00:00:00'); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let result = run_sql( + instance, + "SELECT * FROM `partition_unpartitioned_mito_table` ORDER BY `id`", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected = "\ ++----+----------+---------------------+ +| id | city | ts | ++----+----------+---------------------+ +| 1 | New York | 2022-01-01T00:00:00 | +| 5 | London | 2022-01-02T00:00:00 | +| 10 | Paris | 2022-01-01T00:00:00 | +| 15 | Tokyo | 2022-01-02T00:00:00 | +| 20 | Beijing | 2022-01-01T00:00:00 | +| 25 | Shanghai | 2022-01-02T00:00:00 | ++----+----------+---------------------+"; + check_output_stream(result.data, expected).await; + + run_sql( + instance, + "DROP TABLE `partition_unpartitioned_mito_table`", + query_ctx.clone(), + ) + .await + .unwrap(); +} + +pub async fn test_partition_unpartitioned_metric(store_type: StorageType) { + info!( + "test_partition_unpartitioned_metric: store_type: {:?}", + store_type + ); + let cluster_name = "test_partition_unpartitioned_metric"; + let (store_config, _guard) = get_test_store_config(&store_type); + let datanodes = 3u64; + let mut builder = GreptimeDbClusterBuilder::new(cluster_name).await; + if matches!(store_type, StorageType::File) { + let home_dir = create_temp_dir("test_partition_unpartitioned_metric_data_home"); + builder = builder.with_shared_home_dir(Arc::new(home_dir)); + } + + let cluster = builder + .with_datanodes(datanodes as u32) + .with_store_config(store_config) + .with_datanode_wal_config(DatanodeWalConfig::Noop) + .build(true) + .await; + + let query_ctx = QueryContext::arc(); + let instance = cluster.fe_instance(); + + let sql = r#" + CREATE TABLE `partition_unpartitioned_metric_phy`( + `ts` TIMESTAMP TIME INDEX, + `val` DOUBLE, + `host` STRING PRIMARY KEY + ) ENGINE = metric + WITH ( + "physical_metric_table" = "true" + ); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let sql = r#" + CREATE TABLE `partition_unpartitioned_metric_log`( + `ts` TIMESTAMP TIME INDEX, + `val` DOUBLE, + `host` STRING PRIMARY KEY + ) ENGINE = metric + WITH ( + "on_physical_table" = "partition_unpartitioned_metric_phy" + ); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let sql = r#" + INSERT INTO `partition_unpartitioned_metric_log` (`host`, `ts`, `val`) VALUES + ('a_host', '2022-01-01 00:00:00', 1), + ('z_host', '2022-01-01 00:00:00', 2); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let sql = r#" + ALTER TABLE `partition_unpartitioned_metric_phy` PARTITION ON COLUMNS (`host`) ( + `host` < 'm', + `host` >= 'm' + ); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + // Wait for cache invalidation. + tokio::time::sleep(Duration::from_millis(500)).await; + + let result = run_sql( + instance, + "SELECT * FROM `partition_unpartitioned_metric_log` ORDER BY `host`", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected = "\ ++--------+---------------------+-----+ +| host | ts | val | ++--------+---------------------+-----+ +| a_host | 2022-01-01T00:00:00 | 1.0 | +| z_host | 2022-01-01T00:00:00 | 2.0 | ++--------+---------------------+-----+"; + check_output_stream(result.data, expected).await; + + let result = run_sql( + instance, + "\ +SELECT partition_expression, partition_description \ +FROM information_schema.partitions \ +WHERE table_name = 'partition_unpartitioned_metric_phy' \ +ORDER BY partition_ordinal_position;", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected_partitions = r#"+----------------------+-----------------------+ +| partition_expression | partition_description | ++----------------------+-----------------------+ +| host | host < m | +| host | host >= m | ++----------------------+-----------------------+"#; + check_output_stream(result.data, expected_partitions).await; + + let sql = r#" + INSERT INTO `partition_unpartitioned_metric_log` (`host`, `ts`, `val`) VALUES + ('b_host', '2022-01-02 00:00:00', 3), + ('x_host', '2022-01-02 00:00:00', 4); + "#; + run_sql(instance, sql, query_ctx.clone()).await.unwrap(); + + let result = run_sql( + instance, + "SELECT * FROM `partition_unpartitioned_metric_log` ORDER BY `host`", + query_ctx.clone(), + ) + .await + .unwrap(); + let expected = "\ ++--------+---------------------+-----+ +| host | ts | val | ++--------+---------------------+-----+ +| a_host | 2022-01-01T00:00:00 | 1.0 | +| b_host | 2022-01-02T00:00:00 | 3.0 | +| x_host | 2022-01-02T00:00:00 | 4.0 | +| z_host | 2022-01-01T00:00:00 | 2.0 | ++--------+---------------------+-----+"; + check_output_stream(result.data, expected).await; + + run_sql( + instance, + "DROP TABLE `partition_unpartitioned_metric_log`", + query_ctx.clone(), + ) + .await + .unwrap(); + run_sql( + instance, + "DROP TABLE `partition_unpartitioned_metric_phy`", + query_ctx.clone(), + ) + .await + .unwrap(); +} + async fn trigger_table_gc(metasrv: &Arc, table_name: &str) { info!("triggering table gc for table: {}", table_name); let table_metadata_manager = metasrv.table_metadata_manager(); From 123524474d3b24724e59384badd060f21e323ca0 Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Thu, 28 May 2026 11:45:04 +0800 Subject: [PATCH 24/32] fix: reset procedure manager state on stop (#8174) * fix: reset procedure manager state on stop Signed-off-by: WenyXu * chore: apply suggestions from CR Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu * fix: fix unit tests Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- src/common/procedure/src/local.rs | 287 +++++++++++++++++++++-- src/common/procedure/src/local/runner.rs | 50 ++-- src/common/procedure/src/rwlock.rs | 7 + 3 files changed, 310 insertions(+), 34 deletions(-) diff --git a/src/common/procedure/src/local.rs b/src/common/procedure/src/local.rs index 9e8536308c..5e8717a53a 100644 --- a/src/common/procedure/src/local.rs +++ b/src/common/procedure/src/local.rs @@ -24,7 +24,7 @@ use async_trait::async_trait; use backon::ExponentialBuilder; use common_error::ext::BoxedError; use common_event_recorder::EventRecorderRef; -use common_runtime::{RepeatedTask, TaskFunction}; +use common_runtime::{JoinHandle, RepeatedTask, TaskFunction}; use common_telemetry::tracing_context::{FutureExt, TracingContext}; use common_telemetry::{error, info, tracing}; use snafu::{OptionExt, ResultExt, ensure}; @@ -254,6 +254,8 @@ pub(crate) struct ManagerContext { running_procedures: Mutex>, /// Ids and finished time of finished procedures. finished_procedures: Mutex>, + /// Runner tasks of procedures. + runner_tasks: Mutex>>, /// Running flag. running: Arc, /// Poison manager. @@ -310,6 +312,7 @@ impl ManagerContext { procedures: RwLock::new(HashMap::new()), running_procedures: Mutex::new(HashSet::new()), finished_procedures: Mutex::new(VecDeque::new()), + runner_tasks: Mutex::new(HashMap::new()), running: Arc::new(AtomicBool::new(false)), poison_manager, } @@ -329,6 +332,76 @@ impl ManagerContext { self.running.store(false, Ordering::Relaxed); } + fn reset_runtime_state(&self) { + self.procedures.write().unwrap().clear(); + self.running_procedures.lock().unwrap().clear(); + self.finished_procedures.lock().unwrap().clear(); + for handle in self + .runner_tasks + .lock() + .unwrap() + .drain() + .map(|(_, handle)| handle) + { + handle.abort(); + } + self.key_lock.clear(); + self.dynamic_key_lock.clear(); + } + + fn spawn_runner_task(&self, procedure_id: ProcedureId, spawn: F) -> bool + where + F: FnOnce() -> JoinHandle<()>, + { + let mut tasks = self.runner_tasks.lock().unwrap(); + if !self.running() { + return false; + } + + let handle = spawn(); + let _ = tasks.insert(procedure_id, handle); + true + } + + fn remove_procedure(&self, procedure_id: ProcedureId) { + self.procedures.write().unwrap().remove(&procedure_id); + self.running_procedures + .lock() + .unwrap() + .remove(&procedure_id); + } + + pub(crate) fn remove_runner_task(&self, procedure_id: ProcedureId) { + let _ = self.runner_tasks.lock().unwrap().remove(&procedure_id); + } + + fn take_runner_tasks(&self) -> Vec> { + self.runner_tasks + .lock() + .unwrap() + .drain() + .map(|(_, handle)| handle) + .collect() + } + + async fn abort_runner_tasks(&self) { + let handles = self.take_runner_tasks(); + + for handle in &handles { + handle.abort(); + } + + for handle in handles { + if let Err(e) = handle.await + && !e.is_cancelled() + { + error!( + e; "Procedure runner task exits unexpectedly during stop", + ); + } + } + } + /// Return `ProcedureManager` is running. pub(crate) fn running(&self) -> bool { self.running.load(Ordering::Relaxed) @@ -675,17 +748,25 @@ impl LocalManager { let tracing_context = TracingContext::from_current_span(); - let _handle = common_runtime::spawn_global(async move { - let span = tracing_context.attach(tracing::info_span!( - "LocalManager::submit_root_procedure", - procedure_name = %runner.meta.type_name, - procedure_id = %runner.meta.id, - )); - // Run the root procedure. - // The task was moved to another runtime for execution. - // In order not to interrupt tracing, a span needs to be created to continue tracing the current task. - runner.run().trace(span).await; - }); + ensure!( + self.manager_ctx.spawn_runner_task(procedure_id, || { + common_runtime::spawn_global(async move { + let span = tracing_context.attach(tracing::info_span!( + "LocalManager::submit_root_procedure", + procedure_name = %runner.meta.type_name, + procedure_id = %runner.meta.id, + )); + // Run the root procedure. + // The task was moved to another runtime for execution. + // In order not to interrupt tracing, a span needs to be created to continue tracing the current task. + runner.run().trace(span).await; + }) + }), + { + self.manager_ctx.remove_procedure(procedure_id); + ManagerNotStartSnafu + } + ); Ok(watcher) } @@ -822,6 +903,7 @@ impl ProcedureManager for LocalManager { *task = Some(task_inner); + self.manager_ctx.reset_runtime_state(); self.manager_ctx.start(); info!("LocalManager is start."); @@ -830,14 +912,18 @@ impl ProcedureManager for LocalManager { } async fn stop(&self) -> Result<()> { - let mut task = self.remove_outdated_meta_task.lock().await; - - if let Some(task) = task.take() { - task.stop().await.context(StopRemoveOutdatedMetaTaskSnafu)?; - } - self.manager_ctx.stop(); + let mut task = self.remove_outdated_meta_task.lock().await; + if let Some(task) = task.take() + && let Err(e) = task.stop().await.context(StopRemoveOutdatedMetaTaskSnafu) + { + error!(e; "Failed to stop remove outdated meta task"); + }; + + self.manager_ctx.abort_runner_tasks().await; + self.manager_ctx.reset_runtime_state(); + info!("LocalManager is stopped."); Ok(()) @@ -921,10 +1007,12 @@ pub(crate) mod test_util { #[cfg(test)] mod tests { use std::assert_matches; + use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use common_error::mock::MockError; use common_error::status_code::StatusCode; use common_test_util::temp_dir::create_temp_dir; + use tokio::sync::oneshot; use tokio::time::timeout; use super::*; @@ -954,6 +1042,67 @@ mod tests { assert!(ctx.state(meta.id).unwrap().is_done()); } + #[test] + fn test_reset_runtime_state() { + let ctx = new_test_manager_context(); + ctx.set_running(); + let mut meta = test_util::procedure_meta_for_test(); + meta.lock_key = LockKey::single_exclusive("test.reset_runtime_state"); + let meta = Arc::new(meta); + let procedure_id = meta.id; + + assert!(ctx.try_insert_procedure(meta.clone())); + ctx.finished_procedures + .lock() + .unwrap() + .push_back((procedure_id, Instant::now())); + ctx.spawn_runner_task(procedure_id, || { + common_runtime::spawn_global(std::future::pending::<()>()) + }); + + drop( + ctx.key_lock + .try_write("test.reset_runtime_state".to_string()), + ); + drop( + ctx.dynamic_key_lock + .try_write("test.reset_runtime_state.dynamic".to_string()), + ); + assert!(ctx.contains_procedure(procedure_id)); + assert_eq!(1, ctx.running_procedures.lock().unwrap().len()); + assert_eq!(1, ctx.finished_procedures.lock().unwrap().len()); + assert_eq!(1, ctx.runner_tasks.lock().unwrap().len()); + assert_eq!(1, ctx.key_lock.len()); + assert_eq!(1, ctx.dynamic_key_lock.len()); + + ctx.reset_runtime_state(); + + assert!(!ctx.contains_procedure(procedure_id)); + assert!(ctx.running_procedures.lock().unwrap().is_empty()); + assert!(ctx.finished_procedures.lock().unwrap().is_empty()); + assert!(ctx.runner_tasks.lock().unwrap().is_empty()); + assert!(ctx.key_lock.is_empty()); + assert!(ctx.dynamic_key_lock.is_empty()); + } + + #[test] + fn test_spawn_runner_task_not_started_after_stop() { + let ctx = new_test_manager_context(); + let procedure_id = ProcedureId::random(); + + let spawned = Arc::new(AtomicBool::new(false)); + let spawned_in_task = spawned.clone(); + let started = ctx.spawn_runner_task(procedure_id, || { + common_runtime::spawn_global(async move { + spawned_in_task.store(true, AtomicOrdering::Relaxed); + }) + }); + + assert!(!started); + assert!(!spawned.load(AtomicOrdering::Relaxed)); + assert!(ctx.runner_tasks.lock().unwrap().is_empty()); + } + #[test] fn test_manager_context_insert_duplicate() { let ctx = new_test_manager_context(); @@ -1046,6 +1195,105 @@ mod tests { } } + #[derive(Debug)] + struct BlockingProcedure { + started_tx: Option>, + dropped: Arc, + lock_key: LockKey, + } + + impl Drop for BlockingProcedure { + fn drop(&mut self) { + self.dropped.store(true, AtomicOrdering::Relaxed); + } + } + + #[async_trait] + impl Procedure for BlockingProcedure { + fn type_name(&self) -> &str { + "BlockingProcedure" + } + + async fn execute(&mut self, _ctx: &Context) -> Result { + if let Some(tx) = self.started_tx.take() { + let _ = tx.send(()); + } + std::future::pending::>().await + } + + fn dump(&self) -> Result { + Ok(String::new()) + } + + fn lock_key(&self) -> LockKey { + self.lock_key.clone() + } + } + + #[tokio::test] + async fn test_stop_aborts_runner_and_resets_runtime_state() { + let dir = create_temp_dir("stop_aborts_runner_and_resets_runtime_state"); + let config = ManagerConfig::default(); + let state_store = Arc::new(ObjectStateStore::new(test_util::new_object_store(&dir))); + let poison_manager = Arc::new(InMemoryPoisonStore::new()); + let manager = LocalManager::new(config, state_store, poison_manager, None, None); + manager.start().await.unwrap(); + + let procedure_id = ProcedureId::random(); + let (started_tx, started_rx) = oneshot::channel(); + let dropped = Arc::new(AtomicBool::new(false)); + let procedure = BlockingProcedure { + started_tx: Some(started_tx), + dropped: dropped.clone(), + lock_key: LockKey::single_exclusive("test.stop_aborts_runner"), + }; + + manager + .submit(ProcedureWithId { + id: procedure_id, + procedure: Box::new(procedure), + }) + .await + .unwrap(); + timeout(Duration::from_secs(5), started_rx) + .await + .unwrap() + .unwrap(); + + assert!(manager.manager_ctx.contains_procedure(procedure_id)); + assert_eq!( + 1, + manager.manager_ctx.running_procedures.lock().unwrap().len() + ); + assert_eq!(1, manager.manager_ctx.runner_tasks.lock().unwrap().len()); + assert_eq!(1, manager.manager_ctx.key_lock.len()); + + manager.stop().await.unwrap(); + + assert!(dropped.load(AtomicOrdering::Relaxed)); + assert!(!manager.manager_ctx.running()); + assert!(!manager.manager_ctx.contains_procedure(procedure_id)); + assert!( + manager + .manager_ctx + .running_procedures + .lock() + .unwrap() + .is_empty() + ); + assert!( + manager + .manager_ctx + .finished_procedures + .lock() + .unwrap() + .is_empty() + ); + assert!(manager.manager_ctx.runner_tasks.lock().unwrap().is_empty()); + assert!(manager.manager_ctx.key_lock.is_empty()); + assert!(manager.manager_ctx.dynamic_key_lock.is_empty()); + } + #[test] fn test_register_loader() { let dir = create_temp_dir("register"); @@ -1439,7 +1687,7 @@ mod tests { let state_store = Arc::new(ObjectStateStore::new(test_util::new_object_store(&dir))); let poison_manager = Arc::new(InMemoryPoisonStore::new()); let manager = LocalManager::new(config, state_store, poison_manager, None, None); - manager.manager_ctx.set_running(); + manager.start().await.unwrap(); manager .manager_ctx @@ -1447,7 +1695,6 @@ mod tests { .lock() .unwrap() .insert(ProcedureId::random()); - manager.start().await.unwrap(); // Submit a new procedure should fail. let mut procedure = ProcedureToLoad::new("submit"); diff --git a/src/common/procedure/src/local/runner.rs b/src/common/procedure/src/local/runner.rs index ca3e221f43..509b3a7756 100644 --- a/src/common/procedure/src/local/runner.rs +++ b/src/common/procedure/src/local/runner.rs @@ -20,6 +20,7 @@ use backon::{BackoffBuilder, ExponentialBuilder}; use common_error::ext::PlainError; use common_error::status_code::StatusCode; use common_event_recorder::EventRecorderRef; +use common_telemetry::tracing::warn; use common_telemetry::tracing_context::{FutureExt, TracingContext}; use common_telemetry::{debug, error, info, tracing}; use rand::Rng; @@ -480,6 +481,15 @@ impl Runner { procedure_state: ProcedureState, procedure: BoxedProcedure, ) { + if !self.running() { + warn!( + "ProcedureManager is not running, skip submitting subprocedure {}-{}", + procedure.type_name(), + procedure_id + ); + return; + } + if self.manager_ctx.contains_procedure(procedure_id) { // If the parent has already submitted this procedure, don't submit it again. return; @@ -520,23 +530,29 @@ impl Runner { procedure_id, ); - // Add the id of the subprocedure to the metadata. - self.meta.push_child(procedure_id); let parent_id = self.meta.id; let tracing_context = TracingContext::from_current_span(); - let _handle = common_runtime::spawn_global(async move { - let span = tracing_context.attach(tracing::info_span!( - "LocalManager::submit_subprocedure", - procedure_name = %runner.meta.type_name, - procedure_id = %runner.meta.id, - parent_id = %parent_id, - )); - // Run the root procedure. - // The task was moved to another runtime for execution. - // In order not to interrupt tracing, a span needs to be created to continue tracing the current task. - runner.run().trace(span).await - }); + if !self.manager_ctx.spawn_runner_task(procedure_id, || { + common_runtime::spawn_global(async move { + let span = tracing_context.attach(tracing::info_span!( + "LocalManager::submit_subprocedure", + procedure_name = %runner.meta.type_name, + procedure_id = %runner.meta.id, + parent_id = %parent_id, + )); + // Run the root procedure. + // The task was moved to another runtime for execution. + // In order not to interrupt tracing, a span needs to be created to continue tracing the current task. + runner.run().trace(span).await + }) + }) { + self.manager_ctx.remove_procedure(procedure_id); + return; + } + + // Add the id of the subprocedure to the metadata. + self.meta.push_child(procedure_id); } /// Extend the retry time to wait for the next retry. @@ -702,6 +718,12 @@ impl Runner { } } +impl Drop for Runner { + fn drop(&mut self) { + self.manager_ctx.remove_runner_task(self.meta.id); + } +} + #[cfg(test)] mod tests { use std::assert_matches; diff --git a/src/common/procedure/src/rwlock.rs b/src/common/procedure/src/rwlock.rs index cbdfe30977..c4807cf2f7 100644 --- a/src/common/procedure/src/rwlock.rs +++ b/src/common/procedure/src/rwlock.rs @@ -106,6 +106,13 @@ where locks.remove(key); } } + + /// Clears all key locks. + /// + /// Callers must ensure no tasks are holding or waiting for these locks. + pub fn clear(&self) { + self.inner.lock().unwrap().clear(); + } } #[cfg(test)] From 17815830edb0daa6113a3cbeb1d07706fcc35e2b Mon Sep 17 00:00:00 2001 From: shuiyisong <113876041+shuiyisong@users.noreply.github.com> Date: Thu, 28 May 2026 17:23:30 +0800 Subject: [PATCH 25/32] chore: add `LeaderServicesContext` control to standalone (#8164) * chore: add refresh hook Signed-off-by: shuiyisong * chore: merge start_with_context and start Signed-off-by: shuiyisong * chore: place reset in recover Signed-off-by: shuiyisong * chore: revert stop changes Signed-off-by: shuiyisong * fix: CR issue Signed-off-by: shuiyisong --------- Signed-off-by: shuiyisong --- src/cmd/src/standalone.rs | 90 +++++++++++++------- src/common/meta/src/cache/container.rs | 10 ++- src/common/meta/src/cache/registry.rs | 103 +++++++++++++++++++++++ src/common/meta/src/cache_invalidator.rs | 19 +++++ src/datanode/src/utils.rs | 27 +++++- src/flow/src/adapter/flownode_impl.rs | 5 ++ src/meta-srv/src/cache_invalidator.rs | 7 ++ 7 files changed, 223 insertions(+), 38 deletions(-) diff --git a/src/cmd/src/standalone.rs b/src/cmd/src/standalone.rs index b0601088cf..e0f2c673ff 100644 --- a/src/cmd/src/standalone.rs +++ b/src/cmd/src/standalone.rs @@ -20,6 +20,7 @@ use std::{fs, path}; use async_trait::async_trait; use cache::{build_fundamental_cache_registry, with_default_composite_cache_registry}; +use catalog::CatalogManagerRef; use catalog::information_schema::InformationExtensionRef; use catalog::kvbackend::{CatalogManagerConfiguratorRef, KvBackendCatalogManagerBuilder}; use catalog::process_manager::ProcessManager; @@ -28,7 +29,8 @@ use common_base::Plugins; use common_catalog::consts::{MIN_USER_FLOW_ID, MIN_USER_TABLE_ID}; use common_config::{Configurable, metadata_store_dir}; use common_error::ext::BoxedError; -use common_meta::cache::LayeredCacheRegistryBuilder; +use common_meta::DatanodeId; +use common_meta::cache::{LayeredCacheRegistryBuilder, LayeredCacheRegistryRef}; use common_meta::ddl::flow_meta::FlowMetadataAllocator; use common_meta::ddl::table_meta::TableMetadataAllocator; use common_meta::ddl::{DdlContext, NoopRegionFailureDetectorControl}; @@ -53,8 +55,8 @@ use datanode::config::DatanodeOptions; use datanode::datanode::{Datanode, DatanodeBuilder}; use datanode::region_server::RegionServer; use flow::{ - FlownodeBuilder, FlownodeInstance, FlownodeOptions, FrontendClient, FrontendInvoker, - GrpcQueryHandlerWithBoxedError, + FlowDualEngineRef, FlownodeBuilder, FlownodeInstance, FlownodeOptions, FrontendClient, + FrontendInvoker, GrpcQueryHandlerWithBoxedError, }; use frontend::frontend::Frontend; use frontend::instance::StandaloneDatanodeManager; @@ -124,8 +126,8 @@ pub struct Instance { frontend: Frontend, flownode: FlownodeInstance, procedure_manager: ProcedureManagerRef, - wal_provider: WalProviderRef, leader_services_controller: Box, + leader_services_context: LeaderServicesContext, // Keep the logging guard to prevent the worker from being dropped. _guard: Vec, } @@ -159,11 +161,7 @@ impl App for Instance { self.datanode.start_telemetry(); self.leader_services_controller - .start( - self.procedure_manager.clone(), - self.wal_provider.clone(), - self.datanode.region_server(), - ) + .start(self.leader_services_context.clone()) .await?; plugins::start_frontend_plugins(self.frontend.instance.plugins().clone()) @@ -379,6 +377,8 @@ impl StartCommand { opts.grpc.detect_server_addr(); let fe_opts = opts.frontend_options(); let dn_opts = opts.datanode_options(); + let node_id = dn_opts.node_id; + let init_regions_parallelism = dn_opts.init_regions_parallelism; plugins::setup_frontend_plugins(&mut plugins, &plugin_opts, &fe_opts) .await @@ -491,21 +491,18 @@ impl StartCommand { .await .map_err(BoxedError::new) .context(error::OtherSnafu)?; + let flow_engine = flownode.flow_engine(); // set the ref to query for the local flow state { information_extension - .set_flow_engine(flownode.flow_engine()) + .set_flow_engine(flow_engine.clone()) .await; } let node_manager = creator .node_manager_creator - .create( - &kv_backend, - datanode.region_server(), - flownode.flow_engine(), - ) + .create(&kv_backend, datanode.region_server(), flow_engine.clone()) .await?; let table_id_allocator = creator.table_id_allocator_creator.create(&kv_backend); @@ -596,7 +593,7 @@ impl StartCommand { .await; // set the frontend invoker for flownode - let flow_streaming_engine = flownode.flow_engine().streaming_engine(); + let flow_streaming_engine = flow_engine.streaming_engine(); // flow server need to be able to use frontend to write insert requests back let invoker = FrontendInvoker::build_from( flow_streaming_engine.clone(), @@ -620,14 +617,27 @@ impl StartCommand { servers, heartbeat_task: None, }; + let leader_services_context = LeaderServicesContext { + procedure_manager: procedure_manager.clone(), + wal_provider: wal_provider.clone(), + region_server: datanode.region_server(), + kv_backend: kv_backend.clone(), + cache_registry: layered_cache_registry, + catalog_manager, + flow_engine, + frontend_client, + node_id, + init_regions_parallelism, + plugin_options: plugin_opts, + }; let instance = Instance { datanode, frontend, flownode, procedure_manager, - wal_provider, leader_services_controller: creator.leader_services_controller, + leader_services_context, _guard: vec![], }; let result = InstanceCreatorResult { @@ -743,16 +753,11 @@ impl ProcedureExecutorCreator for DefaultProcedureExecutorCreator { #[async_trait] pub trait StandaloneLeaderServicesController: Send + Sync { - /// Starts services that manage standalone metadata or WAL state. + /// Starts leader services that manage standalone metadata or WAL state. /// /// The default implementation starts the procedure manager and WAL provider /// during instance startup. - async fn start( - &self, - procedure_manager: ProcedureManagerRef, - wal_provider: WalProviderRef, - region_server: RegionServer, - ) -> Result<()>; + async fn start(&self, context: LeaderServicesContext) -> Result<()>; /// Stops services started by [`StandaloneLeaderServicesController::start`]. async fn stop( @@ -762,21 +767,42 @@ pub trait StandaloneLeaderServicesController: Send + Sync { ) -> Result<()>; } +#[derive(Clone)] +/// Additional runtime handles for custom leader-service controllers. +/// +/// The default standalone startup only needs to start/stop the procedure +/// manager and WAL provider. Some embedders need to do more work around +/// leader-service startup, for example reconciling metadata-backed runtime +/// state before publishing writable leadership. Grouping those handles here +/// keeps `Instance` small and avoids expanding +/// [`StandaloneLeaderServicesController::start`] every time a custom lifecycle +/// needs one more standalone component. +pub struct LeaderServicesContext { + pub procedure_manager: ProcedureManagerRef, + pub wal_provider: WalProviderRef, + pub region_server: RegionServer, + pub kv_backend: KvBackendRef, + pub cache_registry: LayeredCacheRegistryRef, + pub catalog_manager: CatalogManagerRef, + pub flow_engine: FlowDualEngineRef, + pub frontend_client: Arc, + pub node_id: Option, + pub init_regions_parallelism: usize, + pub plugin_options: Vec, +} + pub struct DefaultStandaloneLeaderServicesController; #[async_trait] impl StandaloneLeaderServicesController for DefaultStandaloneLeaderServicesController { - async fn start( - &self, - procedure_manager: ProcedureManagerRef, - wal_provider: WalProviderRef, - _region_server: RegionServer, - ) -> Result<()> { - procedure_manager + async fn start(&self, context: LeaderServicesContext) -> Result<()> { + context + .procedure_manager .start() .await .context(error::StartProcedureManagerSnafu)?; - wal_provider + context + .wal_provider .start() .await .context(error::StartWalProviderSnafu) diff --git a/src/common/meta/src/cache/container.rs b/src/common/meta/src/cache/container.rs index e3a3e13a76..e3a1a50adc 100644 --- a/src/common/meta/src/cache/container.rs +++ b/src/common/meta/src/cache/container.rs @@ -196,8 +196,8 @@ where #[async_trait::async_trait] impl CacheInvalidator for CacheContainer where - K: Send + Sync, - V: Send + Sync, + K: Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, { async fn invalidate(&self, _ctx: &Context, caches: &[CacheIdent]) -> Result<()> { let idents = caches @@ -211,6 +211,12 @@ where Ok(()) } + + fn invalidate_all(&self) -> Result<()> { + self.inc_version(); + self.cache.invalidate_all(); + Ok(()) + } } impl CacheContainer diff --git a/src/common/meta/src/cache/registry.rs b/src/common/meta/src/cache/registry.rs index d541525f98..b7ee82b6e5 100644 --- a/src/common/meta/src/cache/registry.rs +++ b/src/common/meta/src/cache/registry.rs @@ -67,6 +67,13 @@ impl CacheInvalidator for LayeredCacheRegistry { } results.into_iter().collect::>>().map(|_| ()) } + + fn invalidate_all(&self) -> Result<()> { + for registry in &self.layers { + registry.invalidate_all()?; + } + Ok(()) + } } impl LayeredCacheRegistry { @@ -124,6 +131,13 @@ impl CacheInvalidator for CacheRegistry { .collect::>>()?; Ok(()) } + + fn invalidate_all(&self) -> Result<()> { + for invalidator in &self.indexes { + invalidator.invalidate_all()?; + } + Ok(()) + } } impl CacheRegistry { @@ -149,6 +163,8 @@ mod tests { use crate::cache::registry::CacheRegistryBuilder; use crate::cache::*; + use crate::cache_invalidator::{CacheInvalidator, Context}; + use crate::error::Result; use crate::instruction::CacheIdent; fn always_true_filter(_: &CacheIdent) -> bool { @@ -259,4 +275,91 @@ mod tests { .unwrap(); assert_eq!(cache.name(), "string_cache"); } + + #[tokio::test] + async fn test_registry_invalidate_all() { + let invalidator: Invalidator<_, String, CacheIdent> = + Box::new(|_, _| Box::pin(async { Ok(()) })); + let i32_cache = Arc::new(test_i32_cache("i32_cache", invalidator)); + let invalidator: Invalidator<_, String, CacheIdent> = + Box::new(|_, _| Box::pin(async { Ok(()) })); + let string_cache = Arc::new(test_cache("string_cache", invalidator)); + + i32_cache.get(1).await.unwrap(); + string_cache.get_by_ref("foo").await.unwrap(); + assert!(i32_cache.contains_key(&1)); + assert!(string_cache.contains_key("foo")); + + let registry = CacheRegistryBuilder::default() + .add_cache(i32_cache.clone()) + .add_cache(string_cache.clone()) + .build(); + + registry.invalidate_all().unwrap(); + + assert!(!i32_cache.contains_key(&1)); + assert!(!string_cache.contains_key("foo")); + } + + struct LayerOrderInvalidator { + expected_order: i32, + order: Arc, + } + + #[async_trait::async_trait] + impl CacheInvalidator for LayerOrderInvalidator { + async fn invalidate(&self, _ctx: &Context, _caches: &[CacheIdent]) -> Result<()> { + Ok(()) + } + + fn invalidate_all(&self) -> Result<()> { + let previous = self.order.fetch_add(1, Ordering::Relaxed); + assert_eq!(self.expected_order, previous); + Ok(()) + } + } + + #[tokio::test] + async fn test_layered_registry_invalidate_all() { + let order = Arc::new(AtomicI32::new(0)); + let invalidator: Invalidator<_, String, CacheIdent> = + Box::new(|_, _| Box::pin(async { Ok(()) })); + let first_layer_cache = Arc::new(test_cache("first_layer_cache", invalidator)); + let first_layer_order = Arc::new(LayerOrderInvalidator { + expected_order: 0, + order: order.clone(), + }); + let first_layer = CacheRegistryBuilder::default() + .add_cache(first_layer_order) + .add_cache(first_layer_cache.clone()) + .build(); + + let invalidator: Invalidator<_, String, CacheIdent> = + Box::new(|_, _| Box::pin(async { Ok(()) })); + let second_layer_cache = Arc::new(test_i32_cache("second_layer_cache", invalidator)); + let second_layer_order = Arc::new(LayerOrderInvalidator { + expected_order: 1, + order: order.clone(), + }); + let second_layer = CacheRegistryBuilder::default() + .add_cache(second_layer_order) + .add_cache(second_layer_cache.clone()) + .build(); + + first_layer_cache.get_by_ref("foo").await.unwrap(); + second_layer_cache.get(1).await.unwrap(); + assert!(first_layer_cache.contains_key("foo")); + assert!(second_layer_cache.contains_key(&1)); + + let registry = LayeredCacheRegistryBuilder::default() + .add_cache_registry(first_layer) + .add_cache_registry(second_layer) + .build(); + + registry.invalidate_all().unwrap(); + + assert_eq!(2, order.load(Ordering::Relaxed)); + assert!(!first_layer_cache.contains_key("foo")); + assert!(!second_layer_cache.contains_key(&1)); + } } diff --git a/src/common/meta/src/cache_invalidator.rs b/src/common/meta/src/cache_invalidator.rs index ffc3dd1c9a..4fe0699ba5 100644 --- a/src/common/meta/src/cache_invalidator.rs +++ b/src/common/meta/src/cache_invalidator.rs @@ -55,6 +55,13 @@ pub struct Context { pub trait CacheInvalidator: Send + Sync { async fn invalidate(&self, ctx: &Context, caches: &[CacheIdent]) -> Result<()>; + /// Invalidates every cache entry owned by this invalidator. + /// + /// This method is required so each implementer explicitly decides how + /// full-cache invalidation should behave. Implementations that intentionally + /// do nothing must document why a no-op is safe. + fn invalidate_all(&self) -> Result<()>; + fn name(&self) -> &'static str { std::any::type_name::() } @@ -69,6 +76,11 @@ impl CacheInvalidator for DummyCacheInvalidator { async fn invalidate(&self, _ctx: &Context, _caches: &[CacheIdent]) -> Result<()> { Ok(()) } + + fn invalidate_all(&self) -> Result<()> { + // Dummy invalidator owns no cache state, so there is nothing to clear. + Ok(()) + } } #[async_trait::async_trait] @@ -157,4 +169,11 @@ where } Ok(()) } + + fn invalidate_all(&self) -> Result<()> { + // KvCacheInvalidator only knows how to invalidate explicit metadata + // keys. There is no safe generic way to enumerate or clear the backend + // keyspace, so full invalidation is intentionally a no-op here. + Ok(()) + } } diff --git a/src/datanode/src/utils.rs b/src/datanode/src/utils.rs index 488ddacdf0..c5cd008c28 100644 --- a/src/datanode/src/utils.rs +++ b/src/datanode/src/utils.rs @@ -29,10 +29,28 @@ use tracing::info; use crate::error::{GetMetadataSnafu, Result}; /// The requests to open regions. -pub(crate) struct RegionOpenRequests { - pub leader_regions: Vec<(RegionId, RegionOpenRequest)>, +pub struct RegionOpenRequests { + pub(crate) leader_regions: Vec<(RegionId, RegionOpenRequest)>, #[cfg(feature = "enterprise")] - pub follower_regions: Vec<(RegionId, RegionOpenRequest)>, + pub(crate) follower_regions: Vec<(RegionId, RegionOpenRequest)>, +} + +impl RegionOpenRequests { + /// Splits the request set into leader and follower regions. + #[allow(clippy::type_complexity)] + pub fn into_parts( + self, + ) -> ( + Vec<(RegionId, RegionOpenRequest)>, + Vec<(RegionId, RegionOpenRequest)>, + ) { + let leader_regions = self.leader_regions; + #[cfg(feature = "enterprise")] + let follower_regions = self.follower_regions; + #[cfg(not(feature = "enterprise"))] + let follower_regions = Vec::new(); + (leader_regions, follower_regions) + } } fn group_region_by_topic( @@ -58,7 +76,8 @@ fn get_replay_checkpoint( }) } -pub(crate) async fn build_region_open_requests( +/// Builds region-open requests from persisted metadata. +pub async fn build_region_open_requests( node_id: DatanodeId, kv_backend: KvBackendRef, ) -> Result { diff --git a/src/flow/src/adapter/flownode_impl.rs b/src/flow/src/adapter/flownode_impl.rs index 53a3265d7d..f4ca149f1a 100644 --- a/src/flow/src/adapter/flownode_impl.rs +++ b/src/flow/src/adapter/flownode_impl.rs @@ -465,6 +465,11 @@ impl FlowDualEngine { Ok(()) } + /// Reconciles in-memory flow tasks from persisted metadata. + pub async fn reconcile_flows_from_metadata(&self) -> Result<(), Error> { + self.check_flow_consistent(true, true).await + } + /// TODO(discord9): also add a `exists` api using flow metadata manager's `exists` method async fn flow_exist_in_metadata(&self, flow_id: FlowId) -> Result { self.flow_metadata_manager diff --git a/src/meta-srv/src/cache_invalidator.rs b/src/meta-srv/src/cache_invalidator.rs index b594d65f48..f6ec0b4fc9 100644 --- a/src/meta-srv/src/cache_invalidator.rs +++ b/src/meta-srv/src/cache_invalidator.rs @@ -84,4 +84,11 @@ impl CacheInvalidator for MetasrvCacheInvalidator { let instruction = Instruction::InvalidateCaches(caches.to_vec()); self.broadcast(ctx, instruction).await } + + fn invalidate_all(&self) -> MetaResult<()> { + // MetasrvCacheInvalidator only broadcasts concrete cache identifiers to + // remote nodes. The heartbeat instruction protocol has no global + // invalidate-all message, so there is no safe broadcast to send here. + Ok(()) + } } From eccd97b5c7ec363b1cb887105896f74969705bcc Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 28 May 2026 17:31:46 +0800 Subject: [PATCH 26/32] feat(flow): support incremental read checkpoints (#8179) * feat: flownode inc mode Signed-off-by: discord9 * chore: rename fallback reason Signed-off-by: discord9 * fix: harden flow incremental checkpoints Signed-off-by: discord9 * fix: address flow watermark lint Signed-off-by: discord9 * fix: address flow clippy Signed-off-by: discord9 * refactor: clarify incremental plan preparation Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 * test: more sqlness test Signed-off-by: discord9 * refactor: per review Signed-off-by: discord9 --------- Signed-off-by: discord9 --- src/flow/src/batching_mode.rs | 1 + src/flow/src/batching_mode/checkpoint.rs | 118 ++- src/flow/src/batching_mode/frontend_client.rs | 25 +- .../src/batching_mode/incremental_filter.rs | 222 +++++ src/flow/src/batching_mode/state.rs | 511 +++++++++++- src/flow/src/batching_mode/task.rs | 188 ++++- src/flow/src/batching_mode/task/ckpt.rs | 181 +++++ src/flow/src/batching_mode/task/inc.rs | 252 ++++++ src/flow/src/batching_mode/task/test.rs | 759 +++++++++++++++++- src/flow/src/batching_mode/utils.rs | 61 +- src/flow/src/batching_mode/utils/test.rs | 170 +++- src/flow/src/metrics.rs | 14 + src/mito2/src/engine/scan_test.rs | 2 +- src/mito2/src/read/scan_region.rs | 42 +- src/query/src/dist_plan/merge_scan.rs | 18 + src/query/src/dummy_catalog.rs | 14 +- src/query/src/metrics.rs | 166 +++- src/store-api/src/storage/requests.rs | 17 + .../common/flow/flow_incremental_aggr.result | 119 +++ .../common/flow/flow_incremental_aggr.sql | 57 ++ .../flow/flow_incremental_memtable.result | 132 +++ .../common/flow/flow_incremental_memtable.sql | 66 ++ .../flow/flow_incremental_partitioned.result | 108 +++ .../flow/flow_incremental_partitioned.sql | 61 ++ 24 files changed, 3201 insertions(+), 103 deletions(-) create mode 100644 src/flow/src/batching_mode/incremental_filter.rs create mode 100644 src/flow/src/batching_mode/task/ckpt.rs create mode 100644 src/flow/src/batching_mode/task/inc.rs create mode 100644 tests/cases/standalone/common/flow/flow_incremental_aggr.result create mode 100644 tests/cases/standalone/common/flow/flow_incremental_aggr.sql create mode 100644 tests/cases/standalone/common/flow/flow_incremental_memtable.result create mode 100644 tests/cases/standalone/common/flow/flow_incremental_memtable.sql create mode 100644 tests/cases/standalone/common/flow/flow_incremental_partitioned.result create mode 100644 tests/cases/standalone/common/flow/flow_incremental_partitioned.sql diff --git a/src/flow/src/batching_mode.rs b/src/flow/src/batching_mode.rs index 47b3054f54..580762a142 100644 --- a/src/flow/src/batching_mode.rs +++ b/src/flow/src/batching_mode.rs @@ -23,6 +23,7 @@ use session::ReadPreference; mod checkpoint; pub(crate) mod engine; pub(crate) mod frontend_client; +mod incremental_filter; mod state; mod table_creator; mod task; diff --git a/src/flow/src/batching_mode/checkpoint.rs b/src/flow/src/batching_mode/checkpoint.rs index c359360dc5..7341d3d9e7 100644 --- a/src/flow/src/batching_mode/checkpoint.rs +++ b/src/flow/src/batching_mode/checkpoint.rs @@ -12,12 +12,116 @@ // See the License for the specific language governing permissions and // limitations under the License. -#[allow(dead_code)] +use crate::batching_mode::state::CheckpointMode; + +pub(super) const CHECKPOINT_DECISION_ADVANCE: &str = "advance"; +pub(super) const CHECKPOINT_DECISION_FALLBACK: &str = "fallback"; +pub(super) const CHECKPOINT_REASON_NONE: &str = "none"; + +/// Why the task fell back to full snapshot mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum CheckpointMode { - /// Full-snapshot reads over the source tables. - FullSnapshot, - /// Incremental reads driven by explicitly emitted incremental scan - /// extensions. - Incremental, +pub(super) enum FlowQueryFallbackReason { + /// The query result did not include a region-watermark map at all. + MissingRegionWatermark, + /// Some participating regions could not prove safe advancement against + /// both the returned watermarks and the checkpoint map. + IncompleteRegionWatermark, + /// The query only covered part of the dirty backlog, so global checkpoints + /// cannot advance yet. Incremental SQL drains all dirty windows before + /// checkpoint advancement; this primarily protects scoped full-snapshot + /// runs capped by the per-query dirty-window limit. + DirtyBacklogPending, + /// The datanode detected a stale incremental cursor and the Flow + /// must recompute from scratch. + StaleCursor, + /// A non-stale-cursor query failure; the Flow resets to full snapshot + /// to avoid cascading errors. + IncrementalQueryFailure, + /// Incremental mode has been permanently disabled for this Flow + /// (e.g. because the query shape is not incrementally safe). + IncrementalDisabled, +} + +impl FlowQueryFallbackReason { + pub(super) fn as_label(self) -> &'static str { + match self { + Self::MissingRegionWatermark => "missing_region_watermark", + Self::IncompleteRegionWatermark => "incomplete_region_watermark", + Self::DirtyBacklogPending => "dirty_backlog_pending", + Self::StaleCursor => "stale_cursor", + Self::IncrementalQueryFailure => "incremental_query_failure", + Self::IncrementalDisabled => "incremental_disabled", + } + } +} + +/// Decision produced by `BatchingTask::apply_query_result_to_state` after +/// each Flow query execution. Describes whether the task advanced its +/// checkpoint state or fell back to full snapshot, and why. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FlowCheckpointDecision { + /// FullSnapshot → Incremental transition. + /// + /// The query exercised every participating region, all returned valid + /// watermarks, and the checkpoint map was populated from scratch. + /// Subsequent executions will use incremental after-seqs. + AdvancedFromFullSnapshot { + participating_regions: usize, + watermarks: usize, + }, + /// Existing Incremental → Incremental (in-place advancement). + /// + /// A subset of participating regions advanced their watermarks. The + /// task stays in incremental mode with an updated checkpoint map. + AdvancedIncremental { + participating_regions: usize, + watermarks: usize, + }, + /// Any mode → FullSnapshot. + /// + /// Watermark information was incomplete, a participating region was + /// absent from the existing checkpoint map, the task has permanently + /// disabled incremental mode, or the query itself failed. The task + /// resets to full snapshot semantics for the next execution. + FallbackToFullSnapshot { + previous_mode: CheckpointMode, + reason: FlowQueryFallbackReason, + }, +} + +impl FlowCheckpointDecision { + pub(super) fn mode_label(self) -> &'static str { + match self { + Self::AdvancedFromFullSnapshot { .. } => { + checkpoint_mode_label(CheckpointMode::FullSnapshot) + } + Self::AdvancedIncremental { .. } => checkpoint_mode_label(CheckpointMode::Incremental), + Self::FallbackToFullSnapshot { previous_mode, .. } => { + checkpoint_mode_label(previous_mode) + } + } + } + + pub(super) fn decision_label(self) -> &'static str { + match self { + Self::AdvancedFromFullSnapshot { .. } | Self::AdvancedIncremental { .. } => { + CHECKPOINT_DECISION_ADVANCE + } + Self::FallbackToFullSnapshot { .. } => CHECKPOINT_DECISION_FALLBACK, + } + } + + pub(super) fn reason_label(self) -> &'static str { + match self { + Self::FallbackToFullSnapshot { reason, .. } => reason.as_label(), + _ => CHECKPOINT_REASON_NONE, + } + } +} + +pub(super) fn checkpoint_mode_label(mode: CheckpointMode) -> &'static str { + match mode { + CheckpointMode::FullSnapshot => "full_snapshot", + CheckpointMode::Incremental => "incremental", + } } diff --git a/src/flow/src/batching_mode/frontend_client.rs b/src/flow/src/batching_mode/frontend_client.rs index 8fbffc5a38..c6194d96b3 100644 --- a/src/flow/src/batching_mode/frontend_client.rs +++ b/src/flow/src/batching_mode/frontend_client.rs @@ -340,12 +340,13 @@ impl FrontendClient { } } - pub async fn query_with_terminal_metrics( + pub(crate) async fn query_with_terminal_metrics( &self, catalog: &str, schema: &str, request: QueryRequest, extensions: &[(&str, &str)], + peer_desc: &mut Option, ) -> Result { let flow_extensions = build_flow_extensions(extensions)?; match self { @@ -358,6 +359,9 @@ impl FrontendClient { (READ_PREFERENCE_HINT, batch_opts.read_preference.as_ref()), ]; let db = self.get_random_active_frontend(catalog, schema).await?; + *peer_desc = Some(PeerDesc::Dist { + peer: db.peer.clone(), + }); db.database .query_with_terminal_metrics_and_flow_extensions(request, &hints, extensions) .await @@ -368,6 +372,7 @@ impl FrontendClient { database_client, query, } => { + *peer_desc = Some(PeerDesc::Standalone); let mut extensions_map = HashMap::from([( QUERY_PARALLELISM_HINT.to_string(), query.parallelism.to_string(), @@ -556,21 +561,24 @@ fn terminal_recordbatch_metrics_from_snapshots( } /// Describe a peer of frontend -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub(crate) enum PeerDesc { + /// The query failed before a frontend peer was selected. + #[default] + Unknown, /// Distributed mode's frontend peer address Dist { /// frontend peer address peer: Peer, }, /// Standalone mode - #[default] Standalone, } impl std::fmt::Display for PeerDesc { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + PeerDesc::Unknown => write!(f, "unknown"), PeerDesc::Dist { peer } => write!(f, "{}", peer.addr), PeerDesc::Standalone => write!(f, "standalone"), } @@ -768,6 +776,7 @@ mod tests { let handler: Arc = Arc::new(MetricsHandler); let client = FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + let mut peer_desc = None; let result = client .query_with_terminal_metrics( @@ -777,9 +786,11 @@ mod tests { query: Some(Query::Sql("select 1".to_string())), }, &[], + &mut peer_desc, ) .await .unwrap(); + assert!(matches!(peer_desc, Some(PeerDesc::Standalone))); let terminal_metrics = result.metrics.clone(); assert!(!result.metrics.is_ready()); @@ -802,6 +813,7 @@ mod tests { let handler: Arc = Arc::new(ExtensionAwareHandler); let client = FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + let mut peer_desc = None; let result = client .query_with_terminal_metrics( @@ -811,9 +823,11 @@ mod tests { query: Some(Query::Sql("insert into t select 1".to_string())), }, &[("flow.return_region_seq", "true")], + &mut peer_desc, ) .await .unwrap(); + assert!(matches!(peer_desc, Some(PeerDesc::Standalone))); assert!(result.metrics.is_ready()); assert!(result.region_watermark_map().is_none()); @@ -824,6 +838,7 @@ mod tests { let handler: Arc = Arc::new(SnapshotBindingHandler); let client = FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + let mut peer_desc = None; let result = client .query_with_terminal_metrics( @@ -833,9 +848,11 @@ mod tests { query: Some(Query::Sql("insert into t select * from src".to_string())), }, &[("flow.return_region_seq", "true")], + &mut peer_desc, ) .await .unwrap(); + assert!(matches!(peer_desc, Some(PeerDesc::Standalone))); assert!(result.metrics.is_ready()); assert_eq!( @@ -849,6 +866,7 @@ mod tests { let handler: Arc = Arc::new(NoopHandler); let client = FrontendClient::from_grpc_handler(Arc::downgrade(&handler), QueryOptions::default()); + let mut peer_desc = None; let err = client .query_with_terminal_metrics( @@ -858,6 +876,7 @@ mod tests { query: Some(Query::Sql("select 1".to_string())), }, &[("flow.return_region_seq", "not-a-bool")], + &mut peer_desc, ) .await .unwrap_err(); diff --git a/src/flow/src/batching_mode/incremental_filter.rs b/src/flow/src/batching_mode/incremental_filter.rs new file mode 100644 index 0000000000..ddc58d0378 --- /dev/null +++ b/src/flow/src/batching_mode/incremental_filter.rs @@ -0,0 +1,222 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use common_telemetry::tracing::debug; +use datafusion_expr::Expr; +use datatypes::schema::Schema; + +use crate::batching_mode::state::FilterExprInfo; +use crate::batching_mode::utils::IncrementalAggregateAnalysis; +use crate::{Error, FlowId}; + +pub(super) fn build_sink_dirty_time_window_filter_expr( + flow_id: FlowId, + analysis: &IncrementalAggregateAnalysis, + sink_schema: &Schema, + dirty_filter: Option<&FilterExprInfo>, +) -> Result, Error> { + let Some(dirty_filter) = dirty_filter else { + return Ok(None); + }; + + let Some(sink_filter_col) = + infer_sink_time_window_filter_col(flow_id, analysis, sink_schema, dirty_filter) + else { + return Ok(None); + }; + + dirty_filter.predicate_for_col(&sink_filter_col) +} + +fn infer_sink_time_window_filter_col( + flow_id: FlowId, + analysis: &IncrementalAggregateAnalysis, + sink_schema: &Schema, + dirty_filter: &FilterExprInfo, +) -> Option { + if analysis.group_key_names.is_empty() { + return None; + } + + let is_timestamp_group_key = |name: &str| { + analysis.group_key_names.iter().any(|key| key == name) + && sink_schema + .column_schema_by_name(name) + .is_some_and(|col| col.data_type.is_timestamp()) + }; + + if is_timestamp_group_key(&dirty_filter.col_name) { + return Some(dirty_filter.col_name.clone()); + } + + let candidates = analysis + .group_key_names + .iter() + .filter(|name| is_timestamp_group_key(name)) + .cloned() + .collect::>(); + + match candidates.as_slice() { + [name] => Some(name.clone()), + [] => { + debug!( + "Flow {} cannot infer sink dirty-window filter column: no timestamp group key in {:?}", + flow_id, analysis.group_key_names + ); + None + } + _ => { + debug!( + "Flow {} cannot infer sink dirty-window filter column: ambiguous timestamp group keys {:?}", + flow_id, candidates + ); + None + } + } +} + +#[cfg(test)] +mod test { + use datatypes::prelude::ConcreteDataType; + use datatypes::schema::ColumnSchema; + use pretty_assertions::assert_eq; + + use super::*; + use crate::adapter::AUTO_CREATED_UPDATE_AT_TS_COL; + use crate::batching_mode::state::FilterExprInfo; + use crate::batching_mode::utils::IncrementalAggregateAnalysis; + + fn test_analysis_with_group_keys(group_key_names: Vec<&str>) -> IncrementalAggregateAnalysis { + IncrementalAggregateAnalysis { + group_key_names: group_key_names + .into_iter() + .map(|name| name.to_string()) + .collect(), + merge_columns: vec![], + literal_columns: vec![], + output_field_names: vec![], + unsupported_exprs: vec![], + } + } + + fn test_dirty_filter(col_name: &str) -> FilterExprInfo { + FilterExprInfo { + expr: datafusion_expr::col(col_name), + col_name: col_name.to_string(), + time_ranges: vec![], + window_size: chrono::Duration::seconds(1), + } + } + + fn test_sink_schema(columns: Vec<(&str, ConcreteDataType)>) -> Schema { + Schema::new( + columns + .into_iter() + .map(|(name, data_type)| ColumnSchema::new(name, data_type, true)) + .collect(), + ) + } + + #[test] + fn test_infer_sink_time_window_filter_col_uses_matching_source_group_key() { + let analysis = test_analysis_with_group_keys(vec!["ts", "host"]); + let sink_schema = test_sink_schema(vec![ + ("ts", ConcreteDataType::timestamp_millisecond_datatype()), + ("host", ConcreteDataType::string_datatype()), + ]); + let dirty_filter = test_dirty_filter("ts"); + + assert_eq!( + Some("ts".to_string()), + infer_sink_time_window_filter_col(1, &analysis, &sink_schema, &dirty_filter) + ); + } + + #[test] + fn test_infer_sink_time_window_filter_col_uses_unique_timestamp_group_key() { + let analysis = test_analysis_with_group_keys(vec!["host", "time_window"]); + let sink_schema = test_sink_schema(vec![ + ("host", ConcreteDataType::string_datatype()), + ( + "time_window", + ConcreteDataType::timestamp_millisecond_datatype(), + ), + ( + AUTO_CREATED_UPDATE_AT_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + ), + ]); + let dirty_filter = test_dirty_filter("ts"); + + assert_eq!( + Some("time_window".to_string()), + infer_sink_time_window_filter_col(1, &analysis, &sink_schema, &dirty_filter) + ); + } + + #[test] + fn test_infer_sink_time_window_filter_col_skips_global_aggregate() { + let analysis = test_analysis_with_group_keys(vec![]); + let sink_schema = test_sink_schema(vec![ + ("number", ConcreteDataType::uint32_datatype()), + ( + "time_window", + ConcreteDataType::timestamp_millisecond_datatype(), + ), + ]); + let dirty_filter = test_dirty_filter("ts"); + + assert_eq!( + None, + infer_sink_time_window_filter_col(1, &analysis, &sink_schema, &dirty_filter) + ); + } + + #[test] + fn test_infer_sink_time_window_filter_col_skips_without_timestamp_group_key() { + let analysis = test_analysis_with_group_keys(vec!["host", "device"]); + let sink_schema = test_sink_schema(vec![ + ("host", ConcreteDataType::string_datatype()), + ("device", ConcreteDataType::string_datatype()), + ( + AUTO_CREATED_UPDATE_AT_TS_COL, + ConcreteDataType::timestamp_millisecond_datatype(), + ), + ]); + let dirty_filter = test_dirty_filter("ts"); + + assert_eq!( + None, + infer_sink_time_window_filter_col(1, &analysis, &sink_schema, &dirty_filter) + ); + } + + #[test] + fn test_infer_sink_time_window_filter_col_skips_ambiguous_timestamp_group_keys() { + let analysis = test_analysis_with_group_keys(vec!["ts", "time_window"]); + let sink_schema = test_sink_schema(vec![ + ("ts", ConcreteDataType::timestamp_millisecond_datatype()), + ( + "time_window", + ConcreteDataType::timestamp_millisecond_datatype(), + ), + ]); + let dirty_filter = test_dirty_filter("source_ts"); + + assert_eq!( + None, + infer_sink_time_window_filter_col(1, &analysis, &sink_schema, &dirty_filter) + ); + } +} diff --git a/src/flow/src/batching_mode/state.rs b/src/flow/src/batching_mode/state.rs index c470a2f2c1..42b71a4ec7 100644 --- a/src/flow/src/batching_mode/state.rs +++ b/src/flow/src/batching_mode/state.rs @@ -15,7 +15,7 @@ //! Batching mode task state, which changes frequently //! -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::time::Duration; use common_telemetry::debug; @@ -50,6 +50,14 @@ pub struct TaskState { /// Dirty Time windows need to be updated /// mapping of `start -> end` and non-overlapping pub(crate) dirty_time_windows: DirtyTimeWindows, + checkpoint_mode: CheckpointMode, + /// Region id -> last consumed watermark sequence. Incremental scans use + /// this as the next lower sequence bound for each source region. + checkpoints: BTreeMap, + /// Once set, the task will never attempt incremental mode again. + /// Set when the flow's query shape is deterministically incompatible + /// with incremental execution (e.g. unsupported aggregate expressions). + incremental_disabled: bool, exec_state: ExecState, /// Shutdown receiver pub(crate) shutdown_rx: oneshot::Receiver<()>, @@ -64,6 +72,9 @@ impl TaskState { last_query_duration: Duration::from_secs(0), last_exec_time_millis: None, dirty_time_windows: Default::default(), + checkpoint_mode: CheckpointMode::FullSnapshot, + checkpoints: Default::default(), + incremental_disabled: false, exec_state: ExecState::Idle, shutdown_rx, task_handle: None, @@ -85,6 +96,84 @@ impl TaskState { self.last_exec_time_millis } + pub fn checkpoint_mode(&self) -> CheckpointMode { + self.checkpoint_mode + } + + pub fn checkpoints(&self) -> &BTreeMap { + &self.checkpoints + } + + pub fn is_incremental_disabled(&self) -> bool { + self.incremental_disabled + } + + /// Permanently disable incremental mode for this task and + /// immediately fall back to full snapshot for the current cycle. + pub fn disable_incremental(&mut self) { + self.incremental_disabled = true; + self.mark_full_snapshot(); + } + + pub fn mark_full_snapshot(&mut self) { + self.checkpoint_mode = CheckpointMode::FullSnapshot; + } + + pub fn advance_checkpoints(&mut self, watermark_map: HashMap) { + self.checkpoints = watermark_map.into_iter().collect(); + if !self.incremental_disabled { + self.checkpoint_mode = CheckpointMode::Incremental; + } + } + + pub fn advance_incremental_checkpoints_with_participation( + &mut self, + participating_regions: &BTreeSet, + watermark_map: HashMap, + ) { + for region_id in participating_regions { + if let Some(seq) = watermark_map.get(region_id) { + self.checkpoints.insert(*region_id, *seq); + } + } + if !self.incremental_disabled { + self.checkpoint_mode = CheckpointMode::Incremental; + } + } + + pub fn can_advance_full_snapshot_checkpoints( + &self, + participating_regions: &BTreeSet, + watermark_map: &HashMap, + ) -> bool { + !participating_regions.is_empty() + && participating_regions.len() == watermark_map.len() + && participating_regions + .iter() + .all(|region_id| watermark_map.contains_key(region_id)) + } + + pub fn can_advance_incremental_checkpoints_with_participation( + &self, + participating_regions: &BTreeSet, + watermark_map: &HashMap, + ) -> bool { + !self.incremental_disabled + && !self.checkpoints.is_empty() + && !participating_regions.is_empty() + && participating_regions.len() == watermark_map.len() + && participating_regions + .iter() + .all(|region_id| self.checkpoints.contains_key(region_id)) + && participating_regions.iter().all(|region_id| { + let checkpoint = self.checkpoints.get(region_id); + watermark_map + .get(region_id) + .zip(checkpoint) + .is_some_and(|(seq, checkpoint)| seq >= checkpoint) + }) + } + /// Compute the next query delay based on the time window size or the last query duration. /// Aiming to avoid too frequent queries. But also not too long delay. /// @@ -95,6 +184,10 @@ impl TaskState { /// if current the dirty time range is longer than one query can handle, /// execute immediately to faster clean up dirty time windows. /// + /// If `prefer_short_incremental_cadence` is true, run incremental queries + /// more often when there is no large dirty backlog. This only reduces the + /// chance of hitting a stale cursor after flush; it is not required for + /// correctness. pub fn get_next_start_query_time( &self, flow_id: FlowId, @@ -102,6 +195,7 @@ impl TaskState { min_refresh_duration: Duration, max_timeout: Option, max_filter_num_per_query: usize, + prefer_short_incremental_cadence: bool, ) -> Instant { // = last query duration, capped by [max(min_run_interval, time_window_size), max_timeout], note at most `max_timeout` let lower = time_window_size.unwrap_or(min_refresh_duration); @@ -120,7 +214,20 @@ impl TaskState { // if dirty time range is more than one query can handle, execute immediately // to faster clean up dirty time windows if cur_dirty_window_size < max_query_update_range { - self.last_update_time + next_duration + if prefer_short_incremental_cadence { + // Run incremental queries sooner than the normal time-window + // cadence, while still backing off by at least the previous + // query duration and respecting the max-timeout cap. + let next_duration = self.last_query_duration.max(min_refresh_duration); + let next_duration = if let Some(max_timeout) = max_timeout { + next_duration.min(max_timeout) + } else { + next_duration + }; + self.last_update_time + next_duration + } else { + self.last_update_time + next_duration + } } else { // if dirty time windows can't be clean up in one query, execute immediately to faster // clean up dirty time windows @@ -314,7 +421,7 @@ impl DirtyTimeWindows { ); self.merge_dirty_time_windows(window_size, expire_lower_bound)?; - if self.windows.len() > self.max_filter_num_per_query { + if self.windows.len() > window_cnt { let first_time_window = self.windows.first_key_value(); let last_time_window = self.windows.last_key_value(); @@ -323,7 +430,7 @@ impl DirtyTimeWindows { "Flow id = {:?}, too many time windows: {}, only the first {} are taken for this query, the group by expression might be wrong. Time window expr={:?}, expire_after={:?}, first_time_window={:?}, last_time_window={:?}, the original query: {:?}", task_ctx.config.flow_id, self.windows.len(), - self.max_filter_num_per_query, + window_cnt, task_ctx.config.time_window_expr, task_ctx.config.expire_after, first_time_window, @@ -335,7 +442,7 @@ impl DirtyTimeWindows { "Flow id = {:?}, too many time windows: {}, only the first {} are taken for this query, the group by expression might be wrong. first_time_window={:?}, last_time_window={:?}", flow_id, self.windows.len(), - self.max_filter_num_per_query, + window_cnt, first_time_window, last_time_window ) @@ -590,6 +697,12 @@ enum ExecState { Executing, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckpointMode { + FullSnapshot, + Incremental, +} + /// Filter Expression's information #[derive(Debug, Clone)] pub struct FilterExprInfo { @@ -607,6 +720,28 @@ impl FilterExprInfo { acc + end.sub(start).unwrap_or(chrono::Duration::zero()) }) } + + pub fn predicate_for_col( + &self, + col_name: &str, + ) -> Result, Error> { + use datafusion_common::Column; + use datafusion_expr::{Expr, lit}; + + let mut expr_lst = Vec::with_capacity(self.time_ranges.len()); + for (start, end) in &self.time_ranges { + let lower = to_df_literal(*start)?; + let upper = to_df_literal(*end)?; + let filter_col = || Expr::Column(Column::new_unqualified(col_name)); + expr_lst.push( + filter_col() + .gt_eq(lit(lower)) + .and(filter_col().lt(lit(upper))), + ); + } + + Ok(expr_lst.into_iter().reduce(|a, b| a.or(b))) + } } #[cfg(test)] @@ -851,4 +986,370 @@ mod test { } } } + + #[test] + fn test_task_state_checkpoint_mode_and_advancement() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.checkpoints().is_empty()); + + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); + + state.mark_full_snapshot(); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); + } + + #[test] + fn test_disable_incremental_persists_full_snapshot_mode() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + + assert!(!state.is_incremental_disabled()); + + // After disable, mode becomes FullSnapshot and flag is set. + state.disable_incremental(); + assert!(state.is_incremental_disabled()); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + + // `advance_checkpoints` will NOT transition to Incremental when disabled. + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); + + // `mark_full_snapshot` does not re-enable incremental. + state.mark_full_snapshot(); + assert!(state.is_incremental_disabled()); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + } + + #[test] + fn test_full_snapshot_checkpoint_advancement_requires_participating_regions() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let state = TaskState::new(query_ctx, rx); + + assert!(!state.can_advance_full_snapshot_checkpoints(&BTreeSet::new(), &HashMap::new())); + assert!(!state.can_advance_full_snapshot_checkpoints( + &BTreeSet::from([1_u64, 2_u64]), + &HashMap::from([(1_u64, 10_u64)]), + )); + assert!(state.can_advance_full_snapshot_checkpoints( + &BTreeSet::from([1_u64, 2_u64]), + &HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]), + )); + } + + #[test] + fn test_incremental_checkpoint_advancement_requires_participation_alignment() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + + assert!( + state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64]), + &HashMap::from([(1_u64, 11_u64)]), + ) + ); + assert!( + !state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64, 2_u64]), + &HashMap::from([(1_u64, 11_u64)]), + ) + ); + assert!( + !state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([3_u64]), + &HashMap::from([(3_u64, 11_u64)]), + ) + ); + assert!( + !state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64]), + &HashMap::from([(1_u64, 9_u64)]), + ) + ); + assert!( + state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64, 2_u64]), + &HashMap::from([(1_u64, 11_u64), (2_u64, 21_u64)]), + ) + ); + + state.disable_incremental(); + assert!( + !state.can_advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64, 2_u64]), + &HashMap::from([(1_u64, 12_u64), (2_u64, 22_u64)]), + ) + ); + } + + #[test] + fn test_incremental_checkpoint_advancement_merges_participating_subset() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.advance_checkpoints(HashMap::from([ + (1_u64, 10_u64), + (2_u64, 20_u64), + (3_u64, 30_u64), + ])); + + state.advance_incremental_checkpoints_with_participation( + &BTreeSet::from([1_u64, 3_u64]), + HashMap::from([(1_u64, 12_u64), (3_u64, 35_u64)]), + ); + + assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 12_u64), (2_u64, 20_u64), (3_u64, 35_u64)]) + ); + } + + #[test] + fn test_filter_expr_info_predicate_for_col_empty_ranges() { + let filter = FilterExprInfo { + expr: datafusion_expr::col("ts"), + col_name: "ts".to_string(), + time_ranges: vec![], + window_size: chrono::Duration::seconds(1), + }; + + assert!(filter.predicate_for_col("time_window").unwrap().is_none()); + } + + #[test] + fn test_filter_expr_info_predicate_for_col_single_range() { + let filter = FilterExprInfo { + expr: datafusion_expr::col("ts"), + col_name: "ts".to_string(), + time_ranges: vec![(Timestamp::new_second(0), Timestamp::new_second(1))], + window_size: chrono::Duration::seconds(1), + }; + + let predicate = filter.predicate_for_col("time_window").unwrap().unwrap(); + let unparser = datafusion::sql::unparser::Unparser::default(); + assert_eq!( + "((time_window >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:01' AS TIMESTAMP)))", + unparser.expr_to_sql(&predicate).unwrap().to_string() + ); + } + + #[test] + fn test_filter_expr_info_predicate_for_col_multiple_ranges() { + let filter = FilterExprInfo { + expr: datafusion_expr::col("ts"), + col_name: "ts".to_string(), + time_ranges: vec![ + (Timestamp::new_second(0), Timestamp::new_second(1)), + (Timestamp::new_second(10), Timestamp::new_second(11)), + ], + window_size: chrono::Duration::seconds(1), + }; + + let predicate = filter.predicate_for_col("time_window").unwrap().unwrap(); + let unparser = datafusion::sql::unparser::Unparser::default(); + assert_eq!( + "(((time_window >= CAST('1970-01-01 00:00:00' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:01' AS TIMESTAMP))) OR ((time_window >= CAST('1970-01-01 00:00:10' AS TIMESTAMP)) AND (time_window < CAST('1970-01-01 00:00:11' AS TIMESTAMP))))", + unparser.expr_to_sql(&predicate).unwrap().to_string() + ); + } + + /// Helper: create a `TaskState` whose `last_update_time` is a known duration in the past. + fn state_with_past_update(age: Duration) -> TaskState { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.last_update_time = Instant::now() - age; + state + } + + #[test] + fn test_short_incremental_cadence_uses_min_refresh() { + // When prefer_short_incremental_cadence is true and dirty backlog is manageable, + // the next start time should be last_update_time + min_refresh (short cadence), + // ignoring the longer time_window_size. + let state = state_with_past_update(Duration::from_secs(10)); + + let time_window_size = Some(Duration::from_secs(60)); // large window + let min_refresh = Duration::from_secs(5); + let flow_id = 1; + + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 20, + true, // prefer_short_incremental_cadence + ); + + // With short cadence, result should be last_update_time + min_refresh. + let expected = state.last_update_time + min_refresh; + assert_eq!(result, expected); + } + + #[test] + fn test_short_incremental_cadence_respects_last_query_duration() { + let mut state = state_with_past_update(Duration::from_secs(10)); + state.last_query_duration = Duration::from_secs(20); + + let time_window_size = Some(Duration::from_secs(60)); + let min_refresh = Duration::from_secs(5); + let flow_id = 1; + + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 20, + true, + ); + + assert_eq!(result, state.last_update_time + state.last_query_duration); + } + + #[test] + fn test_short_incremental_cadence_respects_max_timeout() { + let mut state = state_with_past_update(Duration::from_secs(10)); + state.last_query_duration = Duration::from_secs(20); + + let time_window_size = Some(Duration::from_secs(60)); + let min_refresh = Duration::from_secs(30); + let max_timeout = Duration::from_secs(5); + let flow_id = 1; + + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + Some(max_timeout), + 20, + true, + ); + + assert_eq!(result, state.last_update_time + max_timeout); + } + + #[test] + fn test_full_snapshot_ignores_short_cadence() { + // When prefer_short_incremental_cadence is false (full snapshot mode), + // the normal long-cadence based on time_window_size applies. + let mut state = state_with_past_update(Duration::from_secs(10)); + // Make last_query_duration small so the lower bound (time_window_size) dominates. + state.last_query_duration = Duration::from_secs(1); + + let time_window_size = Some(Duration::from_secs(60)); // large window + let min_refresh = Duration::from_secs(5); + let flow_id = 1; + + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 20, + false, // prefer_short_incremental_cadence = false + ); + + // With normal cadence, result should be last_update_time + time_window_size + // (since last_query_duration < time_window_size). + let expected = state.last_update_time + Duration::from_secs(60); + assert_eq!(result, expected); + } + + #[test] + fn test_dirty_window_overflow_schedules_immediately_even_with_short_cadence() { + // Dirty-window overflow must always schedule immediately, + // regardless of prefer_short_incremental_cadence. + let mut state = state_with_past_update(Duration::from_secs(10)); + // Create a very large dirty backlog. + state + .dirty_time_windows + .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(3600))); + + let time_window_size = Some(Duration::from_secs(1)); // tiny window => overflow + let min_refresh = Duration::from_secs(5); + let flow_id = 1; + + // With short cadence flag. + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 1, // max 1 filter => tiny capacity + true, + ); + assert!( + result <= Instant::now(), + "dirty overflow should schedule immediately" + ); + + // Without short cadence flag — same behavior. + let result2 = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 1, + false, + ); + assert!( + result2 <= Instant::now(), + "dirty overflow should schedule immediately" + ); + } + + #[test] + fn test_incremental_disabled_ignores_short_cadence() { + // When prefer_short_incremental_cadence is true but the dirty backlog is + // manageable, the short cadence is applied. This test verifies that the + // caller-side guard (checkpoint_mode + !is_incremental_disabled) controls + // whether short cadence is requested at all — when incremental is disabled, + // the flag is false, and the long cadence applies. + // + // This simulates the case where the caller computed + // prefer_short_incremental_cadence = false (e.g. incremental disabled + // or FullSnapshot mode), so the long cadence is used. + let mut state = state_with_past_update(Duration::from_secs(10)); + state.last_query_duration = Duration::from_secs(1); + + let time_window_size = Some(Duration::from_secs(60)); + let min_refresh = Duration::from_secs(5); + let flow_id = 1; + + let result = state.get_next_start_query_time( + flow_id, + &time_window_size, + min_refresh, + None, + 20, + false, // prefer_short_incremental_cadence = false + ); + + // With normal cadence, result should be last_update_time + time_window_size. + let expected = state.last_update_time + Duration::from_secs(60); + assert_eq!(result, expected); + } } diff --git a/src/flow/src/batching_mode/task.rs b/src/flow/src/batching_mode/task.rs index 51a417c0d1..3cdf7899a6 100644 --- a/src/flow/src/batching_mode/task.rs +++ b/src/flow/src/batching_mode/task.rs @@ -30,6 +30,7 @@ use datafusion_common::tree_node::{Transformed, TreeNode}; use datafusion_expr::{DmlStatement, LogicalPlan, WriteOp}; use datatypes::schema::Schema; use query::QueryEngineRef; +use query::options::FLOW_INCREMENTAL_MODE; use query::query_engine::DefaultSerializer; use session::context::QueryContextRef; use snafu::{OptionExt, ResultExt}; @@ -42,8 +43,9 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio::time::Instant; use crate::batching_mode::BatchingModeOptions; -use crate::batching_mode::frontend_client::FrontendClient; -use crate::batching_mode::state::{DirtyTimeWindows, FilterExprInfo, TaskState}; +use crate::batching_mode::checkpoint::checkpoint_mode_label; +use crate::batching_mode::frontend_client::{FrontendClient, PeerDesc}; +use crate::batching_mode::state::{CheckpointMode, DirtyTimeWindows, FilterExprInfo, TaskState}; use crate::batching_mode::table_creator::{QueryType, create_table_with_expr}; use crate::batching_mode::time_window::TimeWindowExpr; use crate::batching_mode::utils::{ @@ -62,6 +64,15 @@ use crate::metrics::{ }; use crate::{Error, FlowId}; +mod ckpt; +mod inc; + +/// Maximum number of dirty time-window predicates attached to one incremental +/// SQL query. This keeps generated OR filters bounded so Substrait encoding and +/// downstream planning remain predictable; if the backlog is larger, the flow +/// drains one capped batch and postpones checkpoint advancement to a later run. +const MAX_INCREMENTAL_DIRTY_WINDOW_FILTERS: usize = 4096; + /// The task's config, immutable once created #[derive(Clone)] pub struct TaskConfig { @@ -123,6 +134,7 @@ pub struct TaskArgs<'a> { pub struct PlanInfo { pub plan: LogicalPlan, pub dirty_restore: DirtyRestore, + pub can_advance_checkpoints: bool, } pub enum DirtyRestore { @@ -247,8 +259,17 @@ impl BatchingTask { ) -> Result, Error> { if let Some(new_query) = self.gen_insert_plan(engine, max_window_cnt).await? { debug!("Generate new query: {}", new_query.plan); + let dirty_filter = match &new_query.dirty_restore { + DirtyRestore::Scoped(f) => Some(f), + _ => None, + }; match self - .execute_logical_plan(frontend_client, &new_query.plan) + .execute_logical_plan( + frontend_client, + &new_query.plan, + dirty_filter, + new_query.can_advance_checkpoints, + ) .await { Ok(result) => Ok(result), @@ -330,6 +351,7 @@ impl BatchingTask { let insert_into_info = PlanInfo { plan, dirty_restore: new_query.dirty_restore, + can_advance_checkpoints: new_query.can_advance_checkpoints, }; let insert_into = match insert_into_info @@ -349,6 +371,7 @@ impl BatchingTask { Ok(Some(PlanInfo { plan: insert_into, dirty_restore: insert_into_info.dirty_restore, + can_advance_checkpoints: insert_into_info.can_advance_checkpoints, })) } @@ -369,6 +392,8 @@ impl BatchingTask { &self, frontend_client: &Arc, plan: &LogicalPlan, + dirty_filter: Option<&FilterExprInfo>, + can_advance_checkpoints: bool, ) -> Result, Error> { let instant = Instant::now(); let flow_id = self.config.flow_id; @@ -398,8 +423,40 @@ impl BatchingTask { })? .data; - let mut peer_desc = None; + // For incremental-mode SQL queries, attempt to rewrite the delta aggregate + // plan into a safe delta-LEFT-JOIN-sink form before deciding on extensions. + let incremental_plan = if can_advance_checkpoints { + self.prepare_plan_for_incremental(&plan, dirty_filter) + .await? + } else { + None + }; + let incremental_safe = incremental_plan.is_some(); + let plan = incremental_plan.unwrap_or_else(|| plan.clone()); + let extensions = self + .build_flow_query_extensions(incremental_safe, can_advance_checkpoints) + .await?; + let extension_refs = extensions + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let query_mode = if extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_MODE) + { + CheckpointMode::Incremental + } else { + CheckpointMode::FullSnapshot + }; + Self::record_query_mode(flow_id, query_mode); + debug!( + "Flow {flow_id} executing batching query with checkpoint_mode={}, extension_count={}", + checkpoint_mode_label(query_mode), + extensions.len() + ); + + let mut peer_desc = None; let res = { let _timer = METRIC_FLOW_BATCHING_ENGINE_QUERY_TIME .with_label_values(&[flow_id.to_string().as_str()]) @@ -411,66 +468,83 @@ impl BatchingTask { let message = DFLogicalSubstraitConvertor {} .encode(&insert_plan, DefaultSerializer) .context(SubstraitEncodeLogicalPlanSnafu)?; - api::v1::greptime_request::Request::Query(api::v1::QueryRequest { + api::v1::QueryRequest { query: Some(api::v1::query_request::Query::InsertIntoPlan( api::v1::InsertIntoPlan { table_name: Some(insert_to), logical_plan: message.to_vec(), }, )), - }) + } } else { let message = DFLogicalSubstraitConvertor {} .encode(&plan, DefaultSerializer) .context(SubstraitEncodeLogicalPlanSnafu)?; - api::v1::greptime_request::Request::Query(api::v1::QueryRequest { + api::v1::QueryRequest { query: Some(api::v1::query_request::Query::LogicalPlan(message.to_vec())), - }) + } }; frontend_client - .handle(req, catalog, schema, &mut peer_desc) + .query_with_terminal_metrics(catalog, schema, req, &extension_refs, &mut peer_desc) .await }; let elapsed = instant.elapsed(); - if let Ok(affected_rows) = &res { - debug!( - "Flow {flow_id} executed, affected_rows: {affected_rows:?}, elapsed: {:?}", - elapsed - ); - METRIC_FLOW_ROWS - .with_label_values(&[format!("{}-out-batching", flow_id).as_str()]) - .inc_by(*affected_rows as _); - } else if let Err(err) = &res { + let peer_label = peer_desc + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| PeerDesc::default().to_string()); + if let Err(err) = &res { warn!( - "Failed to execute Flow {flow_id} on frontend {:?}, result: {err:?}, elapsed: {:?} with query: {}", - peer_desc, elapsed, &plan + "Failed to execute Flow {flow_id} on frontend {peer_label}, result: {err:?}, elapsed: {:?} with query: {}", + elapsed, &plan ); + let decision = { + let mut state = self.state.write().unwrap(); + let reason = Self::query_failure_reason(err); + Self::apply_query_failure_to_state(&mut state, elapsed, reason) + }; + if let Some(decision) = decision { + Self::record_checkpoint_decision(flow_id, decision); + } } // record slow query if elapsed >= self.config.batch_opts.slow_query_threshold { warn!( - "Flow {flow_id} on frontend {:?} executed for {:?} before complete, query: {}", - peer_desc, elapsed, &plan + "Flow {flow_id} on frontend {peer_label} executed for {:?} before complete, query: {}", + elapsed, &plan ); + let flow_id = flow_id.to_string(); METRIC_FLOW_BATCHING_ENGINE_SLOW_QUERY - .with_label_values(&[ - flow_id.to_string().as_str(), - &peer_desc.unwrap_or_default().to_string(), - ]) + .with_label_values(&[flow_id.as_str(), peer_label.as_str()]) .observe(elapsed.as_secs_f64()); } - self.state - .write() - .unwrap() - .after_query_exec(elapsed, res.is_ok()); - let res = res?; - Ok(Some((res as usize, elapsed))) + let (affected_rows, _) = res.output.extract_rows_and_cost(); + debug!( + "Flow {flow_id} executed, affected_rows: {affected_rows:?}, elapsed: {:?}, watermark: {:?}", + elapsed, + res.region_watermark_map() + ); + METRIC_FLOW_ROWS + .with_label_values(&[format!("{}-out-batching", flow_id).as_str()]) + .inc_by(affected_rows as _); + { + let mut state = self.state.write().unwrap(); + let decision = Self::apply_query_result_to_state( + &mut state, + &res, + elapsed, + can_advance_checkpoints, + ); + Self::record_checkpoint_decision(flow_id, decision); + } + + Ok(Some((affected_rows, elapsed))) } /// Restore dirty windows consumed by a failed query so they are retried on @@ -563,8 +637,17 @@ impl BatchingTask { }; let res = if let Some(new_query) = &new_query { - self.execute_logical_plan(&frontend_client, &new_query.plan) - .await + let dirty_filter = match &new_query.dirty_restore { + DirtyRestore::Scoped(f) => Some(f), + _ => None, + }; + self.execute_logical_plan( + &frontend_client, + &new_query.plan, + dirty_filter, + new_query.can_advance_checkpoints, + ) + .await } else { Ok(None) }; @@ -592,12 +675,17 @@ impl BatchingTask { .as_ref() .and_then(|t| *t.time_window_size()); + let prefer_short_incremental_cadence = state.checkpoint_mode() + == CheckpointMode::Incremental + && !state.is_incremental_disabled(); + state.get_next_start_query_time( self.config.flow_id, &time_window_size, min_refresh, Some(self.config.batch_opts.query_timeout), self.config.batch_opts.experimental_max_filter_num_per_query, + prefer_short_incremental_cadence, ) }; @@ -733,6 +821,7 @@ impl BatchingTask { return Ok(Some(PlanInfo { plan, dirty_restore: DirtyRestore::Unscoped(dirty_windows_to_restore), + can_advance_checkpoints: true, })); } _ => { @@ -769,6 +858,7 @@ impl BatchingTask { return Ok(Some(PlanInfo { plan, dirty_restore: DirtyRestore::Unscoped(dirty_windows_to_restore), + can_advance_checkpoints: true, })); } }; @@ -799,20 +889,33 @@ impl BatchingTask { ), })?; - let expr = self - .state - .write() - .unwrap() - .dirty_time_windows - .gen_filter_exprs( + let (expr, can_advance_checkpoints) = { + let mut state = self.state.write().unwrap(); + let window_cnt = if state.checkpoint_mode() == CheckpointMode::Incremental + && !state.is_incremental_disabled() + && matches!(self.config.query_type, QueryType::Sql) + { + // Incremental scans are bounded by region sequence checkpoints, + // so the dirty-window filter only narrows sink-side/time-window + // work. Drain more windows than normal, but keep a hard cap to + // avoid building a huge OR filter after a long downtime. If + // windows remain, checkpoints won't advance this round. + MAX_INCREMENTAL_DIRTY_WINDOW_FILTERS + } else { + max_window_cnt + .unwrap_or(self.config.batch_opts.experimental_max_filter_num_per_query) + }; + let expr = state.dirty_time_windows.gen_filter_exprs( &col_name, Some(expire_lower_bound), window_size, - max_window_cnt - .unwrap_or(self.config.batch_opts.experimental_max_filter_num_per_query), + window_cnt, self.config.flow_id, Some(self), )?; + let can_advance_checkpoints = state.dirty_time_windows.is_empty(); + (expr, can_advance_checkpoints) + }; let Some(expr) = expr else { // no new data, hence no need to update @@ -859,6 +962,7 @@ impl BatchingTask { let info = PlanInfo { plan: new_plan.clone(), dirty_restore: DirtyRestore::Scoped(expr), + can_advance_checkpoints, }; Ok(Some(info)) diff --git a/src/flow/src/batching_mode/task/ckpt.rs b/src/flow/src/batching_mode/task/ckpt.rs new file mode 100644 index 0000000000..035d30a079 --- /dev/null +++ b/src/flow/src/batching_mode/task/ckpt.rs @@ -0,0 +1,181 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::time::Duration; + +use client::OutputWithMetrics; +use common_error::ext::ErrorExt; +use common_error::status_code::StatusCode; +use common_telemetry::tracing::warn; +use common_telemetry::{debug, info}; + +use crate::batching_mode::checkpoint::{ + FlowCheckpointDecision, FlowQueryFallbackReason, checkpoint_mode_label, +}; +use crate::batching_mode::state::{CheckpointMode, TaskState}; +use crate::batching_mode::task::BatchingTask; +use crate::metrics::{ + METRIC_FLOW_BATCHING_ENGINE_CHECKPOINT_DECISION_CNT, METRIC_FLOW_BATCHING_ENGINE_QUERY_MODE_CNT, +}; +use crate::{Error, FlowId}; + +impl BatchingTask { + pub(super) fn query_failure_reason(err: &Error) -> FlowQueryFallbackReason { + if err.status_code() == StatusCode::RequestOutdated { + FlowQueryFallbackReason::StaleCursor + } else { + FlowQueryFallbackReason::IncrementalQueryFailure + } + } + + pub(super) fn apply_query_failure_to_state( + state: &mut TaskState, + elapsed: Duration, + reason: FlowQueryFallbackReason, + ) -> Option { + state.after_query_exec(elapsed, false); + let checkpoint_mode = state.checkpoint_mode(); + if checkpoint_mode == CheckpointMode::Incremental { + state.mark_full_snapshot(); + Some(FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: checkpoint_mode, + reason, + }) + } else { + None + } + } + + pub(super) fn apply_query_result_to_state( + state: &mut TaskState, + res: &OutputWithMetrics, + elapsed: Duration, + can_advance_checkpoints: bool, + ) -> FlowCheckpointDecision { + state.after_query_exec(elapsed, true); + let checkpoint_mode = state.checkpoint_mode(); + if !can_advance_checkpoints { + state.mark_full_snapshot(); + return FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: checkpoint_mode, + reason: FlowQueryFallbackReason::DirtyBacklogPending, + }; + } + + if let (Some(participating_regions), Some(watermark_map)) = + (res.participating_regions(), res.region_watermark_map()) + { + let can_advance = match checkpoint_mode { + CheckpointMode::FullSnapshot => state + .can_advance_full_snapshot_checkpoints(&participating_regions, &watermark_map), + CheckpointMode::Incremental => state + .can_advance_incremental_checkpoints_with_participation( + &participating_regions, + &watermark_map, + ), + }; + + if can_advance { + let participating_region_count = participating_regions.len(); + let watermark_count = watermark_map.len(); + match checkpoint_mode { + CheckpointMode::FullSnapshot => { + state.advance_checkpoints(watermark_map); + if state.is_incremental_disabled() { + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::FullSnapshot, + reason: FlowQueryFallbackReason::IncrementalDisabled, + } + } else { + FlowCheckpointDecision::AdvancedFromFullSnapshot { + participating_regions: participating_region_count, + watermarks: watermark_count, + } + } + } + CheckpointMode::Incremental => { + state.advance_incremental_checkpoints_with_participation( + &participating_regions, + watermark_map, + ); + FlowCheckpointDecision::AdvancedIncremental { + participating_regions: participating_region_count, + watermarks: watermark_count, + } + } + } + } else { + state.mark_full_snapshot(); + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: checkpoint_mode, + reason: FlowQueryFallbackReason::IncompleteRegionWatermark, + } + } + } else { + state.mark_full_snapshot(); + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: checkpoint_mode, + reason: FlowQueryFallbackReason::MissingRegionWatermark, + } + } + } + + pub(super) fn record_checkpoint_decision(flow_id: FlowId, decision: FlowCheckpointDecision) { + let flow_id = flow_id.to_string(); + METRIC_FLOW_BATCHING_ENGINE_CHECKPOINT_DECISION_CNT + .with_label_values(&[ + flow_id.as_str(), + decision.mode_label(), + decision.decision_label(), + decision.reason_label(), + ]) + .inc(); + + match decision { + FlowCheckpointDecision::AdvancedFromFullSnapshot { + participating_regions, + watermarks, + } => { + info!( + "Flow {flow_id} switched to incremental mode after full snapshot, participating_regions={participating_regions}, watermarks={watermarks}" + ); + } + FlowCheckpointDecision::AdvancedIncremental { + participating_regions, + watermarks, + } => { + debug!( + "Flow {flow_id} advanced incremental checkpoints, participating_regions={participating_regions}, watermarks={watermarks}" + ); + } + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode, + reason, + } => { + warn!( + "Flow {flow_id} switched to full snapshot mode, previous_mode={}, reason={}", + checkpoint_mode_label(previous_mode), + reason.as_label() + ); + } + } + } + + pub(super) fn record_query_mode(flow_id: FlowId, mode: CheckpointMode) { + let flow_id = flow_id.to_string(); + METRIC_FLOW_BATCHING_ENGINE_QUERY_MODE_CNT + .with_label_values(&[flow_id.as_str(), checkpoint_mode_label(mode)]) + .inc(); + } +} diff --git a/src/flow/src/batching_mode/task/inc.rs b/src/flow/src/batching_mode/task/inc.rs new file mode 100644 index 0000000000..4fb64a676e --- /dev/null +++ b/src/flow/src/batching_mode/task/inc.rs @@ -0,0 +1,252 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use common_error::ext::BoxedError; +use common_telemetry::debug; +use common_telemetry::tracing::warn; +use datafusion_expr::{DmlStatement, LogicalPlan}; +use query::options::{ + FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY, + FLOW_SINK_TABLE_ID, +}; +use snafu::ResultExt; +use table::metadata::TableId; + +use crate::Error; +use crate::batching_mode::incremental_filter::build_sink_dirty_time_window_filter_expr; +use crate::batching_mode::state::{CheckpointMode, FilterExprInfo}; +use crate::batching_mode::table_creator::QueryType; +use crate::batching_mode::task::BatchingTask; +use crate::batching_mode::utils::{ + analyze_incremental_aggregate_plan, get_table_info_df_schema, + rewrite_incremental_aggregate_with_sink_merge, +}; +use crate::error::{ExternalSnafu, UnexpectedSnafu}; + +impl BatchingTask { + async fn sink_table_id(&self) -> Result { + let table = self + .config + .catalog_manager + .table( + &self.config.sink_table_name[0], + &self.config.sink_table_name[1], + &self.config.sink_table_name[2], + None, + ) + .await + .map_err(BoxedError::new) + .context(ExternalSnafu)? + .ok_or_else(|| { + UnexpectedSnafu { + reason: format!( + "Flow {} cannot build incremental extensions because sink table {:?} was not found", + self.config.flow_id, self.config.sink_table_name + ), + } + .build() + })?; + Ok(table.table_info().table_id()) + } + + /// For incremental-mode SQL queries, attempt to prepare an executable plan + /// that is safe for incremental scan extensions. + /// + /// Returns `Some(plan)` when incremental extensions are safe, and `None` + /// when the caller should execute the original plan without incremental + /// extensions. The returned plan may be either a rewritten + /// delta-LEFT-JOIN-sink merge plan or the original plan. In particular, + /// plain GROUP BY queries with no aggregate merge columns are incremental + /// safe without a rewrite, so they return `Some(original_plan)`. + pub(super) async fn prepare_plan_for_incremental( + &self, + plan: &LogicalPlan, + dirty_filter: Option<&FilterExprInfo>, + ) -> Result, Error> { + let is_incremental_sql = { + let state = self.state.read().unwrap(); + if state.is_incremental_disabled() { + return Ok(None); + } + state.checkpoint_mode() == CheckpointMode::Incremental + && matches!(self.config.query_type, QueryType::Sql) + }; + + if !is_incremental_sql { + return Ok(None); + } + + // Extract inner query plan from the DML wrapper. + // Non-DML or non-SQL plans bypass the rewrite and keep checkpoint mode; + // non-aggregate TQL or non-INSERT plans do not need incremental scan extensions. + let inner_plan = match plan { + LogicalPlan::Dml(dml) => dml.input.as_ref().clone(), + _ => return Ok(None), + }; + + // Analyze the plan for incremental rewritability. + // Incremental reads currently require aggregate / group-by plans that + // can be rewritten into a delta-left-join-sink merge. Non-aggregate SQL + // (projection, filter, or other non-aggregate shapes) stays full-snapshot + // until separately supported, and incremental mode is permanently + // disabled for this flow. + let Some(analysis) = analyze_incremental_aggregate_plan(&inner_plan)? else { + warn!( + "Flow {} incremental mode but plan is not an aggregate query; \ + permanently disabling incremental for this flow", + self.config.flow_id + ); + self.state.write().unwrap().disable_incremental(); + return Ok(None); + }; + + if !analysis.unsupported_exprs.is_empty() { + warn!( + "Flow {} incremental aggregate contains unsupported expressions {:?}; \ + permanently disabling incremental for this flow", + self.config.flow_id, analysis.unsupported_exprs + ); + self.state.write().unwrap().disable_incremental(); + return Ok(None); + } + + // Plain GROUP BY without aggregate expressions has no values to + // merge between delta and sink. The incremental delta scan emits + // changed groups, and sink primary-key write semantics make this + // idempotent; no explicit left-join rewrite is needed. + if analysis.merge_columns.is_empty() { + return Ok(Some(plan.clone())); + } + + // Fetch sink table for the merge rewrite. + // Transient errors (catalog, schema, filter, or rewrite) should not + // permanently disable incremental mode. Instead, we fall back to a + // full-snapshot plan for this round while keeping incremental retryable. + let sink_table = match get_table_info_df_schema( + self.config.catalog_manager.clone(), + self.config.sink_table_name.clone(), + ) + .await + { + Ok((table, _)) => table, + Err(err) => { + warn!( + "Flow {} failed to fetch sink table for incremental rewrite; \ + falling back to full snapshot for this round: {:?}", + self.config.flow_id, err + ); + self.state.write().unwrap().mark_full_snapshot(); + return Ok(None); + } + }; + let sink_schema = sink_table.table_info().meta.schema.clone(); + let sink_dirty_filter = match build_sink_dirty_time_window_filter_expr( + self.config.flow_id, + &analysis, + &sink_schema, + dirty_filter, + ) { + Ok(filter) => filter, + Err(err) => { + warn!( + "Flow {} failed to build sink dirty time window filter; \ + falling back to full snapshot for this round: {:?}", + self.config.flow_id, err + ); + self.state.write().unwrap().mark_full_snapshot(); + return Ok(None); + } + }; + + let rewritten_inner = match rewrite_incremental_aggregate_with_sink_merge( + &inner_plan, + &analysis, + sink_table, + &self.config.sink_table_name, + sink_dirty_filter, + ) + .await + { + Ok(plan) => plan, + Err(err) => { + warn!( + "Flow {} failed to rewrite incremental aggregate with sink merge; \ + falling back to full snapshot for this round: {:?}", + self.config.flow_id, err + ); + self.state.write().unwrap().mark_full_snapshot(); + return Ok(None); + } + }; + + // Reconstruct DML plan with the rewritten inner plan + let rewritten = match plan { + LogicalPlan::Dml(dml) => LogicalPlan::Dml(DmlStatement::new( + dml.table_name.clone(), + dml.target.clone(), + dml.op.clone(), + Arc::new(rewritten_inner), + )), + _ => unreachable!("already matched Dml above"), + }; + + debug!( + "Flow {} rewrote incremental SQL aggregate query with sink merge", + self.config.flow_id + ); + + Ok(Some(rewritten)) + } + + pub(super) async fn build_flow_query_extensions( + &self, + incremental_safe: bool, + can_advance_checkpoints: bool, + ) -> Result, Error> { + let mut extensions = vec![("flow.return_region_seq", "true".to_string())]; + + let incremental_checkpoints_json = { + let state = self.state.read().unwrap(); + if incremental_safe + && can_advance_checkpoints + && !state.is_incremental_disabled() + && state.checkpoint_mode() == CheckpointMode::Incremental + && !state.checkpoints().is_empty() + { + Some(serde_json::to_string(state.checkpoints()).map_err(|err| { + UnexpectedSnafu { + reason: format!("Failed to serialize checkpoint map: {err}"), + } + .build() + })?) + } else { + None + } + }; + + if let Some(checkpoints_json) = incremental_checkpoints_json { + let sink_table_id = self.sink_table_id().await?; + extensions.push((FLOW_SINK_TABLE_ID, sink_table_id.to_string())); + extensions.push(( + FLOW_INCREMENTAL_MODE, + FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string(), + )); + extensions.push((FLOW_INCREMENTAL_AFTER_SEQS, checkpoints_json)); + } + + Ok(extensions) + } +} diff --git a/src/flow/src/batching_mode/task/test.rs b/src/flow/src/batching_mode/task/test.rs index 55a0a3057f..959aeb00c9 100644 --- a/src/flow/src/batching_mode/task/test.rs +++ b/src/flow/src/batching_mode/task/test.rs @@ -12,18 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::BTreeMap; + use catalog::RegisterTableRequest; use catalog::memory::MemoryCatalogManager; +use client::OutputWithMetrics; use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME}; +use common_query::Output; use common_recordbatch::RecordBatch; +use common_recordbatch::adapter::{RecordBatchMetrics, RegionWatermarkEntry}; use datatypes::data_type::ConcreteDataType as CDT; use datatypes::schema::ColumnSchema; -use datatypes::vectors::{UInt32Vector, VectorRef}; +use datatypes::vectors::{TimestampMillisecondVector, UInt32Vector, VectorRef}; use pretty_assertions::assert_eq; +use query::options::{FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY}; use session::context::QueryContext; use table::test_util::MemTable; use super::*; +use crate::batching_mode::checkpoint::{ + CHECKPOINT_DECISION_ADVANCE, CHECKPOINT_DECISION_FALLBACK, CHECKPOINT_REASON_NONE, + FlowCheckpointDecision, FlowQueryFallbackReason, +}; +use crate::batching_mode::state::CheckpointMode; use crate::batching_mode::time_window::find_time_window_expr; use crate::test_utils::create_test_query_engine; @@ -172,6 +183,34 @@ fn register_number_only_sink(query_engine: &QueryEngineRef, table_name: &str) { memory_catalog.register_table_sync(request).unwrap(); } +fn register_auto_created_aggregate_sink(query_engine: &QueryEngineRef, table_name: &str) { + let schema = Arc::new(Schema::new(vec![ + ColumnSchema::new("number", CDT::uint32_datatype(), true), + ColumnSchema::new("ts", CDT::timestamp_millisecond_datatype(), false).with_time_index(true), + ColumnSchema::new("update_at", CDT::timestamp_millisecond_datatype(), true), + ])); + let columns: Vec = vec![ + Arc::new(UInt32Vector::from_slice([1_u32])), + Arc::new(TimestampMillisecondVector::from_slice([0_i64])), + Arc::new(TimestampMillisecondVector::from_slice([0_i64])), + ]; + let recordbatch = RecordBatch::new(schema, columns).unwrap(); + let table = MemTable::table(table_name, recordbatch); + let request = RegisterTableRequest { + catalog: DEFAULT_CATALOG_NAME.to_string(), + schema: DEFAULT_SCHEMA_NAME.to_string(), + table_name: table_name.to_string(), + table_id: 9002, + table, + }; + let catalog_manager = query_engine.engine_state().catalog_manager(); + let memory_catalog = catalog_manager + .as_any() + .downcast_ref::() + .unwrap(); + memory_catalog.register_table_sync(request).unwrap(); +} + fn dirty_marker() -> DirtyTimeWindows { let mut dirty = DirtyTimeWindows::default(); dirty.set_dirty(); @@ -204,6 +243,7 @@ async fn assert_unscoped_failure_restore( let unscoped_query = PlanInfo { plan, dirty_restore: DirtyRestore::Unscoped(consumed_dirty_windows), + can_advance_checkpoints: true, }; task.handle_executed_query_failure(Some(&unscoped_query)); @@ -216,6 +256,442 @@ async fn assert_unscoped_failure_restore( ); } +fn output_with_region_watermarks( + watermarks: impl IntoIterator)>, +) -> OutputWithMetrics { + let result = OutputWithMetrics::from_output(Output::new_with_affected_rows(0)); + result.metrics.update(Some(RecordBatchMetrics { + region_watermarks: watermarks + .into_iter() + .map(|(region_id, watermark)| RegionWatermarkEntry { + region_id, + watermark, + }) + .collect(), + ..Default::default() + })); + result.metrics.mark_ready(); + result +} + +#[test] +fn test_apply_query_result_to_state_advances_full_snapshot_to_incremental() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + let result = output_with_region_watermarks([(1_u64, Some(10_u64)), (2_u64, Some(20_u64))]); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + true, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::AdvancedFromFullSnapshot { + participating_regions: 2, + watermarks: 2, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); +} + +#[test] +fn test_apply_query_result_to_state_stays_full_snapshot_when_incremental_disabled() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.disable_incremental(); + assert!(state.is_incremental_disabled()); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + + let result = output_with_region_watermarks([(1_u64, Some(10_u64)), (2_u64, Some(20_u64))]); + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + true, + ); + + // Should NOT claim advancement to incremental; should fallback with correct reason. + assert_eq!( + decision, + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::FullSnapshot, + reason: FlowQueryFallbackReason::IncrementalDisabled, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.is_incremental_disabled()); + // Checkpoints are still updated even if mode doesn't advance. + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); +} + +#[test] +fn test_apply_query_result_to_state_rejects_unproved_watermark() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + let result = output_with_region_watermarks([(1_u64, Some(10_u64)), (2_u64, None)]); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + true, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::FullSnapshot, + reason: FlowQueryFallbackReason::IncompleteRegionWatermark, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.checkpoints().is_empty()); +} + +#[test] +fn test_apply_query_result_to_state_reports_missing_watermark() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + let result = OutputWithMetrics::from_output(Output::new_with_affected_rows(0)); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + true, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::FullSnapshot, + reason: FlowQueryFallbackReason::MissingRegionWatermark, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.checkpoints().is_empty()); +} + +#[test] +fn test_apply_query_result_to_state_advances_incremental_subset() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.advance_checkpoints(HashMap::from([ + (1_u64, 10_u64), + (2_u64, 20_u64), + (3_u64, 30_u64), + ])); + let result = output_with_region_watermarks([(1_u64, Some(12_u64)), (3_u64, Some(35_u64))]); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + true, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::AdvancedIncremental { + participating_regions: 2, + watermarks: 2, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 12_u64), (2_u64, 20_u64), (3_u64, 35_u64)]) + ); +} + +#[test] +fn test_apply_query_result_to_state_blocks_full_snapshot_when_dirty_backlog_pending() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + let result = output_with_region_watermarks([(1_u64, Some(10_u64)), (2_u64, Some(20_u64))]); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + false, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::FullSnapshot, + reason: FlowQueryFallbackReason::DirtyBacklogPending, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.checkpoints().is_empty()); +} + +#[test] +fn test_apply_query_result_to_state_blocks_incremental_when_dirty_backlog_pending() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + let result = output_with_region_watermarks([(1_u64, Some(12_u64)), (2_u64, Some(25_u64))]); + + let decision = BatchingTask::apply_query_result_to_state( + &mut state, + &result, + std::time::Duration::from_millis(1), + false, + ); + + assert_eq!( + decision, + FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::Incremental, + reason: FlowQueryFallbackReason::DirtyBacklogPending, + } + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); +} + +#[test] +fn test_apply_query_failure_to_state_falls_back_from_incremental() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); + + let decision = BatchingTask::apply_query_failure_to_state( + &mut state, + std::time::Duration::from_millis(1), + FlowQueryFallbackReason::IncrementalQueryFailure, + ); + + assert_eq!( + decision, + Some(FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::Incremental, + reason: FlowQueryFallbackReason::IncrementalQueryFailure, + }) + ); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert_eq!( + state.checkpoints(), + &BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]) + ); +} + +#[test] +fn test_apply_query_failure_to_state_keeps_full_snapshot_without_decision() { + let query_ctx = QueryContext::arc(); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut state = TaskState::new(query_ctx, rx); + + let decision = BatchingTask::apply_query_failure_to_state( + &mut state, + std::time::Duration::from_millis(1), + FlowQueryFallbackReason::StaleCursor, + ); + + assert_eq!(decision, None); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); + assert!(state.checkpoints().is_empty()); +} + +#[test] +fn test_checkpoint_decision_labels_are_stable() { + let advance = FlowCheckpointDecision::AdvancedIncremental { + participating_regions: 1, + watermarks: 1, + }; + let fallback = FlowCheckpointDecision::FallbackToFullSnapshot { + previous_mode: CheckpointMode::Incremental, + reason: FlowQueryFallbackReason::StaleCursor, + }; + + assert_eq!(advance.mode_label(), "incremental"); + assert_eq!(advance.decision_label(), CHECKPOINT_DECISION_ADVANCE); + assert_eq!(advance.reason_label(), CHECKPOINT_REASON_NONE); + assert_eq!(fallback.mode_label(), "incremental"); + assert_eq!(fallback.decision_label(), CHECKPOINT_DECISION_FALLBACK); + assert_eq!(fallback.reason_label(), "stale_cursor"); + assert_eq!( + FlowQueryFallbackReason::DirtyBacklogPending.as_label(), + "dirty_backlog_pending" + ); +} + +#[tokio::test] +async fn test_build_flow_query_extensions_switches_with_checkpoint_mode() { + let (task, _) = new_test_task_engine_and_plan_with_query( + "SELECT number, ts FROM numbers_with_ts", + "numbers_with_ts", + ) + .await + .into_task_and_plan(); + + let extensions = task.build_flow_query_extensions(false, true).await.unwrap(); + assert_eq!( + extensions, + vec![("flow.return_region_seq", "true".to_string())] + ); + + task.state + .write() + .unwrap() + .advance_checkpoints(HashMap::from([(1_u64, 10_u64), (2_u64, 20_u64)])); + + let extensions = task.build_flow_query_extensions(false, true).await.unwrap(); + assert!(extensions.contains(&("flow.return_region_seq", "true".to_string()))); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_MODE) + ); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_AFTER_SEQS) + ); + + let extensions = task.build_flow_query_extensions(true, true).await.unwrap(); + + assert!(extensions.contains(&("flow.return_region_seq", "true".to_string()))); + assert!(extensions.contains(&( + FLOW_INCREMENTAL_MODE, + FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string() + ))); + assert!(extensions.contains(&( + FLOW_INCREMENTAL_AFTER_SEQS, + serde_json::json!({"1": 10, "2": 20}).to_string(), + ))); + + let extensions = task.build_flow_query_extensions(true, false).await.unwrap(); + assert!(extensions.contains(&("flow.return_region_seq", "true".to_string()))); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_MODE) + ); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_AFTER_SEQS) + ); + + task.state.write().unwrap().disable_incremental(); + let extensions = task.build_flow_query_extensions(true, true).await.unwrap(); + assert!(extensions.contains(&("flow.return_region_seq", "true".to_string()))); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_MODE) + ); + assert!( + !extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_AFTER_SEQS) + ); +} + +#[tokio::test] +async fn test_full_snapshot_scoped_plan_marks_checkpoint_advance_safe_only_after_backlog_drained() { + let TestTaskParts { + task, + query_engine, + .. + } = new_time_window_test_task_with_query( + "SELECT number, date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window, number", + ) + .await; + { + let mut state = task.state.write().unwrap(); + state + .dirty_time_windows + .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(5))); + state + .dirty_time_windows + .add_window(Timestamp::new_second(30), Some(Timestamp::new_second(35))); + } + let sink_schema = Arc::new(Schema::new(vec![ + ColumnSchema::new("number", CDT::uint32_datatype(), false), + ColumnSchema::new("time_window", CDT::timestamp_millisecond_datatype(), false) + .with_time_index(true), + ])); + + let first = task + .gen_query_with_time_window(query_engine.clone(), &sink_schema, &[], false, Some(1)) + .await + .unwrap() + .unwrap(); + assert!(!first.can_advance_checkpoints); + assert_eq!(task.state.read().unwrap().dirty_time_windows.len(), 1); + + let second = task + .gen_query_with_time_window(query_engine, &sink_schema, &[], false, Some(1)) + .await + .unwrap() + .unwrap(); + assert!(second.can_advance_checkpoints); + assert!(task.state.read().unwrap().dirty_time_windows.is_empty()); +} + +#[tokio::test] +async fn test_incremental_scoped_plan_consumes_all_dirty_windows_for_checkpoint_safety() { + let TestTaskParts { + task, + query_engine, + .. + } = new_time_window_test_task_with_query( + "SELECT number, date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window, number", + ) + .await; + { + let mut state = task.state.write().unwrap(); + state.advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); + state + .dirty_time_windows + .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(5))); + state + .dirty_time_windows + .add_window(Timestamp::new_second(30), Some(Timestamp::new_second(35))); + } + let sink_schema = Arc::new(Schema::new(vec![ + ColumnSchema::new("number", CDT::uint32_datatype(), false), + ColumnSchema::new("time_window", CDT::timestamp_millisecond_datatype(), false) + .with_time_index(true), + ])); + + let plan = task + .gen_query_with_time_window(query_engine, &sink_schema, &[], false, Some(1)) + .await + .unwrap() + .unwrap(); + + assert!(plan.can_advance_checkpoints); + assert!(task.state.read().unwrap().dirty_time_windows.is_empty()); +} + #[tokio::test] async fn test_executed_query_failure_restores_scoped_dirty_windows_for_flush_path() { let (task, plan) = new_test_task_and_plan_with_missing_sink().await; @@ -231,12 +707,293 @@ async fn test_executed_query_failure_restores_scoped_dirty_windows_for_flush_pat time_ranges: vec![(Timestamp::new_second(10), Timestamp::new_second(20))], window_size: chrono::Duration::seconds(10), }), + can_advance_checkpoints: true, }; task.handle_executed_query_failure(Some(&scoped_query)); let state = task.state.read().unwrap(); assert_eq!(state.dirty_time_windows.len(), 1); + assert_eq!( + state.dirty_time_windows.window_size(), + std::time::Duration::from_secs(10) + ); +} + +#[tokio::test] +async fn test_prepare_plan_for_incremental_disables_on_non_aggregate() { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan = sql_to_df_plan( + ctx.clone(), + query_engine.clone(), + "SELECT number, ts FROM numbers_with_ts", + true, + ) + .await + .unwrap(); + + // Build a DML wrapper using a real sink table from the test engine. + let (sink_table, _) = get_table_info_df_schema( + query_engine.engine_state().catalog_manager().clone(), + [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + ) + .await + .unwrap(); + let table_provider = Arc::new(DfTableProviderAdapter::new(sink_table)); + let table_source = Arc::new(DefaultTableSource::new(table_provider)); + let dml_plan = LogicalPlan::Dml(DmlStatement::new( + datafusion_common::TableReference::bare("test"), + table_source, + WriteOp::Insert(datafusion_expr::dml::InsertOp::Append), + Arc::new(plan), + )); + + let (_tx, rx) = tokio::sync::oneshot::channel(); + let task = BatchingTask::try_new(TaskArgs { + flow_id: 1, + query: "SELECT number, ts FROM numbers_with_ts", + plan: dml_plan.clone(), + time_window_expr: None, + expire_after: None, + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + // Put the state into Incremental mode with checkpoints. + task.state + .write() + .unwrap() + .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); + assert_eq!( + task.state.read().unwrap().checkpoint_mode(), + CheckpointMode::Incremental + ); + + let incremental_plan = task + .prepare_plan_for_incremental(&dml_plan, None) + .await + .unwrap(); + assert!(incremental_plan.is_none()); + let state = task.state.read().unwrap(); + assert!(state.is_incremental_disabled()); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); +} + +#[tokio::test] +async fn test_prepare_plan_for_incremental_falls_back_without_disable_on_rewrite_error() { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan = sql_to_df_plan( + ctx.clone(), + query_engine.clone(), + "SELECT sum(number) AS total, ts FROM numbers_with_ts GROUP BY ts", + true, + ) + .await + .unwrap(); + + let (sink_table, _) = get_table_info_df_schema( + query_engine.engine_state().catalog_manager().clone(), + [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + ) + .await + .unwrap(); + let table_provider = Arc::new(DfTableProviderAdapter::new(sink_table)); + let table_source = Arc::new(DefaultTableSource::new(table_provider)); + let dml_plan = LogicalPlan::Dml(DmlStatement::new( + datafusion_common::TableReference::bare("test"), + table_source, + WriteOp::Insert(datafusion_expr::dml::InsertOp::Append), + Arc::new(plan), + )); + + let (_tx, rx) = tokio::sync::oneshot::channel(); + let task = BatchingTask::try_new(TaskArgs { + flow_id: 1, + query: "SELECT sum(number) AS total, ts FROM numbers_with_ts GROUP BY ts", + plan: dml_plan.clone(), + time_window_expr: None, + expire_after: None, + // The sink table exists, but does not have the rewritten aggregate + // output column `total`, so the rewrite fails deterministically. + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + task.state + .write() + .unwrap() + .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); + assert_eq!( + task.state.read().unwrap().checkpoint_mode(), + CheckpointMode::Incremental + ); + + let incremental_plan = task + .prepare_plan_for_incremental(&dml_plan, None) + .await + .unwrap(); + assert!(incremental_plan.is_none()); + let state = task.state.read().unwrap(); + assert!(!state.is_incremental_disabled()); + assert_eq!(state.checkpoint_mode(), CheckpointMode::FullSnapshot); +} + +#[tokio::test] +async fn test_prepare_plan_for_incremental_group_by_without_merge_columns_uses_original_plan() { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let plan = sql_to_df_plan( + ctx.clone(), + query_engine.clone(), + "SELECT ts FROM numbers_with_ts GROUP BY ts", + true, + ) + .await + .unwrap(); + + let (sink_table, _) = get_table_info_df_schema( + query_engine.engine_state().catalog_manager().clone(), + [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + ) + .await + .unwrap(); + let table_provider = Arc::new(DfTableProviderAdapter::new(sink_table)); + let table_source = Arc::new(DefaultTableSource::new(table_provider)); + let dml_plan = LogicalPlan::Dml(DmlStatement::new( + datafusion_common::TableReference::bare("test"), + table_source, + WriteOp::Insert(datafusion_expr::dml::InsertOp::Append), + Arc::new(plan), + )); + + let (_tx, rx) = tokio::sync::oneshot::channel(); + let task = BatchingTask::try_new(TaskArgs { + flow_id: 1, + query: "SELECT ts FROM numbers_with_ts GROUP BY ts", + plan: dml_plan.clone(), + time_window_expr: None, + expire_after: None, + sink_table_name: [ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ], + source_table_names: vec![[ + "greptime".to_string(), + "public".to_string(), + "numbers_with_ts".to_string(), + ]], + query_ctx: ctx, + catalog_manager: query_engine.engine_state().catalog_manager().clone(), + shutdown_rx: rx, + batch_opts: Arc::new(BatchingModeOptions::default()), + flow_eval_interval: None, + }) + .unwrap(); + + task.state + .write() + .unwrap() + .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); + + let incremental_plan = task + .prepare_plan_for_incremental(&dml_plan, None) + .await + .unwrap() + .expect("plain GROUP BY is incremental-safe without a rewrite"); + + assert_eq!(format!("{incremental_plan}"), format!("{dml_plan}")); + assert!(!task.state.read().unwrap().is_incremental_disabled()); +} + +#[tokio::test] +async fn test_auto_created_sql_aggregate_sink_reaches_incremental_safe() { + let sink_table = "auto_created_aggregate_sink"; + let TestTaskParts { + task, query_engine, .. + } = new_test_task_engine_and_plan_with_query( + "SELECT max(number) AS number, ts FROM numbers_with_ts GROUP BY ts", + sink_table, + ) + .await; + register_auto_created_aggregate_sink(&query_engine, sink_table); + task.state.write().unwrap().dirty_time_windows.set_dirty(); + + let plan_info = task + .gen_insert_plan(&query_engine, None) + .await + .unwrap() + .unwrap(); + assert!(plan_info.can_advance_checkpoints); + + task.state + .write() + .unwrap() + .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); + let incremental_plan = task + .prepare_plan_for_incremental(&plan_info.plan, None) + .await + .unwrap(); + let incremental_safe = incremental_plan.is_some(); + + assert!(incremental_safe); + assert!(!task.state.read().unwrap().is_incremental_disabled()); + + let extensions = task + .build_flow_query_extensions(incremental_safe, plan_info.can_advance_checkpoints) + .await + .unwrap(); + assert!(extensions.contains(&( + FLOW_INCREMENTAL_MODE, + FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY.to_string() + ))); + assert!( + extensions + .iter() + .any(|(key, _)| *key == FLOW_INCREMENTAL_AFTER_SEQS) + ); } #[tokio::test] diff --git a/src/flow/src/batching_mode/utils.rs b/src/flow/src/batching_mode/utils.rs index 7b066388ec..e86b1ee3be 100644 --- a/src/flow/src/batching_mode/utils.rs +++ b/src/flow/src/batching_mode/utils.rs @@ -278,7 +278,7 @@ fn collect_output_projection_info(plan: &LogicalPlan) -> OutputProjectionInfo { let mut col_names = Vec::new(); find_column_names(&alias.expr, &mut col_names); match col_names.len() { - 0 if matches!(alias.expr.as_ref(), Expr::Literal(_, _)) => { + 0 if is_passthrough_output_column(&alias_name, alias.expr.as_ref()) => { projection_info.literal_columns.insert(alias_name); } 1 => { @@ -315,10 +315,38 @@ fn collect_output_projection_info(plan: &LogicalPlan) -> OutputProjectionInfo { } } + if projection_info + .output_field_names + .iter() + .any(|name| name == AUTO_CREATED_PLACEHOLDER_TS_COL) + { + projection_info + .literal_columns + .insert(AUTO_CREATED_PLACEHOLDER_TS_COL.to_string()); + } + projection_info.output_aliases = output_aliases; projection_info } +fn is_passthrough_output_column(alias_name: &str, expr: &Expr) -> bool { + matches!(expr, Expr::Literal(_, _)) + || match alias_name { + AUTO_CREATED_UPDATE_AT_TS_COL => expr == &datafusion::prelude::now(), + AUTO_CREATED_PLACEHOLDER_TS_COL => is_literal_or_cast_literal(expr), + _ => false, + } +} + +fn is_literal_or_cast_literal(expr: &Expr) -> bool { + match expr { + Expr::Literal(_, _) => true, + Expr::Cast(cast) => is_literal_or_cast_literal(cast.expr.as_ref()), + Expr::TryCast(cast) => is_literal_or_cast_literal(cast.expr.as_ref()), + _ => false, + } +} + fn merge_op_for_aggregate_expr(aggr_expr: &Expr) -> Result { let Some(aggr_func) = get_aggr_func(aggr_expr) else { return Err(aggr_expr.to_string()); @@ -385,6 +413,11 @@ fn find_uncovered_output_fields( !group_key_names.contains(*name) && !merge_column_names.contains(*name) && !projection_info.literal_columns.contains(*name) + // Auto-created sink columns injected by ColumnMatcherRewriter + // are not part of the original aggregate semantics and must + // not prevent incremental aggregate rewrites. + && name.as_str() != AUTO_CREATED_UPDATE_AT_TS_COL + && name.as_str() != AUTO_CREATED_PLACEHOLDER_TS_COL }) .cloned() .collect() @@ -536,7 +569,8 @@ pub fn analyze_incremental_aggregate_plan( /// /// ```text /// delta = SELECT ts, number FROM AS __flow_delta -/// sink = SELECT ts, number FROM AS __flow_sink +/// sink_scan = SELECT * FROM [WHERE ] +/// sink = SELECT ts, number FROM sink_scan AS __flow_sink /// SELECT /// CASE /// WHEN __flow_sink.number IS NULL THEN __flow_delta.number @@ -548,11 +582,17 @@ pub fn analyze_incremental_aggregate_plan( /// LEFT JOIN sink /// ON __flow_delta.ts IS NOT DISTINCT FROM __flow_sink.ts /// ``` +/// +/// If `sink_dirty_filter` is provided, it is applied to the sink table scan +/// before projection, aliasing, and the left join. The predicate must reference +/// raw sink table columns structurally (unqualified), before the `__flow_sink` +/// alias exists. pub async fn rewrite_incremental_aggregate_with_sink_merge( delta_plan: &LogicalPlan, analysis: &IncrementalAggregateAnalysis, sink_table: TableRef, sink_table_name: &TableName, + sink_dirty_filter: Option, ) -> Result { ensure!( analysis.unsupported_exprs.is_empty(), @@ -637,7 +677,22 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge( .cloned() .map(unqualified_col) .collect::>(); - let sink_selected = LogicalPlanBuilder::from(sink_scan) + let sink_input = if let Some(predicate) = sink_dirty_filter { + LogicalPlanBuilder::from(sink_scan) + .filter(predicate) + .with_context(|_| DatafusionSnafu { + context: "Failed to filter sink table scan for incremental sink merge".to_string(), + })? + .build() + .with_context(|_| DatafusionSnafu { + context: "Failed to build filtered sink plan for incremental sink merge" + .to_string(), + })? + } else { + sink_scan + }; + + let sink_selected = LogicalPlanBuilder::from(sink_input) .project(sink_selected_exprs) .with_context(|_| DatafusionSnafu { context: "Failed to project sink table scan for incremental sink merge".to_string(), diff --git a/src/flow/src/batching_mode/utils/test.rs b/src/flow/src/batching_mode/utils/test.rs index 863580b4ae..5b9cf7f507 100644 --- a/src/flow/src/batching_mode/utils/test.rs +++ b/src/flow/src/batching_mode/utils/test.rs @@ -15,10 +15,13 @@ use std::sync::Arc; use common_recordbatch::RecordBatch; +use common_time::Timestamp; use datafusion_common::tree_node::TreeNode as _; use datafusion_expr::GroupingSet; -use datatypes::prelude::{ConcreteDataType, Scalar, VectorRef}; +use datatypes::prelude::{ConcreteDataType, MutableVector, Scalar, ScalarVectorBuilder, VectorRef}; use datatypes::schema::{ColumnSchema, Schema}; +use datatypes::timestamp::TimestampMillisecond; +use datatypes::vectors::TimestampMillisecondVectorBuilder; use pretty_assertions::assert_eq; use query::query_engine::DefaultSerializer; use session::context::QueryContext; @@ -26,6 +29,7 @@ use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan}; use table::test_util::MemTable; use super::*; +use crate::batching_mode::state::FilterExprInfo; use crate::test_utils::create_test_query_engine; fn u32_table(table_name: &str, columns: Vec<&str>, rows: usize) -> TableRef { @@ -50,6 +54,30 @@ fn empty_u32_table(table_name: &str, columns: Vec<&str>) -> TableRef { u32_table(table_name, columns, 0) } +fn time_window_u32_table(table_name: &str) -> TableRef { + let schema = Arc::new(Schema::new(vec![ + ColumnSchema::new( + "time_window", + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true), + ])); + + let mut time_window_builder = TimestampMillisecondVectorBuilder::with_capacity(1); + time_window_builder.push(Some(TimestampMillisecond::new(0))); + let recordbatch = RecordBatch::new( + schema, + vec![ + time_window_builder.to_vector_cloned(), + Arc::new(::VectorType::from_vec(vec![1])) as VectorRef, + ], + ) + .unwrap(); + MemTable::table(table_name, recordbatch) +} + fn assert_same_logical_plan(actual: &LogicalPlan, expected: &LogicalPlan) { assert_eq!( format!("{}", expected.display_indent()), @@ -84,6 +112,29 @@ fn expected_left_join_rewrite( sink_selected_exprs: Vec, join_keys: (Vec, Vec), projection_exprs: Vec, +) -> LogicalPlan { + expected_left_join_rewrite_with_sink_filter( + delta_plan, + sink_table, + sink_table_name, + delta_selected_exprs, + sink_selected_exprs, + None, + join_keys, + projection_exprs, + ) +} + +#[allow(clippy::too_many_arguments)] +fn expected_left_join_rewrite_with_sink_filter( + delta_plan: &LogicalPlan, + sink_table: TableRef, + sink_table_name: &TableName, + delta_selected_exprs: Vec, + sink_selected_exprs: Vec, + sink_filter: Option, + join_keys: (Vec, Vec), + projection_exprs: Vec, ) -> LogicalPlan { let delta_alias = "__flow_delta"; let sink_alias = "__flow_sink"; @@ -94,7 +145,17 @@ fn expected_left_join_rewrite( .unwrap() .build() .unwrap(); - let sink_selected = LogicalPlanBuilder::from(test_sink_scan(sink_table, sink_table_name)) + let sink_scan = test_sink_scan(sink_table, sink_table_name); + let sink_input = if let Some(predicate) = sink_filter { + LogicalPlanBuilder::from(sink_scan) + .filter(predicate) + .unwrap() + .build() + .unwrap() + } else { + sink_scan + }; + let sink_selected = LogicalPlanBuilder::from(sink_input) .project(sink_selected_exprs) .unwrap() .alias(sink_alias) @@ -576,6 +637,44 @@ async fn test_analyze_incremental_aggregate_plan_keeps_aliases_for_multiple_aggr })); } +#[tokio::test] +async fn test_analyze_incremental_aggregate_plan_allows_auto_created_sink_columns() { + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let sql = format!( + "SELECT max(number) AS total, ts, now() AS {}, CAST('1970-01-01 00:00:00' AS TIMESTAMP) AS {} FROM numbers_with_ts GROUP BY ts", + AUTO_CREATED_UPDATE_AT_TS_COL, AUTO_CREATED_PLACEHOLDER_TS_COL + ); + let plan = sql_to_df_plan(ctx, query_engine, &sql, false) + .await + .unwrap(); + + let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); + assert!( + analysis.unsupported_exprs.is_empty(), + "auto-created sink columns should not disable incremental analysis: {:?}", + analysis.unsupported_exprs + ); + assert!( + analysis + .literal_columns + .iter() + .any(|name| name == AUTO_CREATED_UPDATE_AT_TS_COL) + ); + assert!( + analysis + .literal_columns + .iter() + .any(|name| name == AUTO_CREATED_PLACEHOLDER_TS_COL) + ); + assert_eq!(analysis.merge_columns.len(), 1); + assert_eq!(analysis.merge_columns[0].output_field_name, "total"); + assert_eq!( + analysis.merge_columns[0].merge_op, + IncrementalAggregateMergeOp::Max + ); +} + #[tokio::test] async fn test_analyze_incremental_aggregate_plan_allows_where_before_aggregate() { let query_engine = create_test_query_engine(); @@ -641,6 +740,7 @@ async fn test_rewrite_incremental_aggregate_allows_alias_wrapped_scan() { "public".to_string(), "alias_wrapped_sink".to_string(), ], + None, ) .await .unwrap(); @@ -887,6 +987,7 @@ async fn test_analyze_incremental_aggregate_plan_allows_literal_outputs() { &analysis, sink_table.clone(), &sink_table_name, + None, ) .await .unwrap(); @@ -975,6 +1076,7 @@ async fn test_rewrite_incremental_aggregate_preserves_non_identifier_aliases() { "public".to_string(), "non_identifier_alias_sink".to_string(), ], + None, ) .await .unwrap(); @@ -1161,6 +1263,7 @@ async fn test_rewrite_incremental_aggregate_with_left_join() { &analysis, sink_table.clone(), &sink_table_name, + None, ) .await .unwrap(); @@ -1183,6 +1286,67 @@ async fn test_rewrite_incremental_aggregate_with_left_join() { assert_same_logical_plan(&rewritten, &expected); } +#[tokio::test] +async fn test_rewrite_incremental_aggregate_filters_sink_dirty_time_window() { + // This verifies the rewrite placement when callers supply an already + // inferred sink dirty-window predicate. The task-level inference rules are + // covered by `infer_sink_time_window_filter_col` tests in task.rs. + let query_engine = create_test_query_engine(); + let ctx = QueryContext::arc(); + let sql = "SELECT max(number) AS number, date_bin(INTERVAL '1 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window"; + let plan = sql_to_df_plan(ctx.clone(), query_engine.clone(), sql, false) + .await + .unwrap(); + let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); + let sink_table = time_window_u32_table("time_window_sink"); + let sink_table_name = [ + "greptime".to_string(), + "public".to_string(), + "time_window_sink".to_string(), + ]; + let dirty_filter = FilterExprInfo { + expr: unqualified_col("ts"), + col_name: "ts".to_string(), + time_ranges: vec![( + Timestamp::new_millisecond(0), + Timestamp::new_millisecond(1000), + )], + window_size: chrono::Duration::seconds(1), + }; + let sink_filter = dirty_filter + .predicate_for_col("time_window") + .unwrap() + .unwrap(); + + let rewritten = rewrite_incremental_aggregate_with_sink_merge( + &plan, + &analysis, + sink_table.clone(), + &sink_table_name, + Some(sink_filter.clone()), + ) + .await + .unwrap(); + + let expected = expected_left_join_rewrite_with_sink_filter( + &plan, + sink_table, + &sink_table_name, + vec![unqualified_col("time_window"), unqualified_col("number")], + vec![unqualified_col("time_window"), unqualified_col("number")], + Some(sink_filter), + ( + vec![qualified_column("__flow_delta", "time_window")], + vec![qualified_column("__flow_sink", "time_window")], + ), + vec![ + max_merge_expr("number"), + qualified_col("__flow_delta", "time_window").alias("time_window"), + ], + ); + assert_same_logical_plan(&rewritten, &expected); +} + #[tokio::test] async fn test_analyze_incremental_aggregate_plan_rejects_global_aggregate() { let query_engine = create_test_query_engine(); @@ -1230,6 +1394,7 @@ async fn test_rewrite_incremental_aggregate_rejects_empty_group_keys() { &analysis, sink_table, &sink_table_name, + None, ) .await .unwrap_err(); @@ -1261,6 +1426,7 @@ async fn test_rewrite_incremental_aggregate_preserves_raw_aggregate_field_name() &analysis, sink_table.clone(), &sink_table_name, + None, ) .await .unwrap(); diff --git a/src/flow/src/metrics.rs b/src/flow/src/metrics.rs index 58c01793cc..00f93d47ab 100644 --- a/src/flow/src/metrics.rs +++ b/src/flow/src/metrics.rs @@ -87,6 +87,20 @@ lazy_static! { &["flow_id"], ) .unwrap(); + pub static ref METRIC_FLOW_BATCHING_ENGINE_CHECKPOINT_DECISION_CNT: IntCounterVec = + register_int_counter_vec!( + "greptime_flow_batching_checkpoint_decision_count", + "flow batching checkpoint state-machine decisions", + &["flow_id", "mode", "decision", "reason"], + ) + .unwrap(); + pub static ref METRIC_FLOW_BATCHING_ENGINE_QUERY_MODE_CNT: IntCounterVec = + register_int_counter_vec!( + "greptime_flow_batching_query_mode_count", + "flow batching query attempts by checkpoint mode", + &["flow_id", "mode"], + ) + .unwrap(); pub static ref METRIC_FLOW_RUN_INTERVAL_MS: IntGauge = register_int_gauge!("greptime_flow_run_interval_ms", "flow run interval in ms").unwrap(); pub static ref METRIC_FLOW_ROWS: IntCounterVec = register_int_counter_vec!( diff --git a/src/mito2/src/engine/scan_test.rs b/src/mito2/src/engine/scan_test.rs index 75fbc848ea..e4010940fa 100644 --- a/src/mito2/src/engine/scan_test.rs +++ b/src/mito2/src/engine/scan_test.rs @@ -100,7 +100,7 @@ async fn test_incremental_query_stale_error() { region_id, ScanRequest { memtable_min_sequence: Some(min_readable_seq), - sst_min_sequence: Some(u64::MAX), + skip_sst_files: true, ..Default::default() }, ) diff --git a/src/mito2/src/read/scan_region.rs b/src/mito2/src/read/scan_region.rs index ea779c145f..baf6964c27 100644 --- a/src/mito2/src/read/scan_region.rs +++ b/src/mito2/src/read/scan_region.rs @@ -456,26 +456,28 @@ impl ScanRegion { let ssts = &self.version.ssts; let mut files = Vec::new(); - for level in ssts.levels() { - for file in level.files.values() { - let exceed_min_sequence = match (sst_min_sequence, file.meta_ref().sequence) { - (Some(min_sequence), Some(file_sequence)) => file_sequence > min_sequence, - // If the file's sequence is None (or actually is zero), it could mean the file - // is generated and added to the region "directly". In this case, its data should - // be considered as fresh as the memtable. So its sequence is treated greater than - // the min_sequence, whatever the value of min_sequence is. Hence the default - // "true" in this arm. - (Some(_), None) => true, - (None, _) => true, - }; + if !self.request.skip_sst_files { + for level in ssts.levels() { + for file in level.files.values() { + let exceed_min_sequence = match (sst_min_sequence, file.meta_ref().sequence) { + (Some(min_sequence), Some(file_sequence)) => file_sequence > min_sequence, + // If the file's sequence is None (or actually is zero), it could mean the file + // is generated and added to the region "directly". In this case, its data should + // be considered as fresh as the memtable. So its sequence is treated greater than + // the min_sequence, whatever the value of min_sequence is. Hence the default + // "true" in this arm. + (Some(_), None) => true, + (None, _) => true, + }; - // Finds SST files in range. - if exceed_min_sequence && file_in_range(file, &time_range) { - files.push(file.clone()); + // Finds SST files in range. + if exceed_min_sequence && file_in_range(file, &time_range) { + files.push(file.clone()); + } + // There is no need to check and prune for file's sequence here as the sequence number is usually very new, + // unless the timing is too good, or the sequence number wouldn't be in file. + // and the batch will be filtered out by tree reader anyway. } - // There is no need to check and prune for file's sequence here as the sequence number is usually very new, - // unless the timing is too good, or the sequence number wouldn't be in file. - // and the batch will be filtered out by tree reader anyway. } } @@ -579,7 +581,9 @@ impl ScanRegion { .with_vector_index_k(vector_index_k); #[cfg(feature = "enterprise")] - let input = if let Some(provider) = self.extension_range_provider { + let input = if !self.request.skip_sst_files + && let Some(provider) = self.extension_range_provider + { let ranges = provider .find_extension_ranges(self.version.flushed_sequence, time_range, &self.request) .await?; diff --git a/src/query/src/dist_plan/merge_scan.rs b/src/query/src/dist_plan/merge_scan.rs index 470b4d325f..e1dd635de7 100644 --- a/src/query/src/dist_plan/merge_scan.rs +++ b/src/query/src/dist_plan/merge_scan.rs @@ -51,6 +51,7 @@ use tracing::{Instrument, Span}; use crate::dist_plan::analyzer::AliasMapping; use crate::dist_plan::analyzer::utils::patch_batch_timezone; use crate::metrics::{MERGE_SCAN_ERRORS_TOTAL, MERGE_SCAN_POLL_ELAPSED, MERGE_SCAN_REGIONS}; +use crate::options::FlowQueryExtensions; use crate::region_query::RegionQueryHandlerRef; #[derive(Debug, Hash, PartialOrd, PartialEq, Eq, Clone)] @@ -481,6 +482,23 @@ impl MergeScanExec { &self.regions } + pub fn is_flow_sink_scan(&self) -> bool { + let Some(sink_table_id) = + FlowQueryExtensions::parse_flow_extensions(&self.query_ctx.extensions()) + .ok() + .flatten() + .and_then(|extensions| extensions.sink_table_id) + else { + return false; + }; + + !self.regions.is_empty() + && self + .regions + .iter() + .all(|region_id| region_id.table_id() == sink_table_id) + } + pub fn partition_count(&self) -> usize { self.target_partition } diff --git a/src/query/src/dummy_catalog.rs b/src/query/src/dummy_catalog.rs index f08c6e6ec6..c79c8d88ea 100644 --- a/src/query/src/dummy_catalog.rs +++ b/src/query/src/dummy_catalog.rs @@ -45,7 +45,7 @@ use table::metadata::{TableId, TableInfoRef}; use table::table::scan::RegionScanExec; use crate::error::{GetRegionMetadataSnafu, Result}; -use crate::options::FlowQueryExtensions; +use crate::options::{FlowIncrementalMode, FlowQueryExtensions}; /// Resolve to the given region (specified by [RegionId]) unconditionally. #[derive(Clone, Debug)] @@ -357,6 +357,8 @@ struct FlowScanDecision { /// When present, this becomes the effective memtable upper bound and suppresses /// binding a new snapshot on scan open. memtable_max_sequence: Option, + /// Whether to skip SST files for memtable-only incremental source scans. + skip_sst_files: bool, } impl FlowScanDecision { @@ -366,6 +368,7 @@ impl FlowScanDecision { snapshot_on_scan: false, memtable_min_sequence: None, memtable_max_sequence: None, + skip_sst_files: false, } } } @@ -379,6 +382,7 @@ fn decide_flow_scan(query_ctx: &QueryContext, region_id: RegionId) -> Result Result) -> Vec() { + if let Some(merge_scan) = plan.as_any().downcast_ref::() + && !merge_scan.is_flow_sink_scan() + { merge_merge_scan_region_watermarks( &mut merged, merge_scan @@ -281,8 +280,6 @@ fn collect_region_watermarks(plan: Arc) -> Vec match existing { - MergeState::Participated | MergeState::Proved(_) => { + MergeState::Proved(_) => { *existing = MergeState::Unproved; } MergeState::Unproved | MergeState::Conflict { .. } => {} }, Some(seq) => match existing { - MergeState::Participated => { - *existing = MergeState::Proved(seq); - } MergeState::Unproved => {} MergeState::Proved(existing_seq) if *existing_seq == seq => {} MergeState::Proved(existing_seq) => { @@ -336,16 +330,32 @@ fn merge_merge_scan_region_watermarks( regions: impl IntoIterator, sub_stage_metrics: impl IntoIterator, ) { - // Regions listed by MergeScanExec participated even when no sub-stage can - // prove a watermark. Keep them as explicit `None` entries so callers can - // distinguish unproved participation from non-participation. - for region_id in regions { - merged.entry(region_id).or_insert(MergeState::Participated); - } - + let regions = regions.into_iter().collect::>(); + let mut proved_or_unproved_regions = BTreeSet::new(); for metrics in sub_stage_metrics { + proved_or_unproved_regions.extend( + metrics + .region_watermarks + .iter() + .map(|entry| entry.region_id), + ); merge_region_watermark_entries(merged, metrics.region_watermarks); } + + // Regions listed by a MergeScanExec participated even when no sub-stage can + // prove a watermark. Merge missing per-scan region entries as explicit + // `None` entries so an unproved participating branch vetoes any proof from + // another branch for the same region. + merge_region_watermark_entries( + merged, + regions + .into_iter() + .filter(|region_id| !proved_or_unproved_regions.contains(region_id)) + .map(|region_id| RegionWatermarkEntry { + region_id, + watermark: None, + }), + ); } fn finalize_region_watermarks(merged: BTreeMap) -> Vec { @@ -354,7 +364,6 @@ fn finalize_region_watermarks(merged: BTreeMap) -> Vec None, MergeState::Unproved => None, MergeState::Proved(seq) => Some(seq), MergeState::Conflict { watermarks } => { @@ -371,10 +380,35 @@ fn finalize_region_watermarks(merged: BTreeMap) -> Vec Result { + unreachable!("metrics tests should not execute remote queries") + } + } fn metrics_with_region_watermarks(entries: &[(u64, Option)]) -> RecordBatchMetrics { RecordBatchMetrics { @@ -389,12 +423,66 @@ mod tests { } } + fn test_merge_scan_exec(table_id: u32, query_ctx: QueryContextRef) -> Arc { + let session_state = SessionStateBuilder::new().with_default_features().build(); + let plan = LogicalPlanBuilder::empty(false).build().unwrap(); + let schema = ArrowSchema::empty(); + + Arc::new( + MergeScanExec::new( + &session_state, + TableName::new("greptime", "public", "test"), + vec![RegionId::new(table_id, 0)], + plan, + &schema, + Arc::new(NoopRegionQueryHandler), + query_ctx, + 1, + BTreeMap::>::new(), + ) + .unwrap(), + ) + } + + fn flow_query_ctx_with_sink_table_id(sink_table_id: u32) -> QueryContextRef { + Arc::new( + QueryContextBuilder::default() + .set_extension(FLOW_RETURN_REGION_SEQ.to_string(), "true".to_string()) + .set_extension(FLOW_SINK_TABLE_ID.to_string(), sink_table_id.to_string()) + .build(), + ) + } + #[test] fn terminal_metrics_returns_none_without_merge_scan() { let plan: Arc = Arc::new(EmptyExec::new(Arc::new(ArrowSchema::empty()))); assert!(terminal_recordbatch_metrics_from_plan(plan).is_none()); } + #[test] + fn terminal_metrics_skip_flow_sink_merge_scan_regions() { + let query_ctx = flow_query_ctx_with_sink_table_id(42); + let plan = test_merge_scan_exec(42, query_ctx); + + assert!(terminal_recordbatch_metrics_from_plan(plan).is_none()); + } + + #[test] + fn terminal_metrics_keep_source_merge_scan_regions_with_sink_extension() { + let query_ctx = flow_query_ctx_with_sink_table_id(42); + let plan = test_merge_scan_exec(43, query_ctx); + + assert_eq!( + terminal_recordbatch_metrics_from_plan(plan) + .unwrap() + .region_watermarks, + vec![RegionWatermarkEntry { + region_id: RegionId::new(43, 0).as_u64(), + watermark: None, + }] + ); + } + #[test] fn merge_merge_scan_region_watermarks_marks_missing_watermarks_unproved() { let mut merged = BTreeMap::new(); @@ -503,4 +591,44 @@ mod tests { }] ); } + + #[test] + fn merge_merge_scan_region_watermarks_missing_branch_vetoes_proved_value() { + let mut merged = BTreeMap::new(); + + merge_merge_scan_region_watermarks( + &mut merged, + [9], + [metrics_with_region_watermarks(&[(9, Some(21))])], + ); + merge_merge_scan_region_watermarks(&mut merged, [9], std::iter::empty()); + + assert_eq!( + finalize_region_watermarks(merged), + vec![RegionWatermarkEntry { + region_id: 9, + watermark: None, + }] + ); + } + + #[test] + fn merge_merge_scan_region_watermarks_missing_branch_vetoes_proved_value_regardless_of_order() { + let mut merged = BTreeMap::new(); + + merge_merge_scan_region_watermarks(&mut merged, [9], std::iter::empty()); + merge_merge_scan_region_watermarks( + &mut merged, + [9], + [metrics_with_region_watermarks(&[(9, Some(21))])], + ); + + assert_eq!( + finalize_region_watermarks(merged), + vec![RegionWatermarkEntry { + region_id: 9, + watermark: None, + }] + ); + } } diff --git a/src/store-api/src/storage/requests.rs b/src/store-api/src/storage/requests.rs index 57b2ca8e88..b27119d881 100644 --- a/src/store-api/src/storage/requests.rs +++ b/src/store-api/src/storage/requests.rs @@ -124,6 +124,9 @@ pub struct ScanRequest { /// Optional constraint on the minimal sequence number in the SST files. /// If set, only the SST files that contain sequences greater than this value will be scanned. pub sst_min_sequence: Option, + /// Whether to skip all SST files. + /// This is stronger than `sst_min_sequence` and also skips SST files without sequence metadata. + pub skip_sst_files: bool, /// Whether to bind the effective snapshot upper bound when opening the scan. pub snapshot_on_scan: bool, /// Optional hint for the distribution of time-series data. @@ -211,6 +214,14 @@ impl Display for ScanRequest { sst_min_sequence )?; } + if self.skip_sst_files { + write!( + f, + "{}skip_sst_files: {}", + delimiter.as_str(), + self.skip_sst_files + )?; + } if self.snapshot_on_scan { write!( f, @@ -321,5 +332,11 @@ mod tests { request.to_string(), "ScanRequest { snapshot_on_scan: true }" ); + + let request = ScanRequest { + skip_sst_files: true, + ..Default::default() + }; + assert_eq!(request.to_string(), "ScanRequest { skip_sst_files: true }"); } } diff --git a/tests/cases/standalone/common/flow/flow_incremental_aggr.result b/tests/cases/standalone/common/flow/flow_incremental_aggr.result new file mode 100644 index 0000000000..bb66d5362c --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_aggr.result @@ -0,0 +1,119 @@ +CREATE TABLE incremental_aggr_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) WITH ( + append_mode = 'true' +); + +Affected Rows: 0 + +CREATE FLOW incremental_aggr_flow SINK TO incremental_aggr_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + incremental_aggr_input +GROUP BY + time_window; + +Affected Rows: 0 + +INSERT INTO incremental_aggr_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (2, 20, '2024-01-01 00:00:30'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('incremental_aggr_flow') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 30 | 10 | 20 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- Move already checkpointed source rows into SST. The next incremental run +-- must still read only the memtable delta and must not merge these old SST +-- rows again. +ADMIN FLUSH_TABLE('incremental_aggr_input'); + ++---------------------------------------------+ +| ADMIN FLUSH_TABLE('incremental_aggr_input') | ++---------------------------------------------+ +| 0 | ++---------------------------------------------+ + +-- Insert more rows into the same time window. An incremental-safe flow should +-- merge the delta aggregate with the existing sink aggregate state. +INSERT INTO incremental_aggr_input VALUES + (3, 30, '2024-01-01 00:00:15'), + (4, 40, '2024-01-01 00:00:45'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('incremental_aggr_flow') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 100 | 10 | 40 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- Insert a row into a new time window to cover append of a new aggregate key. +INSERT INTO incremental_aggr_input VALUES + (5, 50, '2024-01-01 00:01:00'); + +Affected Rows: 1 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('incremental_aggr_flow') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 100 | 10 | 40 | 2024-01-01T00:00:00 | +| 50 | 50 | 50 | 2024-01-01T00:01:00 | ++-------+-------+-------+---------------------+ + +DROP FLOW incremental_aggr_flow; + +Affected Rows: 0 + +DROP TABLE incremental_aggr_input; + +Affected Rows: 0 + +DROP TABLE incremental_aggr_sink; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/flow/flow_incremental_aggr.sql b/tests/cases/standalone/common/flow/flow_incremental_aggr.sql new file mode 100644 index 0000000000..51dd431fef --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_aggr.sql @@ -0,0 +1,57 @@ +CREATE TABLE incremental_aggr_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) WITH ( + append_mode = 'true' +); + +CREATE FLOW incremental_aggr_flow SINK TO incremental_aggr_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + incremental_aggr_input +GROUP BY + time_window; + +INSERT INTO incremental_aggr_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (2, 20, '2024-01-01 00:00:30'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + +-- Move already checkpointed source rows into SST. The next incremental run +-- must still read only the memtable delta and must not merge these old SST +-- rows again. +ADMIN FLUSH_TABLE('incremental_aggr_input'); + +-- Insert more rows into the same time window. An incremental-safe flow should +-- merge the delta aggregate with the existing sink aggregate state. +INSERT INTO incremental_aggr_input VALUES + (3, 30, '2024-01-01 00:00:15'), + (4, 40, '2024-01-01 00:00:45'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + +-- Insert a row into a new time window to cover append of a new aggregate key. +INSERT INTO incremental_aggr_input VALUES + (5, 50, '2024-01-01 00:01:00'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('incremental_aggr_flow'); + +SELECT total, min_n, max_n, time_window FROM incremental_aggr_sink ORDER BY time_window; + +DROP FLOW incremental_aggr_flow; +DROP TABLE incremental_aggr_input; +DROP TABLE incremental_aggr_sink; diff --git a/tests/cases/standalone/common/flow/flow_incremental_memtable.result b/tests/cases/standalone/common/flow/flow_incremental_memtable.result new file mode 100644 index 0000000000..1e452b21ad --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_memtable.result @@ -0,0 +1,132 @@ +-- Validate that a flow performing an incremental aggregate read only reads memtable +-- data and does NOT re-read source rows that have already been flushed to SST after +-- a previous checkpoint. +CREATE TABLE flow_incr_memtable_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) WITH ( + append_mode = 'true' +); + +Affected Rows: 0 + +CREATE FLOW flow_incr_memtable SINK TO flow_incr_memtable_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_memtable_input +GROUP BY + time_window; + +Affected Rows: 0 + +-- ==== Phase 1: initial insert + checkpoint ==== +INSERT INTO flow_incr_memtable_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (2, 20, '2024-01-01 00:00:30'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + ++----------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_memtable') | ++----------------------------------------+ +| FLOW_FLUSHED | ++----------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 30 | 10 | 20 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- ==== Phase 2: flush sink and source tables to SST ==== +-- The next incremental run must still read the flushed sink aggregate state, +-- while skipping already-checkpointed source SST files. +ADMIN FLUSH_TABLE('flow_incr_memtable_sink'); + ++----------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_memtable_sink') | ++----------------------------------------------+ +| 0 | ++----------------------------------------------+ + +ADMIN FLUSH_TABLE('flow_incr_memtable_input'); + ++-----------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_memtable_input') | ++-----------------------------------------------+ +| 0 | ++-----------------------------------------------+ + +-- ==== Phase 3: empty incremental window ==== +-- Flush the flow without inserting any new source rows to verify that +-- the incremental read correctly handles the case where no new memtable +-- data exists. +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + ++----------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_memtable') | ++----------------------------------------+ +| FLOW_FLUSHED | ++----------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 30 | 10 | 20 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- ==== Phase 4: insert new delta within the same time window ==== +INSERT INTO flow_incr_memtable_input VALUES + (3, 30, '2024-01-01 00:00:15'), + (4, 40, '2024-01-01 00:00:45'); + +Affected Rows: 2 + +-- ==== Phase 5: flush flow again (incremental read) ==== +-- The flow must only read the new memtable delta and merge with the existing +-- sink aggregate. If it mistakenly re-reads the SST, the result will be +-- inflated (initial data counted twice). +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + ++----------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_memtable') | ++----------------------------------------+ +| FLOW_FLUSHED | ++----------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 100 | 10 | 40 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- Clean up +DROP FLOW flow_incr_memtable; + +Affected Rows: 0 + +DROP TABLE flow_incr_memtable_input; + +Affected Rows: 0 + +DROP TABLE flow_incr_memtable_sink; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/flow/flow_incremental_memtable.sql b/tests/cases/standalone/common/flow/flow_incremental_memtable.sql new file mode 100644 index 0000000000..66dccbb8b3 --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_memtable.sql @@ -0,0 +1,66 @@ +-- Validate that a flow performing an incremental aggregate read only reads memtable +-- data and does NOT re-read source rows that have already been flushed to SST after +-- a previous checkpoint. +CREATE TABLE flow_incr_memtable_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) WITH ( + append_mode = 'true' +); + +CREATE FLOW flow_incr_memtable SINK TO flow_incr_memtable_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_memtable_input +GROUP BY + time_window; + +-- ==== Phase 1: initial insert + checkpoint ==== +INSERT INTO flow_incr_memtable_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (2, 20, '2024-01-01 00:00:30'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + +-- ==== Phase 2: flush sink and source tables to SST ==== +-- The next incremental run must still read the flushed sink aggregate state, +-- while skipping already-checkpointed source SST files. +ADMIN FLUSH_TABLE('flow_incr_memtable_sink'); +ADMIN FLUSH_TABLE('flow_incr_memtable_input'); + +-- ==== Phase 3: empty incremental window ==== +-- Flush the flow without inserting any new source rows to verify that +-- the incremental read correctly handles the case where no new memtable +-- data exists. +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + +-- ==== Phase 4: insert new delta within the same time window ==== +INSERT INTO flow_incr_memtable_input VALUES + (3, 30, '2024-01-01 00:00:15'), + (4, 40, '2024-01-01 00:00:45'); + +-- ==== Phase 5: flush flow again (incremental read) ==== +-- The flow must only read the new memtable delta and merge with the existing +-- sink aggregate. If it mistakenly re-reads the SST, the result will be +-- inflated (initial data counted twice). +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_memtable'); + +SELECT total, min_n, max_n, time_window FROM flow_incr_memtable_sink ORDER BY time_window; + +-- Clean up +DROP FLOW flow_incr_memtable; +DROP TABLE flow_incr_memtable_input; +DROP TABLE flow_incr_memtable_sink; diff --git a/tests/cases/standalone/common/flow/flow_incremental_partitioned.result b/tests/cases/standalone/common/flow/flow_incremental_partitioned.result new file mode 100644 index 0000000000..b56b390abd --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_partitioned.result @@ -0,0 +1,108 @@ +-- Validate that a flow performing an incremental aggregate read on a +-- partitioned source table (multiple regions) only reads memtable data +-- and does NOT re-read source rows that have already been flushed to SST. +CREATE TABLE flow_incr_part_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) +PARTITION ON COLUMNS (host_id) ( + host_id < 3, + host_id >= 3 +) +WITH ( + append_mode = 'true' +); + +Affected Rows: 0 + +CREATE FLOW flow_incr_part SINK TO flow_incr_part_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_part_input +GROUP BY + time_window; + +Affected Rows: 0 + +-- ==== Phase 1: initial insert across both partitions ==== +INSERT INTO flow_incr_part_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (4, 20, '2024-01-01 00:00:30'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_part'); + ++------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_part') | ++------------------------------------+ +| FLOW_FLUSHED | ++------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM flow_incr_part_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 30 | 10 | 20 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- ==== Phase 2: flush source table to SST ==== +-- Move already checkpointed source rows into SST so the next incremental run +-- must skip them. +ADMIN FLUSH_TABLE('flow_incr_part_input'); + ++-------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_part_input') | ++-------------------------------------------+ +| 0 | ++-------------------------------------------+ + +-- ==== Phase 3: insert new delta across both partitions, same time window ==== +INSERT INTO flow_incr_part_input VALUES + (2, 30, '2024-01-01 00:00:15'), + (3, 40, '2024-01-01 00:00:45'); + +Affected Rows: 2 + +-- ==== Phase 4: flush flow again (incremental read) ==== +-- The flow must only read the new memtable delta from both regions and merge +-- with the existing sink aggregate. If it mistakenly re-reads the SST, the +-- result will be inflated (initial data counted twice). +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_part'); + ++------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_part') | ++------------------------------------+ +| FLOW_FLUSHED | ++------------------------------------+ + +SELECT total, min_n, max_n, time_window FROM flow_incr_part_sink ORDER BY time_window; + ++-------+-------+-------+---------------------+ +| total | min_n | max_n | time_window | ++-------+-------+-------+---------------------+ +| 100 | 10 | 40 | 2024-01-01T00:00:00 | ++-------+-------+-------+---------------------+ + +-- Clean up +DROP FLOW flow_incr_part; + +Affected Rows: 0 + +DROP TABLE flow_incr_part_input; + +Affected Rows: 0 + +DROP TABLE flow_incr_part_sink; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/flow/flow_incremental_partitioned.sql b/tests/cases/standalone/common/flow/flow_incremental_partitioned.sql new file mode 100644 index 0000000000..234c9b9085 --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_partitioned.sql @@ -0,0 +1,61 @@ +-- Validate that a flow performing an incremental aggregate read on a +-- partitioned source table (multiple regions) only reads memtable data +-- and does NOT re-read source rows that have already been flushed to SST. +CREATE TABLE flow_incr_part_input ( + host_id INT, + n INT, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id) +) +PARTITION ON COLUMNS (host_id) ( + host_id < 3, + host_id >= 3 +) +WITH ( + append_mode = 'true' +); + +CREATE FLOW flow_incr_part SINK TO flow_incr_part_sink AS +SELECT + sum(n) AS total, + min(n) AS min_n, + max(n) AS max_n, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_part_input +GROUP BY + time_window; + +-- ==== Phase 1: initial insert across both partitions ==== +INSERT INTO flow_incr_part_input VALUES + (1, 10, '2024-01-01 00:00:00'), + (4, 20, '2024-01-01 00:00:30'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_part'); + +SELECT total, min_n, max_n, time_window FROM flow_incr_part_sink ORDER BY time_window; + +-- ==== Phase 2: flush source table to SST ==== +-- Move already checkpointed source rows into SST so the next incremental run +-- must skip them. +ADMIN FLUSH_TABLE('flow_incr_part_input'); + +-- ==== Phase 3: insert new delta across both partitions, same time window ==== +INSERT INTO flow_incr_part_input VALUES + (2, 30, '2024-01-01 00:00:15'), + (3, 40, '2024-01-01 00:00:45'); + +-- ==== Phase 4: flush flow again (incremental read) ==== +-- The flow must only read the new memtable delta from both regions and merge +-- with the existing sink aggregate. If it mistakenly re-reads the SST, the +-- result will be inflated (initial data counted twice). +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_part'); + +SELECT total, min_n, max_n, time_window FROM flow_incr_part_sink ORDER BY time_window; + +-- Clean up +DROP FLOW flow_incr_part; +DROP TABLE flow_incr_part_input; +DROP TABLE flow_incr_part_sink; From 9a4e5e8457f3ef7717fbe7125dda7737d59dd229 Mon Sep 17 00:00:00 2001 From: "Lei, HUANG" <6406592+v0y4g3r@users.noreply.github.com> Date: Thu, 28 May 2026 17:52:37 +0800 Subject: [PATCH 27/32] chore: expose region info inspection table (#8178) * chore/region-sync-diff: add region info inspection core - `store-api`: add `RegionInfoEntry` schema and plan builder in `src/store-api/src/region_info.rs` and export it from `src/store-api/src/lib.rs` - `mito2`: collect region runtime metadata with `MitoEngine::all_region_infos` and `RegionRoleState::as_str` in `src/mito2/src/engine.rs`, `src/mito2/src/region.rs`, `src/mito2/src/engine/basic_test.rs`, `src/mito2/Cargo.toml`, and `Cargo.lock` - `datanode`: expose the reserved `InspectRegionInfo` provider in `src/datanode/src/region_server/catalog.rs` Signed-off-by: Lei, HUANG * chore/region-sync-diff: expose region info schema table - `information_schema.region_info`: add frontend table wiring in `src/catalog/src/system_schema/information_schema.rs`, `src/catalog/src/system_schema/information_schema/region_info.rs`, `src/catalog/src/system_schema/information_schema/table_names.rs`, and `src/common/catalog/src/consts.rs` - `region_group` removal: drop `region_group` from `src/store-api/src/region_info.rs`, `src/mito2/src/region.rs`, and `src/mito2/src/engine/basic_test.rs` - `SQLness coverage`: add standalone coverage in `tests/cases/standalone/common/information_schema/region_info.sql` and `tests/cases/standalone/common/information_schema/region_info.result` Signed-off-by: Lei, HUANG * chore/region-sync-diff: restore region group info - `region_info` schema: restore `region_group` alongside `region_sequence` in `src/store-api/src/region_info.rs`, `src/mito2/src/region.rs`, `src/mito2/src/engine/basic_test.rs`, and `tests/cases/standalone/common/information_schema/region_info.result` - `MitoEngine::all_region_infos`: remove redundant iterator conversion in `src/mito2/src/engine.rs` Signed-off-by: Lei, HUANG * fix: sqlness Signed-off-by: Lei, HUANG * fix: sqlness Signed-off-by: Lei, HUANG * chore/region-sync-diff: clarify region sequence columns - `region_info` schema: rename `sequence` to `committed_sequence` and add nullable `flushed_sequence` in `src/store-api/src/region_info.rs` and `src/mito2/src/region.rs` - `region_info` coverage: update sequence assertions and expected metadata in `src/mito2/src/engine/basic_test.rs`, `tests/cases/standalone/common/information_schema/region_info.sql`, `tests/cases/standalone/common/information_schema/region_info.result`, and `tests/cases/standalone/common/system/information_schema.result` Signed-off-by: Lei, HUANG * chore/region-sync-diff: report region options errors - `region_info` output: preserve `region_options` serialization failures as JSON error objects in `src/mito2/src/region.rs` Signed-off-by: Lei, HUANG --------- Signed-off-by: Lei, HUANG --- Cargo.lock | 1 + .../src/system_schema/information_schema.rs | 38 ++ .../information_schema/region_info.rs | 86 +++++ .../information_schema/table_names.rs | 1 + src/common/catalog/src/consts.rs | 2 + src/datanode/src/region_server/catalog.rs | 71 ++++ src/mito2/Cargo.toml | 1 + src/mito2/src/engine.rs | 11 + src/mito2/src/engine/basic_test.rs | 52 +++ src/mito2/src/region.rs | 69 ++++ src/store-api/src/lib.rs | 1 + src/store-api/src/region_info.rs | 360 ++++++++++++++++++ .../information_schema/region_info.result | 63 +++ .../common/information_schema/region_info.sql | 24 ++ .../common/show/show_databases_tables.result | 3 + .../common/system/information_schema.result | 16 + .../standalone/common/view/create.result | 1 + 17 files changed, 800 insertions(+) create mode 100644 src/catalog/src/system_schema/information_schema/region_info.rs create mode 100644 src/store-api/src/region_info.rs create mode 100644 tests/cases/standalone/common/information_schema/region_info.result create mode 100644 tests/cases/standalone/common/information_schema/region_info.sql diff --git a/Cargo.lock b/Cargo.lock index b801b342c1..63ba289947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8324,6 +8324,7 @@ dependencies = [ "either", "futures", "greptime-proto", + "humantime", "humantime-serde", "index", "itertools 0.14.0", diff --git a/src/catalog/src/system_schema/information_schema.rs b/src/catalog/src/system_schema/information_schema.rs index 9715aa9402..a35950194c 100644 --- a/src/catalog/src/system_schema/information_schema.rs +++ b/src/catalog/src/system_schema/information_schema.rs @@ -20,6 +20,7 @@ pub mod key_column_usage; mod partitions; mod procedure_info; pub mod process_list; +mod region_info; pub mod region_peers; mod region_statistics; pub mod schemata; @@ -47,6 +48,8 @@ use datatypes::schema::SchemaRef; use lazy_static::lazy_static; use paste::paste; use process_list::InformationSchemaProcessList; +use region_info::InformationSchemaRegionInfo; +use store_api::region_info::RegionInfoEntry; use store_api::sst_entry::{ManifestSstEntry, PuffinIndexMetaEntry, StorageSstEntry}; use store_api::storage::{ScanRequest, TableId}; use table::TableRef; @@ -242,6 +245,9 @@ impl SystemSchemaProviderInner for InformationSchemaProvider { self.catalog_manager.clone(), ), ) as _), + REGION_INFO => Some(Arc::new(InformationSchemaRegionInfo::new( + self.catalog_manager.clone(), + )) as _), PROCESS_LIST => self .process_manager .as_ref() @@ -320,6 +326,10 @@ impl InformationSchemaProvider { REGION_STATISTICS.to_string(), self.build_table(REGION_STATISTICS).unwrap(), ); + tables.insert( + REGION_INFO.to_string(), + self.build_table(REGION_INFO).unwrap(), + ); tables.insert( SSTS_MANIFEST.to_string(), self.build_table(SSTS_MANIFEST).unwrap(), @@ -447,6 +457,8 @@ pub enum DatanodeInspectKind { SstStorage, /// List index metadata collected from manifest SstIndexMeta, + /// List region runtime and manifest info + RegionInfo, } impl DatanodeInspectRequest { @@ -456,6 +468,7 @@ impl DatanodeInspectRequest { DatanodeInspectKind::SstManifest => ManifestSstEntry::build_plan(self.scan), DatanodeInspectKind::SstStorage => StorageSstEntry::build_plan(self.scan), DatanodeInspectKind::SstIndexMeta => PuffinIndexMetaEntry::build_plan(self.scan), + DatanodeInspectKind::RegionInfo => RegionInfoEntry::build_plan(self.scan), } } } @@ -488,3 +501,28 @@ impl InformationExtension for NoopInformationExtension { Ok(common_recordbatch::RecordBatches::empty().as_stream()) } } + +#[cfg(test)] +mod tests { + use store_api::region_info::RegionInfoEntry; + + use super::*; + + #[test] + fn test_datanode_inspect_region_info_build_plan() { + let plan = DatanodeInspectRequest { + kind: DatanodeInspectKind::RegionInfo, + scan: ScanRequest::default(), + } + .build_plan() + .unwrap(); + + let LogicalPlan::TableScan(scan) = plan else { + panic!("expected table scan"); + }; + assert_eq!( + scan.table_name.to_string(), + RegionInfoEntry::reserved_table_name_for_inspection() + ); + } +} diff --git a/src/catalog/src/system_schema/information_schema/region_info.rs b/src/catalog/src/system_schema/information_schema/region_info.rs new file mode 100644 index 0000000000..ffc9dfc7ae --- /dev/null +++ b/src/catalog/src/system_schema/information_schema/region_info.rs @@ -0,0 +1,86 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::{Arc, Weak}; + +use common_catalog::consts::INFORMATION_SCHEMA_REGION_INFO_TABLE_ID; +use common_error::ext::BoxedError; +use common_recordbatch::SendableRecordBatchStream; +use common_recordbatch::adapter::AsyncRecordBatchStreamAdapter; +use datatypes::schema::SchemaRef; +use snafu::ResultExt; +use store_api::region_info::RegionInfoEntry; +use store_api::storage::{ScanRequest, TableId}; + +use crate::CatalogManager; +use crate::error::{ProjectSchemaSnafu, Result}; +use crate::information_schema::{ + DatanodeInspectKind, DatanodeInspectRequest, InformationTable, REGION_INFO, +}; +use crate::system_schema::utils; + +/// Information schema table for region info. +pub struct InformationSchemaRegionInfo { + schema: SchemaRef, + catalog_manager: Weak, +} + +impl InformationSchemaRegionInfo { + pub(super) fn new(catalog_manager: Weak) -> Self { + Self { + schema: RegionInfoEntry::schema(), + catalog_manager, + } + } +} + +impl InformationTable for InformationSchemaRegionInfo { + fn table_id(&self) -> TableId { + INFORMATION_SCHEMA_REGION_INFO_TABLE_ID + } + + fn table_name(&self) -> &'static str { + REGION_INFO + } + + fn schema(&self) -> SchemaRef { + self.schema.clone() + } + + fn to_stream(&self, request: ScanRequest) -> Result { + let schema = if let Some(p) = request.projection_indices() { + Arc::new(self.schema.try_project(p).context(ProjectSchemaSnafu)?) + } else { + self.schema.clone() + }; + + let info_ext = utils::information_extension(&self.catalog_manager)?; + let req = DatanodeInspectRequest { + kind: DatanodeInspectKind::RegionInfo, + scan: request, + }; + + let future = async move { + info_ext + .inspect_datanode(req) + .await + .map_err(BoxedError::new) + .context(common_recordbatch::error::ExternalSnafu) + }; + Ok(Box::pin(AsyncRecordBatchStreamAdapter::new( + schema, + Box::pin(future), + ))) + } +} diff --git a/src/catalog/src/system_schema/information_schema/table_names.rs b/src/catalog/src/system_schema/information_schema/table_names.rs index 2a3329fece..3a4c86487a 100644 --- a/src/catalog/src/system_schema/information_schema/table_names.rs +++ b/src/catalog/src/system_schema/information_schema/table_names.rs @@ -45,6 +45,7 @@ pub const CLUSTER_INFO: &str = "cluster_info"; pub const VIEWS: &str = "views"; pub const FLOWS: &str = "flows"; pub const PROCEDURE_INFO: &str = "procedure_info"; +pub const REGION_INFO: &str = "region_info"; pub const REGION_STATISTICS: &str = "region_statistics"; pub const PROCESS_LIST: &str = "process_list"; pub const SSTS_MANIFEST: &str = "ssts_manifest"; diff --git a/src/common/catalog/src/consts.rs b/src/common/catalog/src/consts.rs index 1cd5db8a0c..dd09893177 100644 --- a/src/common/catalog/src/consts.rs +++ b/src/common/catalog/src/consts.rs @@ -112,6 +112,8 @@ pub const INFORMATION_SCHEMA_SSTS_STORAGE_TABLE_ID: u32 = 38; pub const INFORMATION_SCHEMA_SSTS_INDEX_META_TABLE_ID: u32 = 39; /// id for information_schema.alerts pub const INFORMATION_SCHEMA_ALERTS_TABLE_ID: u32 = 40; +/// id for information_schema.region_info +pub const INFORMATION_SCHEMA_REGION_INFO_TABLE_ID: u32 = 41; // ----- End of information_schema tables ----- diff --git a/src/datanode/src/region_server/catalog.rs b/src/datanode/src/region_server/catalog.rs index 1c0f48951f..a4df422b75 100644 --- a/src/datanode/src/region_server/catalog.rs +++ b/src/datanode/src/region_server/catalog.rs @@ -27,6 +27,7 @@ use datafusion_expr::{LogicalPlan, TableSource}; use futures::TryStreamExt; use session::context::QueryContextRef; use snafu::{OptionExt, ResultExt}; +use store_api::region_info::RegionInfoEntry; use store_api::sst_entry::{ManifestSstEntry, PuffinIndexMetaEntry, StorageSstEntry}; use store_api::storage::RegionId; @@ -41,6 +42,7 @@ enum InternalTableKind { InspectSstManifest, InspectSstStorage, InspectSstIndexMeta, + InspectRegionInfo, } impl InternalTableKind { @@ -55,6 +57,9 @@ impl InternalTableKind { if name.eq_ignore_ascii_case(PuffinIndexMetaEntry::reserved_table_name_for_inspection()) { return Some(Self::InspectSstIndexMeta); } + if name.eq_ignore_ascii_case(RegionInfoEntry::reserved_table_name_for_inspection()) { + return Some(Self::InspectRegionInfo); + } None } @@ -64,6 +69,7 @@ impl InternalTableKind { Self::InspectSstManifest => server.inspect_sst_manifest_provider().await, Self::InspectSstStorage => server.inspect_sst_storage_provider().await, Self::InspectSstIndexMeta => server.inspect_sst_index_meta_provider().await, + Self::InspectRegionInfo => server.inspect_region_info_provider().await, } } } @@ -128,6 +134,25 @@ impl RegionServer { let table = MemTable::try_new(schema, vec![vec![batch]]).context(DataFusionSnafu)?; Ok(Arc::new(table)) } + + /// Expose region info across the engine as an in-memory table. + pub async fn inspect_region_info_provider(&self) -> Result> { + let mito = { + let guard = self.inner.mito_engine.read().unwrap(); + guard.as_ref().cloned().context(UnexpectedSnafu { + violated: "mito engine not available", + })? + }; + + let entries = mito.all_region_infos().await; + let schema = RegionInfoEntry::schema().arrow_schema().clone(); + let batch = RegionInfoEntry::to_record_batch(&entries) + .map_err(DataFusionError::from) + .context(DataFusionSnafu)?; + + let table = MemTable::try_new(schema, vec![vec![batch]]).context(DataFusionSnafu)?; + Ok(Arc::new(table)) + } } /// A catalog list that resolves `TableProvider` by table name: @@ -347,6 +372,7 @@ mod tests { use datatypes::arrow::array::Int32Array; use datatypes::arrow::datatypes::{DataType, Field, Schema}; use datatypes::arrow::record_batch::RecordBatch; + use store_api::region_info::RegionInfoEntry; use super::*; // bring rewrite() into scope @@ -409,6 +435,18 @@ mod tests { b3.reserved_table_needed, vec![InternalTableKind::InspectSstManifest] ); + + let region_info = RegionInfoEntry::reserved_table_name_for_inspection(); + let plan4 = table_scan(Some(region_info), &schema, None) + .unwrap() + .build() + .unwrap(); + let b4 = NameAwareDataSourceInjectorBuilder::from_plan(&plan4).unwrap(); + assert!(!b4.need_region_provider); + assert_eq!( + b4.reserved_table_needed, + vec![InternalTableKind::InspectRegionInfo] + ); } #[test] @@ -445,6 +483,39 @@ mod tests { } } + #[test] + fn test_rewriter_replaces_with_region_info_reserved_source() { + let schema = test_schema(); + let table_name = RegionInfoEntry::reserved_table_name_for_inspection(); + let plan = table_scan(Some(table_name), &schema, None) + .unwrap() + .build() + .unwrap(); + + let provider = empty_mem_table(); + let source = provider_as_source(provider); + + let mut injector = NameAwareDataSourceInjector { + reserved_sources: { + let mut m = HashMap::new(); + m.insert(InternalTableKind::InspectRegionInfo, source.clone()); + m + }, + region_source: None, + }; + + let transformed = plan.rewrite(&mut injector).unwrap(); + let new_plan = transformed.data; + + if let LogicalPlan::TableScan(scan) = new_plan { + let src_ptr = Arc::as_ptr(&scan.source); + let want_ptr = Arc::as_ptr(&source); + assert!(std::ptr::eq(src_ptr, want_ptr)); + } else { + panic!("expected TableScan after rewrite"); + } + } + #[test] fn test_rewriter_replaces_with_region_source_for_normal() { let schema = test_schema(); diff --git a/src/mito2/Cargo.toml b/src/mito2/Cargo.toml index dde1e44ea1..3e3a18a24d 100644 --- a/src/mito2/Cargo.toml +++ b/src/mito2/Cargo.toml @@ -53,6 +53,7 @@ dashmap.workspace = true dotenv.workspace = true either.workspace = true futures.workspace = true +humantime.workspace = true humantime-serde.workspace = true index.workspace = true itertools.workspace = true diff --git a/src/mito2/src/engine.rs b/src/mito2/src/engine.rs index ec38dec105..64f7576139 100644 --- a/src/mito2/src/engine.rs +++ b/src/mito2/src/engine.rs @@ -117,6 +117,7 @@ use store_api::region_engine::{ RemapManifestsResponse, SetRegionRoleStateResponse, SettableRegionRoleState, SyncRegionFromRequest, SyncRegionFromResponse, }; +use store_api::region_info::RegionInfoEntry; use store_api::region_request::{ AffectedRows, RegionCatchupRequest, RegionOpenRequest, RegionRequest, }; @@ -653,6 +654,16 @@ impl MitoEngine { results } + /// Lists region info entries of all regions in the engine. + pub async fn all_region_infos(&self) -> Vec { + let node_id = self.inner.workers.file_ref_manager().node_id(); + self.inner + .workers + .all_regions() + .map(|region| region.region_info_entry(node_id)) + .collect() + } + /// Lists all SSTs from the storage layer of all regions in the engine. pub fn all_ssts_from_storage(&self) -> impl Stream> { let node_id = self.inner.workers.file_ref_manager().node_id(); diff --git a/src/mito2/src/engine/basic_test.rs b/src/mito2/src/engine/basic_test.rs index f256f88694..e1e462f692 100644 --- a/src/mito2/src/engine/basic_test.rs +++ b/src/mito2/src/engine/basic_test.rs @@ -978,6 +978,58 @@ async fn test_list_ssts_with_format( assert_eq!(debug_format, expected_storage_ssts, "{}", debug_format); } +#[tokio::test] +async fn test_all_region_infos() { + let mut env = TestEnv::with_prefix("all-region-infos").await; + let engine = env + .create_engine(MitoConfig { + default_flat_format: true, + ..Default::default() + }) + .await; + + let region_id = RegionId::new(1024, 7); + let request = CreateRequestBuilder::new().build(); + let column_schemas = rows_schema(&request); + engine + .handle_request(region_id, RegionRequest::Create(request)) + .await + .unwrap(); + + let rows = Rows { + schema: column_schemas, + rows: build_rows_for_key("region-info", 0, 3, 0), + }; + put_rows(&engine, region_id, rows).await; + engine + .handle_request( + region_id, + RegionRequest::Flush(RegionFlushRequest::default()), + ) + .await + .unwrap(); + + let entries = engine.all_region_infos().await; + let entry = entries + .iter() + .find(|entry| entry.region_id == region_id) + .expect("region info entry should exist"); + + assert_eq!(region_id.as_u64(), entry.region_id.as_u64()); + assert_eq!(region_id.table_id(), entry.table_id); + assert_eq!(region_id.region_number(), entry.region_number); + assert_eq!(region_id.region_group(), entry.region_group); + assert_eq!(region_id.region_sequence(), entry.region_sequence); + assert!(!entry.state.is_empty()); + assert_eq!("Leader", entry.role); + assert!(entry.writable); + assert_eq!(3, entry.committed_sequence); + assert_eq!(Some(3), entry.flushed_sequence); + assert!(entry.manifest_version > 0); + assert!(serde_json::from_str::(&entry.region_options).is_ok()); + assert_eq!("flat", entry.sst_format); +} + #[tokio::test] async fn test_all_index_metas_list_all_types() { test_all_index_metas_list_all_types_with_format(false, r#" diff --git a/src/mito2/src/region.rs b/src/mito2/src/region.rs index c85599bf58..f6d2a17bba 100644 --- a/src/mito2/src/region.rs +++ b/src/mito2/src/region.rs @@ -37,6 +37,7 @@ use store_api::metadata::RegionMetadataRef; use store_api::region_engine::{ RegionManifestInfo, RegionRole, RegionStatistic, SettableRegionRoleState, }; +use store_api::region_info::RegionInfoEntry; use store_api::region_request::{PathType, StagingPartitionDirective}; use store_api::sst_entry::ManifestSstEntry; use store_api::storage::{FileId, RegionId, SequenceNumber}; @@ -111,6 +112,22 @@ impl RegionRoleState { RegionRoleState::Follower => None, } } + + pub(crate) fn as_str(&self) -> &'static str { + match self { + RegionRoleState::Follower => "Follower", + RegionRoleState::Leader(RegionLeaderState::Writable) => "Leader(Writable)", + RegionRoleState::Leader(RegionLeaderState::Staging) => "Leader(Staging)", + RegionRoleState::Leader(RegionLeaderState::EnteringStaging) => { + "Leader(EnteringStaging)" + } + RegionRoleState::Leader(RegionLeaderState::Altering) => "Leader(Altering)", + RegionRoleState::Leader(RegionLeaderState::Dropping) => "Leader(Dropping)", + RegionRoleState::Leader(RegionLeaderState::Truncating) => "Leader(Truncating)", + RegionRoleState::Leader(RegionLeaderState::Editing) => "Leader(Editing)", + RegionRoleState::Leader(RegionLeaderState::Downgrading) => "Leader(Downgrading)", + } + } } /// Metadata and runtime status of a region. @@ -648,6 +665,41 @@ impl MitoRegion { self.access_layer.clone() } + /// Returns the region info entry of the region. + pub(crate) fn region_info_entry(&self, node_id: Option) -> RegionInfoEntry { + let region_id = self.region_id; + let version = self.version(); + let state = self.state(); + let role = self.region_role(); + let region_options = serde_json::to_string(&version.options) + .unwrap_or_else(|err| serde_json::json!({ "error": err.to_string() }).to_string()); + let sst_format = match version.options.sst_format.unwrap_or_default() { + crate::sst::FormatType::PrimaryKey => "primary_key", + crate::sst::FormatType::Flat => "flat", + } + .to_string(); + + RegionInfoEntry { + region_id, + table_id: region_id.table_id(), + region_number: region_id.region_number(), + region_group: region_id.region_group(), + region_sequence: region_id.region_sequence(), + state: state.as_str().to_string(), + role: role.to_string(), + writable: self.is_writable(), + committed_sequence: self.find_committed_sequence(), + flushed_sequence: Some(self.flushed_sequence()).filter(|sequence| *sequence > 0), + manifest_version: self.stats.manifest_version(), + compaction_time_window: version + .compaction_time_window + .map(|duration| humantime::format_duration(duration).to_string()), + region_options, + sst_format, + node_id, + } + } + /// Returns the SST entries of the region. pub async fn manifest_sst_entries(&self) -> Vec { let table_dir = self.table_dir(); @@ -1623,6 +1675,23 @@ mod tests { assert!(AtomicCell::::is_lock_free()); } + #[test] + fn test_region_role_state_as_str() { + assert_eq!("Follower", RegionRoleState::Follower.as_str()); + assert_eq!( + "Leader(Writable)", + RegionRoleState::Leader(RegionLeaderState::Writable).as_str() + ); + assert_eq!( + "Leader(Staging)", + RegionRoleState::Leader(RegionLeaderState::Staging).as_str() + ); + assert_eq!( + "Leader(Downgrading)", + RegionRoleState::Leader(RegionLeaderState::Downgrading).as_str() + ); + } + async fn build_test_region(env: &SchedulerEnv) -> MitoRegion { let builder = VersionControlBuilder::new(); let version_control = Arc::new(builder.build()); diff --git a/src/store-api/src/lib.rs b/src/store-api/src/lib.rs index cb39875d74..f97e348842 100644 --- a/src/store-api/src/lib.rs +++ b/src/store-api/src/lib.rs @@ -23,6 +23,7 @@ mod metrics; pub mod mito_engine_options; pub mod path_utils; pub mod region_engine; +pub mod region_info; pub mod region_request; pub mod sst_entry; pub mod storage; diff --git a/src/store-api/src/region_info.rs b/src/store-api/src/region_info.rs new file mode 100644 index 0000000000..2d099d2734 --- /dev/null +++ b/src/store-api/src/region_info.rs @@ -0,0 +1,360 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use common_recordbatch::DfRecordBatch; +use datafusion_common::DataFusionError; +use datafusion_expr::{LogicalPlan, LogicalPlanBuilder, LogicalTableSource}; +use datatypes::arrow::array::{ + ArrayRef, BooleanArray, StringArray, UInt8Array, UInt32Array, UInt64Array, +}; +use datatypes::arrow::error::ArrowError; +use datatypes::schema::{ColumnSchema, Schema, SchemaRef}; +use serde::{Deserialize, Serialize}; + +use crate::storage::{RegionGroup, RegionId, RegionNumber, RegionSeq, ScanRequest, TableId}; + +/// Runtime and manifest information of a region for inspection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegionInfoEntry { + /// The region id. + pub region_id: RegionId, + /// The table id this region belongs to. + pub table_id: TableId, + /// The region number inside the table. + pub region_number: RegionNumber, + /// The region group. + pub region_group: RegionGroup, + /// The region sequence inside the group. + pub region_sequence: RegionSeq, + /// The full runtime role/state label. + pub state: String, + /// The coarse region role. + pub role: String, + /// Whether the region accepts writes. + pub writable: bool, + /// The committed sequence of the region. + pub committed_sequence: u64, + /// The latest sequence that has been persisted into SSTs. + pub flushed_sequence: Option, + /// The manifest version of the region. + pub manifest_version: u64, + /// Human-readable compaction time window. + pub compaction_time_window: Option, + /// Region options encoded as JSON. + pub region_options: String, + /// SST format used by the region. + pub sst_format: String, + /// Datanode id that reports the row. + pub node_id: Option, +} + +impl RegionInfoEntry { + /// Returns the schema of the region info entry. + pub fn schema() -> SchemaRef { + use datatypes::prelude::ConcreteDataType as Ty; + Arc::new(Schema::new(vec![ + ColumnSchema::new("region_id", Ty::uint64_datatype(), false), + ColumnSchema::new("table_id", Ty::uint32_datatype(), false), + ColumnSchema::new("region_number", Ty::uint32_datatype(), false), + ColumnSchema::new("region_group", Ty::uint8_datatype(), false), + ColumnSchema::new("region_sequence", Ty::uint32_datatype(), false), + ColumnSchema::new("state", Ty::string_datatype(), false), + ColumnSchema::new("role", Ty::string_datatype(), false), + ColumnSchema::new("writable", Ty::boolean_datatype(), false), + ColumnSchema::new("committed_sequence", Ty::uint64_datatype(), false), + ColumnSchema::new("flushed_sequence", Ty::uint64_datatype(), true), + ColumnSchema::new("manifest_version", Ty::uint64_datatype(), false), + ColumnSchema::new("compaction_time_window", Ty::string_datatype(), true), + ColumnSchema::new("region_options", Ty::string_datatype(), false), + ColumnSchema::new("sst_format", Ty::string_datatype(), false), + ColumnSchema::new("node_id", Ty::uint64_datatype(), true), + ])) + } + + /// Converts a list of region info entries to a record batch. + pub fn to_record_batch(entries: &[Self]) -> Result { + let schema = Self::schema(); + let region_ids = entries.iter().map(|e| e.region_id.as_u64()); + let table_ids = entries.iter().map(|e| e.table_id); + let region_numbers = entries.iter().map(|e| e.region_number); + let region_groups = entries.iter().map(|e| e.region_group); + let region_sequences = entries.iter().map(|e| e.region_sequence); + let states = entries.iter().map(|e| e.state.as_str()); + let roles = entries.iter().map(|e| e.role.as_str()); + let writable = entries.iter().map(|e| e.writable); + let committed_sequences = entries.iter().map(|e| e.committed_sequence); + let flushed_sequences = entries.iter().map(|e| e.flushed_sequence); + let manifest_versions = entries.iter().map(|e| e.manifest_version); + let compaction_time_windows = entries.iter().map(|e| e.compaction_time_window.as_ref()); + let region_options = entries.iter().map(|e| e.region_options.as_str()); + let sst_formats = entries.iter().map(|e| e.sst_format.as_str()); + let node_ids = entries.iter().map(|e| e.node_id); + + let columns: Vec = vec![ + Arc::new(UInt64Array::from_iter_values(region_ids)), + Arc::new(UInt32Array::from_iter_values(table_ids)), + Arc::new(UInt32Array::from_iter_values(region_numbers)), + Arc::new(UInt8Array::from_iter_values(region_groups)), + Arc::new(UInt32Array::from_iter_values(region_sequences)), + Arc::new(StringArray::from_iter_values(states)), + Arc::new(StringArray::from_iter_values(roles)), + Arc::new(BooleanArray::from_iter(writable)), + Arc::new(UInt64Array::from_iter_values(committed_sequences)), + Arc::new(UInt64Array::from_iter(flushed_sequences)), + Arc::new(UInt64Array::from_iter_values(manifest_versions)), + Arc::new(StringArray::from_iter(compaction_time_windows)), + Arc::new(StringArray::from_iter_values(region_options)), + Arc::new(StringArray::from_iter_values(sst_formats)), + Arc::new(UInt64Array::from_iter(node_ids)), + ]; + + DfRecordBatch::try_new(schema.arrow_schema().clone(), columns) + } + + /// Reserved internal inspect table name for region info. + pub fn reserved_table_name_for_inspection() -> &'static str { + "__inspect/__mito/__region_info" + } + + /// Builds a logical plan for scanning region info entries. + pub fn build_plan(scan_request: ScanRequest) -> Result { + let table_source = LogicalTableSource::new(Self::schema().arrow_schema().clone()); + + let projection = scan_request.projection_input.map(|input| input.projection); + let mut builder = LogicalPlanBuilder::scan( + Self::reserved_table_name_for_inspection(), + Arc::new(table_source), + projection, + )?; + + for filter in scan_request.filters { + builder = builder.filter(filter)?; + } + + if let Some(limit) = scan_request.limit { + builder = builder.limit(0, Some(limit))?; + } + + builder.build() + } +} + +#[cfg(test)] +mod tests { + use datafusion_common::TableReference; + use datafusion_expr::{LogicalPlan, Operator, binary_expr, col, lit}; + use datatypes::arrow::array::{ + Array, BooleanArray, StringArray, UInt8Array, UInt32Array, UInt64Array, + }; + + use super::*; + use crate::storage::{RegionId, ScanRequest}; + + #[test] + fn test_region_info_schema() { + let schema = RegionInfoEntry::schema(); + let columns = schema.column_schemas(); + + let names = columns.iter().map(|c| c.name.as_str()).collect::>(); + assert_eq!( + names, + vec![ + "region_id", + "table_id", + "region_number", + "region_group", + "region_sequence", + "state", + "role", + "writable", + "committed_sequence", + "flushed_sequence", + "manifest_version", + "compaction_time_window", + "region_options", + "sst_format", + "node_id", + ] + ); + assert!(!columns[0].is_nullable()); + assert!(!columns[8].is_nullable()); + assert!(columns[9].is_nullable()); + assert!(columns[11].is_nullable()); + assert!(columns[14].is_nullable()); + } + + #[test] + fn test_region_info_to_record_batch() { + let region_id1 = RegionId::with_group_and_seq(10, 1, 20); + let region_id2 = RegionId::with_group_and_seq(11, 0, 21); + let entries = vec![ + RegionInfoEntry { + region_id: region_id1, + table_id: region_id1.table_id(), + region_number: region_id1.region_number(), + region_group: region_id1.region_group(), + region_sequence: region_id1.region_sequence(), + state: "Leader(Writable)".to_string(), + role: "Leader".to_string(), + writable: true, + committed_sequence: 42, + flushed_sequence: Some(41), + manifest_version: 7, + compaction_time_window: Some("1h".to_string()), + region_options: "{\"sst_format\":\"flat\"}".to_string(), + sst_format: "flat".to_string(), + node_id: Some(3), + }, + RegionInfoEntry { + region_id: region_id2, + table_id: region_id2.table_id(), + region_number: region_id2.region_number(), + region_group: region_id2.region_group(), + region_sequence: region_id2.region_sequence(), + state: "Follower".to_string(), + role: "Follower".to_string(), + writable: false, + committed_sequence: 9, + flushed_sequence: None, + manifest_version: 2, + compaction_time_window: None, + region_options: "{}".to_string(), + sst_format: "primary_key".to_string(), + node_id: None, + }, + ]; + + let batch = RegionInfoEntry::to_record_batch(&entries).unwrap(); + assert_eq!(batch.num_rows(), 2); + + let region_ids = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(region_id1.as_u64(), region_ids.value(0)); + assert_eq!(region_id2.as_u64(), region_ids.value(1)); + + let table_ids = batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(10, table_ids.value(0)); + assert_eq!(11, table_ids.value(1)); + + let region_groups = batch + .column(3) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(1, region_groups.value(0)); + assert_eq!(0, region_groups.value(1)); + + let states = batch + .column(5) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!("Leader(Writable)", states.value(0)); + assert_eq!("Follower", states.value(1)); + + let writable = batch + .column(7) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(writable.value(0)); + assert!(!writable.value(1)); + + let committed_sequences = batch + .column(8) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(42, committed_sequences.value(0)); + assert_eq!(9, committed_sequences.value(1)); + + let flushed_sequences = batch + .column(9) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(41, flushed_sequences.value(0)); + assert!(flushed_sequences.is_null(1)); + + let compaction_time_windows = batch + .column(11) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!("1h", compaction_time_windows.value(0)); + assert!(compaction_time_windows.is_null(1)); + + let node_ids = batch + .column(14) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(3, node_ids.value(0)); + assert!(node_ids.is_null(1)); + } + + #[test] + fn test_region_info_build_plan() { + let projection_input = Some(vec![0, 5, 7, 11].into()); + let request = ScanRequest { + projection_input, + filters: vec![binary_expr(col("writable"), Operator::Eq, lit(true))], + limit: Some(10), + ..Default::default() + }; + + let plan = RegionInfoEntry::build_plan(request).unwrap(); + let (scan, has_filter, has_limit) = extract_scan(&plan); + assert!(has_filter); + assert!(has_limit); + assert_eq!( + scan.table_name, + TableReference::bare(RegionInfoEntry::reserved_table_name_for_inspection()) + ); + assert_eq!(scan.projection, Some(vec![0, 5, 7, 11])); + + let fields = scan.projected_schema.fields(); + assert_eq!(fields.len(), 4); + assert_eq!(fields[0].name(), "region_id"); + assert_eq!(fields[1].name(), "state"); + assert_eq!(fields[2].name(), "writable"); + assert_eq!(fields[3].name(), "compaction_time_window"); + } + + fn extract_scan(plan: &LogicalPlan) -> (&datafusion_expr::logical_plan::TableScan, bool, bool) { + use datafusion_expr::logical_plan::Limit; + + match plan { + LogicalPlan::Filter(f) => { + let (scan, _, has_limit) = extract_scan(&f.input); + (scan, true, has_limit) + } + LogicalPlan::Limit(Limit { input, .. }) => { + let (scan, has_filter, _) = extract_scan(input); + (scan, has_filter, true) + } + LogicalPlan::TableScan(scan) => (scan, false, false), + other => panic!("unexpected plan: {other:?}"), + } + } +} diff --git a/tests/cases/standalone/common/information_schema/region_info.result b/tests/cases/standalone/common/information_schema/region_info.result new file mode 100644 index 0000000000..ed9a07add6 --- /dev/null +++ b/tests/cases/standalone/common/information_schema/region_info.result @@ -0,0 +1,63 @@ +DESC TABLE information_schema.region_info; + ++------------------------+---------+-----+------+---------+---------------+ +| Column | Type | Key | Null | Default | Semantic Type | ++------------------------+---------+-----+------+---------+---------------+ +| region_id | UInt64 | | NO | | FIELD | +| table_id | UInt32 | | NO | | FIELD | +| region_number | UInt32 | | NO | | FIELD | +| region_group | UInt8 | | NO | | FIELD | +| region_sequence | UInt32 | | NO | | FIELD | +| state | String | | NO | | FIELD | +| role | String | | NO | | FIELD | +| writable | Boolean | | NO | | FIELD | +| committed_sequence | UInt64 | | NO | | FIELD | +| flushed_sequence | UInt64 | | YES | | FIELD | +| manifest_version | UInt64 | | NO | | FIELD | +| compaction_time_window | String | | YES | | FIELD | +| region_options | String | | NO | | FIELD | +| sst_format | String | | NO | | FIELD | +| node_id | UInt64 | | YES | | FIELD | ++------------------------+---------+-----+------+---------+---------------+ + +CREATE TABLE region_info_case ( + a INT PRIMARY KEY, + ts TIMESTAMP TIME INDEX, +) +WITH ("sst_format" = "flat"); + +Affected Rows: 0 + +INSERT INTO region_info_case VALUES (1, 1), (2, 2); + +Affected Rows: 2 + +ADMIN FLUSH_TABLE('region_info_case'); + ++---------------------------------------+ +| ADMIN FLUSH_TABLE('region_info_case') | ++---------------------------------------+ +| 0 | ++---------------------------------------+ + +-- SQLNESS REPLACE (\s+\d+\s+) +-- SQLNESS REPLACE (\{".*"\}) +-- SQLNESS REPLACE (-{40,}) ---------------- +-- SQLNESS REPLACE (region_options\s+\|) region_options | +SELECT region_id, state, role, writable, committed_sequence, flushed_sequence, manifest_version, compaction_time_window, region_options, sst_format +FROM information_schema.region_info +WHERE region_id IN ( + SELECT region_id FROM information_schema.region_peers WHERE table_name = 'region_info_case' +) +ORDER BY region_id; + ++---------------+------------------+--------+----------+--------------------+------------------+------------------+------------------------+----------------+------------+ +| region_id | state | role | writable | committed_sequence | flushed_sequence | manifest_version | compaction_time_window | region_options | sst_format | ++---------------+------------------+--------+----------+--------------------+------------------+------------------+------------------------+----------------+------------+ +|| Leader(Writable) | Leader | true |||| | | flat | ++---------------+------------------+--------+----------+--------------------+------------------+------------------+------------------------+----------------+------------+ + +DROP TABLE region_info_case; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/information_schema/region_info.sql b/tests/cases/standalone/common/information_schema/region_info.sql new file mode 100644 index 0000000000..1aa682393f --- /dev/null +++ b/tests/cases/standalone/common/information_schema/region_info.sql @@ -0,0 +1,24 @@ +DESC TABLE information_schema.region_info; + +CREATE TABLE region_info_case ( + a INT PRIMARY KEY, + ts TIMESTAMP TIME INDEX, +) +WITH ("sst_format" = "flat"); + +INSERT INTO region_info_case VALUES (1, 1), (2, 2); + +ADMIN FLUSH_TABLE('region_info_case'); + +-- SQLNESS REPLACE (\s+\d+\s+) +-- SQLNESS REPLACE (\{".*"\}) +-- SQLNESS REPLACE (-{40,}) ---------------- +-- SQLNESS REPLACE (region_options\s+\|) region_options | +SELECT region_id, state, role, writable, committed_sequence, flushed_sequence, manifest_version, compaction_time_window, region_options, sst_format +FROM information_schema.region_info +WHERE region_id IN ( + SELECT region_id FROM information_schema.region_peers WHERE table_name = 'region_info_case' +) +ORDER BY region_id; + +DROP TABLE region_info_case; diff --git a/tests/cases/standalone/common/show/show_databases_tables.result b/tests/cases/standalone/common/show/show_databases_tables.result index d817227392..e816e989d9 100644 --- a/tests/cases/standalone/common/show/show_databases_tables.result +++ b/tests/cases/standalone/common/show/show_databases_tables.result @@ -49,6 +49,7 @@ SHOW TABLES; | process_list | | profiling | | referential_constraints | +| region_info | | region_peers | | region_statistics | | routines | @@ -99,6 +100,7 @@ SHOW FULL TABLES; | process_list | LOCAL TEMPORARY | | profiling | LOCAL TEMPORARY | | referential_constraints | LOCAL TEMPORARY | +| region_info | LOCAL TEMPORARY | | region_peers | LOCAL TEMPORARY | | region_statistics | LOCAL TEMPORARY | | routines | LOCAL TEMPORARY | @@ -143,6 +145,7 @@ SHOW TABLE STATUS; |process_list||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| |profiling||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| |referential_constraints||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| +|region_info||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| |region_peers||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| |region_statistics||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| |routines||11|Fixed|0|0|0|0|0|0|0|DATETIME|DATETIME||utf8_bin|0||| diff --git a/tests/cases/standalone/common/system/information_schema.result b/tests/cases/standalone/common/system/information_schema.result index 38f3ea52a4..6cff6e2ce9 100644 --- a/tests/cases/standalone/common/system/information_schema.result +++ b/tests/cases/standalone/common/system/information_schema.result @@ -36,6 +36,7 @@ order by table_schema, table_name; |greptime|information_schema|process_list|LOCALTEMPORARY|36|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| |greptime|information_schema|profiling|LOCALTEMPORARY|19|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| |greptime|information_schema|referential_constraints|LOCALTEMPORARY|20|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| +|greptime|information_schema|region_info|LOCALTEMPORARY|41|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| |greptime|information_schema|region_peers|LOCALTEMPORARY|29|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| |greptime|information_schema|region_statistics|LOCALTEMPORARY|35|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| |greptime|information_schema|routines|LOCALTEMPORARY|21|0|0|0|0|0||11|Fixed|0|0|0|DATETIME|DATETIME||utf8_bin|0|||Y| @@ -316,6 +317,21 @@ select * from information_schema.columns order by table_schema, table_name, colu | greptime | information_schema | referential_constraints | unique_constraint_name | 6 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | | greptime | information_schema | referential_constraints | unique_constraint_schema | 5 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | | greptime | information_schema | referential_constraints | update_rule | 8 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | +| greptime | information_schema | region_info | committed_sequence | 9 | | | 20 | 0 | | | | | | select,insert | | UInt64 | bigint unsigned | FIELD | | No | bigint unsigned | | | +| greptime | information_schema | region_info | compaction_time_window | 12 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | Yes | string | | | +| greptime | information_schema | region_info | flushed_sequence | 10 | | | 20 | 0 | | | | | | select,insert | | UInt64 | bigint unsigned | FIELD | | Yes | bigint unsigned | | | +| greptime | information_schema | region_info | manifest_version | 11 | | | 20 | 0 | | | | | | select,insert | | UInt64 | bigint unsigned | FIELD | | No | bigint unsigned | | | +| greptime | information_schema | region_info | node_id | 15 | | | 20 | 0 | | | | | | select,insert | | UInt64 | bigint unsigned | FIELD | | Yes | bigint unsigned | | | +| greptime | information_schema | region_info | region_group | 4 | | | 3 | 0 | | | | | | select,insert | | UInt8 | tinyint unsigned | FIELD | | No | tinyint unsigned | | | +| greptime | information_schema | region_info | region_id | 1 | | | 20 | 0 | | | | | | select,insert | | UInt64 | bigint unsigned | FIELD | | No | bigint unsigned | | | +| greptime | information_schema | region_info | region_number | 3 | | | 10 | 0 | | | | | | select,insert | | UInt32 | int unsigned | FIELD | | No | int unsigned | | | +| greptime | information_schema | region_info | region_options | 13 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | +| greptime | information_schema | region_info | region_sequence | 5 | | | 10 | 0 | | | | | | select,insert | | UInt32 | int unsigned | FIELD | | No | int unsigned | | | +| greptime | information_schema | region_info | role | 7 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | +| greptime | information_schema | region_info | sst_format | 14 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | +| greptime | information_schema | region_info | state | 6 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | No | string | | | +| greptime | information_schema | region_info | table_id | 2 | | | 10 | 0 | | | | | | select,insert | | UInt32 | int unsigned | FIELD | | No | int unsigned | | | +| greptime | information_schema | region_info | writable | 8 | | | | | | | | | | select,insert | | Boolean | boolean | FIELD | | No | boolean | | | | greptime | information_schema | region_peers | down_seconds | 9 | | | 19 | 0 | | | | | | select,insert | | Int64 | bigint | FIELD | | Yes | bigint | | | | greptime | information_schema | region_peers | is_leader | 7 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | Yes | string | | | | greptime | information_schema | region_peers | peer_addr | 6 | 2147483647 | 2147483647 | | | | utf8 | utf8_bin | | | select,insert | | String | string | FIELD | | Yes | string | | | diff --git a/tests/cases/standalone/common/view/create.result b/tests/cases/standalone/common/view/create.result index 76b9838628..4674f83c98 100644 --- a/tests/cases/standalone/common/view/create.result +++ b/tests/cases/standalone/common/view/create.result @@ -116,6 +116,7 @@ SELECT * FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_NAME, TABLE_TYPE; |greptime|information_schema|process_list|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| |greptime|information_schema|profiling|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| |greptime|information_schema|referential_constraints|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| +|greptime|information_schema|region_info|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| |greptime|information_schema|region_peers|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| |greptime|information_schema|region_statistics|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| |greptime|information_schema|routines|LOCALTEMPORARY|ID|ID|ID|ID|ID|ID||ID|Fixed|ID|ID|ID|DATETIME|DATETIME||utf8_bin|ID|||Y| From 1a2d0463514a6c9b9527c74a4b896cdbc1b4b60d Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Thu, 28 May 2026 18:04:39 +0800 Subject: [PATCH 28/32] fix(mito): count owned SSTs in region stats (#8191) * fix(mito): count owned SSTs in region stats Signed-off-by: WenyXu * fix(mito): use origin region for index metadata Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu * chore: apply suggestions Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- src/mito2/src/engine.rs | 4 +- src/mito2/src/region.rs | 8 +-- src/mito2/src/sst/version.rs | 88 +++++++++++++++++++++++++----- src/store-api/src/region_engine.rs | 17 ++++-- 4 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/mito2/src/engine.rs b/src/mito2/src/engine.rs index 64f7576139..782c82b54a 100644 --- a/src/mito2/src/engine.rs +++ b/src/mito2/src/engine.rs @@ -613,8 +613,10 @@ impl MitoEngine { return Vec::new(); } }; + // The index file path is derived from the physical file owner. After + // repartition, `entry.region_id` is only the referring region. let region_index_id = RegionIndexId::new( - RegionFileId::new(entry.region_id, file_id), + RegionFileId::new(entry.origin_region_id, file_id), index_version, ); let context = IndexEntryContext { diff --git a/src/mito2/src/region.rs b/src/mito2/src/region.rs index f6d2a17bba..9d214caed3 100644 --- a/src/mito2/src/region.rs +++ b/src/mito2/src/region.rs @@ -601,14 +601,14 @@ impl MitoRegion { let memtables = &version.memtables; let memtable_usage = (memtables.mutable_usage() + memtables.immutables_usage()) as u64; - let sst_usage = version.ssts.sst_usage(); - let index_usage = version.ssts.index_usage(); + let sst_usage = version.ssts.owned_sst_usage(self.region_id); + let index_usage = version.ssts.owned_index_usage(self.region_id); let flushed_entry_id = version.flushed_entry_id; let wal_usage = self.estimated_wal_usage(memtable_usage); let manifest_usage = self.stats.total_manifest_size(); - let num_rows = version.ssts.num_rows() + version.memtables.num_rows(); - let num_files = version.ssts.num_files(); + let num_rows = version.ssts.owned_num_rows(self.region_id) + version.memtables.num_rows(); + let num_files = version.ssts.owned_num_files(self.region_id); let manifest_version = self.stats.manifest_version(); let file_removed_cnt = self.stats.file_removed_cnt(); diff --git a/src/mito2/src/sst/version.rs b/src/mito2/src/sst/version.rs index 5958cf7513..67d41a3b82 100644 --- a/src/mito2/src/sst/version.rs +++ b/src/mito2/src/sst/version.rs @@ -18,7 +18,7 @@ use std::fmt; use std::sync::Arc; use common_time::{TimeToLive, Timestamp}; -use store_api::storage::FileId; +use store_api::storage::{FileId, RegionId}; use crate::sst::file::{FileHandle, FileMeta, Level, MAX_LEVEL}; use crate::sst::file_purger::FilePurgerRef; @@ -106,15 +106,19 @@ impl SstVersion { } } - /// Returns the number of rows in SST files. + /// Returns the number of rows in SST files owned by `region_id`. + /// + /// Rows from SST files referenced from other regions, for example after + /// repartition, are not counted. /// For historical reasons, the result is not precise for old SST files. - pub(crate) fn num_rows(&self) -> u64 { + pub(crate) fn owned_num_rows(&self, region_id: RegionId) -> u64 { self.levels .iter() .map(|level_meta| { level_meta .files .values() + .filter(|file_handle| file_handle.region_id() == region_id) .map(|file_handle| { let meta = file_handle.meta_ref(); meta.num_rows @@ -124,22 +128,29 @@ impl SstVersion { .sum() } - /// Returns the number of SST files. - pub(crate) fn num_files(&self) -> u64 { - self.levels - .iter() - .map(|level_meta| level_meta.files.len() as u64) - .sum() - } - - /// Returns SST data files'space occupied in current version. - pub(crate) fn sst_usage(&self) -> u64 { + /// Returns the number of SST files owned by `region_id`. + pub(crate) fn owned_num_files(&self, region_id: RegionId) -> u64 { self.levels .iter() .map(|level_meta| { level_meta .files .values() + .filter(|file_handle| file_handle.region_id() == region_id) + .count() as u64 + }) + .sum() + } + + /// Returns the space occupied by SST data files owned by `region_id`. + pub(crate) fn owned_sst_usage(&self, region_id: RegionId) -> u64 { + self.levels + .iter() + .map(|level_meta| { + level_meta + .files + .values() + .filter(|file_handle| file_handle.region_id() == region_id) .map(|file_handle| { let meta = file_handle.meta_ref(); meta.file_size @@ -149,14 +160,15 @@ impl SstVersion { .sum() } - /// Returns SST index files'space occupied in current version. - pub(crate) fn index_usage(&self) -> u64 { + /// Returns the space occupied by SST index files owned by `region_id`. + pub(crate) fn owned_index_usage(&self, region_id: RegionId) -> u64 { self.levels .iter() .map(|level_meta| { level_meta .files .values() + .filter(|file_handle| file_handle.region_id() == region_id) .map(|file_handle| { let meta = file_handle.meta_ref(); meta.index_file_size @@ -257,4 +269,50 @@ mod tests { assert!(added_files.contains_key(&f.file_id)); }); } + + #[test] + fn test_usage_only_counts_owned_files() { + let purger = new_noop_file_purger(); + let region_id = RegionId::new(1, 1); + let other_region_id = RegionId::new(1, 2); + + let files = [ + FileMeta { + region_id, + file_id: FileId::random(), + file_size: 100, + index_file_size: 10, + num_rows: 1, + ..Default::default() + }, + FileMeta { + region_id, + file_id: FileId::random(), + file_size: 200, + index_file_size: 20, + num_rows: 2, + ..Default::default() + }, + FileMeta { + region_id: other_region_id, + file_id: FileId::random(), + file_size: 300, + index_file_size: 30, + num_rows: 3, + ..Default::default() + }, + ]; + + let mut version = SstVersion::new(); + version.add_files(purger, files.iter().cloned()); + + assert_eq!(3, version.owned_num_rows(region_id)); + assert_eq!(2, version.owned_num_files(region_id)); + assert_eq!(300, version.owned_sst_usage(region_id)); + assert_eq!(30, version.owned_index_usage(region_id)); + assert_eq!(3, version.owned_num_rows(other_region_id)); + assert_eq!(1, version.owned_num_files(other_region_id)); + assert_eq!(300, version.owned_sst_usage(other_region_id)); + assert_eq!(30, version.owned_index_usage(other_region_id)); + } } diff --git a/src/store-api/src/region_engine.rs b/src/store-api/src/region_engine.rs index b235fcffc7..e5ab05e5e7 100644 --- a/src/store-api/src/region_engine.rs +++ b/src/store-api/src/region_engine.rs @@ -483,7 +483,10 @@ pub type BatchResponses = Vec<(RegionId, Result)>; /// Represents the statistics of a region. #[derive(Debug, Deserialize, Serialize, Default)] pub struct RegionStatistic { - /// The number of rows + /// The number of rows stored in SST files owned by this region plus rows in memtables. + /// + /// Rows from SST files referenced from other regions, for example after repartition, + /// are not counted to avoid table-level double counting when summing region statistics. #[serde(default)] pub num_rows: u64, /// The size of memtable in bytes. @@ -492,11 +495,17 @@ pub struct RegionStatistic { pub wal_size: u64, /// The size of manifest in bytes. pub manifest_size: u64, - /// The size of SST data files in bytes. + /// The size of SST data files owned by this region in bytes. + /// + /// SST files referenced from other regions, for example after repartition, are not counted. pub sst_size: u64, - /// The num of SST files. + /// The number of SST files owned by this region. + /// + /// SST files referenced from other regions, for example after repartition, are not counted. pub sst_num: u64, - /// The size of SST index files in bytes. + /// The size of SST index files owned by this region in bytes. + /// + /// SST index files referenced from other regions, for example after repartition, are not counted. #[serde(default)] pub index_size: u64, /// The details of the region. From 85ae29cb0c53461494497253bb6a77a69c2cda5c Mon Sep 17 00:00:00 2001 From: Ruihang Xia Date: Thu, 28 May 2026 19:30:30 +0800 Subject: [PATCH 29/32] perf: collect narrow binary join (#8193) * perf(promql): collect narrow binary join build side * fix projection Signed-off-by: Ruihang Xia * finalize Signed-off-by: Ruihang Xia * rename mod Signed-off-by: Ruihang Xia * update sqlness result Signed-off-by: Ruihang Xia --------- Signed-off-by: Ruihang Xia --- src/query/src/optimizer.rs | 1 + .../src/optimizer/promql_tsid_narrow_join.rs | 271 ++++++++++++++++++ src/query/src/query_engine/state.rs | 7 +- .../promql/tsid_binary_join_regression.result | 12 +- .../common/tql-explain-analyze/explain.result | 3 + 5 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/query/src/optimizer/promql_tsid_narrow_join.rs diff --git a/src/query/src/optimizer.rs b/src/query/src/optimizer.rs index b85320a495..ffbfff5ee2 100644 --- a/src/query/src/optimizer.rs +++ b/src/query/src/optimizer.rs @@ -19,6 +19,7 @@ pub mod count_wildcard; pub(crate) mod json_type_concretize; pub mod parallelize_scan; pub mod pass_distribution; +pub mod promql_tsid_narrow_join; pub mod remove_duplicate; pub mod scan_hint; pub mod string_normalization; diff --git a/src/query/src/optimizer/promql_tsid_narrow_join.rs b/src/query/src/optimizer/promql_tsid_narrow_join.rs new file mode 100644 index 0000000000..419415662e --- /dev/null +++ b/src/query/src/optimizer/promql_tsid_narrow_join.rs @@ -0,0 +1,271 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use arrow_schema::{DataType, SchemaRef}; +use datafusion::config::ConfigOptions; +use datafusion::physical_optimizer::PhysicalOptimizerRule; +use datafusion::physical_plan::ExecutionPlan; +use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode}; +use datafusion_common::Result as DfResult; +use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode}; +use datafusion_expr::JoinType; +use datafusion_physical_expr::expressions::Column; +use store_api::metric_engine_consts::DATA_SCHEMA_TSID_COLUMN_NAME; + +/// Chooses a broadcast-style hash join for the PromQL vector-vector shape where +/// the build side only carries value, `__tsid`, and timestamp columns. +/// +/// PromQL arithmetic joins often keep one side narrow (without raw labels) and the other side wide +/// with all output labels. Partitioning both sides shuffles the wide stream. +/// `CollectLeft` only gathers the narrow build side and lets the wide probe side +/// keep its existing partitioning. +#[derive(Debug)] +pub struct PromqlTsidNarrowJoin; + +impl PhysicalOptimizerRule for PromqlTsidNarrowJoin { + fn optimize( + &self, + plan: Arc, + _config: &ConfigOptions, + ) -> DfResult> { + plan.transform_up(Self::rewrite_join).data() + } + + fn name(&self) -> &str { + "PromqlTsidNarrowJoin" + } + + fn schema_check(&self) -> bool { + true + } +} + +impl PromqlTsidNarrowJoin { + fn rewrite_join(plan: Arc) -> DfResult>> { + let Some(hash_join) = plan.as_any().downcast_ref::() else { + return Ok(Transformed::no(plan)); + }; + + if !Self::should_collect_left(hash_join) { + return Ok(Transformed::no(plan)); + } + + Ok(Transformed::yes( + hash_join + .builder() + .with_partition_mode(PartitionMode::CollectLeft) + .reset_state() + .build_exec()?, + )) + } + + fn should_collect_left(hash_join: &HashJoinExec) -> bool { + hash_join.partition_mode() == &PartitionMode::Partitioned + && hash_join.join_type() == &JoinType::Inner + && hash_join.filter().is_none() + && hash_join.right().schema().fields().len() > hash_join.left().schema().fields().len() + && Self::is_promql_value_tsid_time_schema(&hash_join.left().schema()) + && Self::joins_on_tsid_and_time(hash_join) + } + + fn is_promql_value_tsid_time_schema(schema: &SchemaRef) -> bool { + let mut has_value = false; + let mut has_tsid = false; + let mut has_time = false; + + for field in schema.fields() { + match field.name().as_str() { + "greptime_value" => has_value = true, + DATA_SCHEMA_TSID_COLUMN_NAME => has_tsid = true, + _ if matches!(field.data_type(), DataType::Timestamp(_, _)) => has_time = true, + _ => return false, + } + } + + has_value && has_tsid && has_time + } + + fn joins_on_tsid_and_time(hash_join: &HashJoinExec) -> bool { + let mut has_tsid = false; + let mut has_time = false; + + for (left, right) in hash_join.on() { + let (Some(left_col), Some(right_col)) = ( + left.as_any().downcast_ref::(), + right.as_any().downcast_ref::(), + ) else { + return false; + }; + + if left_col.name() == DATA_SCHEMA_TSID_COLUMN_NAME + && right_col.name() == DATA_SCHEMA_TSID_COLUMN_NAME + { + has_tsid = true; + } else if matches!( + hash_join + .left() + .schema() + .field(left_col.index()) + .data_type(), + DataType::Timestamp(_, _) + ) && matches!( + hash_join + .right() + .schema() + .field(right_col.index()) + .data_type(), + DataType::Timestamp(_, _) + ) { + has_time = true; + } + } + + has_tsid && has_time + } +} + +#[cfg(test)] +mod tests { + use arrow_schema::{DataType, Field, Schema, TimeUnit}; + use datafusion::common::NullEquality; + use datafusion::physical_optimizer::PhysicalOptimizerRule; + use datafusion::physical_plan::displayable; + use datafusion::physical_plan::empty::EmptyExec; + use datafusion::physical_plan::joins::HashJoinExec; + use datafusion_common::config::ConfigOptions; + use datafusion_physical_expr::PhysicalExpr; + + use super::*; + + #[test] + fn chooses_collect_left_for_narrow_promql_build_side() { + let left = Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![ + Field::new("greptime_value", DataType::Float64, true), + Field::new(DATA_SCHEMA_TSID_COLUMN_NAME, DataType::UInt64, false), + Field::new( + "greptime_timestamp", + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + ])))) as Arc; + let right = Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![ + Field::new("greptime_value", DataType::Float64, true), + Field::new("host", DataType::Utf8, true), + Field::new(DATA_SCHEMA_TSID_COLUMN_NAME, DataType::UInt64, false), + Field::new( + "greptime_timestamp", + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + ])))) as Arc; + let on = vec![ + ( + Arc::new(Column::new(DATA_SCHEMA_TSID_COLUMN_NAME, 1)) as Arc, + Arc::new(Column::new(DATA_SCHEMA_TSID_COLUMN_NAME, 2)) as Arc, + ), + ( + Arc::new(Column::new("greptime_timestamp", 2)) as Arc, + Arc::new(Column::new("greptime_timestamp", 3)) as Arc, + ), + ]; + let join = Arc::new( + HashJoinExec::try_new( + left, + right, + on, + None, + &JoinType::Inner, + Some(vec![0, 3, 4, 5, 6]), + PartitionMode::Partitioned, + NullEquality::NullEqualsNull, + false, + ) + .unwrap(), + ) as Arc; + let original_schema = join.schema(); + + let optimized = PromqlTsidNarrowJoin + .optimize(join, &ConfigOptions::default()) + .unwrap(); + let optimized_join = optimized.as_any().downcast_ref::().unwrap(); + + assert_eq!(optimized_join.partition_mode(), &PartitionMode::CollectLeft); + assert_eq!(optimized.schema(), original_schema); + assert!( + displayable(optimized.as_ref()) + .one_line() + .to_string() + .contains( + "projection=[greptime_value@0, greptime_value@3, host@4, __tsid@5, greptime_timestamp@6]" + ) + ); + } + + #[test] + fn keeps_partitioned_join_when_left_side_carries_labels() { + let left = Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![ + Field::new("greptime_value", DataType::Float64, true), + Field::new("host", DataType::Utf8, true), + Field::new(DATA_SCHEMA_TSID_COLUMN_NAME, DataType::UInt64, false), + Field::new( + "greptime_timestamp", + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + ])))) as Arc; + let right = Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![ + Field::new("greptime_value", DataType::Float64, true), + Field::new(DATA_SCHEMA_TSID_COLUMN_NAME, DataType::UInt64, false), + Field::new( + "greptime_timestamp", + DataType::Timestamp(TimeUnit::Millisecond, None), + false, + ), + ])))) as Arc; + let join = Arc::new( + HashJoinExec::try_new( + left, + right, + vec![ + ( + Arc::new(Column::new(DATA_SCHEMA_TSID_COLUMN_NAME, 2)) + as Arc, + Arc::new(Column::new(DATA_SCHEMA_TSID_COLUMN_NAME, 1)) + as Arc, + ), + ( + Arc::new(Column::new("greptime_timestamp", 3)) as Arc, + Arc::new(Column::new("greptime_timestamp", 2)) as Arc, + ), + ], + None, + &JoinType::Inner, + None, + PartitionMode::Partitioned, + NullEquality::NullEqualsNull, + false, + ) + .unwrap(), + ) as Arc; + + let optimized = PromqlTsidNarrowJoin + .optimize(join, &ConfigOptions::default()) + .unwrap(); + let optimized_join = optimized.as_any().downcast_ref::().unwrap(); + + assert_eq!(optimized_join.partition_mode(), &PartitionMode::Partitioned); + } +} diff --git a/src/query/src/query_engine/state.rs b/src/query/src/query_engine/state.rs index 45a5700781..4262428091 100644 --- a/src/query/src/query_engine/state.rs +++ b/src/query/src/query_engine/state.rs @@ -66,6 +66,7 @@ use crate::optimizer::count_wildcard::CountWildcardToTimeIndexRule; use crate::optimizer::json_type_concretize::JsonTypeConcretizeRule; use crate::optimizer::parallelize_scan::ParallelizeScan; use crate::optimizer::pass_distribution::PassDistribution; +use crate::optimizer::promql_tsid_narrow_join::PromqlTsidNarrowJoin; use crate::optimizer::remove_duplicate::RemoveDuplicate; use crate::optimizer::scan_hint::ScanHintRule; use crate::optimizer::string_normalization::StringNormalizationRule; @@ -189,9 +190,13 @@ impl QueryEngineState { physical_optimizer .rules .insert(6, Arc::new(PassDistribution)); + // Prefer collecting narrow PromQL build sides over repartitioning wide label streams. + physical_optimizer + .rules + .insert(7, Arc::new(PromqlTsidNarrowJoin)); // Enforce sorting AFTER custom rules that modify the plan structure physical_optimizer.rules.insert( - 7, + 8, Arc::new(datafusion::physical_optimizer::enforce_sorting::EnforceSorting {}), ); // Add rule for windowed sort diff --git a/tests/cases/standalone/common/promql/tsid_binary_join_regression.result b/tests/cases/standalone/common/promql/tsid_binary_join_regression.result index 3640291dc3..d414eb6bba 100644 --- a/tests/cases/standalone/common/promql/tsid_binary_join_regression.result +++ b/tests/cases/standalone/common/promql/tsid_binary_join_regression.result @@ -71,11 +71,11 @@ TQL ANALYZE (0, 5, '5s') tsid_binary_join_left / tsid_binary_join_right; | stage | node | plan_| +-+-+-+ | 0_| 0_|_ProjectionExec: expr=[host@2 as host, job@3 as job, ts@5 as ts, __tsid@4 as __tsid, greptime_value@0 / greptime_value@1 as tsid_binary_join_left.greptime_value / tsid_binary_join_right.greptime_value] REDACTED -|_|_|_HashJoinExec: mode=Partitioned, join_type=Inner, on=[(__tsid@1, __tsid@3), (ts@2, ts@4)], projection=[greptime_value@0, greptime_value@3, host@4, job@5, __tsid@6, ts@7], NullsEqual: true REDACTED -|_|_|_RepartitionExec: partitioning=Hash([__tsid@1, ts@2],REDACTED +|_|_|_HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(__tsid@1, __tsid@3), (ts@2, ts@4)], projection=[greptime_value@0, greptime_value@3, host@4, job@5, __tsid@6, ts@7], NullsEqual: true REDACTED +|_|_|_CoalescePartitionsExec REDACTED |_|_|_ProjectionExec: expr=[greptime_value@0 as greptime_value, __tsid@3 as __tsid, ts@4 as ts] REDACTED |_|_|_MergeScanExec: REDACTED -|_|_|_RepartitionExec: partitioning=Hash([__tsid@3, ts@4],REDACTED +|_|_|_CooperativeExec REDACTED |_|_|_MergeScanExec: REDACTED |_|_|_| | 1_| 0_|_PromInstantManipulateExec: range=[0..5000], lookback=[300000], interval=[5000], time index=[ts] REDACTED @@ -189,11 +189,11 @@ TQL ANALYZE (0, 5, '5s') tsid_binary_join_left > bool tsid_binary_join_right; | stage | node | plan_| +-+-+-+ | 0_| 0_|_ProjectionExec: expr=[host@2 as host, job@3 as job, ts@5 as ts, __tsid@4 as __tsid, CAST(greptime_value@1 < greptime_value@0 AS Float64) as tsid_binary_join_left.greptime_value > tsid_binary_join_right.greptime_value] REDACTED -|_|_|_HashJoinExec: mode=Partitioned, join_type=Inner, on=[(__tsid@1, __tsid@3), (ts@2, ts@4)], projection=[greptime_value@0, greptime_value@3, host@4, job@5, __tsid@6, ts@7], NullsEqual: true REDACTED -|_|_|_RepartitionExec: partitioning=Hash([__tsid@1, ts@2],REDACTED +|_|_|_HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(__tsid@1, __tsid@3), (ts@2, ts@4)], projection=[greptime_value@0, greptime_value@3, host@4, job@5, __tsid@6, ts@7], NullsEqual: true REDACTED +|_|_|_CoalescePartitionsExec REDACTED |_|_|_ProjectionExec: expr=[greptime_value@0 as greptime_value, __tsid@3 as __tsid, ts@4 as ts] REDACTED |_|_|_MergeScanExec: REDACTED -|_|_|_RepartitionExec: partitioning=Hash([__tsid@3, ts@4],REDACTED +|_|_|_CooperativeExec REDACTED |_|_|_MergeScanExec: REDACTED |_|_|_| | 1_| 0_|_PromInstantManipulateExec: range=[0..5000], lookback=[300000], interval=[5000], time index=[ts] REDACTED diff --git a/tests/cases/standalone/common/tql-explain-analyze/explain.result b/tests/cases/standalone/common/tql-explain-analyze/explain.result index 65532d738b..e60a6b74f6 100644 --- a/tests/cases/standalone/common/tql-explain-analyze/explain.result +++ b/tests/cases/standalone/common/tql-explain-analyze/explain.result @@ -182,6 +182,7 @@ TQL EXPLAIN VERBOSE (0, 10, '5s') test; | physical_plan after FilterPushdown_| SAME TEXT AS ABOVE_| | physical_plan after parallelize_scan_| SAME TEXT AS ABOVE_| | physical_plan after PassDistributionRule_| SAME TEXT AS ABOVE_| +| physical_plan after PromqlTsidNarrowJoin_| SAME TEXT AS ABOVE_| | physical_plan after EnforceSorting_| SAME TEXT AS ABOVE_| | physical_plan after EnforceDistribution_| SAME TEXT AS ABOVE_| | physical_plan after CombinePartialFinalAggregate_| SAME TEXT AS ABOVE_| @@ -332,6 +333,7 @@ TQL EXPLAIN VERBOSE (0, 10, '5s') test AS series; | physical_plan after FilterPushdown_| SAME TEXT AS ABOVE_| | physical_plan after parallelize_scan_| SAME TEXT AS ABOVE_| | physical_plan after PassDistributionRule_| SAME TEXT AS ABOVE_| +| physical_plan after PromqlTsidNarrowJoin_| SAME TEXT AS ABOVE_| | physical_plan after EnforceSorting_| SAME TEXT AS ABOVE_| | physical_plan after EnforceDistribution_| SAME TEXT AS ABOVE_| | physical_plan after CombinePartialFinalAggregate_| SAME TEXT AS ABOVE_| @@ -654,6 +656,7 @@ TQL EXPLAIN VERBOSE (0, 10, '5s') test_nano; | physical_plan after FilterPushdown_| SAME TEXT AS ABOVE_| | physical_plan after parallelize_scan_| SAME TEXT AS ABOVE_| | physical_plan after PassDistributionRule_| SAME TEXT AS ABOVE_| +| physical_plan after PromqlTsidNarrowJoin_| SAME TEXT AS ABOVE_| | physical_plan after EnforceSorting_| OutputRequirementExec: order_by=[], dist_by=Unspecified_| |_|_PromInstantManipulateExec: range=[0..10000], lookback=[300000], interval=[5000], time index=[j]_| |_|_PromSeriesDivideExec: tags=["k"]_| From b5f56b92b45150ab92c7879e5e2cd6733dfab059 Mon Sep 17 00:00:00 2001 From: Ruihang Xia Date: Fri, 29 May 2026 12:06:00 +0800 Subject: [PATCH 30/32] fix: classify InsertInto as write request (#8200) Signed-off-by: Ruihang Xia --- src/auth/src/permission.rs | 21 +++++++-- tests-integration/src/grpc.rs | 84 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/auth/src/permission.rs b/src/auth/src/permission.rs index 88adfda633..8914635290 100644 --- a/src/auth/src/permission.rs +++ b/src/auth/src/permission.rs @@ -16,6 +16,7 @@ use std::fmt::Debug; use std::sync::Arc; use api::v1::greptime_request::Request; +use api::v1::query_request::Query; use common_telemetry::debug; use sql::statements::statement::Statement; @@ -42,10 +43,12 @@ impl<'a> PermissionReq<'a> { /// Returns true if the permission request is for read operations. pub fn is_readonly(&self) -> bool { match self { - PermissionReq::GrpcRequest(Request::Query(_)) - | PermissionReq::PromQuery - | PermissionReq::LogQuery - | PermissionReq::PromStoreRead => true, + PermissionReq::GrpcRequest(Request::Query(query_request)) => { + !matches!(query_request.query, Some(Query::InsertIntoPlan(_))) + } + PermissionReq::PromQuery | PermissionReq::LogQuery | PermissionReq::PromStoreRead => { + true + } PermissionReq::SqlStatement(stmt) => stmt.is_readonly(), PermissionReq::GrpcRequest(_) @@ -196,4 +199,14 @@ mod tests { assert!(matches!(read_result, PermissionResp::Reject)); assert!(matches!(write_result, PermissionResp::Allow)); } + + #[test] + fn test_grpc_insert_into_plan_is_write_request() { + let request = Request::Query(api::v1::QueryRequest { + query: Some(Query::InsertIntoPlan(api::v1::InsertIntoPlan::default())), + }); + let req = PermissionReq::GrpcRequest(&request); + + assert!(req.is_write()); + } } diff --git a/tests-integration/src/grpc.rs b/tests-integration/src/grpc.rs index 181e698e87..7c2148e130 100644 --- a/tests-integration/src/grpc.rs +++ b/tests-integration/src/grpc.rs @@ -54,13 +54,19 @@ mod test { use api::v1::{ AddColumn, AddColumns, AlterTableExpr, Column, ColumnDataType, ColumnDataTypeExtension, ColumnDef, CreateDatabaseExpr, CreateTableExpr, DdlRequest, DeleteRequest, DeleteRequests, - DropTableExpr, InsertRequest, InsertRequests, QueryRequest, SemanticType, + DropTableExpr, InsertIntoPlan, InsertRequest, InsertRequests, QueryRequest, SemanticType, VectorTypeExtension, alter_table_expr, }; + use auth::{ + DefaultPermissionChecker, Identity, Password, PermissionCheckerRef, UserProvider, + static_user_provider_from_option, + }; use client::OutputData; + use common_base::Plugins; use common_catalog::consts::MITO_ENGINE; use common_meta::rpc::router::region_distribution; use common_query::Output; + use common_query::logical_plan::breakup_insert_plan; use common_recordbatch::RecordBatches; use frontend::instance::Instance; use query::parser::QueryLanguageParser; @@ -129,6 +135,82 @@ mod test { .unwrap() } + #[tokio::test(flavor = "multi_thread")] + async fn test_grpc_insert_into_plan_rejects_readonly_user() { + let plugins = Plugins::new(); + plugins.insert::(DefaultPermissionChecker::arc()); + + let standalone = + GreptimeDbStandaloneBuilder::new("test_grpc_insert_into_plan_rejects_readonly_user") + .with_plugin(plugins) + .build() + .await; + let instance = standalone.fe_instance(); + let table_name = "grpc_insert_into_plan_auth"; + + create_table( + instance, + format!("CREATE TABLE {table_name} (host STRING, val DOUBLE, ts TIMESTAMP TIME INDEX)"), + ) + .await; + + let stmt = QueryLanguageParser::parse_sql( + &format!("INSERT INTO {table_name} VALUES ('readonly-bypass', 42.0, 1000)"), + &QueryContext::arc(), + ) + .unwrap(); + let plan = instance + .statement_executor() + .plan(&stmt, QueryContext::arc()) + .await + .unwrap(); + let (table_name, insert_plan) = breakup_insert_plan(&plan, "greptime", "public").unwrap(); + let logical_plan = DFLogicalSubstraitConvertor + .encode(&insert_plan, DefaultSerializer) + .unwrap() + .to_vec(); + + let request = Request::Query(QueryRequest { + query: Some(Query::InsertIntoPlan(InsertIntoPlan { + table_name: Some(table_name), + logical_plan, + })), + }); + let ctx = QueryContext::arc(); + let provider = + static_user_provider_from_option("static_user_provider:cmd:readonly:ro=readonly_pwd") + .unwrap(); + let readonly_user = provider + .authenticate( + Identity::UserId("readonly", None), + Password::PlainText("readonly_pwd".to_string().into()), + ) + .await + .unwrap(); + ctx.set_current_user(readonly_user); + + let err = GrpcQueryHandler::do_query(instance.as_ref(), request, ctx) + .await + .unwrap_err(); + let err_msg = format!("{err:?}"); + assert!( + err_msg.contains("not authorized"), + "unexpected error: {err_msg}" + ); + + query_and_expect( + instance, + "SELECT count(*) FROM grpc_insert_into_plan_auth", + "\ ++----------+ +| count(*) | ++----------+ +| 0 | ++----------+", + ) + .await; + } + async fn test_handle_multi_ddl_request(instance: &Instance) { let request = Request::Ddl(DdlRequest { expr: Some(DdlExpr::CreateDatabase(CreateDatabaseExpr { From ba15a9c056668e22357860376a7502b1928a15c3 Mon Sep 17 00:00:00 2001 From: discord9 Date: Fri, 29 May 2026 14:59:21 +0800 Subject: [PATCH 31/32] feat: support pending flow metadata with defer_on_missing_source (#8124) * feat: support defer_on_missing_source for pending flow creation Add `defer_on_missing_source` flow option that allows creating flows even when source tables do not yet exist. The flow enters a pending state and is automatically activated when source tables become available. Key changes: - New `FlowStatus::PendingSources` and fields in `FlowInfoValue` for unresolved source table names and last activation error - `defer_on_missing_source` create-time-only option: stripped from runtime/flownode `CreateRequest` but preserved in metadata for SQL round-trip (`SHOW CREATE FLOW`, `information_schema.flows`) - `CreateFlowProcedure` creates pending metadata when sources are missing and `defer_on_missing_source=true`; falls back to `FlowType::Batching` for missing-source flows - `PendingFlowReconcileManager` in meta-srv periodically checks pending flows and activates them when source tables resolve - `ActivatePendingFlowProcedure` handles activation: allocates peers, creates flows on flownodes, updates metadata, invalidates cache - `OR REPLACE` properly handles pending<->active transitions, including peer allocation and flownode flow teardown - `FlowMetadataAllocator::alloc_peers` for peer allocation at activation time - Validated flow options: only `defer_on_missing_source` allowed; unknown options rejected - Known issue: standalone mode does not support flownodes, so pending flow flush/sink behavior covered only in distributed sqlness; operator and meta unit tests cover activation logic Tests: - operator `determine_flow_type_for_source_state` (3 passed) - common-meta `create_flow` (19 passed) including replacement - common-meta `activate_flow` (4 passed) - meta-srv `flow` (11 passed) - sqlness: `flow_pending` covers create/replace/round-trip Signed-off-by: discord9 * chore: simplify pending flow PR scope Reduce PR #8124 to the metadata-only MVP after complexity review. Changes: - Remove automatic activation procedure and meta-srv reconcile wiring - Remove activation tests and activation-only metadata fields - Reject cross-state pending<->active `OR REPLACE` transitions for now - Keep pending metadata creation and SQL round-trip behavior - Allow `DROP FLOW` for pending flows without routes - Reduce flow_pending sqlness to metadata/round-trip/drop coverage only Deferred follow-ups are documented locally in `.tmp/tasks/pending-defer-semantics/deferred-followups.md` and intentionally not committed. Tests: - `cargo test -p operator determine_flow_type_for_source_state` - `cargo test -p common-meta create_flow` - `cargo test -p common-meta drop_flow` - `cargo sqlness bare --test-filter flow_pending --bins-dir /mnt/nvme_rust/rust-targets/pending_defer/debug` Signed-off-by: discord9 * test: cover pending flow metadata edge cases Signed-off-by: discord9 * test: fix pending flow metadata test lint Signed-off-by: discord9 * docs: document pending flow metadata fields Signed-off-by: discord9 * chore: more sleep when test Signed-off-by: discord9 --------- Signed-off-by: discord9 --- .../meta/src/cache/flow/table_flownode.rs | 5 +- src/common/meta/src/ddl/create_flow.rs | 132 ++++- .../meta/src/ddl/create_flow/metadata.rs | 29 +- src/common/meta/src/ddl/drop_flow/metadata.rs | 2 +- src/common/meta/src/ddl/tests/create_flow.rs | 487 +++++++++++++++++- src/common/meta/src/ddl/tests/drop_flow.rs | 44 +- src/common/meta/src/key/flow.rs | 10 + src/common/meta/src/key/flow/flow_info.rs | 69 ++- src/operator/src/statement/ddl.rs | 95 +++- .../common/flow/flow_pending.result | 52 ++ .../standalone/common/flow/flow_pending.sql | 24 + .../common/flow/flow_rebuild.result | 1 + .../standalone/common/flow/flow_rebuild.sql | 1 + 13 files changed, 910 insertions(+), 41 deletions(-) create mode 100644 tests/cases/standalone/common/flow/flow_pending.result create mode 100644 tests/cases/standalone/common/flow/flow_pending.sql diff --git a/src/common/meta/src/cache/flow/table_flownode.rs b/src/common/meta/src/cache/flow/table_flownode.rs index ebe3664202..4d3513a21d 100644 --- a/src/common/meta/src/cache/flow/table_flownode.rs +++ b/src/common/meta/src/cache/flow/table_flownode.rs @@ -210,7 +210,7 @@ mod tests { use crate::cache::flow::table_flownode::{FlowIdent, new_table_flownode_set_cache}; use crate::instruction::{CacheIdent, CreateFlow, DropFlow}; use crate::key::flow::FlowMetadataManager; - use crate::key::flow::flow_info::FlowInfoValue; + use crate::key::flow::flow_info::{FlowInfoValue, FlowStatus}; use crate::key::flow::flow_route::FlowRouteValue; use crate::kv_backend::memory::MemoryKvBackend; use crate::peer::Peer; @@ -242,11 +242,14 @@ mod tests { catalog_name: DEFAULT_CATALOG_NAME.to_string(), query_context: None, flow_name: "my_flow".to_string(), + all_source_table_names: vec![], + unresolved_source_table_names: vec![], raw_sql: "sql".to_string(), expire_after: Some(300), eval_interval_secs: None, comment: "comment".to_string(), options: Default::default(), + status: FlowStatus::Active, created_time: chrono::Utc::now(), updated_time: chrono::Utc::now(), }, diff --git a/src/common/meta/src/ddl/create_flow.rs b/src/common/meta/src/ddl/create_flow.rs index 7120e50425..ddfb0c0759 100644 --- a/src/common/meta/src/ddl/create_flow.rs +++ b/src/common/meta/src/ddl/create_flow.rs @@ -14,7 +14,7 @@ mod metadata; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use api::v1::ExpireAfter; @@ -34,13 +34,14 @@ use serde::{Deserialize, Serialize}; use snafu::{ResultExt, ensure}; use strum::AsRefStr; use table::metadata::TableId; +use table::table_name::TableName; use crate::cache_invalidator::Context; use crate::ddl::DdlContext; use crate::ddl::utils::{add_peer_context_if_needed, map_to_procedure_error}; use crate::error::{self, Result, UnexpectedSnafu}; use crate::instruction::{CacheIdent, CreateFlow, DropFlow}; -use crate::key::flow::flow_info::FlowInfoValue; +use crate::key::flow::flow_info::{FlowInfoValue, FlowStatus}; use crate::key::flow::flow_route::FlowRouteValue; use crate::key::table_name::TableNameKey; use crate::key::{DeserializedValueWithBytes, FlowId, FlowPartitionId}; @@ -67,6 +68,7 @@ impl CreateFlowProcedure { flow_id: None, peers: vec![], source_table_ids: vec![], + unresolved_source_table_names: vec![], flow_context: query_context.into(), // Convert to FlowQueryContext state: CreateFlowState::Prepare, prev_flow_info_value: None, @@ -89,6 +91,8 @@ impl CreateFlowProcedure { let create_if_not_exists = self.data.task.create_if_not_exists; let or_replace = self.data.task.or_replace; + validate_flow_options(&self.data.task)?; + let flow_name_value = self .context .flow_metadata_manager @@ -167,6 +171,21 @@ impl CreateFlowProcedure { } self.collect_source_tables().await?; + ensure!( + self.data.unresolved_source_table_names.is_empty() + || defer_on_missing_source(&self.data.task)?, + error::UnsupportedSnafu { + operation: format!( + "Create flow with missing source tables requires WITH ('{DEFER_ON_MISSING_SOURCE_KEY}'='true'): {}", + self.data + .unresolved_source_table_names + .iter() + .map(ToString::to_string) + .join(", ") + ) + } + ); + self.ensure_supported_replace_transition()?; // Validate that source and sink tables are not the same let sink_table_name = &self.data.task.sink_table_name; @@ -189,13 +208,38 @@ impl CreateFlowProcedure { if self.data.flow_id.is_none() { self.allocate_flow_id().await?; } - self.data.state = CreateFlowState::CreateFlows; - // determine flow type self.data.flow_type = Some(get_flow_type_from_options(&self.data.task)?); + self.data.state = if self.data.is_pending() { + self.data.peers.clear(); + CreateFlowState::CreateMetadata + } else { + CreateFlowState::CreateFlows + }; + Ok(Status::executing(true)) } + fn ensure_supported_replace_transition(&self) -> Result<()> { + if !self.data.task.or_replace { + return Ok(()); + } + + let Some(prev_flow_info) = self.data.prev_flow_info_value.as_ref() else { + return Ok(()); + }; + let prev_pending = prev_flow_info.get_inner_ref().is_pending(); + let new_pending = self.data.is_pending(); + ensure!( + prev_pending == new_pending, + error::UnsupportedSnafu { + operation: "Replacing between pending and active flow states is not supported yet" + } + ); + + Ok(()) + } + async fn on_flownode_create_flows(&mut self) -> Result { // Safety: must be allocated. let mut create_flow = Vec::with_capacity(self.data.peers.len()); @@ -365,6 +409,61 @@ pub fn get_flow_type_from_options(flow_task: &CreateFlowTask) -> Result Result { + flow_task + .flow_options + .get(DEFER_ON_MISSING_SOURCE_KEY) + .map(|value| { + value + .trim() + .to_ascii_lowercase() + .parse::() + .map_err(|_| { + error::UnexpectedSnafu { + err_msg: format!( + "Invalid flow option '{DEFER_ON_MISSING_SOURCE_KEY}': {value}" + ), + } + .build() + }) + }) + .transpose() + .map(|value| value.unwrap_or(false)) +} + +pub fn validate_flow_options(flow_task: &CreateFlowTask) -> Result<()> { + for key in flow_task.flow_options.keys() { + match key.as_str() { + DEFER_ON_MISSING_SOURCE_KEY | FlowType::FLOW_TYPE_KEY => {} + unknown => { + return UnexpectedSnafu { + err_msg: format!( + "Unknown flow option '{unknown}', supported user options: {DEFER_ON_MISSING_SOURCE_KEY}" + ), + } + .fail(); + } + } + } + + defer_on_missing_source(flow_task)?; + get_flow_type_from_options(flow_task)?; + Ok(()) +} + +fn user_runtime_flow_options(options: &HashMap) -> HashMap { + let mut options = options.clone(); + options.remove(DEFER_ON_MISSING_SOURCE_KEY); + options +} + +fn metadata_flow_options(options: &HashMap) -> HashMap { + options.clone() +} + /// The state of [CreateFlowProcedure]. #[derive(Debug, Clone, Serialize, Deserialize, AsRefStr, PartialEq)] pub enum CreateFlowState { @@ -411,6 +510,8 @@ pub struct CreateFlowData { pub(crate) flow_id: Option, pub(crate) peers: Vec, pub(crate) source_table_ids: Vec, + #[serde(default)] + pub(crate) unresolved_source_table_names: Vec, /// Use alias for backward compatibility with QueryContext serialized data #[serde(alias = "query_context")] pub(crate) flow_context: FlowQueryContext, @@ -424,6 +525,16 @@ pub struct CreateFlowData { pub(crate) flow_type: Option, } +impl CreateFlowData { + pub(crate) fn is_pending(&self) -> bool { + !self.unresolved_source_table_names.is_empty() + } + + pub(crate) fn is_active(&self) -> bool { + !self.is_pending() + } +} + impl From<&CreateFlowData> for CreateRequest { fn from(value: &CreateFlowData) -> Self { let flow_id = value.flow_id.unwrap(); @@ -446,7 +557,7 @@ impl From<&CreateFlowData> for CreateRequest { .map(|seconds| api::v1::EvalInterval { seconds }), comment: value.task.comment.clone(), sql: value.task.sql.clone(), - flow_options: value.task.flow_options.clone(), + flow_options: user_runtime_flow_options(&value.task.flow_options), }; let flow_type = value.flow_type.unwrap_or_default().to_string(); @@ -466,9 +577,9 @@ impl From<&CreateFlowData> for (FlowInfoValue, Vec<(FlowPartitionId, FlowRouteVa eval_interval_secs: eval_interval, comment, sql, - flow_options: mut options, .. } = value.task.clone(); + let mut options = metadata_flow_options(&value.task.flow_options); let flownode_ids = value .peers @@ -484,7 +595,7 @@ impl From<&CreateFlowData> for (FlowInfoValue, Vec<(FlowPartitionId, FlowRouteVa .collect::>(); let flow_type = value.flow_type.unwrap_or_default().to_string(); - options.insert("flow_type".to_string(), flow_type); + options.insert(FlowType::FLOW_TYPE_KEY.to_string(), flow_type); let mut create_time = chrono::Utc::now(); if let Some(prev_flow_value) = value.prev_flow_info_value.as_ref() @@ -495,6 +606,8 @@ impl From<&CreateFlowData> for (FlowInfoValue, Vec<(FlowPartitionId, FlowRouteVa let flow_info: FlowInfoValue = FlowInfoValue { source_table_ids: value.source_table_ids.clone(), + all_source_table_names: value.task.source_table_names.clone(), + unresolved_source_table_names: value.unresolved_source_table_names.clone(), sink_table_name, flownode_ids, catalog_name, @@ -506,6 +619,11 @@ impl From<&CreateFlowData> for (FlowInfoValue, Vec<(FlowPartitionId, FlowRouteVa eval_interval_secs: eval_interval, comment, options, + status: if value.is_active() { + FlowStatus::Active + } else { + FlowStatus::PendingSources + }, created_time: create_time, updated_time: chrono::Utc::now(), }; diff --git a/src/common/meta/src/ddl/create_flow/metadata.rs b/src/common/meta/src/ddl/create_flow/metadata.rs index 27b85b7946..f97ecfdf4a 100644 --- a/src/common/meta/src/ddl/create_flow/metadata.rs +++ b/src/common/meta/src/ddl/create_flow/metadata.rs @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -use snafu::OptionExt; - use crate::ddl::create_flow::CreateFlowProcedure; -use crate::error::{self, Result}; +use crate::error::Result; use crate::key::table_name::TableNameKey; impl CreateFlowProcedure { @@ -34,9 +32,8 @@ impl CreateFlowProcedure { Ok(()) } - /// Ensures all source tables exist and collects source table ids + /// Collects source table ids and keeps track of missing tables. pub(crate) async fn collect_source_tables(&mut self) -> Result<()> { - // Ensures all source tables exist. let keys = self .data .task @@ -52,22 +49,24 @@ impl CreateFlowProcedure { .batch_get(keys) .await?; - let source_table_ids = self + let mut resolved = Vec::with_capacity(self.data.task.source_table_names.len()); + let mut unresolved = Vec::new(); + + for (name, table_id) in self .data .task .source_table_names .iter() .zip(source_table_ids) - .map(|(name, table_id)| { - Ok(table_id - .with_context(|| error::TableNotFoundSnafu { - table_name: name.to_string(), - })? - .table_id()) - }) - .collect::>>()?; + { + match table_id { + Some(table_id) => resolved.push(table_id.table_id()), + None => unresolved.push(name.clone()), + } + } - self.data.source_table_ids = source_table_ids; + self.data.source_table_ids = resolved; + self.data.unresolved_source_table_names = unresolved; Ok(()) } } diff --git a/src/common/meta/src/ddl/drop_flow/metadata.rs b/src/common/meta/src/ddl/drop_flow/metadata.rs index 0437098be3..7afd00f9d5 100644 --- a/src/common/meta/src/ddl/drop_flow/metadata.rs +++ b/src/common/meta/src/ddl/drop_flow/metadata.rs @@ -43,7 +43,7 @@ impl DropFlowProcedure { .map(|(_, value)| value) .collect::>(); ensure!( - !flow_route_values.is_empty(), + flow_info_value.is_pending() || !flow_route_values.is_empty(), error::FlowRouteNotFoundSnafu { flow_name: format_full_flow_name(catalog_name, flow_name), } diff --git a/src/common/meta/src/ddl/tests/create_flow.rs b/src/common/meta/src/ddl/tests/create_flow.rs index 344fc05024..a1a6c040f1 100644 --- a/src/common/meta/src/ddl/tests/create_flow.rs +++ b/src/common/meta/src/ddl/tests/create_flow.rs @@ -16,12 +16,17 @@ use std::assert_matches; use std::collections::HashMap; use std::sync::Arc; +use api::v1::flow::CreateRequest; use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME}; +use common_procedure::Status; use common_procedure_test::execute_procedure_until_done; use table::table_name::TableName; use crate::ddl::DdlContext; -use crate::ddl::create_flow::{CreateFlowData, CreateFlowProcedure, CreateFlowState, FlowType}; +use crate::ddl::create_flow::{ + CreateFlowData, CreateFlowProcedure, CreateFlowState, DEFER_ON_MISSING_SOURCE_KEY, FlowType, + defer_on_missing_source, +}; use crate::ddl::test_util::create_table::test_create_table_task; use crate::ddl::test_util::flownode_handler::NaiveFlownodeHandler; use crate::error; @@ -63,6 +68,11 @@ pub(crate) fn test_create_flow_task( } } +fn enable_defer_on_missing_source(task: &mut CreateFlowTask) { + task.flow_options + .insert(DEFER_ON_MISSING_SOURCE_KEY.to_string(), "true".to_string()); +} + #[tokio::test] async fn test_create_flow_source_table_not_found() { let source_table_names = vec![TableName::new( @@ -78,7 +88,261 @@ async fn test_create_flow_source_table_not_found() { let query_ctx = test_query_context(); let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context); let err = procedure.on_prepare().await.unwrap_err(); - assert_matches!(err, error::Error::TableNotFound { .. }); + assert_matches!(err, error::Error::Unsupported { .. }); + assert!( + err.to_string() + .contains("requires WITH ('defer_on_missing_source'='true')") + ); +} + +#[tokio::test] +async fn test_create_pending_flow_source_table_not_found_with_defer() { + let source_table_names = vec![TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "my_table", + )]; + let sink_table_name = + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"); + let mut task = test_create_flow_task("my_flow", source_table_names, sink_table_name, false); + enable_defer_on_missing_source(&mut task); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context.clone()); + let status = procedure.on_prepare().await.unwrap(); + assert_matches!(status, Status::Executing { persist: true, .. }); + assert_eq!(procedure.data.unresolved_source_table_names.len(), 1); + assert_eq!(procedure.data.source_table_ids, Vec::::new()); + + let output = execute_procedure_until_done(&mut procedure).await.unwrap(); + let flow_id = *output.downcast_ref::().unwrap(); + let flow_info = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(flow_id) + .await + .unwrap() + .unwrap(); + assert_eq!(flow_info.source_table_ids(), Vec::::new()); + assert_eq!( + flow_info + .options() + .get(DEFER_ON_MISSING_SOURCE_KEY) + .map(String::as_str), + Some("true") + ); +} + +#[tokio::test] +async fn test_create_pending_flow_source_table_not_found_with_defer_false() { + let source_table_names = vec![TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "my_table", + )]; + let sink_table_name = + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"); + let mut task = test_create_flow_task("my_flow", source_table_names, sink_table_name, false); + task.flow_options + .insert(DEFER_ON_MISSING_SOURCE_KEY.to_string(), "false".to_string()); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context); + let err = procedure.on_prepare().await.unwrap_err(); + assert_matches!(err, error::Error::Unsupported { .. }); + assert!( + err.to_string() + .contains("requires WITH ('defer_on_missing_source'='true')") + ); +} + +#[tokio::test] +async fn test_create_pending_flow_records_partial_source_resolution() { + let existing_source = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "partial_existing_source_table", + ); + let missing_source = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "partial_missing_source_table", + ); + let sink_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "partial_pending_sink_table", + ); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + + let existing_table_id = 3026; + let create_table_task = + test_create_table_task("partial_existing_source_table", existing_table_id); + ddl_context + .table_metadata_manager + .create_table_metadata( + create_table_task.table_info.clone(), + TableRouteValue::physical(vec![]), + HashMap::new(), + ) + .await + .unwrap(); + + let mut task = test_create_flow_task( + "partial_pending_flow", + vec![existing_source.clone(), missing_source.clone()], + sink_table_name, + false, + ); + enable_defer_on_missing_source(&mut task); + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context.clone()); + let status = procedure.on_prepare().await.unwrap(); + assert_matches!(status, Status::Executing { persist: true, .. }); + assert_eq!(procedure.data.source_table_ids, vec![existing_table_id]); + assert_eq!( + procedure.data.unresolved_source_table_names, + vec![missing_source.clone()] + ); + + let output = execute_procedure_until_done(&mut procedure).await.unwrap(); + let flow_id = *output.downcast_ref::().unwrap(); + let flow_info = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(flow_id) + .await + .unwrap() + .unwrap(); + + assert!(flow_info.is_pending()); + assert_eq!(flow_info.source_table_ids(), &[existing_table_id]); + let expected_all_sources = vec![existing_source, missing_source.clone()]; + assert_eq!( + flow_info.all_source_table_names(), + expected_all_sources.as_slice() + ); + assert_eq!(flow_info.unresolved_source_table_names(), &[missing_source]); + assert!(flow_info.flownode_ids().is_empty()); +} + +#[test] +fn test_defer_on_missing_source_defaults_false() { + let task = test_create_flow_task( + "my_flow", + vec![], + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"), + false, + ); + + assert!(!defer_on_missing_source(&task).unwrap()); +} + +#[test] +fn test_defer_on_missing_source_true() { + let mut task = test_create_flow_task( + "my_flow", + vec![], + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"), + false, + ); + task.flow_options + .insert(DEFER_ON_MISSING_SOURCE_KEY.to_string(), "true".to_string()); + + assert!(defer_on_missing_source(&task).unwrap()); +} + +#[test] +fn test_defer_on_missing_source_invalid_value() { + let mut task = test_create_flow_task( + "my_flow", + vec![], + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"), + false, + ); + task.flow_options.insert( + DEFER_ON_MISSING_SOURCE_KEY.to_string(), + "invalid".to_string(), + ); + + let err = defer_on_missing_source(&task).unwrap_err(); + assert!( + err.to_string() + .contains("Invalid flow option 'defer_on_missing_source': invalid") + ); +} + +#[tokio::test] +async fn test_create_flow_rejects_unknown_option_in_meta_task() { + let mut task = test_create_flow_task( + "my_flow", + vec![], + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"), + false, + ); + task.flow_options + .insert("unknown_option".to_string(), "value".to_string()); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context); + + let err = procedure.on_prepare().await.unwrap_err(); + assert_matches!(err, error::Error::Unexpected { .. }); + assert!( + err.to_string() + .contains("Unknown flow option 'unknown_option'") + ); +} + +#[test] +fn test_create_request_strips_defer_on_missing_source_runtime_option() { + let mut task = test_create_flow_task( + "my_flow", + vec![], + TableName::new(DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, "my_sink_table"), + false, + ); + enable_defer_on_missing_source(&mut task); + + let data = CreateFlowData { + state: CreateFlowState::CreateFlows, + task, + flow_id: Some(1024), + peers: vec![], + source_table_ids: vec![], + unresolved_source_table_names: vec![], + flow_context: FlowQueryContext { + catalog: DEFAULT_CATALOG_NAME.to_string(), + schema: DEFAULT_SCHEMA_NAME.to_string(), + timezone: "UTC".to_string(), + extensions: HashMap::new(), + channel: 0, + snapshot_seqs: HashMap::new(), + sst_min_sequences: HashMap::new(), + }, + prev_flow_info_value: None, + did_replace: false, + flow_type: Some(FlowType::Batching), + }; + + let request: CreateRequest = (&data).into(); + + assert!( + !request + .flow_options + .contains_key(DEFER_ON_MISSING_SOURCE_KEY) + ); + assert_eq!( + request + .flow_options + .get(FlowType::FLOW_TYPE_KEY) + .map(String::as_str), + Some(FlowType::BATCHING) + ); } pub(crate) async fn create_test_flow( @@ -101,6 +365,27 @@ pub(crate) async fn create_test_flow( *flow_id } +pub(crate) async fn create_test_pending_flow( + ddl_context: &DdlContext, + flow_name: &str, + source_table_names: Vec, + sink_table_name: TableName, +) -> FlowId { + let mut task = test_create_flow_task( + flow_name, + source_table_names.clone(), + sink_table_name.clone(), + false, + ); + enable_defer_on_missing_source(&mut task); + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(task, query_ctx, ddl_context.clone()); + let output = execute_procedure_until_done(&mut procedure).await.unwrap(); + let flow_id = output.downcast_ref::().unwrap(); + + *flow_id +} + #[tokio::test] async fn test_create_flow() { let table_id = 1024; @@ -154,6 +439,201 @@ async fn test_create_flow() { assert_matches!(err, error::Error::FlowAlreadyExists { .. }); } +#[tokio::test] +async fn test_replace_pending_flow_with_active_flow_is_unsupported() { + let source_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_pending_source_table", + ); + let sink_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_pending_sink_table", + ); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + + let pending_flow_id = create_test_pending_flow( + &ddl_context, + "replace_pending_flow", + vec![source_table_name.clone()], + sink_table_name.clone(), + ) + .await; + + let pending_flow = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(pending_flow_id) + .await + .unwrap() + .unwrap(); + assert!(pending_flow.is_pending()); + assert!(pending_flow.flownode_ids().is_empty()); + + let create_table_task = test_create_table_task("replace_pending_source_table", 1026); + ddl_context + .table_metadata_manager + .create_table_metadata( + create_table_task.table_info.clone(), + TableRouteValue::physical(vec![]), + HashMap::new(), + ) + .await + .unwrap(); + + let mut replace_task = test_create_flow_task( + "replace_pending_flow", + vec![source_table_name], + sink_table_name, + false, + ); + replace_task.or_replace = true; + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(replace_task, query_ctx, ddl_context.clone()); + let err = procedure.on_prepare().await.unwrap_err(); + assert_matches!(err, error::Error::Unsupported { .. }); + assert!( + err.to_string() + .contains("Replacing between pending and active flow states") + ); +} + +#[tokio::test] +async fn test_replace_active_flow_with_pending_flow_is_unsupported() { + let existing_source_table = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_active_source_table", + ); + let missing_source_table = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_missing_source_table", + ); + let sink_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_active_sink_table", + ); + + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + + let create_table_task = test_create_table_task("replace_active_source_table", 2026); + ddl_context + .table_metadata_manager + .create_table_metadata( + create_table_task.table_info.clone(), + TableRouteValue::physical(vec![]), + HashMap::new(), + ) + .await + .unwrap(); + + let _flow_id = create_test_flow( + &ddl_context, + "replace_active_flow_to_pending", + vec![existing_source_table], + sink_table_name.clone(), + ) + .await; + + let mut replace_task = test_create_flow_task( + "replace_active_flow_to_pending", + vec![missing_source_table], + sink_table_name, + false, + ); + enable_defer_on_missing_source(&mut replace_task); + replace_task.or_replace = true; + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(replace_task, query_ctx, ddl_context.clone()); + let err = procedure.on_prepare().await.unwrap_err(); + assert_matches!(err, error::Error::Unsupported { .. }); + assert!( + err.to_string() + .contains("Replacing between pending and active flow states") + ); +} + +#[tokio::test] +async fn test_replace_pending_flow_with_pending_flow_updates_metadata() { + let first_missing_source = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_pending_first_missing_source", + ); + let second_missing_source = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_pending_second_missing_source", + ); + let sink_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "replace_pending_to_pending_sink_table", + ); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + + let original_flow_id = create_test_pending_flow( + &ddl_context, + "replace_pending_to_pending_flow", + vec![first_missing_source.clone()], + sink_table_name.clone(), + ) + .await; + + let original_flow = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(original_flow_id) + .await + .unwrap() + .unwrap(); + assert!(original_flow.is_pending()); + assert_eq!( + original_flow.unresolved_source_table_names(), + &[first_missing_source] + ); + assert!(original_flow.flownode_ids().is_empty()); + + let mut replace_task = test_create_flow_task( + "replace_pending_to_pending_flow", + vec![second_missing_source.clone()], + sink_table_name, + false, + ); + enable_defer_on_missing_source(&mut replace_task); + replace_task.or_replace = true; + let query_ctx = test_query_context(); + let mut procedure = CreateFlowProcedure::new(replace_task, query_ctx, ddl_context.clone()); + let output = execute_procedure_until_done(&mut procedure).await.unwrap(); + let replaced_flow_id = *output.downcast_ref::().unwrap(); + assert_eq!(replaced_flow_id, original_flow_id); + + let replaced_flow = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(replaced_flow_id) + .await + .unwrap() + .unwrap(); + assert!(replaced_flow.is_pending()); + assert_eq!(replaced_flow.source_table_ids(), Vec::::new()); + assert_eq!( + replaced_flow.unresolved_source_table_names(), + std::slice::from_ref(&second_missing_source) + ); + assert_eq!( + replaced_flow.all_source_table_names(), + &[second_missing_source] + ); + assert!(replaced_flow.flownode_ids().is_empty()); +} + #[tokio::test] async fn test_create_flow_same_source_and_sink_table() { let table_id = 1024; @@ -228,6 +708,7 @@ fn test_create_flow_data_serialization_backward_compatibility() { "flow_id": null, "peers": [], "source_table_ids": [], + "unresolved_source_table_names": [], "query_context": { "current_catalog": "old_catalog", "current_schema": "old_schema", @@ -265,6 +746,7 @@ fn test_create_flow_data_new_format_serialization() { flow_id: None, peers: vec![], source_table_ids: vec![], + unresolved_source_table_names: vec![], flow_context, prev_flow_info_value: None, did_replace: false, @@ -327,6 +809,7 @@ fn test_flow_info_conversion_with_flow_context() { flow_id: Some(123), peers: vec![], source_table_ids: vec![456, 789], + unresolved_source_table_names: vec![], flow_context, prev_flow_info_value: None, did_replace: false, diff --git a/src/common/meta/src/ddl/tests/drop_flow.rs b/src/common/meta/src/ddl/tests/drop_flow.rs index af34da4809..400fd2e118 100644 --- a/src/common/meta/src/ddl/tests/drop_flow.rs +++ b/src/common/meta/src/ddl/tests/drop_flow.rs @@ -23,7 +23,7 @@ use table::table_name::TableName; use crate::ddl::drop_flow::DropFlowProcedure; use crate::ddl::test_util::create_table::test_create_table_task; use crate::ddl::test_util::flownode_handler::NaiveFlownodeHandler; -use crate::ddl::tests::create_flow::create_test_flow; +use crate::ddl::tests::create_flow::{create_test_flow, create_test_pending_flow}; use crate::error; use crate::key::table_route::TableRouteValue; use crate::rpc::ddl::DropFlowTask; @@ -91,3 +91,45 @@ async fn test_drop_flow() { let err = procedure.on_prepare().await.unwrap_err(); assert_matches!(err, error::Error::FlowNotFound { .. }); } + +#[tokio::test] +async fn test_drop_pending_flow_without_routes() { + let source_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "drop_pending_missing_source_table", + ); + let sink_table_name = TableName::new( + DEFAULT_CATALOG_NAME, + DEFAULT_SCHEMA_NAME, + "drop_pending_sink_table", + ); + let node_manager = Arc::new(MockFlownodeManager::new(NaiveFlownodeHandler)); + let ddl_context = new_ddl_context(node_manager); + + let flow_id = create_test_pending_flow( + &ddl_context, + "drop_pending_flow", + vec![source_table_name], + sink_table_name, + ) + .await; + let flow_info = ddl_context + .flow_metadata_manager + .flow_info_manager() + .get(flow_id) + .await + .unwrap() + .unwrap(); + assert!(flow_info.is_pending()); + assert!(flow_info.flownode_ids().is_empty()); + + let task = test_drop_flow_task("drop_pending_flow", flow_id, false); + let mut procedure = DropFlowProcedure::new(task, ddl_context.clone()); + execute_procedure_until_done(&mut procedure).await; + + let task = test_drop_flow_task("drop_pending_flow", flow_id, false); + let mut procedure = DropFlowProcedure::new(task, ddl_context); + let err = procedure.on_prepare().await.unwrap_err(); + assert_matches!(err, error::Error::FlowNotFound { .. }); +} diff --git a/src/common/meta/src/key/flow.rs b/src/common/meta/src/key/flow.rs index d581b92685..bc9aaaa6b3 100644 --- a/src/common/meta/src/key/flow.rs +++ b/src/common/meta/src/key/flow.rs @@ -459,6 +459,7 @@ mod tests { use super::*; use crate::FlownodeId; + use crate::key::flow::flow_info::FlowStatus; use crate::key::flow::table_flow::TableFlowKey; use crate::key::node_address::{NodeAddressKey, NodeAddressValue}; use crate::key::{FlowPartitionId, MetadataValue}; @@ -522,6 +523,8 @@ mod tests { query_context: None, flow_name: flow_name.to_string(), source_table_ids, + all_source_table_names: vec![], + unresolved_source_table_names: vec![], sink_table_name, flownode_ids, raw_sql: "raw".to_string(), @@ -529,6 +532,7 @@ mod tests { eval_interval_secs: None, comment: "hi".to_string(), options: Default::default(), + status: FlowStatus::Active, created_time: chrono::Utc::now(), updated_time: chrono::Utc::now(), } @@ -774,6 +778,8 @@ mod tests { query_context: None, flow_name: "flow".to_string(), source_table_ids: vec![1024, 1025, 1026], + all_source_table_names: vec![], + unresolved_source_table_names: vec![], sink_table_name: another_sink_table_name, flownode_ids: [(0, 1u64)].into(), raw_sql: "raw".to_string(), @@ -781,6 +787,7 @@ mod tests { eval_interval_secs: None, comment: "hi".to_string(), options: Default::default(), + status: FlowStatus::Active, created_time: chrono::Utc::now(), updated_time: chrono::Utc::now(), }; @@ -1151,6 +1158,8 @@ mod tests { query_context: None, flow_name: "flow".to_string(), source_table_ids: vec![1024, 1025, 1026], + all_source_table_names: vec![], + unresolved_source_table_names: vec![], sink_table_name: another_sink_table_name, flownode_ids: [(0, 1u64)].into(), raw_sql: "raw".to_string(), @@ -1158,6 +1167,7 @@ mod tests { eval_interval_secs: None, comment: "hi".to_string(), options: Default::default(), + status: FlowStatus::Active, created_time: chrono::Utc::now(), updated_time: chrono::Utc::now(), }; diff --git a/src/common/meta/src/key/flow/flow_info.rs b/src/common/meta/src/key/flow/flow_info.rs index d501822c3c..b1056902da 100644 --- a/src/common/meta/src/key/flow/flow_info.rs +++ b/src/common/meta/src/key/flow/flow_info.rs @@ -16,6 +16,8 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use chrono::{DateTime, Utc}; +use futures::TryStreamExt; +use futures::stream::BoxStream; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -27,12 +29,27 @@ use crate::FlownodeId; use crate::error::{self, Result}; use crate::key::flow::FlowScoped; use crate::key::txn_helper::TxnOpGetResponseSet; -use crate::key::{DeserializedValueWithBytes, FlowId, FlowPartitionId, MetadataKey, MetadataValue}; +use crate::key::{ + BytesAdapter, DeserializedValueWithBytes, FlowId, FlowPartitionId, MetadataKey, MetadataValue, +}; use crate::kv_backend::KvBackendRef; use crate::kv_backend::txn::{Compare, CompareOp, Txn, TxnOp}; +use crate::range_stream::{DEFAULT_PAGE_SIZE, PaginationStream}; +use crate::rpc::KeyValue; +use crate::rpc::store::RangeRequest; pub const FLOW_INFO_KEY_PREFIX: &str = "info"; +/// The lifecycle status of a flow stored in metadata. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum FlowStatus { + /// The flow metadata exists, but at least one source table did not exist at create time. + PendingSources, + /// The flow has resolved source tables and can be scheduled on flownodes. + #[default] + Active, +} + lazy_static! { static ref FLOW_INFO_KEY_PATTERN: Regex = Regex::new(&format!("^{FLOW_INFO_KEY_PREFIX}/([0-9]+)$")).unwrap(); @@ -114,7 +131,12 @@ impl<'a> MetadataKey<'a, FlowInfoKeyInner> for FlowInfoKeyInner { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FlowInfoValue { /// The source tables used by the flow. + #[serde(default)] pub source_table_ids: Vec, + #[serde(default)] + pub all_source_table_names: Vec, + #[serde(default)] + pub unresolved_source_table_names: Vec, /// The sink table used by the flow. pub sink_table_name: TableName, /// Which flow nodes this flow is running on. @@ -145,6 +167,8 @@ pub struct FlowInfoValue { pub comment: String, /// The options. pub options: HashMap, + #[serde(default)] + pub status: FlowStatus, /// The created time #[serde(default)] pub created_time: DateTime, @@ -154,6 +178,14 @@ pub struct FlowInfoValue { } impl FlowInfoValue { + pub fn is_pending(&self) -> bool { + self.status == FlowStatus::PendingSources + } + + pub fn is_active(&self) -> bool { + self.status == FlowStatus::Active + } + /// Returns the `flownode_id`. pub fn flownode_ids(&self) -> &BTreeMap { &self.flownode_ids @@ -173,6 +205,14 @@ impl FlowInfoValue { &self.source_table_ids } + pub fn all_source_table_names(&self) -> &[TableName] { + &self.all_source_table_names + } + + pub fn unresolved_source_table_names(&self) -> &[TableName] { + &self.unresolved_source_table_names + } + pub fn catalog_name(&self) -> &String { &self.catalog_name } @@ -209,6 +249,10 @@ impl FlowInfoValue { &self.options } + pub fn status(&self) -> &FlowStatus { + &self.status + } + pub fn created_time(&self) -> &DateTime { &self.created_time } @@ -225,6 +269,12 @@ pub struct FlowInfoManager { kv_backend: KvBackendRef, } +pub fn flow_info_decoder(kv: KeyValue) -> Result<(FlowInfoKey, FlowInfoValue)> { + let key = FlowInfoKey::from_bytes(&kv.key)?; + let value = FlowInfoValue::try_from_raw_value(&kv.value)?; + Ok((key, value)) +} + impl FlowInfoManager { /// Returns a new [FlowInfoManager]. pub fn new(kv_backend: KvBackendRef) -> Self { @@ -254,6 +304,23 @@ impl FlowInfoManager { .transpose() } + pub fn flow_infos(&self) -> BoxStream<'static, Result<(FlowId, FlowInfoValue)>> { + let start_key = FlowScoped::new(BytesAdapter::from( + format!("{FLOW_INFO_KEY_PREFIX}/").into_bytes(), + )) + .to_bytes(); + let req = RangeRequest::new().with_prefix(start_key); + let stream = PaginationStream::new( + self.kv_backend.clone(), + req, + DEFAULT_PAGE_SIZE, + flow_info_decoder, + ) + .into_stream(); + + Box::pin(stream.map_ok(|(key, value)| (key.flow_id(), value))) + } + /// Builds a create flow transaction. /// It is expected that the `__flow/info/{flow_id}` wasn't occupied. /// Otherwise, the transaction will retrieve existing value. diff --git a/src/operator/src/statement/ddl.rs b/src/operator/src/statement/ddl.rs index 1fab624f46..aef164c6bb 100644 --- a/src/operator/src/statement/ddl.rs +++ b/src/operator/src/statement/ddl.rs @@ -35,7 +35,7 @@ use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, is_reado use common_catalog::{format_full_flow_name, format_full_table_name}; use common_error::ext::BoxedError; use common_meta::cache_invalidator::Context; -use common_meta::ddl::create_flow::FlowType; +use common_meta::ddl::create_flow::{DEFER_ON_MISSING_SOURCE_KEY, FlowType}; use common_meta::instruction::CacheIdent; use common_meta::key::schema_name::{SchemaName, SchemaNameKey}; use common_meta::procedure_executor::ExecutorContext; @@ -114,7 +114,6 @@ struct DdlSubmitOptions { timeout: Duration, } -const DEFER_ON_MISSING_SOURCE_KEY: &str = "defer_on_missing_source"; const ALLOWED_FLOW_OPTIONS: [&str; 1] = [DEFER_ON_MISSING_SOURCE_KEY]; fn build_procedure_id_output(procedure_id: Vec) -> Result { @@ -205,6 +204,39 @@ fn validate_and_normalize_flow_options( .collect() } +fn determine_flow_type_for_source_state( + flow_name: &str, + flow_options: &HashMap, + has_missing_source_table: bool, + has_instant_ttl_source_table: bool, +) -> Result> { + if has_missing_source_table { + let defer_on_missing_source = flow_options + .get(DEFER_ON_MISSING_SOURCE_KEY) + .is_some_and(|value| value == "true"); + ensure!( + defer_on_missing_source, + InvalidSqlSnafu { + err_msg: format!( + "missing source tables for flow '{}'; use WITH ({DEFER_ON_MISSING_SOURCE_KEY} = true) to create a pending flow", + flow_name + ) + } + ); + info!( + "Flow `{}` is created as a pending batching flow because source tables are missing and defer_on_missing_source=true", + flow_name + ); + return Ok(Some(FlowType::Batching)); + } + + if has_instant_ttl_source_table { + return Ok(Some(FlowType::Streaming)); + } + + Ok(None) +} + impl StatementExecutor { pub fn catalog_manager(&self) -> CatalogManagerRef { self.catalog_manager.clone() @@ -715,7 +747,9 @@ impl StatementExecutor { expr: &CreateFlowExpr, query_ctx: QueryContextRef, ) -> Result { - // first check if source table's ttl is instant, if it is, force streaming mode + let mut has_missing_source_table = false; + let mut has_instant_ttl_source_table = false; + for src_table_name in &expr.source_table_names { let table = self .catalog_manager() @@ -727,16 +761,13 @@ impl StatementExecutor { ) .await .map_err(BoxedError::new) - .context(ExternalSnafu)? - .with_context(|| TableNotFoundSnafu { - table_name: format_full_table_name( - &src_table_name.catalog_name, - &src_table_name.schema_name, - &src_table_name.table_name, - ), - })?; + .context(ExternalSnafu)?; + + let Some(table) = table else { + has_missing_source_table = true; + continue; + }; - // instant source table can only be handled by streaming mode if table.table_info().meta.options.ttl == Some(common_time::TimeToLive::Instant) { warn!( "Source table `{}` for flow `{}`'s ttl=instant, fallback to streaming mode", @@ -747,10 +778,19 @@ impl StatementExecutor { ), expr.flow_name ); - return Ok(FlowType::Streaming); + has_instant_ttl_source_table = true; } } + if let Some(flow_type) = determine_flow_type_for_source_state( + &expr.flow_name, + &expr.flow_options, + has_missing_source_table, + has_instant_ttl_source_table, + )? { + return Ok(flow_type); + } + let engine = &self.query_engine; let stmts = ParserContext::create_with_dialect( &expr.sql, @@ -2515,6 +2555,35 @@ SELECT max(c1), min(c2) FROM schema_2.table_2;"; )); } + #[test] + fn test_determine_flow_type_for_source_state_missing_sources_require_opt_in() { + let err = determine_flow_type_for_source_state("my_flow", &HashMap::new(), true, false) + .unwrap_err(); + + assert!(err.to_string().contains( + "missing source tables for flow 'my_flow'; use WITH (defer_on_missing_source = true) to create a pending flow" + )); + } + + #[test] + fn test_determine_flow_type_for_source_state_missing_sources_prefer_batching() { + let flow_options = + HashMap::from([(DEFER_ON_MISSING_SOURCE_KEY.to_string(), "true".to_string())]); + + assert_eq!( + determine_flow_type_for_source_state("my_flow", &flow_options, true, true).unwrap(), + Some(FlowType::Batching) + ); + } + + #[test] + fn test_determine_flow_type_for_source_state_instant_ttl_without_missing_sources() { + assert_eq!( + determine_flow_type_for_source_state("my_flow", &HashMap::new(), false, true).unwrap(), + Some(FlowType::Streaming) + ); + } + #[test] fn test_name_is_match() { assert!(!NAME_PATTERN_REG.is_match("/adaf")); diff --git a/tests/cases/standalone/common/flow/flow_pending.result b/tests/cases/standalone/common/flow/flow_pending.result new file mode 100644 index 0000000000..d6fe01b38a --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_pending.result @@ -0,0 +1,52 @@ +CREATE FLOW pending_without_defer +SINK TO pending_sink +AS SELECT val FROM pending_source; + +Error: 1004(InvalidArguments), Invalid SQL, error: missing source tables for flow 'pending_without_defer'; use WITH (defer_on_missing_source = true) to create a pending flow + +CREATE FLOW pending_with_defer +SINK TO pending_sink +WITH (defer_on_missing_source = true) +AS SELECT val FROM pending_source WHERE val > 10; + +Affected Rows: 0 + +SHOW CREATE FLOW pending_with_defer; + ++--------------------+--------------------------------------------------+ +| Flow | Create Flow | ++--------------------+--------------------------------------------------+ +| pending_with_defer | CREATE FLOW IF NOT EXISTS pending_with_defer | +| | SINK TO public.pending_sink | +| | WITH (defer_on_missing_source = 'true') | +| | AS SELECT val FROM pending_source WHERE val > 10 | ++--------------------+--------------------------------------------------+ + +SELECT + flow_definition, + source_table_ids, + source_table_names, + flownode_ids, + options LIKE '%"defer_on_missing_source":"true"%' AS has_defer_option, + options LIKE '%"flow_type":"batching"%' AS has_flow_type_option +FROM INFORMATION_SCHEMA.FLOWS +WHERE flow_name = 'pending_with_defer'; + ++--------------------------------------------------+------------------+--------------------+--------------+------------------+----------------------+ +| flow_definition | source_table_ids | source_table_names | flownode_ids | has_defer_option | has_flow_type_option | ++--------------------------------------------------+------------------+--------------------+--------------+------------------+----------------------+ +| CREATE FLOW IF NOT EXISTS pending_with_defer | [] | | {} | true | true | +| SINK TO public.pending_sink | | | | | | +| WITH (defer_on_missing_source = 'true') | | | | | | +| AS SELECT val FROM pending_source WHERE val > 10 | | | | | | ++--------------------------------------------------+------------------+--------------------+--------------+------------------+----------------------+ + +DROP FLOW pending_with_defer; + +Affected Rows: 0 + +SELECT flow_name FROM INFORMATION_SCHEMA.FLOWS WHERE flow_name = 'pending_with_defer'; + +++ +++ + diff --git a/tests/cases/standalone/common/flow/flow_pending.sql b/tests/cases/standalone/common/flow/flow_pending.sql new file mode 100644 index 0000000000..498f5b2782 --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_pending.sql @@ -0,0 +1,24 @@ +CREATE FLOW pending_without_defer +SINK TO pending_sink +AS SELECT val FROM pending_source; + +CREATE FLOW pending_with_defer +SINK TO pending_sink +WITH (defer_on_missing_source = true) +AS SELECT val FROM pending_source WHERE val > 10; + +SHOW CREATE FLOW pending_with_defer; + +SELECT + flow_definition, + source_table_ids, + source_table_names, + flownode_ids, + options LIKE '%"defer_on_missing_source":"true"%' AS has_defer_option, + options LIKE '%"flow_type":"batching"%' AS has_flow_type_option +FROM INFORMATION_SCHEMA.FLOWS +WHERE flow_name = 'pending_with_defer'; + +DROP FLOW pending_with_defer; + +SELECT flow_name FROM INFORMATION_SCHEMA.FLOWS WHERE flow_name = 'pending_with_defer'; diff --git a/tests/cases/standalone/common/flow/flow_rebuild.result b/tests/cases/standalone/common/flow/flow_rebuild.result index db2f314b32..bd2bf9c892 100644 --- a/tests/cases/standalone/common/flow/flow_rebuild.result +++ b/tests/cases/standalone/common/flow/flow_rebuild.result @@ -273,6 +273,7 @@ ADMIN FLUSH_FLOW('test_wildcard_basic'); | FLOW_FLUSHED | +-----------------------------------------+ +-- SQLNESS SLEEP 3s SELECT wildcard FROM out_basic; +----------+ diff --git a/tests/cases/standalone/common/flow/flow_rebuild.sql b/tests/cases/standalone/common/flow/flow_rebuild.sql index c86c781d5d..4f30c80ea2 100644 --- a/tests/cases/standalone/common/flow/flow_rebuild.sql +++ b/tests/cases/standalone/common/flow/flow_rebuild.sql @@ -151,6 +151,7 @@ VALUES -- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | ADMIN FLUSH_FLOW('test_wildcard_basic'); +-- SQLNESS SLEEP 3s SELECT wildcard FROM out_basic; -- test again, this time with db restart From 869a584f8af7dd2d9f5f9ba5ff86f7a29857b6e7 Mon Sep 17 00:00:00 2001 From: LFC <990479+MichaelScofield@users.noreply.github.com> Date: Fri, 29 May 2026 15:07:49 +0800 Subject: [PATCH 32/32] ci: add nightly jsonbench test (#7750) Signed-off-by: luofucong --- .github/workflows/nightly-jsonbench.yaml | 162 ++++++++++++++++++ src/datatypes/src/json.rs | 85 +++++---- src/datatypes/src/json/value.rs | 108 +++++++----- src/datatypes/src/types/json_type.rs | 10 +- src/datatypes/src/vectors/json/builder.rs | 4 +- .../standalone/common/types/json/json2.result | 23 ++- .../standalone/common/types/json/json2.sql | 9 + 7 files changed, 315 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/nightly-jsonbench.yaml diff --git a/.github/workflows/nightly-jsonbench.yaml b/.github/workflows/nightly-jsonbench.yaml new file mode 100644 index 0000000000..3667ee26a6 --- /dev/null +++ b/.github/workflows/nightly-jsonbench.yaml @@ -0,0 +1,162 @@ +name: Nightly JSONBench + +on: + schedule: + # Trigger at 00:00(Asia/Shanghai) on every weekday. + - cron: "0 16 * * 0-4" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + allocate-runner: + name: Allocate runner + if: ${{ github.repository == 'GreptimeTeam/greptimedb' }} + runs-on: ubuntu-latest + outputs: + linux-arm64-runner: ${{ steps.start-linux-arm64-runner.outputs.label }} + + # The following EC2 resource id will be used for resource releasing. + linux-arm64-ec2-runner-label: ${{ steps.start-linux-arm64-runner.outputs.label }} + linux-arm64-ec2-runner-instance-id: ${{ steps.start-linux-arm64-runner.outputs.ec2-instance-id }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Allocate Linux ARM64 runner + uses: ./.github/actions/start-runner + id: start-linux-arm64-runner + with: + runner: ${{ vars.DEFAULT_ARM64_RUNNER }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.EC2_RUNNER_REGION }} + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + image-id: ${{ vars.EC2_RUNNER_LINUX_ARM64_IMAGE_ID }} + security-group-id: ${{ vars.EC2_RUNNER_SECURITY_GROUP_ID }} + subnet-id: ${{ vars.EC2_RUNNER_SUBNET_ID }} + + jsonbench: + name: Run JSONBench + if: ${{ github.repository == 'GreptimeTeam/greptimedb' }} + needs: [ allocate-runner ] + runs-on: ${{ needs.allocate-runner.outputs.linux-arm64-runner }} + timeout-minutes: 120 + env: + JSONBENCH_DATA_DIR: /home/runner/data/bluesky + JSONBENCH_OUTPUT_PREFIX: _ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: "nightly-jsonbench" + cache-all-crates: "true" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Build GreptimeDB + run: cargo build --profile nightly --bin greptime + + - name: Reclaim disk space + shell: bash + run: | + set -euo pipefail + + mkdir -p "${RUNNER_TEMP}/greptimedb-bin" + cp ./target/nightly/greptime "${RUNNER_TEMP}/greptimedb-bin/greptime" + chmod +x "${RUNNER_TEMP}/greptimedb-bin/greptime" + + rm -rf ./target + + - name: Run JSONBench + shell: bash + run: | + set -euo pipefail + + cd "${RUNNER_TEMP}" + cp "${RUNNER_TEMP}/greptimedb-bin/greptime" ./greptime + chmod +x ./greptime + + export GREPTIMEDB_STANDALONE__WAL__DIR=greptimedb_data/wal + export GREPTIMEDB_STANDALONE__STORAGE__DATA_HOME=greptimedb_data + export GREPTIMEDB_STANDALONE__LOGGING__DIR=greptimedb_data/logs + export GREPTIMEDB_STANDALONE__LOGGING__APPEND_STDOUT=false + export GREPTIMEDB_STANDALONE__HTTP__BODY_LIMIT=1GB + export GREPTIMEDB_STANDALONE__HTTP__TIMEOUT=500s + + ./greptime standalone start > greptimedb.log 2>&1 & + greptime_pid=$! + trap 'kill "${greptime_pid}" 2>/dev/null || true' EXIT + + until curl -s --fail -o /dev/null http://localhost:4000/health; do + if ! kill -0 "${greptime_pid}" 2>/dev/null; then + cat greptimedb.log + exit 1 + fi + sleep 1 + done + + git clone --branch greptimedb-new-json --depth 1 https://github.com/GreptimeTeam/JSONBench.git JSONBench + cp ./greptime JSONBench/greptimedb/greptime + + cd JSONBench/greptimedb + ./main.sh 3 "${JSONBENCH_DATA_DIR}" success.log error.log "${JSONBENCH_OUTPUT_PREFIX}" false + + - name: Upload JSONBench results + if: always() + uses: actions/upload-artifact@v4 + with: + name: jsonbench-results + path: | + ${{ runner.temp }}/greptimedb.log + ${{ runner.temp }}/JSONBench/greptimedb/*.log + ${{ runner.temp }}/JSONBench/greptimedb/*.total_size + ${{ runner.temp }}/JSONBench/greptimedb/*.data_size + ${{ runner.temp }}/JSONBench/greptimedb/*.index_size + ${{ runner.temp }}/JSONBench/greptimedb/*.count + ${{ runner.temp }}/JSONBench/greptimedb/*.results_runtime + ${{ runner.temp }}/JSONBench/greptimedb/*.query_results + if-no-files-found: ignore + retention-days: 7 + + stop-linux-arm64-runner: + name: Stop Linux ARM64 runner + # It's always run as the last job in the workflow to make sure that the runner is released. + if: ${{ always() }} + runs-on: ubuntu-latest + needs: [ + allocate-runner, + jsonbench, + ] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Stop Linux ARM64 runner + uses: ./.github/actions/stop-runner + with: + label: ${{ needs.allocate-runner.outputs.linux-arm64-ec2-runner-label }} + ec2-instance-id: ${{ needs.allocate-runner.outputs.linux-arm64-ec2-runner-instance-id }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.EC2_RUNNER_REGION }} + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} diff --git a/src/datatypes/src/json.rs b/src/datatypes/src/json.rs index db657abbcb..33104084ad 100644 --- a/src/datatypes/src/json.rs +++ b/src/datatypes/src/json.rs @@ -26,12 +26,12 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as Json}; -use snafu::{OptionExt, ResultExt, ensure}; +use snafu::{OptionExt, ResultExt}; use crate::error::{self, InvalidJsonSnafu, Result, SerializeSnafu}; use crate::json::value::{JsonValue, JsonVariant}; use crate::types::json_type::{JsonNativeType, JsonNumberType, JsonObjectType}; -use crate::types::{StructField, StructType}; +use crate::types::{JsonType, StructField, StructType}; use crate::value::{ListValue, StructValue, Value}; /// The configuration of JSON encoding @@ -305,33 +305,47 @@ fn encode_json_array_with_context<'a>( ) -> Result { let json_array_len = json_array.len(); let mut items = Vec::with_capacity(json_array_len); - let mut element_type = item_type.cloned(); for (index, value) in json_array.into_iter().enumerate() { let array_context = context.with_key(&index.to_string()); - let item_value = - encode_json_value_with_context(value, element_type.as_ref(), &array_context)?; - let item_type = item_value.json_type().native_type().clone(); - items.push(item_value.into_variant()); - - // Determine the common type for the list - if let Some(current_type) = &element_type { - // It's valid for json array to have different types of items, for example, - // ["a string", 1]. However, the `JsonValue` will be converted to Arrow list array, - // which requires all items have exactly same type. So we forbid the different types - // case here. Besides, it's not common for items in a json array to differ. So I think - // we are good here. - ensure!( - item_type == *current_type, - error::InvalidJsonSnafu { - value: "all items in json array must have the same type" - } - ); - } else { - element_type = Some(item_type); - } + let item_value = encode_json_value_with_context(value, None, &array_context)?; + items.push(item_value); } + // In specification, it's valid for a JSON array to have different types of items, for example, + // ["a string", 1]. However, in implementation, the `JsonValue` will be converted to Arrow list + // array, which requires all items have exactly the same type. So we merge out the maybe + // different item types to a unified type, and align all the item values to it. + + let provided_item_type = item_type.map(|x| JsonType::new_json2(x.clone())); + let merged_item_type = if let Some((first, rests)) = items.split_first() { + let mut merged = first.json_type().clone(); + for rest in rests.iter().map(|x| x.json_type()) { + if matches!(merged.native_type(), JsonNativeType::Variant) { + break; + } + merged.merge(rest)?; + } + Some(merged) + } else { + None + }; + let unified_item_type = match (provided_item_type, merged_item_type) { + (Some(mut x), Some(y)) => { + x.merge(&y)?; + Some(x) + } + (x, y) => x.or(y), + }; + if let Some(unified_item_type) = unified_item_type { + for item in &mut items { + item.try_align(&unified_item_type)?; + } + } + let items = items + .into_iter() + .map(|x| x.into_variant()) + .collect::>(); Ok(JsonValue::new(JsonVariant::Array(items))) } @@ -1050,11 +1064,8 @@ mod tests { fn test_encode_json_array_mixed_types() { let json = json!([1, "hello", true, 3.15]); let settings = JsonStructureSettings::Structured(None); - let result = settings.encode_with_type(json, None); - assert_eq!( - result.unwrap_err().to_string(), - "Invalid JSON: all items in json array must have the same type" - ); + let value = settings.encode_with_type(json, None).unwrap(); + assert_eq!(value.data_type().to_string(), r#"Json2[""]"#); } #[test] @@ -1276,12 +1287,12 @@ mod tests { #[test] fn test_encode_json_array_with_item_type() { let json = json!([1, 2, 3]); - let item_type = Arc::new(ConcreteDataType::uint64_datatype()); + let item_type = Arc::new(ConcreteDataType::int64_datatype()); let settings = JsonStructureSettings::Structured(None); let result = settings .encode_with_type( json, - Some(&JsonNativeType::Array(Box::new(JsonNativeType::u64()))), + Some(&JsonNativeType::Array(Box::new(JsonNativeType::i64()))), ) .unwrap() .into_json_inner() @@ -1289,9 +1300,9 @@ mod tests { if let Value::List(list_value) = result { assert_eq!(list_value.items().len(), 3); - assert_eq!(list_value.items()[0], Value::UInt64(1)); - assert_eq!(list_value.items()[1], Value::UInt64(2)); - assert_eq!(list_value.items()[2], Value::UInt64(3)); + assert_eq!(list_value.items()[0], Value::Int64(1)); + assert_eq!(list_value.items()[1], Value::Int64(2)); + assert_eq!(list_value.items()[2], Value::Int64(3)); assert_eq!(list_value.datatype(), item_type); } else { panic!("Expected List value"); @@ -2249,10 +2260,10 @@ mod tests { )])), ); - let decoded_struct = settings.decode_struct(array_struct); + let decoded_struct = settings.decode_struct(array_struct).unwrap(); assert_eq!( - decoded_struct.unwrap_err().to_string(), - "Invalid JSON: all items in json array must have the same type" + format!("{decoded_struct:?}"), + r#"StructValue { items: [List(ListValue { items: [Binary(Bytes(b"1")), Binary(Bytes(b"\"hello\"")), Binary(Bytes(b"true")), Binary(Bytes(b"3.15"))], datatype: Binary(BinaryType { repr_type: Binary }) })], fields: StructType { fields: [StructField { name: "value", data_type: List(ListType { item_type: Binary(BinaryType { repr_type: Binary }) }), nullable: true, metadata: {} }] } }"# ); } diff --git a/src/datatypes/src/json/value.rs b/src/datatypes/src/json/value.rs index f3b652a549..4350630003 100644 --- a/src/datatypes/src/json/value.rs +++ b/src/datatypes/src/json/value.rs @@ -65,6 +65,14 @@ impl JsonNumber { JsonNumber::Float(n) => n.0, } } + + fn native_type(&self) -> JsonNativeType { + match self { + JsonNumber::PosInt(_) => JsonNativeType::u64(), + JsonNumber::NegInt(_) => JsonNativeType::i64(), + JsonNumber::Float(_) => JsonNativeType::f64(), + } + } } impl From for JsonNumber { @@ -147,26 +155,14 @@ impl JsonVariant { match self { JsonVariant::Null => JsonNativeType::Null, JsonVariant::Bool(_) => JsonNativeType::Bool, - JsonVariant::Number(n) => match n { - JsonNumber::PosInt(_) => JsonNativeType::u64(), - JsonNumber::NegInt(_) => JsonNativeType::i64(), - JsonNumber::Float(_) => JsonNativeType::f64(), - }, + JsonVariant::Number(n) => n.native_type(), JsonVariant::String(_) => JsonNativeType::String, JsonVariant::Array(array) => { - let item_type = if let Some(first) = array.first() { - first.native_type() - } else { - JsonNativeType::Null - }; - JsonNativeType::Array(Box::new(item_type)) + json_array_native_type(array.iter().map(JsonVariant::native_type)) + } + JsonVariant::Object(object) => { + json_object_native_type(object.iter().map(|(k, v)| (k, v.native_type()))) } - JsonVariant::Object(object) => JsonNativeType::Object( - object - .iter() - .map(|(k, v)| (k.clone(), v.native_type())) - .collect(), - ), JsonVariant::Variant(_) => JsonNativeType::Variant, } } @@ -469,6 +465,7 @@ impl JsonValue { .collect::>()?, ), + (JsonVariant::Object(kvs), _) if kvs.is_empty() => JsonVariant::Null, (JsonVariant::Object(mut kvs), JsonNativeType::Object(expected)) => { ensure!( expected.keys().len() >= kvs.keys().len() @@ -517,7 +514,7 @@ impl JsonValue { let x = std::mem::take(&mut self.json_variant); self.json_variant = helper(x, expected.native_type())?; - self.json_type = OnceLock::from(expected.clone()); + self.json_type = OnceLock::new(); Ok(()) } } @@ -623,35 +620,55 @@ pub enum JsonVariantRef<'a> { } impl JsonVariantRef<'_> { - fn json_type(&self) -> JsonType { - fn native_type(v: &JsonVariantRef<'_>) -> JsonNativeType { - match v { - JsonVariantRef::Null => JsonNativeType::Null, - JsonVariantRef::Bool(_) => JsonNativeType::Bool, - JsonVariantRef::Number(n) => match n { - JsonNumber::PosInt(_) => JsonNativeType::u64(), - JsonNumber::NegInt(_) => JsonNativeType::i64(), - JsonNumber::Float(_) => JsonNativeType::f64(), - }, - JsonVariantRef::String(_) => JsonNativeType::String, - JsonVariantRef::Array(array) => { - let item_type = if let Some(first) = array.first() { - native_type(first) - } else { - JsonNativeType::Null - }; - JsonNativeType::Array(Box::new(item_type)) - } - JsonVariantRef::Object(object) => JsonNativeType::Object( - object - .iter() - .map(|(k, v)| (k.to_string(), native_type(v))) - .collect(), - ), - JsonVariantRef::Variant(_) => JsonNativeType::Variant, + fn native_type(&self) -> JsonNativeType { + match self { + JsonVariantRef::Null => JsonNativeType::Null, + JsonVariantRef::Bool(_) => JsonNativeType::Bool, + JsonVariantRef::Number(n) => n.native_type(), + JsonVariantRef::String(_) => JsonNativeType::String, + JsonVariantRef::Array(array) => { + json_array_native_type(array.iter().map(JsonVariantRef::native_type)) } + JsonVariantRef::Object(object) => { + json_object_native_type(object.iter().map(|(k, v)| (*k, v.native_type()))) + } + JsonVariantRef::Variant(_) => JsonNativeType::Variant, } - JsonType::new_json2(native_type(self)) + } + + fn json_type(&self) -> JsonType { + JsonType::new_json2(self.native_type()) + } +} + +fn json_array_native_type(items: I) -> JsonNativeType +where + I: IntoIterator, +{ + let mut iter = items.into_iter(); + let mut item_type = match iter.next() { + Some(t) => t, + None => return JsonNativeType::Array(Box::new(JsonNativeType::Null)), + }; + for x in iter { + if matches!(item_type, JsonNativeType::Variant) { + break; + } + item_type.merge(&x); + } + JsonNativeType::Array(Box::new(item_type)) +} + +fn json_object_native_type(fields: I) -> JsonNativeType +where + I: IntoIterator, + K: Into, +{ + let mut fields = fields.into_iter().peekable(); + if fields.peek().is_none() { + JsonNativeType::Null + } else { + JsonNativeType::Object(fields.map(|(k, v)| (k.into(), v)).collect()) } } @@ -941,7 +958,6 @@ mod tests { ("name".to_string(), JsonVariant::Null), ]))) ); - assert_eq!(value.json_type(), &expected); // Object alignment should fail if the expected type misses any field from the value. let expected = JsonType::new_json2(JsonNativeType::Object(JsonObjectType::from([( diff --git a/src/datatypes/src/types/json_type.rs b/src/datatypes/src/types/json_type.rs index e8d06543ed..652847da43 100644 --- a/src/datatypes/src/types/json_type.rs +++ b/src/datatypes/src/types/json_type.rs @@ -115,6 +115,14 @@ impl JsonNativeType { (JsonNativeType::Null, that) => that.clone(), (this, JsonNativeType::Null) => this, (this, that) if this == *that => this, + + (JsonNativeType::Number(x), JsonNativeType::Number(y)) => { + JsonNativeType::Number(match (x, y) { + (x, y) if x == *y => x, + (JsonNumberType::F64, _) | (_, JsonNumberType::F64) => JsonNumberType::F64, + _ => JsonNumberType::I64, + }) + } _ => JsonNativeType::Variant, }; } @@ -822,7 +830,7 @@ mod tests { test( "1.5", &mut JsonType::new_json2(JsonNativeType::i64()), - Ok(r#""""#), + Ok(r#""""#), )?; // Object merge should preserve existing fields and append missing fields. diff --git a/src/datatypes/src/vectors/json/builder.rs b/src/datatypes/src/vectors/json/builder.rs index be79a921c7..7ca1ff2f6a 100644 --- a/src/datatypes/src/vectors/json/builder.rs +++ b/src/datatypes/src/vectors/json/builder.rs @@ -89,7 +89,9 @@ impl MutableVector for JsonVectorBuilder { .fail(); }; let json_type = value.json_type(); - self.merged_type.merge(json_type)?; + if !self.merged_type.is_include(json_type) { + self.merged_type.merge(json_type)?; + } let value = JsonValue::new(JsonVariant::from(value.variant().clone())); self.values.push(value); diff --git a/tests/cases/standalone/common/types/json/json2.result b/tests/cases/standalone/common/types/json/json2.result index 71e119307c..7de73f2a78 100644 --- a/tests/cases/standalone/common/types/json/json2.result +++ b/tests/cases/standalone/common/types/json/json2.result @@ -126,7 +126,7 @@ select j.a, j.a.x from json2_table order by ts; | {"b":-2} | | | {"b":3} | | | {"b":-4} | | -| {"b":null} | | +| | | | | | | {"b":"s7"} | | | {"b":8} | | @@ -151,6 +151,14 @@ select j.c, j.y from json2_table order by ts; | | false | +-----------------------------------+-----------------------------------+ +select j from json2_table order by ts; + +Error: 3001(EngineExecuteQuery), Failed to align JSON array, reason: Invalid argument error: use StructArray::try_new_with_length or StructArray::new_empty_fields to create a struct array with no fields so that the length can be set correctly + +select * from json2_table order by ts; + +Error: 3001(EngineExecuteQuery), Failed to align JSON array, reason: Invalid argument error: use StructArray::try_new_with_length or StructArray::new_empty_fields to create a struct array with no fields so that the length can be set correctly + select j.a.b + 1 from json2_table order by ts; +------------------------------------------------------------+ @@ -168,6 +176,19 @@ select j.a.b + 1 from json2_table order by ts; | 11 | +------------------------------------------------------------+ +select abs(j.a.b) from json2_table order by ts; + +Error: 3000(PlanQuery), Failed to plan SQL: Error during planning: Function 'abs' expects NativeType::Numeric but received NativeType::String No function matches the given name and argument types 'abs(Utf8View)'. You might need to add explicit type casts. + Candidate functions: + abs(Numeric(1)) + +-- "j.c" is of type "String", "abs" is expected to be all "null"s. +select abs(j.c) from json2_table order by ts; + +Error: 3000(PlanQuery), Failed to plan SQL: Error during planning: Function 'abs' expects NativeType::Numeric but received NativeType::String No function matches the given name and argument types 'abs(Utf8View)'. You might need to add explicit type casts. + Candidate functions: + abs(Numeric(1)) + select j.d from json2_table order by ts; +-----------------------------------+ diff --git a/tests/cases/standalone/common/types/json/json2.sql b/tests/cases/standalone/common/types/json/json2.sql index 8dd6789bce..cb8df2f8b9 100644 --- a/tests/cases/standalone/common/types/json/json2.sql +++ b/tests/cases/standalone/common/types/json/json2.sql @@ -46,8 +46,17 @@ select j.a, j.a.x from json2_table order by ts; select j.c, j.y from json2_table order by ts; +select j from json2_table order by ts; + +select * from json2_table order by ts; + select j.a.b + 1 from json2_table order by ts; +select abs(j.a.b) from json2_table order by ts; + +-- "j.c" is of type "String", "abs" is expected to be all "null"s. +select abs(j.c) from json2_table order by ts; + select j.d from json2_table order by ts; drop table json2_table;