feat(flow): support durable incremental aggregate state

Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
discord9
2026-09-04 20:08:46 +08:00
parent a5a6da186a
commit e1f95aa41e
12 changed files with 2115 additions and 860 deletions
@@ -18,6 +18,7 @@ use datatypes::arrow::datatypes::DataType;
use crate::aggrs::aggr_wrapper::DeltaMergeWrapper;
use crate::function_registry::FunctionRegistry;
pub mod avg;
pub mod hll;
pub mod uddsketch;
pub mod welford;
@@ -26,6 +27,16 @@ pub(crate) struct ApproximateFunction;
impl ApproximateFunction {
pub fn register(registry: &FunctionRegistry) {
let avg_merge = avg::AvgAccumulator::merge_udf_impl();
registry.register_aggr(avg::AvgAccumulator::state_udf_impl());
registry.register_aggr(avg_merge.clone());
registry.register_aggr(AggregateUDF::new_from_impl(DeltaMergeWrapper::new(
avg_merge.clone(),
avg::AVG_STATE_NAME,
vec![DataType::Binary],
DataType::Binary,
)));
let uddsketch_state = uddsketch::UddSketchState::state_udf_impl();
let uddsketch_merge = uddsketch::UddSketchState::merge_udf_impl();
let uddsketch_delta = AggregateUDF::new_from_impl(DeltaMergeWrapper::new(
@@ -0,0 +1,545 @@
// 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 datafusion::arrow::array::{ArrayRef, Float64Array};
use datafusion::arrow::compute::sum;
use datafusion::common::cast::{as_binary_array, as_primitive_array};
use datafusion::common::not_impl_err;
use datafusion::error::{DataFusionError, Result as DfResult};
use datafusion::logical_expr::function::AccumulatorArgs;
use datafusion::logical_expr::{Accumulator as DfAccumulator, AggregateUDF, Volatility};
use datafusion::prelude::create_udaf;
use datafusion_common::ScalarValue;
use datatypes::arrow::datatypes::{DataType, Float64Type};
pub const AVG_STATE_NAME: &str = "avg_state";
pub const AVG_MERGE_NAME: &str = "avg_merge";
const ENCODED_LEN: usize = 20;
const MAGIC: &[u8; 4] = b"AVG1";
/// The portable state used by the Float64 average aggregate functions.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AvgState {
count: u64,
sum: f64,
}
impl Default for AvgState {
fn default() -> Self {
Self { count: 0, sum: 0.0 }
}
}
impl AvgState {
/// Returns the exact AVG1 representation of this state.
pub(crate) fn encode(&self) -> [u8; ENCODED_LEN] {
let mut encoded = [0; ENCODED_LEN];
encoded[..4].copy_from_slice(MAGIC);
encoded[4..12].copy_from_slice(&self.count.to_le_bytes());
encoded[12..20].copy_from_slice(&self.sum.to_bits().to_le_bytes());
encoded
}
/// Decodes and validates an AVG1 state.
pub fn decode(encoded: &[u8]) -> DfResult<Self> {
if encoded.len() != ENCODED_LEN || &encoded[..4] != MAGIC {
return Err(invalid_state());
}
let count = decode_u64(encoded, 4);
let sum = f64::from_bits(decode_u64(encoded, 12));
if count == 0 && sum.to_bits() != 0 {
return Err(invalid_state());
}
Ok(Self { count, sum })
}
/// Returns the number of non-null input values in this state.
pub(crate) fn count(&self) -> u64 {
self.count
}
/// Returns the average, or `None` for the canonical empty state.
pub fn average(&self) -> Option<f64> {
(self.count() != 0).then(|| self.sum / self.count() as f64)
}
}
fn decode_u64(encoded: &[u8], offset: usize) -> u64 {
let mut bytes = [0; 8];
bytes.copy_from_slice(&encoded[offset..offset + 8]);
u64::from_le_bytes(bytes)
}
fn invalid_state() -> DataFusionError {
DataFusionError::Execution("Invalid AVG1 state".to_string())
}
fn count_overflow() -> DataFusionError {
DataFusionError::Execution("AVG count overflow".to_string())
}
#[derive(Debug, Clone, Copy)]
enum InputKind {
Float64,
Binary,
}
/// Accumulates and merges AVG1 states.
#[derive(Debug)]
pub(crate) struct AvgAccumulator {
state: AvgState,
input: InputKind,
}
impl Default for AvgAccumulator {
fn default() -> Self {
Self {
state: AvgState::default(),
input: InputKind::Float64,
}
}
}
impl AvgAccumulator {
pub fn state_udf_impl() -> AggregateUDF {
create_udaf(
AVG_STATE_NAME,
vec![DataType::Float64],
Arc::new(DataType::Binary),
Volatility::Immutable,
Arc::new(Self::create_accumulator),
Arc::new(vec![DataType::Binary]),
)
}
pub fn merge_udf_impl() -> AggregateUDF {
create_udaf(
AVG_MERGE_NAME,
vec![DataType::Binary],
Arc::new(DataType::Binary),
Volatility::Immutable,
Arc::new(Self::create_accumulator),
Arc::new(vec![DataType::Binary]),
)
}
fn create_accumulator(args: AccumulatorArgs) -> DfResult<Box<dyn DfAccumulator>> {
if args.is_distinct {
return not_impl_err!("AVG DISTINCT aggregations are not available");
}
let input = match args.exprs[0].data_type(args.schema)? {
DataType::Float64 => InputKind::Float64,
DataType::Binary => InputKind::Binary,
data_type => return not_impl_err!("AVG functions do not support {data_type:?}"),
};
Ok(Box::new(Self {
state: AvgState::default(),
input,
}))
}
fn update_float64(&mut self, array: &ArrayRef) -> DfResult<()> {
let array = as_primitive_array::<Float64Type>(array)?;
let mut count = self.state.count;
for _ in array.iter().flatten() {
count = count.checked_add(1).ok_or_else(count_overflow)?;
}
let sum = sum(array)
.map(|batch_sum| self.state.sum + batch_sum)
.unwrap_or(self.state.sum);
self.state = AvgState { count, sum };
Ok(())
}
fn merge_states(&mut self, array: &ArrayRef) -> DfResult<()> {
let array = as_binary_array(array)?;
let states = array
.iter()
.flatten()
.map(AvgState::decode)
.collect::<DfResult<Vec<_>>>()?;
let count = states.iter().try_fold(self.state.count, |count, state| {
count.checked_add(state.count).ok_or_else(count_overflow)
})?;
let sums = states
.iter()
.filter(|state| state.count != 0)
.map(|state| Some(state.sum))
.collect::<Vec<_>>();
let sum = sum(&Float64Array::from(sums))
.map(|batch_sum| self.state.sum + batch_sum)
.unwrap_or(self.state.sum);
self.state = AvgState { count, sum };
Ok(())
}
}
impl DfAccumulator for AvgAccumulator {
fn update_batch(&mut self, values: &[ArrayRef]) -> DfResult<()> {
let array = &values[0];
match (self.input, array.data_type()) {
(InputKind::Float64, DataType::Float64) => self.update_float64(array),
(InputKind::Binary, DataType::Binary) => self.merge_states(array),
(_, data_type) => not_impl_err!("AVG input type does not match: {data_type:?}"),
}
}
fn evaluate(&mut self) -> DfResult<ScalarValue> {
Ok(ScalarValue::Binary(Some(self.state.encode().to_vec())))
}
fn size(&self) -> usize {
std::mem::size_of::<Self>()
}
fn state(&mut self) -> DfResult<Vec<ScalarValue>> {
Ok(vec![ScalarValue::Binary(Some(
self.state.encode().to_vec(),
))])
}
fn merge_batch(&mut self, states: &[ArrayRef]) -> DfResult<()> {
self.merge_states(&states[0])
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::array::{BinaryArray, Float64Array};
use datafusion_common::ScalarValue;
use datafusion_common::arrow::datatypes::DataType;
use datafusion_expr::TypeSignature;
use datafusion_physical_expr::aggregate::AggregateExprBuilder;
use datafusion_physical_expr::expressions::{Column, lit as physical_lit};
use super::*;
use crate::aggrs::aggr_wrapper::{aggr_delta_merge_func_name, aggr_state_func_name};
use crate::function_registry::FUNCTION_REGISTRY;
fn state(count: u64, sum: f64) -> Vec<u8> {
AvgState { count, sum }.encode().to_vec()
}
#[test]
fn codec_golden_and_roundtrip() {
let empty = AvgState::default().encode();
assert_eq!(empty.len(), ENCODED_LEN);
assert_eq!(empty.as_slice(), b"AVG1\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0");
let mut accumulator = AvgAccumulator::default();
accumulator
.update_batch(&[Arc::new(Float64Array::from(vec![Some(1.5)]))])
.unwrap();
let one = accumulator.state.encode();
assert_eq!(one.len(), ENCODED_LEN);
assert_eq!(
one.as_slice(),
&[
b'A', b'V', b'G', b'1', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xf8, 0x3f,
]
);
assert_eq!(&one[12..20], &1.5f64.to_bits().to_le_bytes());
assert_eq!(AvgState::decode(&empty).unwrap().encode(), empty);
assert_eq!(AvgState::decode(&one).unwrap().encode(), one);
}
#[test]
fn codec_rejects_malformed_states() {
assert!(AvgState::decode(b"").is_err());
assert!(AvgState::decode(&[0; 19]).is_err());
assert!(AvgState::decode(&[0; 21]).is_err());
let mut avg2 = AvgState::default().encode();
avg2[..4].copy_from_slice(b"AVG2");
assert!(AvgState::decode(&avg2).is_err());
let mut wrong_magic = AvgState::default().encode();
wrong_magic[0] = b'X';
assert!(AvgState::decode(&wrong_magic).is_err());
for sum in [1.0, -0.0] {
assert!(AvgState::decode(&state(0, sum)).is_err());
}
let mut count = AvgState {
count: 0x0102_0304_0506_0708,
sum: 0.0,
}
.encode();
assert_eq!(&count[4..12], &0x0102_0304_0506_0708u64.to_le_bytes());
count[4..12].reverse();
assert_ne!(
AvgState::decode(&count).unwrap().count(),
0x0102_0304_0506_0708
);
let mut sum = AvgState { count: 1, sum: 1.5 }.encode();
assert_eq!(&sum[12..20], &1.5f64.to_bits().to_le_bytes());
sum[12..20].reverse();
assert_ne!(AvgState::decode(&sum).unwrap().average(), Some(1.5));
}
#[test]
fn codec_preserves_populated_float_bits() {
for bits in [
0.0f64.to_bits(),
(-0.0f64).to_bits(),
f64::INFINITY.to_bits(),
f64::NEG_INFINITY.to_bits(),
0x7ff8_0000_0000_0001,
0x7ff0_0000_0000_0001,
] {
let encoded = state(1, f64::from_bits(bits));
assert_eq!(
AvgState::decode(&encoded).unwrap().encode().as_slice(),
encoded
);
}
}
#[test]
fn distinct_is_rejected() {
let udf = AvgAccumulator::state_udf_impl();
let schema = arrow_schema::Schema::empty();
let expr = physical_lit(1.0f64);
let field = Arc::new(arrow_schema::Field::new("in", DataType::Float64, true));
let args = AccumulatorArgs {
return_field: Arc::new(arrow_schema::Field::new("out", DataType::Binary, true)),
schema: &schema,
ignore_nulls: false,
order_bys: &[],
is_reversed: false,
name: AVG_STATE_NAME,
is_distinct: true,
exprs: std::slice::from_ref(&expr),
expr_fields: std::slice::from_ref(&field),
};
assert!(udf.accumulator(args).is_err());
}
#[test]
fn state_counts_nulls_and_empty_is_canonical() {
let mut accumulator = AvgAccumulator::default();
accumulator
.update_batch(&[Arc::new(Float64Array::from(vec![None, None]))])
.unwrap();
assert_eq!(accumulator.state.encode(), AvgState::default().encode());
accumulator
.update_batch(&[Arc::new(Float64Array::from(vec![
Some(1.0),
None,
Some(3.0),
Some(8.0),
]))])
.unwrap();
assert_eq!(accumulator.state.count(), 3);
assert_eq!(accumulator.state.average(), Some(4.0));
}
#[test]
fn merge_preserves_populated_negative_zero_for_empty_input() {
let mut accumulator = AvgAccumulator {
state: AvgState {
count: 1,
sum: -0.0,
},
input: InputKind::Binary,
};
let expected = accumulator.state.encode();
accumulator
.update_batch(&[Arc::new(BinaryArray::from(vec![
None,
Some(AvgState::default().encode().as_slice()),
]))])
.unwrap();
assert_eq!(accumulator.state.encode(), expected);
}
#[test]
fn merge_ignores_nulls_and_merges_weighted_states() {
let mut accumulator = AvgAccumulator {
state: AvgState::default(),
input: InputKind::Binary,
};
accumulator
.update_batch(&[Arc::new(BinaryArray::from(vec![
Some(state(2, 4.0).as_slice()),
None,
Some(state(3, 15.0).as_slice()),
]))])
.unwrap();
assert_eq!(accumulator.state.count(), 5);
assert_eq!(accumulator.state.average(), Some(19.0 / 5.0));
let before = accumulator.state;
assert!(
accumulator
.update_batch(&[Arc::new(BinaryArray::from(vec![Some(&[][..])]))])
.is_err()
);
assert_eq!(accumulator.state, before);
}
#[test]
fn overflow_does_not_mutate_update_or_merge() {
let mut update = AvgAccumulator {
state: AvgState {
count: u64::MAX,
sum: 1.0,
},
input: InputKind::Float64,
};
let before = update.state;
assert!(
update
.update_batch(&[Arc::new(Float64Array::from(vec![Some(2.0)]))])
.is_err()
);
assert_eq!(update.state, before);
let mut merge = AvgAccumulator {
state: AvgState {
count: u64::MAX,
sum: 1.0,
},
input: InputKind::Binary,
};
let before = merge.state;
assert!(
merge
.update_batch(&[Arc::new(BinaryArray::from(vec![Some(
state(1, 2.0).as_slice()
)]))])
.is_err()
);
assert_eq!(merge.state, before);
}
#[test]
fn registered_delta_merge_has_four_way_and_malformed_behavior() {
let udf = FUNCTION_REGISTRY
.get_aggr_func(&aggr_delta_merge_func_name(AVG_STATE_NAME))
.unwrap();
assert_eq!(udf.name(), "__avg_state_delta_merge");
assert_eq!(
udf.signature().type_signature,
TypeSignature::Exact(vec![DataType::Binary, DataType::Binary])
);
let schema = Arc::new(arrow_schema::Schema::new(vec![
arrow_schema::Field::new("delta", DataType::Binary, true),
arrow_schema::Field::new("persisted", DataType::Binary, true),
]));
let expr = AggregateExprBuilder::new(
Arc::new(udf),
vec![
Arc::new(Column::new("delta", 0)),
Arc::new(Column::new("persisted", 1)),
],
)
.schema(schema)
.alias("avg_delta_merge")
.build()
.unwrap();
let delta = state(2, 3.0);
let persisted = state(2, 7.0);
for (left, right, expected) in [
(
Some(delta.as_slice()),
None,
AvgState { count: 2, sum: 3.0 }.encode(),
),
(
None,
Some(persisted.as_slice()),
AvgState { count: 2, sum: 7.0 }.encode(),
),
(None, None, AvgState::default().encode()),
(
Some(delta.as_slice()),
Some(persisted.as_slice()),
AvgState {
count: 4,
sum: 10.0,
}
.encode(),
),
] {
let mut accumulator = expr.create_accumulator().unwrap();
accumulator
.update_batch(&[
Arc::new(BinaryArray::from(vec![left])),
Arc::new(BinaryArray::from(vec![right])),
])
.unwrap();
let ScalarValue::Binary(Some(actual)) = accumulator.evaluate().unwrap() else {
panic!("AVG delta merge state must be binary");
};
assert_eq!(actual.as_slice(), expected.as_slice());
}
let mut accumulator = expr.create_accumulator().unwrap();
assert!(
accumulator
.update_batch(&[
Arc::new(BinaryArray::from(vec![Some(&[][..])])),
Arc::new(BinaryArray::from(vec![None])),
])
.is_err()
);
let mut accumulator = expr.create_accumulator().unwrap();
assert!(
accumulator
.update_batch(&[
Arc::new(BinaryArray::from(vec![None])),
Arc::new(BinaryArray::from(vec![Some(&[][..])])),
])
.is_err()
);
let mut accumulator = expr.create_accumulator().unwrap();
accumulator
.update_batch(&[
Arc::new(BinaryArray::from(vec![Some(delta.as_slice())])),
Arc::new(BinaryArray::from(vec![None])),
])
.unwrap();
assert!(
accumulator
.update_batch(&[
Arc::new(BinaryArray::from(vec![Some(delta.as_slice())])),
Arc::new(BinaryArray::from(vec![Some(
AvgState {
count: u64::MAX,
sum: 1.0
}
.encode()
.as_slice()
)])),
])
.is_err()
);
}
#[test]
fn avg_registry_does_not_replace_native_state_registry() {
let avg = FUNCTION_REGISTRY.get_aggr_func(AVG_STATE_NAME).unwrap();
let native = FUNCTION_REGISTRY
.get_aggr_func(&aggr_state_func_name("avg"))
.unwrap();
assert_eq!(
avg.return_type(&[DataType::Float64]).unwrap(),
DataType::Binary
);
assert!(matches!(
native.return_type(&[DataType::Float64]).unwrap(),
DataType::Struct(_)
));
}
}
+21
View File
@@ -568,6 +568,24 @@ pub fn validate_flow_options(flow_task: &CreateFlowTask) -> Result<()> {
}
}
if let Some(value) = flow_task
.flow_options
.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
{
if value != FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE {
value
.parse::<bool>()
.map_err(|_| {
UnexpectedSnafu {
err_msg: format!(
"Invalid flow option {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}: {value}"
),
}
.build()
})?;
}
}
defer_on_missing_source(flow_task)?;
get_flow_type_from_options(flow_task)?;
Ok(())
@@ -770,6 +788,9 @@ pub enum FlowType {
pub const FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY: &str =
"experimental_enable_incremental_read";
/// Reserved internal value for Enterprise flows requiring exact sequence ranges.
pub const FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE: &str =
"__greptime_internal_exact_sequence_range";
impl FlowType {
pub const BATCHING: &str = "batching";
+83 -2
View File
@@ -38,7 +38,10 @@ use crate::ddl::alter_logical_tables::AlterLogicalTablesProcedure;
use crate::ddl::alter_table::{AlterTableProcedure, RegionRouteChanged, only_enables_skip_wal};
use crate::ddl::comment_on::CommentOnProcedure;
use crate::ddl::create_database::{CreateDatabaseMetadataCommitterRef, CreateDatabaseProcedure};
use crate::ddl::create_flow::CreateFlowProcedure;
use crate::ddl::create_flow::{
CreateFlowProcedure, FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY,
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE,
};
use crate::ddl::create_logical_tables::CreateLogicalTablesProcedure;
use crate::ddl::create_table::CreateTableProcedure;
use crate::ddl::create_view::CreateViewProcedure;
@@ -112,6 +115,8 @@ pub struct DdlManager {
trigger_ddl_manager: Option<TriggerDdlManagerRef>,
#[cfg(feature = "enterprise")]
create_flow_handler: Option<CreateFlowHandlerRef>,
#[cfg(feature = "enterprise")]
drop_flow_handler: Option<DropFlowHandlerRef>,
}
/// This trait is responsible for handling DDL tasks about triggers. e.g.,
@@ -160,6 +165,20 @@ pub trait CreateFlowHandler: Send + Sync {
#[cfg(feature = "enterprise")]
pub type CreateFlowHandlerRef = Arc<dyn CreateFlowHandler>;
/// Hook for classifying and handling DROP FLOW requests.
#[async_trait::async_trait]
pub trait DropFlowHandler: Send + Sync {
async fn drop_flow(
&self,
drop_flow_task: DropFlowTask,
procedure_manager: ProcedureManagerRef,
ddl_context: DdlContext,
procedure_context: ProcedureContext,
) -> Result<SubmitDdlTaskResponse>;
}
pub type DropFlowHandlerRef = Arc<dyn DropFlowHandler>;
macro_rules! procedure_loader_entry {
($procedure:ident) => {
(
@@ -256,9 +275,17 @@ impl DdlManager {
trigger_ddl_manager: None,
#[cfg(feature = "enterprise")]
create_flow_handler: None,
#[cfg(feature = "enterprise")]
drop_flow_handler: None,
}
}
#[cfg(feature = "enterprise")]
pub fn with_drop_flow_handler(mut self, drop_flow_handler: DropFlowHandlerRef) -> Self {
self.drop_flow_handler = Some(drop_flow_handler);
self
}
#[cfg(feature = "enterprise")]
pub fn with_trigger_ddl_manager(mut self, trigger_ddl_manager: TriggerDdlManagerRef) -> Self {
self.trigger_ddl_manager = Some(trigger_ddl_manager);
@@ -992,6 +1019,17 @@ impl DdlManager {
.await
}
DropFlow(drop_flow_task) => {
#[cfg(feature = "enterprise")]
if let Some(handler) = self.drop_flow_handler.as_ref() {
return handler
.drop_flow(
drop_flow_task,
self.procedure_manager.clone(),
self.ddl_context.clone(),
procedure_context,
)
.await;
}
handle_drop_flow_task(self, drop_flow_task, procedure_context).await
}
CreateView(create_view_task) => {
@@ -1392,6 +1430,19 @@ async fn handle_create_flow_task(
query_context: QueryContext,
procedure_context: ProcedureContext,
) -> Result<SubmitDdlTaskResponse> {
if create_flow_task
.flow_options
.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
.is_some_and(|value| value == FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE)
{
return error::UnexpectedSnafu {
err_msg: format!(
"reserved flow option value for {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY} is internal"
),
}
.fail();
}
#[cfg(feature = "enterprise")]
if let Some(handler) = ddl_manager.create_flow_handler.as_ref() {
return handler
@@ -1578,7 +1629,11 @@ mod tests {
use crate::ddl::create_database::{
AtomicCreateOutcome, CreateDatabaseMetadataCommitter, CreateDatabaseProcedure,
};
use crate::ddl::create_flow::CreateFlowProcedure;
#[cfg(feature = "enterprise")]
use crate::ddl::create_flow::{
CreateFlowProcedure, FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY,
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE,
};
use crate::ddl::create_table::CreateTableProcedure;
use crate::ddl::drop_table::DropTableProcedure;
use crate::ddl::flow_meta::FlowMetadataAllocator;
@@ -1901,6 +1956,32 @@ mod tests {
)
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn test_reserved_sequence_range_is_rejected_before_enterprise_handler() {
let handler = Arc::new(RecordingCreateFlowHandler::default());
let ddl_manager =
build_soft_drop_test_ddl_manager().with_create_flow_handler(handler.clone());
let mut task = test_create_flow_task();
task.flow_options.insert(
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE.to_string(),
);
let result = ddl_manager
.submit_ddl_task(
ExecutorContext {
query_context: Some(QueryContext::default()),
..Default::default()
},
SubmitDdlTaskRequest::new(DdlTask::new_create_flow(task)),
)
.await;
assert!(result.is_err());
assert!(handler.tasks.lock().unwrap().is_empty());
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn test_create_flow_handler_dispatches_without_procedure() {
+310 -26
View File
@@ -21,7 +21,10 @@ use std::time::Duration;
use api::v1::flow::DirtyWindowRequests;
use catalog::CatalogManagerRef;
use common_error::ext::BoxedError;
use common_meta::ddl::create_flow::{FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY, FlowType};
use common_meta::ddl::create_flow::{
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY,
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE, FlowType,
};
use common_meta::key::TableMetadataManagerRef;
use common_meta::key::flow::FlowMetadataManagerRef;
use common_meta::key::flow::flow_state::FlowStat;
@@ -488,21 +491,24 @@ impl BatchingEngine {
fn batch_opts_for_flow_options(
&self,
flow_options: &HashMap<String, String>,
exact_sequence_range_required: bool,
) -> Result<Arc<BatchingModeOptions>, Error> {
let mut batch_opts = (*self.batch_opts).clone();
if let Some(enable_incremental_read) =
flow_options.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
{
batch_opts.experimental_enable_incremental_read = enable_incremental_read
.parse::<bool>()
.map_err(|_| {
batch_opts.experimental_enable_incremental_read = if exact_sequence_range_required {
true
} else {
enable_incremental_read.parse::<bool>().map_err(|_| {
InvalidQuerySnafu {
reason: format!(
"Invalid flow option {FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY}: {enable_incremental_read}"
),
}
.build()
})?;
})?
};
}
Ok(Arc::new(batch_opts))
@@ -625,8 +631,14 @@ impl BatchingEngine {
}
);
let batch_opts = self.batch_opts_for_flow_options(&flow_options)?;
// The meta layer validates this reserved sentinel before it reaches the
// flownode. Derive the requirement once and pass it directly to task
// config; query-context extensions are irrelevant.
let exact_sequence_range_required = flow_options
.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
.is_some_and(|value| value == FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE);
let batch_opts =
self.batch_opts_for_flow_options(&flow_options, exact_sequence_range_required)?;
let mut source_table_names = Vec::with_capacity(2);
for src_id in source_table_ids {
// also check table option to see if ttl!=instant
@@ -728,23 +740,42 @@ impl BatchingEngine {
eval_schedule,
};
let task = BatchingTask::try_new(task_args)?;
let task = BatchingTask::try_new_with_exact_sequence_range_required(
task_args,
exact_sequence_range_required,
)?;
let task_inner = task.clone();
let engine = self.query_engine.clone();
let frontend = self.frontend_client.clone();
if task.config.exact_sequence_range_required {
ensure!(
task.sequence_range_capable().await?,
UnsupportedSnafu {
reason: format!(
"Flow {flow_id} requires exact sequence-range reads, but a source table lacks the Mito preserve_row_sequence capability"
),
}
);
}
// Create the sink before configuring persistence. A persistence-backed sink may
// contain ordinary metadata columns supplied by `begin_attempt`, so strict plan/schema
// validation is deferred to execution for that path. OSS flows without a factory keep
// the existing creation-time validation.
// validation is deferred to execution only when persistence is actually created. Flows
// without a created collaborator keep the existing creation-time validation.
let table = task.check_or_create_sink_table(&engine, &frontend).await?;
let persistence = if let Some(factory) = &self.persistence_factory {
let table_info = table.table_info();
let meta = &table_info.meta;
let effective_mode = if task.config.batch_opts.experimental_enable_incremental_read
&& task.sequence_range_capable().await
let effective_mode = if task.config.exact_sequence_range_required {
crate::IncrementalMode::SequenceRange
} else if task.config.batch_opts.experimental_enable_incremental_read
&& task
.sequence_range_capable()
.await
.is_ok_and(|capable| capable)
{
crate::IncrementalMode::SequenceRange
} else {
@@ -782,9 +813,11 @@ impl BatchingEngine {
};
factory.create(context).await?
} else {
task.validate_sink_table_schema(&engine).await?;
None
};
if persistence.is_none() {
task.validate_sink_table_schema(&engine).await?;
}
task.set_persistence(persistence).await?;
let (start_tx, start_rx) = oneshot::channel();
@@ -1118,17 +1151,24 @@ impl FlowEngine for BatchingEngine {
#[cfg(test)]
mod tests {
use api::v1::flow::{DirtyWindowRequest, TimeRange};
use catalog::memory::new_memory_catalog_manager;
use catalog::RegisterTableRequest;
use catalog::memory::{MemoryCatalogManager, new_memory_catalog_manager};
use common_meta::key::TableMetadataManager;
use common_meta::key::flow::FlowMetadataManager;
use common_meta::key::table_route::TableRouteValue;
use common_meta::key::test_utils::new_test_table_info_with_name;
use common_meta::kv_backend::memory::MemoryKvBackend;
use common_recordbatch::RecordBatch;
use common_time::timestamp::TimeUnit;
use datatypes::schema::{ColumnSchema, Schema};
use datatypes::vectors::{TimestampMillisecondVector, UInt32Vector, VectorRef};
use query::options::QueryOptions;
use session::context::QueryContext;
use super::*;
use crate::batching_mode::persistence::{
BatchingAttempt, BatchingPersistence, Factory, FactoryPlugin, RestoreOutcome,
};
use crate::test_utils::create_test_query_engine;
struct DropNotify(Option<oneshot::Sender<()>>);
@@ -1141,6 +1181,43 @@ mod tests {
}
}
struct TestPersistenceFactory {
create_persistence: bool,
}
struct TestPersistence;
#[async_trait::async_trait]
impl BatchingPersistence for TestPersistence {
async fn restore(&self) -> crate::Result<RestoreOutcome> {
Ok(RestoreOutcome::TrustedCheckpoint(BTreeMap::new()))
}
async fn begin_attempt(&self) -> crate::Result<BatchingAttempt> {
Ok(BatchingAttempt::default())
}
async fn persist(
&self,
_attempt: BatchingAttempt,
_validated_checkpoints: BTreeMap<u64, u64>,
) -> crate::Result<()> {
Ok(())
}
}
#[async_trait::async_trait]
impl Factory for TestPersistenceFactory {
async fn create(
&self,
_context: PersistenceContext,
) -> crate::Result<Option<Arc<dyn BatchingPersistence>>> {
Ok(self
.create_persistence
.then_some(Arc::new(TestPersistence) as Arc<dyn BatchingPersistence>))
}
}
async fn new_test_engine() -> BatchingEngine {
let kv_backend = Arc::new(MemoryKvBackend::new());
let table_meta = Arc::new(TableMetadataManager::new(kv_backend.clone()));
@@ -1161,20 +1238,227 @@ mod tests {
)
}
#[tokio::test]
async fn test_flow_option_overrides_incremental_read_switch() {
let engine = new_test_engine().await;
async fn new_test_engine_with_persistence(
persistence_factory: Option<FactoryPlugin>,
) -> 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 query_engine = create_test_query_engine();
let catalog_manager = query_engine.engine_state().catalog_manager().clone();
let (frontend_client, _handler) =
FrontendClient::from_empty_grpc_handler(QueryOptions::default());
let default_opts = engine.batch_opts_for_flow_options(&HashMap::new()).unwrap();
assert!(!default_opts.experimental_enable_incremental_read);
let enabled_opts = engine
.batch_opts_for_flow_options(&HashMap::from([(
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
"true".to_string(),
)]))
let engine = BatchingEngine::new_with_persistence(
Arc::new(frontend_client),
query_engine,
flow_meta,
table_meta,
catalog_manager,
BatchingModeOptions::default(),
persistence_factory,
);
engine
.table_meta
.create_table_metadata(
new_test_table_info_with_name(1, "numbers_with_ts"),
TableRouteValue::physical(vec![]),
HashMap::new(),
)
.await
.unwrap();
assert!(enabled_opts.experimental_enable_incremental_read);
engine
}
fn register_sink_with_schema(engine: &BatchingEngine, name: &str, extended: bool) {
let mut columns = vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), false),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
];
let mut vectors: Vec<VectorRef> = vec![
Arc::new(UInt32Vector::from_slice([1_u32])),
Arc::new(TimestampMillisecondVector::from_slice([0_i64])),
];
if extended {
columns.push(ColumnSchema::new(
"checkpoint",
ConcreteDataType::uint32_datatype(),
false,
));
vectors.push(Arc::new(UInt32Vector::from_slice([1_u32])));
}
let schema = Arc::new(Schema::new(columns));
let recordbatch = RecordBatch::new(schema, vectors).unwrap();
let table = table::test_util::MemTable::table(name, recordbatch);
let request = RegisterTableRequest {
catalog: "greptime".to_string(),
schema: "public".to_string(),
table_name: name.to_string(),
table_id: 9000,
table,
};
engine
.catalog_manager
.as_any()
.downcast_ref::<MemoryCatalogManager>()
.unwrap()
.register_table_sync(request)
.unwrap();
}
fn flow_create_args(flow_id: FlowId, sink: &str) -> CreateFlowArgs {
CreateFlowArgs {
flow_id,
sink_table_name: [
"greptime".to_string(),
"public".to_string(),
sink.to_string(),
],
source_table_ids: vec![1],
create_if_not_exists: false,
or_replace: false,
expire_after: None,
eval_interval: Some(10),
comment: None,
sql: "SELECT number, ts FROM numbers_with_ts".to_string(),
flow_options: HashMap::new(),
query_ctx: Some(QueryContext::arc().as_ref().clone()),
eval_schedule: None,
}
}
#[tokio::test]
async fn test_installed_persistence_factory_none_still_validates_sink_schema() {
let engine = new_test_engine_with_persistence(Some(FactoryPlugin(Arc::new(
TestPersistenceFactory {
create_persistence: false,
},
))))
.await;
register_sink_with_schema(&engine, "sink_factory_none", true);
let result = engine
.create_flow_inner(flow_create_args(1, "sink_factory_none"))
.await;
assert!(
result.is_err(),
"ordinary sink validation must reject mismatch"
);
assert!(!engine.flow_exist_inner(1).await);
}
#[tokio::test]
async fn test_matching_persistence_factory_allows_extended_sink_schema() {
let engine = new_test_engine_with_persistence(Some(FactoryPlugin(Arc::new(
TestPersistenceFactory {
create_persistence: true,
},
))))
.await;
register_sink_with_schema(&engine, "sink_factory_some", true);
let result = engine
.create_flow_inner(flow_create_args(2, "sink_factory_some"))
.await
.unwrap();
assert_eq!(Some(2), result);
assert!(engine.flow_exist_inner(2).await);
engine.remove_flow_inner(2).await.unwrap();
}
#[tokio::test]
async fn test_no_persistence_factory_still_validates_sink_schema() {
let engine = new_test_engine_with_persistence(None).await;
register_sink_with_schema(&engine, "sink_no_factory", true);
let result = engine
.create_flow_inner(flow_create_args(3, "sink_no_factory"))
.await;
assert!(
result.is_err(),
"ordinary sink validation must reject mismatch"
);
assert!(!engine.flow_exist_inner(3).await);
}
#[tokio::test]
async fn test_flow_option_parser_matrix() {
let engine = new_test_engine().await;
let cases = [
(None, false, false),
(Some("true"), true, false),
(Some("false"), false, false),
(
Some(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE),
true,
true,
),
(Some("malformed"), false, false),
];
for (value, enabled, required) in cases {
let options = value
.map(|value| {
HashMap::from([(
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
value.to_string(),
)])
})
.unwrap_or_default();
let required_from_validated_sentinel = options
.get(FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY)
.is_some_and(|value| {
value == FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE
});
match engine.batch_opts_for_flow_options(&options, required_from_validated_sentinel) {
Ok(opts) => {
assert_eq!(opts.experimental_enable_incremental_read, enabled);
assert_eq!(required_from_validated_sentinel, required);
}
Err(_) => assert!(!enabled && !required),
}
}
}
#[tokio::test]
async fn test_forged_query_context_does_not_enable_exact_sequence_range() {
let engine = new_test_engine_with_persistence(Some(FactoryPlugin(Arc::new(
TestPersistenceFactory {
create_persistence: true,
},
))))
.await;
register_sink_with_schema(&engine, "forged_query_context", true);
let mut args = flow_create_args(4, "forged_query_context");
let mut query_ctx = QueryContext::arc().as_ref().clone();
query_ctx.set_extension("__old_forged_required_extension", "true");
args.query_ctx = Some(query_ctx);
engine.create_flow_inner(args).await.unwrap();
let task = engine.runtime.read().await.tasks.get(&4).cloned().unwrap();
assert!(!task.config.exact_sequence_range_required);
engine.remove_flow_inner(4).await.unwrap();
}
#[tokio::test]
async fn test_exact_sequence_range_capability_is_checked_before_task_startup() {
let engine = new_test_engine_with_persistence(None).await;
let mut args = flow_create_args(5, "exact_requires_capability");
args.flow_options.insert(
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_KEY.to_string(),
FLOW_EXPERIMENTAL_ENABLE_INCREMENTAL_READ_SEQUENCE_RANGE.to_string(),
);
assert!(engine.create_flow_inner(args).await.is_err());
assert!(!engine.flow_exist_inner(5).await);
}
#[test]
+11 -2
View File
@@ -152,6 +152,7 @@ pub struct TaskConfig {
pub catalog_manager: CatalogManagerRef,
pub query_type: QueryType,
pub batch_opts: Arc<BatchingModeOptions>,
pub exact_sequence_range_required: bool,
pub flow_eval_interval: Option<Duration>,
/// Typed schedule configuration, pre-parsed at task creation time.
pub eval_schedule: Option<EvalSchedule>,
@@ -299,7 +300,11 @@ struct ExecuteOnceOutcome {
impl BatchingTask {
#[allow(clippy::too_many_arguments)]
pub fn try_new(
pub fn try_new(args: TaskArgs<'_>) -> Result<Self, Error> {
Self::try_new_with_exact_sequence_range_required(args, false)
}
pub fn try_new_with_exact_sequence_range_required(
TaskArgs {
flow_id,
query,
@@ -315,6 +320,7 @@ impl BatchingTask {
flow_eval_interval,
eval_schedule,
}: TaskArgs<'_>,
exact_sequence_range_required: bool,
) -> Result<Self, Error> {
let mut state = TaskState::with_dirty_time_windows(
query_ctx.clone(),
@@ -339,6 +345,7 @@ impl BatchingTask {
catalog_manager,
output_schema: plan.schema().clone(),
query_type: determine_query_type(query, &query_ctx)?,
exact_sequence_range_required,
batch_opts,
flow_eval_interval,
eval_schedule,
@@ -663,6 +670,7 @@ impl BatchingTask {
async fn execute_logical_plan_unlocked(
&self,
engine: &QueryEngineRef,
frontend_client: &Arc<FrontendClient>,
plan: &LogicalPlan,
dirty_restore: &DirtyRestore,
@@ -700,7 +708,7 @@ impl BatchingTask {
// 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 coverage.is_incremental_delta() {
self.prepare_plan_for_incremental(&plan).await?
self.prepare_plan_for_incremental(engine, &plan).await?
} else {
None
};
@@ -1431,6 +1439,7 @@ impl BatchingTask {
};
let res = self
.execute_logical_plan_unlocked(
engine,
frontend_client,
&new_query.plan,
&new_query.dirty_restore,
-45
View File
@@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::error::Error as StdError;
use std::time::Duration;
use client::OutputWithMetrics;
@@ -31,42 +30,6 @@ use crate::metrics::{
};
use crate::{Error, FlowId};
/// Liveness guard: when a fenced repair query fails with a wrapped error whose
/// text indicates a stale snapshot fence (even when `StatusCode::RequestOutdated`
/// was lost through client layers), classify it as `SnapshotFenceExpired` to
/// break the retry loop and force a rebind of the fence high `H`.
///
/// Long-term the structured `StatusCode` / retry hint path should be preserved
/// end-to-end; this text fallback is a narrow safety measure.
fn matches_stale_snapshot_fence_text(err: &Error) -> bool {
let markers = [
"STALE_SNAPSHOT_FENCE",
"REBIND_SNAPSHOT_FENCE",
"snapshot upper bound stale",
];
// Check the top-level error Display and Debug.
let debug_str = format!("{:?}", err);
let display_str = err.to_string();
for marker in &markers {
if debug_str.contains(marker) || display_str.contains(marker) {
return true;
}
}
// Walk the error source chain.
let mut source = err.source();
while let Some(s) = source {
let debug_str = format!("{:?}", s);
let display_str = s.to_string();
for marker in &markers {
if debug_str.contains(marker) || display_str.contains(marker) {
return true;
}
}
source = s.source();
}
false
}
impl BatchingTask {
/// Classify execution errors into checkpoint fallback reasons. A stale
/// snapshot fence is special only for fenced repair chunks.
@@ -80,14 +43,6 @@ impl BatchingTask {
} else {
FlowQueryFallbackReason::StaleCursor
}
} else if matches!(coverage, QueryCoverage::FencedRepairChunk { .. })
&& matches_stale_snapshot_fence_text(err)
{
// Narrow text-based fallback for wrapped errors where the
// structured StatusCode::RequestOutdated was lost through
// frontend/client layers. Without this fenced repair will
// retry the same stale `given_seq` every refresh tick.
FlowQueryFallbackReason::SnapshotFenceExpired
} else if matches!(coverage, QueryCoverage::IncrementalDelta) {
FlowQueryFallbackReason::IncrementalQueryFailure
} else {
+52 -23
View File
@@ -18,6 +18,7 @@ use common_error::ext::BoxedError;
use common_telemetry::debug;
use common_telemetry::tracing::warn;
use datafusion_expr::{DmlStatement, LogicalPlan};
use query::QueryEngineRef;
use query::options::{
FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY,
FLOW_SINK_TABLE_ID,
@@ -36,6 +37,9 @@ use crate::batching_mode::utils::{
};
use crate::error::{ExternalSnafu, UnexpectedSnafu};
// Kept local until the query-side extension enum exposes the exact scan mode.
const FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE: &str = "sequence_range";
impl BatchingTask {
async fn sink_table_id(&self) -> Result<TableId, Error> {
let table = self
@@ -69,7 +73,7 @@ impl BatchingTask {
/// table that cannot be resolved, is not the mito engine, or lacks the
/// option — this returns `false` so the caller keeps the historical
/// `memtable_only` mode instead of upgrading.
pub(crate) async fn sequence_range_capable(&self) -> bool {
pub(crate) async fn sequence_range_capable(&self) -> Result<bool, Error> {
for name in &self.config.source_table_names {
let table = match self
.config
@@ -79,23 +83,16 @@ impl BatchingTask {
{
Ok(Some(table)) => table,
Ok(None) => {
debug!(
"Flow {} source table {} not found; retaining memtable_only incremental mode",
self.config.flow_id,
name.join(".")
);
return false;
}
Err(err) => {
warn!(
"Flow {} failed to resolve source table {} for sequence_range capability check; \
retaining memtable_only incremental mode: {:?}",
self.config.flow_id,
name.join("."),
err
);
return false;
return Err(UnexpectedSnafu {
reason: format!(
"Flow {} source table {} not found for sequence_range capability check",
self.config.flow_id,
name.join(".")
),
}
.build());
}
Err(err) => Err(BoxedError::new(err)).context(ExternalSnafu)?,
};
let info = table.table_info();
@@ -107,10 +104,10 @@ impl BatchingTask {
.get(PRESERVE_ROW_SEQUENCE)
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
if !preserves {
return false;
return Ok(false);
}
}
!self.config.source_table_names.is_empty()
Ok(!self.config.source_table_names.is_empty())
}
/// For incremental-mode SQL queries, attempt to prepare an executable plan
@@ -126,6 +123,7 @@ impl BatchingTask {
/// incremental safe without a rewrite, so they return `Some(original_plan)`.
pub(super) async fn prepare_plan_for_incremental(
&self,
engine: &QueryEngineRef,
plan: &LogicalPlan,
) -> Result<Option<LogicalPlan>, Error> {
let is_incremental_sql = {
@@ -208,6 +206,7 @@ impl BatchingTask {
let rewritten_inner = match rewrite_incremental_aggregate_with_sink_merge(
&inner_plan,
&analysis,
engine,
sink_table,
&self.config.sink_table_name,
None,
@@ -271,12 +270,42 @@ impl BatchingTask {
};
if let Some(checkpoints_json) = incremental_checkpoints_json {
// Select `sequence_range` only when every append-only source table
// proves the `preserve_row_sequence` capability; otherwise retain
// the historical `memtable_only` mode. The `sequence_range` scan
// keeps SSTs and reads the exact (checkpoint, scan-open snapshot]
// row-level delta; the engine fails closed when the capability
// does not hold at scan time.
let capable = match self.sequence_range_capable().await {
Ok(capable) => capable,
Err(err) => {
if self.config.exact_sequence_range_required {
return Err(err);
}
false
}
};
if self.config.exact_sequence_range_required && !capable {
return Err(UnexpectedSnafu {
reason: format!(
"Flow {} requires exact sequence-range reads, but source capability was revoked",
self.config.flow_id
),
}
.build());
}
let sink_table_id = self.sink_table_id().await?;
let incremental_mode = if capable {
debug!(
"Flow {} selected sequence_range incremental mode",
self.config.flow_id
);
FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE
} else {
FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY
};
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_MODE, incremental_mode.to_string()));
extensions.push((FLOW_INCREMENTAL_AFTER_SEQS, checkpoints_json));
}
+78 -531
View File
@@ -13,8 +13,6 @@
// limitations under the License.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use catalog::RegisterTableRequest;
use catalog::memory::MemoryCatalogManager;
@@ -39,18 +37,14 @@ use query::options::{
use session::context::QueryContext;
use snafu::ResultExt;
use table::test_util::MemTable;
use table::{Table, TableRef};
use tokio::sync::Notify;
use super::*;
use crate::Result;
use crate::batching_mode::checkpoint::{
CHECKPOINT_DECISION_ADVANCE, CHECKPOINT_DECISION_FALLBACK, CHECKPOINT_REASON_NONE,
FlowCheckpointDecision, FlowQueryFallbackReason,
};
use crate::batching_mode::eval_schedule::{FlowMissedTickPolicy, FlowScheduleConfig};
use crate::batching_mode::persistence::{BatchingAttempt, BatchingPersistence, RestoreOutcome};
use crate::batching_mode::state::{CheckpointMode, TaskStateCheckpointSnapshot};
use crate::batching_mode::state::CheckpointMode;
use crate::batching_mode::time_window::find_time_window_expr;
use crate::test_utils::create_test_query_engine;
@@ -91,6 +85,18 @@ async fn new_test_task_engine_and_plan_with_query_and_opts(
query: &str,
sink_table: &str,
batch_opts: Arc<BatchingModeOptions>,
) -> TestTaskParts {
new_test_task_engine_and_plan_with_query_and_opts_and_required(
query, sink_table, batch_opts, false,
)
.await
}
async fn new_test_task_engine_and_plan_with_query_and_opts_and_required(
query: &str,
sink_table: &str,
batch_opts: Arc<BatchingModeOptions>,
exact_sequence_range_required: bool,
) -> TestTaskParts {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
@@ -104,29 +110,32 @@ async fn new_test_task_engine_and_plan_with_query_and_opts(
.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,
flow_eval_interval: None,
eval_schedule: None,
})
let task = BatchingTask::try_new_with_exact_sequence_range_required(
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,
flow_eval_interval: None,
eval_schedule: None,
},
exact_sequence_range_required,
)
.unwrap();
TestTaskParts {
@@ -397,50 +406,6 @@ fn flow_error_with_status(status_code: StatusCode) -> Error {
.unwrap_err()
}
/// Test-only error that carries a non-RequestOutdated status code but
/// displays a stale-snapshot-fence marker string, simulating the real-world
/// scenario where the structured status code is lost through frontend/client
/// wrapping layers.
#[derive(Debug)]
struct StaleFenceTextError {
code: StatusCode,
message: String,
}
impl std::fmt::Display for StaleFenceTextError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for StaleFenceTextError {}
impl common_error::ext::ErrorExt for StaleFenceTextError {
fn status_code(&self) -> StatusCode {
self.code
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl common_error::ext::StackError for StaleFenceTextError {
fn debug_fmt(&self, _: usize, _: &mut Vec<String>) {}
fn next(&self) -> Option<&dyn common_error::ext::StackError> {
None
}
}
fn flow_error_with_code_and_text(code: StatusCode, text: &str) -> Error {
let inner = StaleFenceTextError {
code,
message: text.to_string(),
};
Err::<(), _>(BoxedError::new(inner))
.context(crate::error::ExternalSnafu)
.unwrap_err()
}
fn dirty_range(start: i64, end: i64) -> DirtyTimeWindows {
let mut dirty = DirtyTimeWindows::default();
dirty.add_window(
@@ -1453,78 +1418,6 @@ fn test_query_failure_reason_distinguishes_fenced_repair_stale_fence() {
);
}
/// Wrapped errors carrying stale snapshot fence marker text in their
/// Display/Debug chain should be classified as `SnapshotFenceExpired` on
/// fenced repair coverage, even when the structured `StatusCode::RequestOutdated`
/// was lost through client layering. This prevents an infinite retry loop
/// where the fenced chunk re-sends the same stale `given_seq` every tick.
#[test]
fn test_query_failure_reason_text_fallback_stale_snapshot_fence() {
let high = BTreeMap::new();
let fenced = QueryCoverage::FencedRepairChunk { high: high.clone() };
// STALE_SNAPSHOT_FENCE marker with a non-RequestOutdated status code
let err = flow_error_with_code_and_text(
StatusCode::Internal,
"gRPC error: STALE_SNAPSHOT_FENCE: snapshot upper bound stale, region: 1024/0",
);
assert_eq!(
BatchingTask::query_failure_reason(&err, &fenced),
FlowQueryFallbackReason::SnapshotFenceExpired
);
// REBIND_SNAPSHOT_FENCE marker
let err = flow_error_with_code_and_text(
StatusCode::Internal,
"STALE_SNAPSHOT_FENCE ... retry_hint: REBIND_SNAPSHOT_FENCE",
);
assert_eq!(
BatchingTask::query_failure_reason(&err, &fenced),
FlowQueryFallbackReason::SnapshotFenceExpired
);
// snapshot upper bound stale marker (the natural-language fragment)
let err = flow_error_with_code_and_text(
StatusCode::Internal,
"query failed: snapshot upper bound stale, consider rebinding",
);
assert_eq!(
BatchingTask::query_failure_reason(&err, &fenced),
FlowQueryFallbackReason::SnapshotFenceExpired
);
// Fenced coverage with a generic wrapped error (no stale-fence marker) →
// still QueryFailure
let generic_err =
flow_error_with_code_and_text(StatusCode::Internal, "some transient network error");
assert_eq!(
BatchingTask::query_failure_reason(&generic_err, &fenced),
FlowQueryFallbackReason::QueryFailure
);
// Non-fenced incremental coverage with stale-fence marker text must NOT
// classify as SnapshotFenceExpired; it should remain IncrementalQueryFailure.
let err = flow_error_with_code_and_text(
StatusCode::Internal,
"STALE_SNAPSHOT_FENCE blob in unexpected context",
);
assert_eq!(
BatchingTask::query_failure_reason(&err, &QueryCoverage::IncrementalDelta),
FlowQueryFallbackReason::IncrementalQueryFailure
);
// Existing RequestOutdated behavior is unchanged.
let outdated_err = flow_error_with_status(StatusCode::RequestOutdated);
assert_eq!(
BatchingTask::query_failure_reason(&outdated_err, &fenced),
FlowQueryFallbackReason::SnapshotFenceExpired
);
assert_eq!(
BatchingTask::query_failure_reason(&outdated_err, &QueryCoverage::IncrementalDelta),
FlowQueryFallbackReason::StaleCursor
);
}
#[test]
fn test_fenced_repair_coverage_produces_snapshot_seq_map_for_distributed_metadata_path() {
// Covers the metadata boundary between QueryCoverage and the
@@ -1672,82 +1565,6 @@ fn test_fenced_repair_transient_non_stale_failure_retries_same_high() {
);
}
/// When `query_failure_reason` classifies a wrapped error as
/// `SnapshotFenceExpired` via the text-marker fallback (not via
/// `StatusCode::RequestOutdated`), the state machine must still
/// abandon the fenced repair and produce a `ScopedBaseRepair` plan
/// next, exactly like the structured-code path.
#[tokio::test]
async fn test_text_fallback_stale_fence_produces_scoped_base_repair() {
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 high = BTreeMap::from([(1_u64, 10_u64), (2_u64, 20_u64)]);
let filter = {
let mut state = task.state.write().unwrap();
state
.dirty_time_windows
.add_window(Timestamp::new_second(10), Some(Timestamp::new_second(15)));
state
.dirty_time_windows
.add_window(Timestamp::new_second(100), Some(Timestamp::new_second(105)));
state.start_fenced_repair(high.clone()).unwrap();
next_fenced_repair_filter(&mut state, 1)
};
// Construct a wrapped error that hits the text fallback (non-RequestOutdated
// status code with STALE_SNAPSHOT_FENCE marker text).
let err = flow_error_with_code_and_text(
StatusCode::Internal,
"STALE_SNAPSHOT_FENCE: snapshot upper bound stale, retry_hint: REBIND_SNAPSHOT_FENCE",
);
let coverage = QueryCoverage::FencedRepairChunk { high };
let reason = BatchingTask::query_failure_reason(&err, &coverage);
assert_eq!(reason, FlowQueryFallbackReason::SnapshotFenceExpired);
{
let mut state = task.state.write().unwrap();
let decision = BatchingTask::apply_query_failure_to_state(
&mut state,
std::time::Duration::from_millis(1),
&coverage,
reason,
);
assert_eq!(
decision,
Some(FlowCheckpointDecision::FallbackToFullSnapshot {
previous_mode: CheckpointMode::FullSnapshot,
reason: FlowQueryFallbackReason::SnapshotFenceExpired,
})
);
assert!(state.pending_fenced_repair().is_none());
// Simulate the outer execution failure restore for the in-flight chunk.
state.restore_scoped_windows(&filter);
}
let plan = task
.gen_query_with_time_window(
query_engine,
&aggregate_time_window_sink_schema(),
&[],
false,
Some(1),
)
.await
.unwrap()
.expect("text-fallback stale fence should restore dirty windows for a fresh scoped repair");
assert!(
matches!(plan.coverage, QueryCoverage::ScopedBaseRepair),
"next plan after text-fallback stale fence should be ScopedBaseRepair"
);
}
#[test]
fn test_checkpoint_decision_labels_are_stable() {
let advance = FlowCheckpointDecision::AdvancedIncremental {
@@ -1779,6 +1596,35 @@ fn test_checkpoint_decision_labels_are_stable() {
);
}
#[tokio::test]
async fn test_exact_required_attempt_rejects_revoked_capability_without_extensions() {
let task = new_test_task_engine_and_plan_with_query_and_opts_and_required(
"SELECT number, ts FROM numbers_with_ts",
"exact_required_revoked",
incremental_batch_opts(),
true,
)
.await
.into_task_and_plan()
.0;
task.state
.write()
.unwrap()
.advance_checkpoints(HashMap::from([(1_u64, 10_u64)]));
let checkpoints_before = task.state.read().unwrap().checkpoints().clone();
let err = task
.build_flow_query_extensions(true, true)
.await
.unwrap_err();
assert!(err.to_string().contains("requires exact sequence-range"));
assert_eq!(
task.state.read().unwrap().checkpoints(),
&checkpoints_before
);
}
#[tokio::test]
async fn test_build_flow_query_extensions_switches_with_checkpoint_mode() {
let (task, _) = new_test_task_engine_and_plan_with_query(
@@ -2348,7 +2194,10 @@ async fn test_prepare_plan_for_incremental_disables_on_non_aggregate() {
CheckpointMode::Incremental
);
let incremental_plan = task.prepare_plan_for_incremental(&dml_plan).await.unwrap();
let incremental_plan = task
.prepare_plan_for_incremental(&query_engine, &dml_plan)
.await
.unwrap();
assert!(incremental_plan.is_none());
let state = task.state.read().unwrap();
assert!(state.is_incremental_disabled());
@@ -2425,6 +2274,7 @@ async fn test_unsafe_incremental_plan_skip_restores_dirty_without_query() {
let result = task
.execute_logical_plan_unlocked(
&query_engine,
&Arc::new(frontend_client),
&dml_plan,
&dirty_restore,
@@ -2512,7 +2362,7 @@ async fn test_prepare_plan_for_incremental_group_by_without_merge_columns_uses_o
.advance_checkpoints(HashMap::from([(1_u64, 10_u64)]));
let incremental_plan = task
.prepare_plan_for_incremental(&dml_plan)
.prepare_plan_for_incremental(&query_engine, &dml_plan)
.await
.unwrap()
.expect("plain GROUP BY is incremental-safe without a rewrite");
@@ -2557,7 +2407,10 @@ async fn test_auto_created_sql_aggregate_sink_reaches_incremental_safe() {
.write()
.unwrap()
.advance_checkpoints(HashMap::from([(1_u64, 10_u64)]));
let incremental_plan = task.prepare_plan_for_incremental(&dml_plan).await.unwrap();
let incremental_plan = task
.prepare_plan_for_incremental(&query_engine, &dml_plan)
.await
.unwrap();
let incremental_safe = incremental_plan.is_some();
assert!(incremental_safe);
@@ -2687,309 +2540,3 @@ async fn test_insert_plan_matching_failure_restores_consumed_dirty_marker() {
std::time::Duration::from_secs(5)
);
}
struct TestPersistence {
restores: AtomicUsize,
begins: AtomicUsize,
persists: AtomicUsize,
fail_persist: AtomicBool,
}
struct BlockingPersistence {
persists: AtomicUsize,
fail: AtomicBool,
started: Notify,
release: Notify,
}
#[async_trait::async_trait]
impl BatchingPersistence for BlockingPersistence {
async fn restore(&self) -> Result<RestoreOutcome> {
Ok(RestoreOutcome::TrustedCheckpoint(BTreeMap::new()))
}
async fn begin_attempt(&self) -> Result<BatchingAttempt> {
Ok(BatchingAttempt {
ordinary_values: BTreeMap::new(),
})
}
async fn persist(
&self,
_attempt: BatchingAttempt,
_checkpoints: BTreeMap<u64, u64>,
) -> Result<()> {
self.persists.fetch_add(1, Ordering::SeqCst);
self.started.notify_one();
self.release.notified().await;
if self.fail.load(Ordering::SeqCst) {
Err(crate::Error::External {
source: BoxedError::new(MockError::new(StatusCode::Internal)),
location: snafu::location!(),
})
} else {
Ok(())
}
}
}
fn blocking_persistence(fail: bool) -> Arc<BlockingPersistence> {
Arc::new(BlockingPersistence {
persists: AtomicUsize::new(0),
fail: AtomicBool::new(fail),
started: Notify::new(),
release: Notify::new(),
})
}
fn install_persistence(task: &BatchingTask, persistence: Arc<BlockingPersistence>) {
*task.persistence.write().unwrap() = Some(persistence);
}
fn candidate_transaction_states(
task: &BatchingTask,
) -> (TaskStateCheckpointSnapshot, TaskStateCheckpointSnapshot) {
let mut state = task.state.write().unwrap();
state.advance_checkpoints(HashMap::from([(1, 10)]));
state.request_full_repair();
let snapshot = state.checkpoint_snapshot();
let mut candidate = snapshot.clone();
candidate.checkpoint_mode = CheckpointMode::Incremental;
candidate.checkpoints = BTreeMap::from([(1, 20)]);
candidate.last_query_duration = Duration::from_millis(42);
candidate.last_exec_time_millis = Some(42);
state.restore_checkpoint_snapshot(snapshot.clone());
(snapshot, candidate)
}
fn test_attempt() -> BatchingAttempt {
BatchingAttempt {
ordinary_values: BTreeMap::new(),
}
}
fn assert_candidate_state(task: &BatchingTask) {
let state = task.state.read().unwrap();
assert_eq!(state.checkpoints(), &BTreeMap::from([(1, 20)]));
assert_eq!(state.last_query_duration(), Duration::from_millis(42));
assert!(state.last_execution_time_millis().is_some());
}
#[tokio::test]
async fn test_checkpoint_persist_success_commits_candidate_after_release() {
let task = new_test_task_engine_and_plan_with_query(
"SELECT number, ts FROM numbers_with_ts",
"missing_sink",
)
.await
.task;
let persistence = blocking_persistence(false);
install_persistence(&task, persistence.clone());
let (snapshot, candidate) = candidate_transaction_states(&task);
let task_for_persist = task.clone();
let attempt = test_attempt();
let persist = tokio::spawn(async move {
task_for_persist
.persist_checkpoint_candidate(
snapshot.clone(),
candidate,
Some(&attempt),
DirtyRestore::Unscoped(DirtyTimeWindows::default()),
)
.await
});
persistence.started.notified().await;
{
let state = task.state.read().unwrap();
assert_eq!(state.checkpoints(), &BTreeMap::from([(1, 10)]));
}
persistence.release.notify_one();
persist.await.unwrap().unwrap();
assert_candidate_state(&task);
assert_eq!(persistence.persists.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_checkpoint_persist_error_restores_pre_state_and_unions_dirty_once() {
let task = new_test_task_engine_and_plan_with_query(
"SELECT number, ts FROM numbers_with_ts",
"missing_sink",
)
.await
.task;
let persistence = blocking_persistence(true);
install_persistence(&task, persistence.clone());
let (snapshot, candidate) = candidate_transaction_states(&task);
let detached = dirty_range(1, 2);
let live = dirty_range(3, 4);
let task_for_persist = task.clone();
let attempt = test_attempt();
let persist = tokio::spawn(async move {
task_for_persist
.persist_checkpoint_candidate(
snapshot,
candidate,
Some(&attempt),
DirtyRestore::Unscoped(detached),
)
.await
});
persistence.started.notified().await;
task.state
.write()
.unwrap()
.dirty_time_windows
.add_dirty_windows(&live);
persistence.release.notify_one();
assert!(persist.await.unwrap().is_err());
let state = task.state.read().unwrap();
assert_eq!(state.checkpoints(), &BTreeMap::from([(1, 10)]));
assert_eq!(state.dirty_time_windows.len(), 2);
assert_eq!(persistence.persists.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_full_repair_checkpoint_persist_handles_success_and_error() {
for fail in [false, true] {
let task = new_test_task_engine_and_plan_with_query(
"SELECT number, ts FROM numbers_with_ts",
"missing_sink",
)
.await
.task;
let persistence = blocking_persistence(fail);
install_persistence(&task, persistence.clone());
let (snapshot, candidate) = candidate_transaction_states(&task);
let dirty = dirty_range(1, 2);
let task_for_persist = task.clone();
let attempt = test_attempt();
let persist = tokio::spawn(async move {
task_for_persist
.persist_checkpoint_candidate(
snapshot,
candidate,
Some(&attempt),
DirtyRestore::FullRepair(dirty),
)
.await
});
persistence.started.notified().await;
task.state
.write()
.unwrap()
.dirty_time_windows
.add_dirty_windows(&dirty_range(1, 2));
persistence.release.notify_one();
let result = persist.await.unwrap();
assert_eq!(result.is_err(), fail);
let state = task.state.read().unwrap();
assert_eq!(state.dirty_time_windows.len(), 1);
assert_eq!(state.full_repair_required(), fail);
assert_eq!(persistence.persists.load(Ordering::SeqCst), 1);
}
}
#[async_trait::async_trait]
impl BatchingPersistence for TestPersistence {
async fn restore(&self) -> Result<RestoreOutcome> {
self.restores.fetch_add(1, Ordering::SeqCst);
Ok(RestoreOutcome::TrustedCheckpoint(BTreeMap::from([(1, 2)])))
}
async fn begin_attempt(&self) -> Result<BatchingAttempt> {
self.begins.fetch_add(1, Ordering::SeqCst);
Ok(BatchingAttempt {
ordinary_values: BTreeMap::new(),
})
}
async fn persist(
&self,
_attempt: BatchingAttempt,
checkpoints: BTreeMap<u64, u64>,
) -> Result<()> {
assert_eq!(checkpoints, BTreeMap::from([(1, 2)]));
self.persists.fetch_add(1, Ordering::SeqCst);
if self.fail_persist.load(Ordering::SeqCst) {
Err(crate::Error::External {
source: BoxedError::new(MockError::new(StatusCode::Internal)),
location: snafu::location!(),
})
} else {
Ok(())
}
}
}
#[tokio::test]
async fn test_persistence_restore_is_wired() {
let parts = new_test_task_engine_and_plan_with_query(
"SELECT number, ts FROM numbers_with_ts",
"missing_sink",
)
.await;
let state = Arc::new(TestPersistence {
restores: AtomicUsize::new(0),
begins: AtomicUsize::new(0),
persists: AtomicUsize::new(0),
fail_persist: AtomicBool::new(false),
});
let config = state.clone();
parts.task.set_persistence(Some(config)).await.unwrap();
assert_eq!(state.restores.load(Ordering::SeqCst), 1);
assert_eq!(
parts.task.state.read().unwrap().checkpoints(),
&BTreeMap::from([(1, 2)])
);
}
#[tokio::test]
async fn test_full_repair_restore_is_sticky_and_unfiltered() {
let parts = 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;
// Use a persistence implementation whose restore requests a full repair.
struct FullRepairPersistence;
#[async_trait::async_trait]
impl BatchingPersistence for FullRepairPersistence {
async fn restore(&self) -> Result<RestoreOutcome> {
Ok(RestoreOutcome::FullRepair)
}
async fn begin_attempt(&self) -> Result<BatchingAttempt> {
Ok(BatchingAttempt {
ordinary_values: BTreeMap::new(),
})
}
async fn persist(
&self,
_attempt: BatchingAttempt,
_checkpoints: BTreeMap<u64, u64>,
) -> Result<()> {
Ok(())
}
}
let config = Arc::new(FullRepairPersistence);
let sink = aggregate_time_window_sink_schema();
parts
.task
.state
.write()
.unwrap()
.dirty_time_windows
.add_window(Timestamp::new_second(10), Some(Timestamp::new_second(15)));
parts.task.set_persistence(Some(config)).await.unwrap();
let plan = parts
.task
.gen_query_with_time_window(parts.query_engine, &sink, &[], false, Some(1))
.await
.unwrap()
.expect("full repair should always produce a plan");
assert!(matches!(plan.coverage, QueryCoverage::UnfilteredFull));
assert!(matches!(plan.dirty_restore, DirtyRestore::FullRepair(_)));
let plan_text = plan.plan.to_string();
assert!(!plan_text.contains("Filter:"));
assert!(!plan_text.contains("TimestampMillisecond("));
// Full repair detaches the consumed dirty ownership from the live signal.
assert_eq!(parts.task.state.read().unwrap().dirty_time_windows.len(), 0);
assert!(parts.task.state.read().unwrap().full_repair_required());
}
+340 -135
View File
@@ -21,6 +21,7 @@ use catalog::CatalogManagerRef;
use common_error::ext::BoxedError;
use common_function::aggrs::aggr_wrapper::get_aggr_func;
use common_telemetry::debug;
use datafusion::arrow::datatypes::DataType as ArrowDataType;
use datafusion::datasource::DefaultTableSource;
use datafusion::error::Result as DfResult;
use datafusion::logical_expr::Expr;
@@ -67,6 +68,9 @@ mod test;
/// `max(numbers_with_ts.number)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncrementalAggregateMergeColumn {
/// Delta-plan field containing the aggregate result/state column. Repeated
/// projections intentionally share this field while using distinct outputs.
pub input_field_name: String,
/// Final output/sink field name for the aggregate result/state column.
///
pub output_field_name: String,
@@ -74,9 +78,10 @@ pub struct IncrementalAggregateMergeColumn {
}
impl IncrementalAggregateMergeColumn {
/// Create a new merge column.
/// Create a new merge column whose delta and output fields have the same name.
pub fn new(output_field_name: String, merge_op: IncrementalAggregateMergeOp) -> Self {
Self {
input_field_name: output_field_name.clone(),
output_field_name,
merge_op,
}
@@ -93,6 +98,7 @@ pub enum IncrementalAggregateMergeOp {
BitAnd,
BitOr,
BitXor,
AvgDeltaMerge,
}
/// Analysis result for an incremental aggregate plan.
@@ -114,24 +120,6 @@ pub struct IncrementalAggregateAnalysis {
pub unsupported_exprs: Vec<String>,
}
/// Recursively find all `Expr::Column` names inside an expression tree.
/// Only recurses into wrappers that are merge-transparent.
/// Non-transparent wrappers (e.g., `ScalarFunction`, `Negative`, `Cast`) are
/// intentionally not recursed into since their merge semantics would be
/// incorrect.
///
/// `Cast`/`TryCast` are intentionally opaque: merging already-casted aggregate
/// outputs is not generally equivalent to casting the final merged aggregate.
fn find_column_names(expr: &Expr, names: &mut Vec<String>) {
match expr {
Expr::Column(col) => {
names.push(col.name.clone());
}
Expr::Alias(alias) => find_column_names(&alias.expr, names),
_ => {}
}
}
fn unqualified_col(name: impl Into<String>) -> Expr {
Expr::Column(Column::from_name(name.into()))
}
@@ -231,8 +219,10 @@ fn check_input_plan_shape(plan: &LogicalPlan) -> Result<(), String> {
#[derive(Debug, Default)]
struct OutputProjectionInfo {
has_top_level_projection: bool,
/// Aggregate expression name and projected output field, in projection order.
aggregate_outputs: Vec<(String, String)>,
/// Original single-instance resolver mapping, retained for compatibility.
output_aliases: HashMap<String, String>,
duplicate_aggregate_aliases: BTreeSet<String>,
literal_columns: HashSet<String>,
output_field_names: Vec<String>,
}
@@ -271,41 +261,36 @@ fn collect_output_projection_info(plan: &LogicalPlan) -> OutputProjectionInfo {
for expr in &projection.expr {
match expr {
Expr::Alias(alias) => {
// Alias resolution has three cases:
// - 0 Column refs (e.g., literal `42 AS lit`): record literal output
// - 1 Column ref: record the mapping (e.g., `sum(x) AS total`)
// - >1 Column refs (e.g., `COALESCE(sum(x), sum(y))`):
// skip — ambiguous merge semantics
// Only a direct aggregate output column has the same
// merge semantics as the original resolver. In particular,
// do not mine aggregate columns through CAST/TryCast or
// other output wrappers.
let alias_name = alias.name.clone();
let mut col_names = Vec::new();
find_column_names(&alias.expr, &mut col_names);
match col_names.len() {
0 if is_passthrough_output_column(&alias_name, alias.expr.as_ref()) => {
projection_info.literal_columns.insert(alias_name);
}
1 => {
if let Some(col_name) = col_names.into_iter().next() {
if let Some(existing_alias) = output_aliases.get(&col_name) {
if existing_alias != &alias_name {
projection_info.duplicate_aggregate_aliases.insert(format!(
"same aggregate output {col_name} is used by multiple aliases: {existing_alias}, {alias_name}"
));
}
} else {
output_aliases.insert(col_name, alias_name);
}
}
}
_ => {}
if let Expr::Column(column) = alias.expr.as_ref() {
output_aliases
.entry(column.name.clone())
.or_insert_with(|| alias_name.clone());
projection_info
.aggregate_outputs
.push((column.name.clone(), alias_name));
} else if let Expr::Alias(inner_alias) = alias.expr.as_ref()
&& inner_alias.name.eq_ignore_ascii_case("count(*)")
&& let Expr::Column(column) = inner_alias.expr.as_ref()
{
output_aliases
.entry(column.name.clone())
.or_insert_with(|| alias_name.clone());
projection_info
.aggregate_outputs
.push((column.name.clone(), alias_name));
} else if is_passthrough_output_column(&alias_name, alias.expr.as_ref()) {
projection_info.literal_columns.insert(alias_name);
}
// If >1 column references detected (e.g., COALESCE(sum(x), sum(y))),
// intentionally skip alias mapping — the merge semantics are ambiguous.
}
Expr::Column(col) => {
output_aliases
.entry(col.name.clone())
.or_insert(col.name.clone());
projection_info
.aggregate_outputs
.push((col.name.clone(), col.name.clone()));
}
Expr::Literal(_, _) => {
projection_info
@@ -349,7 +334,10 @@ fn is_literal_or_cast_literal(expr: &Expr) -> bool {
}
}
fn merge_op_for_aggregate_expr(aggr_expr: &Expr) -> Result<IncrementalAggregateMergeOp, String> {
fn merge_op_for_aggregate_expr(
aggr_expr: &Expr,
input_schema: &DFSchema,
) -> Result<IncrementalAggregateMergeOp, String> {
let Some(aggr_func) = get_aggr_func(aggr_expr) else {
return Err(aggr_expr.to_string());
};
@@ -372,28 +360,50 @@ fn merge_op_for_aggregate_expr(aggr_expr: &Expr) -> Result<IncrementalAggregateM
"bit_and" => Ok(IncrementalAggregateMergeOp::BitAnd),
"bit_or" => Ok(IncrementalAggregateMergeOp::BitOr),
"bit_xor" => Ok(IncrementalAggregateMergeOp::BitXor),
"avg_state" => match aggr_func.params.args.as_slice() {
[_] => Ok(IncrementalAggregateMergeOp::AvgDeltaMerge),
_ => Err(aggr_expr.to_string()),
},
"avg_merge" => match aggr_func.params.args.as_slice() {
[arg] if arg.get_type(input_schema).ok() == Some(ArrowDataType::Binary) => {
Ok(IncrementalAggregateMergeOp::AvgDeltaMerge)
}
_ => Err(aggr_expr.to_string()),
},
_ => Err(aggr_expr.to_string()),
}
}
fn resolve_aggregate_output_field_name(
fn resolve_aggregate_output_fields(
aggr_expr: &Expr,
projection_info: &OutputProjectionInfo,
output_field_name_set: &HashSet<String>,
) -> Option<String> {
) -> Vec<(String, String)> {
// qualified_name() returns (Option<String>, String) where the second
// element is the unqualified column/alias name. This relies on
// DataFusion's internal naming convention: aggregate expressions
// emit a column named after the aggregate itself (e.g. "SUM(x)"),
// which matches what the projection aliases reference.
// emit a column named after the aggregate itself (e.g. "SUM(x)").
// Keep every matching projection occurrence because DataFusion can share
// one aggregate input field for identical expressions.
let raw_name = aggr_expr.qualified_name().1;
if let Some(alias) = projection_info.output_aliases.get(&raw_name) {
Some(alias.clone())
} else if !projection_info.has_top_level_projection && output_field_name_set.contains(&raw_name)
{
Some(raw_name)
if projection_info.has_top_level_projection {
let outputs = projection_info
.aggregate_outputs
.iter()
.filter(|(input_name, _)| input_name == &raw_name)
.cloned()
.collect::<Vec<_>>();
if outputs.len() > 1 {
outputs
} else if let Some(alias) = projection_info.output_aliases.get(&raw_name) {
vec![(raw_name, alias.clone())]
} else {
outputs
}
} else if output_field_name_set.contains(&raw_name) {
vec![(raw_name.clone(), raw_name)]
} else {
None
vec![]
}
}
@@ -479,7 +489,6 @@ pub fn analyze_incremental_aggregate_plan(
.map(|name| format!("duplicate output field name: {name}"))
.collect::<Vec<_>>();
unsupported_exprs.push(reason);
unsupported_exprs.extend(projection_info.duplicate_aggregate_aliases.iter().cloned());
return Ok(Some(IncrementalAggregateAnalysis {
group_key_names,
merge_columns: vec![],
@@ -513,27 +522,48 @@ pub fn analyze_incremental_aggregate_plan(
aggregate,
&group_key_names,
));
unsupported_exprs.extend(projection_info.duplicate_aggregate_aliases.iter().cloned());
for aggr_expr in aggr_exprs {
let merge_op = match merge_op_for_aggregate_expr(&aggr_expr) {
let merge_op = match merge_op_for_aggregate_expr(&aggr_expr, aggregate.input.schema()) {
Ok(merge_op) => merge_op,
Err(reason) => {
unsupported_exprs.push(reason);
continue;
}
};
let Some(output_field_name) = resolve_aggregate_output_field_name(
&aggr_expr,
&projection_info,
&output_field_name_set,
) else {
let aggregate_outputs =
resolve_aggregate_output_fields(&aggr_expr, &projection_info, &output_field_name_set);
if aggregate_outputs.is_empty() {
unsupported_exprs.push(aggr_expr.to_string());
continue;
}
let Some((_, input_field_name)) = aggregate_outputs.first() else {
continue;
};
merge_columns.push(IncrementalAggregateMergeColumn::new(
output_field_name,
merge_op,
));
// The old single-alias resolver selected the projected output name as
// the delta field. Keep that exact field for the shared input and only
// vary the final sink/output alias for repeated projections.
let input_field_name = input_field_name.clone();
for (_, output_field_name) in aggregate_outputs {
merge_columns.push(IncrementalAggregateMergeColumn {
input_field_name: input_field_name.clone(),
output_field_name,
merge_op: merge_op.clone(),
});
}
}
if projection_info.has_top_level_projection {
let output_positions = projection_info
.output_field_names
.iter()
.enumerate()
.map(|(position, name)| (name.as_str(), position))
.collect::<HashMap<_, _>>();
merge_columns.sort_by_key(|column| {
output_positions
.get(column.output_field_name.as_str())
.copied()
.unwrap_or(usize::MAX)
});
}
unsupported_exprs.extend(
find_uncovered_output_fields(&projection_info, &group_key_names, &merge_columns)
@@ -592,6 +622,7 @@ pub fn analyze_incremental_aggregate_plan(
pub async fn rewrite_incremental_aggregate_with_sink_merge(
delta_plan: &LogicalPlan,
analysis: &IncrementalAggregateAnalysis,
engine: &QueryEngineRef,
sink_table: TableRef,
sink_table_name: &TableName,
sink_dirty_filter: Option<Expr>,
@@ -626,6 +657,10 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge(
let delta_alias = "__flow_delta";
let sink_alias = "__flow_sink";
let state_merge = analysis
.merge_columns
.iter()
.any(|column| matches!(column.merge_op, IncrementalAggregateMergeOp::AvgDeltaMerge));
let mut selected_columns = analysis.group_key_names.clone();
selected_columns.extend(
analysis
@@ -633,8 +668,18 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge(
.iter()
.map(|c| c.output_field_name.clone()),
);
let mut delta_selected_columns = selected_columns.clone();
let mut selected_column_names = HashSet::new();
selected_columns.retain(|name| selected_column_names.insert(name.clone()));
let mut delta_selected_columns = analysis.group_key_names.clone();
delta_selected_columns.extend(
analysis
.merge_columns
.iter()
.map(|c| c.input_field_name.clone()),
);
delta_selected_columns.extend(analysis.literal_columns.iter().cloned());
let mut delta_selected_column_names = HashSet::new();
delta_selected_columns.retain(|name| delta_selected_column_names.insert(name.clone()));
let delta_selected_exprs = delta_selected_columns
.iter()
@@ -722,7 +767,6 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge(
.map(|c| qualified_column(sink_alias, c))
.collect::<Vec<_>>(),
);
let joined = LogicalPlanBuilder::from(delta_selected)
.join_detailed(
sink_selected,
@@ -747,21 +791,28 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge(
.iter()
.map(|c| (&c.output_field_name, c))
.collect::<HashMap<_, _>>();
let mut projection_exprs = Vec::with_capacity(analysis.output_field_names.len());
let mut group_exprs = Vec::new();
let mut state_aggr_exprs = Vec::new();
for output_field_name in &analysis.output_field_names {
if group_key_names.contains(output_field_name)
|| literal_columns.contains(output_field_name)
{
projection_exprs.push(
qualified_col(delta_alias, output_field_name.clone()).alias(output_field_name),
);
let expr =
qualified_col(delta_alias, output_field_name.clone()).alias(output_field_name);
projection_exprs.push(expr.clone());
group_exprs.push(expr);
} else if let Some(merge_col) = merge_columns.get(output_field_name) {
projection_exprs.push(build_left_join_merge_expr(
delta_alias,
sink_alias,
merge_col,
)?);
if matches!(
merge_col.merge_op,
IncrementalAggregateMergeOp::AvgDeltaMerge
) {
state_aggr_exprs.push(build_state_delta_merge_expr(engine, merge_col)?);
} else {
let expr = build_left_join_merge_expr(delta_alias, sink_alias, merge_col)?;
projection_exprs.push(expr.clone());
group_exprs.push(expr);
}
} else {
return InvalidQuerySnafu {
reason: format!(
@@ -772,15 +823,72 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge(
}
}
LogicalPlanBuilder::from(joined)
.project(projection_exprs)
.with_context(|_| DatafusionSnafu {
context: "Failed to build projection merge plan for incremental sink merge".to_string(),
})?
.build()
.with_context(|_| DatafusionSnafu {
context: "Failed to finalize incremental aggregate sink merge plan".to_string(),
if state_merge {
let aggregated = LogicalPlanBuilder::from(joined)
.aggregate(group_exprs, state_aggr_exprs)
.with_context(|_| DatafusionSnafu {
context: "Failed to aggregate state delta merge plan".to_string(),
})?
.build()
.with_context(|_| DatafusionSnafu {
context: "Failed to build state delta merge plan".to_string(),
})?;
let output_exprs = analysis
.output_field_names
.iter()
.cloned()
.map(unqualified_col)
.collect::<Vec<_>>();
LogicalPlanBuilder::from(aggregated)
.project(output_exprs)
.with_context(|_| DatafusionSnafu {
context: "Failed to project state delta merge plan".to_string(),
})?
.build()
.with_context(|_| DatafusionSnafu {
context: "Failed to finalize incremental aggregate sink merge plan".to_string(),
})
} else {
LogicalPlanBuilder::from(joined)
.project(projection_exprs)
.with_context(|_| DatafusionSnafu {
context: "Failed to build projection merge plan for incremental sink merge"
.to_string(),
})?
.build()
.with_context(|_| DatafusionSnafu {
context: "Failed to finalize incremental aggregate sink merge plan".to_string(),
})
}
}
fn build_state_delta_merge_expr(
engine: &QueryEngineRef,
merge_col: &IncrementalAggregateMergeColumn,
) -> Result<Expr, Error> {
let Some(udaf) = engine
.engine_state()
.aggr_function("__avg_state_delta_merge")
.or_else(|| {
engine
.engine_state()
.session_state()
.aggregate_functions()
.get("__avg_state_delta_merge")
.map(|udaf| udaf.as_ref().clone())
})
else {
return InvalidQuerySnafu {
reason: "Aggregate function __avg_state_delta_merge is not registered".to_string(),
}
.fail();
};
Ok(udaf
.call(vec![
qualified_col("__flow_delta", merge_col.input_field_name.clone()),
qualified_col("__flow_sink", merge_col.output_field_name.clone()),
])
.alias(merge_col.output_field_name.clone()))
}
fn build_left_join_merge_expr(
@@ -788,7 +896,7 @@ fn build_left_join_merge_expr(
sink_alias: &str,
merge_col: &IncrementalAggregateMergeColumn,
) -> Result<Expr, Error> {
let left = qualified_col(delta_alias, merge_col.output_field_name.clone());
let left = qualified_col(delta_alias, merge_col.input_field_name.clone());
let right = qualified_col(sink_alias, merge_col.output_field_name.clone());
let merged = match merge_col.merge_op {
IncrementalAggregateMergeOp::Sum => when(is_null(left.clone()), right.clone())
@@ -839,6 +947,12 @@ fn build_left_join_merge_expr(
.with_context(|_| DatafusionSnafu {
context: "Failed to build BIT_XOR merge expression".to_string(),
})?,
IncrementalAggregateMergeOp::AvgDeltaMerge => {
return InvalidQuerySnafu {
reason: "state aggregate must be built with its delta UDAF".to_string(),
}
.fail();
}
};
Ok(merged.alias(merge_col.output_field_name.clone()))
}
@@ -1149,53 +1263,67 @@ impl ColumnMatcherRewriter {
input_schema: &DFSchema,
) -> DfResult<Vec<Expr>> {
let original_exprs = exprs.clone();
for column in self.schema.column_schemas() {
if let Some(value) = self.ordinary_values.get(&column.name) {
if value.data_type() != column.data_type.as_arrow_type() {
return Err(DataFusionError::Plan(format!(
"Configured batching metadata column {} has incompatible type",
column.name
)));
}
if !exprs
.iter()
.any(|expr| expr.qualified_name().1 == column.name)
{
exprs.push(datafusion_expr::lit(value.clone()).alias(column.name.clone()));
self.validate_ordinary_values(&original_exprs)?;
let original_names = original_exprs
.iter()
.map(|expr| expr.qualified_name().1)
.collect::<Vec<_>>();
let duplicated_output_names = duplicate_names(&original_names);
if !duplicated_output_names.is_empty() {
return Err(DataFusionError::Plan(format!(
"Flow output schema contains duplicate column(s) {:?}. {}",
duplicated_output_names,
format_flow_sink_schema_mismatch(&original_exprs, self.schema.as_ref())
)));
}
if self.allow_partial {
// Partial matching is intentionally name-based. Ordinary values are injected before
// it so they follow the same direct partial path as the other supplied columns.
for (idx, column) in self.schema.column_schemas().iter().enumerate() {
if let Some(value) = self.ordinary_values.get(&column.name) {
exprs.insert(
idx.min(exprs.len()),
datafusion_expr::lit(value.clone()).alias(column.name.clone()),
);
}
}
}
if self.allow_partial {
return self.modify_project_exprs_with_partial(exprs);
}
let all_names = self
// Ordinary values are persistence-owned columns, not flow outputs. Remove them from the
// effective sink sequence while deciding whether the existing auto-column rules apply.
// This keeps those columns from hiding an auto-created update_at column that precedes them.
let effective_sink_columns = self
.schema
.column_schemas()
.iter()
.map(|c| c.name.clone())
.collect::<BTreeSet<_>>();
// add columns if have different column count
.enumerate()
.filter(|(_, column)| !self.ordinary_values.contains_key(&column.name))
.collect::<Vec<_>>();
let query_col_cnt = exprs.len();
let table_col_cnt = self.schema.column_schemas().len();
debug!("query_col_cnt={query_col_cnt}, table_col_cnt={table_col_cnt}");
let effective_sink_col_cnt = effective_sink_columns.len();
debug!("query_col_cnt={query_col_cnt}, effective_sink_col_cnt={effective_sink_col_cnt}");
let placeholder_ts_expr =
datafusion::logical_expr::lit(ScalarValue::TimestampMillisecond(Some(0), None))
.alias(AUTO_CREATED_PLACEHOLDER_TS_COL);
if query_col_cnt == table_col_cnt {
// still need to add alias, see below
} else if query_col_cnt + 1 == table_col_cnt {
let last_col_schema = self.schema.column_schemas().last().unwrap();
if query_col_cnt == effective_sink_col_cnt {
// still need to add aliases, see below
} else if query_col_cnt + 1 == effective_sink_col_cnt {
let (_, last_col_schema) = effective_sink_columns.last().unwrap();
// if time index column is auto created add it
if last_col_schema.name == AUTO_CREATED_PLACEHOLDER_TS_COL
&& self.schema.timestamp_index() == Some(table_col_cnt - 1)
&& self.schema.timestamp_index()
== Some(
self.schema
.column_index_by_name(&last_col_schema.name)
.unwrap(),
)
{
exprs.push(placeholder_ts_expr);
} else if last_col_schema.data_type.is_timestamp() {
// is the update at column
exprs.push(datafusion::prelude::now().alias(&last_col_schema.name));
} else {
return Err(DataFusionError::Plan(format_flow_sink_schema_mismatch(
@@ -1203,10 +1331,11 @@ impl ColumnMatcherRewriter {
self.schema.as_ref(),
)));
}
} else if query_col_cnt + 2 == table_col_cnt {
let mut col_iter = self.schema.column_schemas().iter().rev();
let last_col_schema = col_iter.next().unwrap();
let second_last_col_schema = col_iter.next().unwrap();
} else if query_col_cnt + 2 == effective_sink_col_cnt {
let (_, last_col_schema) = effective_sink_columns.last().unwrap();
let (_, second_last_col_schema) = effective_sink_columns
.get(effective_sink_col_cnt - 2)
.unwrap();
if second_last_col_schema.data_type.is_timestamp() {
exprs.push(datafusion::prelude::now().alias(&second_last_col_schema.name));
} else {
@@ -1217,7 +1346,12 @@ impl ColumnMatcherRewriter {
}
if last_col_schema.name == AUTO_CREATED_PLACEHOLDER_TS_COL
&& self.schema.timestamp_index() == Some(table_col_cnt - 1)
&& self.schema.timestamp_index()
== Some(
self.schema
.column_index_by_name(&last_col_schema.name)
.unwrap(),
)
{
exprs.push(placeholder_ts_expr);
} else {
@@ -1233,7 +1367,74 @@ impl ColumnMatcherRewriter {
)));
}
self.match_extra_output_columns(exprs, input_schema, &original_exprs, &all_names)
let exprs = self.match_extra_output_columns(
exprs,
input_schema,
&original_exprs,
&effective_sink_columns,
)?;
// Put persistence-owned values back at their physical sink positions only after matching
// flow expressions against the effective sequence.
let mut exprs = exprs;
for (idx, column) in self.schema.column_schemas().iter().enumerate() {
if let Some(value) = self.ordinary_values.get(&column.name) {
exprs.insert(
idx.min(exprs.len()),
datafusion_expr::lit(value.clone()).alias(column.name.clone()),
);
}
}
self.order_by_sink_schema(exprs, &original_exprs)
}
fn order_by_sink_schema(
&self,
exprs: Vec<Expr>,
original_exprs: &[Expr],
) -> DfResult<Vec<Expr>> {
let mut by_name = exprs
.into_iter()
.map(|expr| (expr.qualified_name().1, expr))
.collect::<HashMap<_, _>>();
let mut ordered = Vec::with_capacity(self.schema.column_schemas().len());
for column in self.schema.column_schemas() {
if let Some(expr) = by_name.remove(&column.name) {
ordered.push(expr);
}
}
if !by_name.is_empty() || ordered.len() != self.schema.column_schemas().len() {
return Err(DataFusionError::Plan(format_flow_sink_schema_mismatch(
original_exprs,
self.schema.as_ref(),
)));
}
Ok(ordered)
}
fn validate_ordinary_values(&self, output_exprs: &[Expr]) -> DfResult<()> {
let output_names = output_exprs
.iter()
.map(|expr| expr.qualified_name().1)
.collect::<HashSet<_>>();
for (name, value) in &self.ordinary_values {
let Some(column) = self.schema.column_schema_by_name(name) else {
return Err(DataFusionError::Plan(format!(
"Configured batching metadata column {name} does not exist in sink schema"
)));
};
if output_names.contains(name) {
return Err(DataFusionError::Plan(format!(
"Configured batching metadata column {name} collides with a flow output"
)));
}
if value.data_type() != column.data_type.as_arrow_type() {
return Err(DataFusionError::Plan(format!(
"Configured batching metadata column {name} has incompatible type"
)));
}
}
Ok(())
}
/// Match flow output columns whose names are not in the sink schema by the same position only.
@@ -1251,24 +1452,28 @@ impl ColumnMatcherRewriter {
mut exprs: Vec<Expr>,
input_schema: &DFSchema,
original_exprs: &[Expr],
all_names: &BTreeSet<String>,
effective_sink_columns: &[(usize, &ColumnSchema)],
) -> DfResult<Vec<Expr>> {
let mut output_names = exprs
.iter()
.map(|expr| expr.qualified_name().1)
.collect::<Vec<_>>();
let sink_names = effective_sink_columns
.iter()
.map(|(_, column)| column.name.as_str())
.collect::<HashSet<_>>();
let output_name_set = output_names.iter().cloned().collect::<BTreeSet<_>>();
let extra_expr_indices = output_names
.iter()
.enumerate()
.filter_map(|(idx, name)| (!all_names.contains(name)).then_some(idx))
.filter_map(|(idx, name)| (!sink_names.contains(name.as_str())).then_some(idx))
.collect::<Vec<_>>();
let missing_sink_indices = self
.schema
.column_schemas()
let missing_sink_indices = effective_sink_columns
.iter()
.enumerate()
.filter_map(|(idx, column)| (!output_name_set.contains(&column.name)).then_some(idx))
.filter_map(|(idx, (_, column))| {
(!output_name_set.contains(&column.name)).then_some(idx)
})
.collect::<Vec<_>>();
if extra_expr_indices.is_empty() && missing_sink_indices.is_empty() {
@@ -1291,7 +1496,7 @@ impl ColumnMatcherRewriter {
)));
}
let target_col_schema = &self.schema.column_schemas()[expr_idx];
let (_, target_col_schema) = effective_sink_columns[expr_idx];
let expr_type =
ConcreteDataType::from_arrow_type(&exprs[expr_idx].get_type(input_schema)?);
if is_obviously_incompatible_positional_match(&expr_type, &target_col_schema.data_type)
+663 -87
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::BTreeMap;
use std::sync::Arc;
use catalog::RegisterTableRequest;
@@ -273,6 +274,81 @@ async fn test_sql_plan_convert() {
);
}
#[test]
fn test_df_plan_to_sql_quotes_colon_table_name() {
// Prometheus-style table names contain ':' (e.g.
// `kube_pod_cpu_cores:sum`). The unparser dialect must quote them,
// otherwise the re-parsed SQL is invalid (`keyword: :`).
let table = single_row_u32_table("kube_pod_cpu_cores:sum", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "kube_pod_cpu_cores:sum");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.build()
.unwrap();
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("\"kube_pod_cpu_cores:sum\""),
"expected quoted table name in {sql}"
);
// The only occurrence of `cores:sum` must be inside the quoted identifier.
assert_eq!(
sql.matches("cores:sum").count(),
1,
"colon should only appear inside quotes in {sql}"
);
}
#[test]
fn test_df_plan_to_sql_does_not_quote_plain_lowercase() {
let table = single_row_u32_table("plain_table", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "plain_table");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.project(vec![datafusion_expr::col("value")])
.unwrap()
.build()
.unwrap();
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("plain_table") && !sql.contains("\"plain_table\""),
"plain lowercase table should stay unquoted in {sql}"
);
// `value` is not a reserved word, so the column stays unquoted.
assert!(
sql.contains("plain_table.value"),
"column unquoted in {sql}"
);
assert!(!sql.contains('`'), "no backtick quoting in {sql}");
}
#[test]
fn test_df_plan_to_sql_quotes_digit_leading_table_name() {
// A table literally named `123metrics` starts with a digit and must be
// quoted, otherwise the re-parsed SQL is invalid.
let table = single_row_u32_table("123metrics", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "123metrics");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.project(vec![datafusion_expr::col("value")])
.unwrap()
.build()
.unwrap();
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("\"123metrics\""),
"expected digit-leading table name quoted in {sql}"
);
}
#[tokio::test]
async fn test_add_filter() {
let testcases = vec![
@@ -668,8 +744,8 @@ async fn test_gen_plan_with_matching_schema_accepts_out_of_order_matching_names(
output_names,
vec![
"number".to_string(),
"ts".to_string(),
"time_window".to_string()
"time_window".to_string(),
"ts".to_string()
]
);
assert!(duplicate_names(&output_names).is_empty());
@@ -844,6 +920,120 @@ async fn test_validate_sink_table_schema_rejects_existing_sink_missing_flow_colu
assert!(err.contains("extra"), "{err}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_injects_attempt_columns_in_sink_order() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("marker", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new("payload", ConcreteDataType::string_datatype(), true),
ColumnSchema::new("epoch", ConcreteDataType::uint64_datatype(), true),
ColumnSchema::new(
AUTO_CREATED_UPDATE_AT_TS_COL,
ConcreteDataType::timestamp_millisecond_datatype(),
true,
),
]));
let values = BTreeMap::from([
("marker".to_string(), ScalarValue::UInt32(Some(7))),
(
"payload".to_string(),
ScalarValue::Utf8(Some("state".to_string())),
),
("epoch".to_string(), ScalarValue::UInt64(Some(9))),
]);
let plan = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[],
false,
Some(&values),
)
.await
.unwrap();
let output_names = plan
.schema()
.fields()
.iter()
.map(|f| f.name().clone())
.collect::<Vec<_>>();
assert_eq!(
output_names,
vec!["number", "ts", "marker", "payload", "epoch", "update_at"]
);
let sql = df_plan_to_sql(&plan).unwrap();
assert!(sql.contains("7 AS marker"), "{sql}");
assert!(sql.contains("'state' AS payload"), "{sql}");
assert!(sql.contains("9 AS epoch"), "{sql}");
assert!(sql.contains("now() AS update_at"), "{sql}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_arbitrary_missing_attempt_column() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("missing", ConcreteDataType::uint32_datatype(), true),
]));
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[],
false,
Some(&BTreeMap::new()),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("missing sink columns"), "{err}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_no_attempt_still_rejects_missing_column() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("state", ConcreteDataType::uint32_datatype(), true),
]));
assert!(
gen_plan_with_matching_schema(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[],
false,
)
.await
.is_err()
);
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_allow_partial_fills_nullable_columns() {
let query_engine = create_test_query_engine();
@@ -1106,13 +1296,16 @@ async fn test_rewrite_incremental_aggregate_allows_alias_wrapped_scan() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT max(n.number) AS number, n.ts FROM numbers_with_ts AS n GROUP BY n.ts";
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false)
.await
.unwrap();
let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap();
assert!(analysis.unsupported_exprs.is_empty());
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
single_row_u32_table("alias_wrapped_sink", vec!["ts", "number"]),
&[
"greptime".to_string(),
@@ -1364,6 +1557,7 @@ async fn test_analyze_incremental_aggregate_plan_allows_literal_outputs() {
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table.clone(),
&sink_table_name,
None,
@@ -1437,7 +1631,9 @@ async fn test_rewrite_incremental_aggregate_preserves_non_identifier_aliases() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT max(number) AS \"max value\", number, 42 AS \"literal value\" FROM numbers_with_ts GROUP BY number";
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false)
.await
.unwrap();
let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap();
assert!(analysis.unsupported_exprs.is_empty());
assert_eq!(
@@ -1449,6 +1645,7 @@ async fn test_rewrite_incremental_aggregate_preserves_non_identifier_aliases() {
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table,
&[
"greptime".to_string(),
@@ -1522,24 +1719,34 @@ async fn test_datafusion_rejects_duplicate_output_names() {
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_rejects_same_aggregate_multiple_aliases() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT sum(number) AS a, sum(number) AS b, ts FROM numbers_with_ts GROUP BY ts";
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
async fn test_analyze_incremental_aggregate_plan_supports_same_aggregate_multiple_aliases() {
let analysis = analyze_test_sql(
"SELECT sum(number) AS a, sum(number) AS b, ts FROM numbers_with_ts GROUP BY ts",
)
.await;
let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap();
assert!(analysis.unsupported_exprs.is_empty());
assert_eq!(analysis.merge_columns.len(), 2);
assert_eq!(analysis.merge_columns[0].input_field_name, "a");
assert_eq!(analysis.merge_columns[1].input_field_name, "a");
assert!(
analysis
.unsupported_exprs
.merge_columns
.iter()
.any(|expr| expr.contains("same aggregate output")
&& expr.contains("a")
&& expr.contains("b")),
"same aggregate with multiple aliases should be unsupported until explicit reproduction is implemented: {:?}",
analysis.unsupported_exprs
.all(|column| { column.merge_op == IncrementalAggregateMergeOp::Sum })
);
assert!(
analysis
.merge_columns
.iter()
.any(|column| column.output_field_name == "a")
);
assert!(
analysis
.merge_columns
.iter()
.any(|column| column.output_field_name == "b")
);
assert!(analysis.merge_columns.is_empty());
}
#[test]
@@ -1583,6 +1790,94 @@ async fn test_analyze_incremental_aggregate_plan_rejects_avg() {
assert!(!analysis.unsupported_exprs.is_empty());
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_supports_avg_state() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT avg_state(number) AS avg_num, ts FROM numbers_with_ts GROUP BY ts";
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(),
"avg_state should be supported: {:?}",
analysis.unsupported_exprs
);
assert_eq!(analysis.merge_columns.len(), 1);
assert_eq!(analysis.merge_columns[0].output_field_name, "avg_num");
assert_eq!(
analysis.merge_columns[0].merge_op,
IncrementalAggregateMergeOp::AvgDeltaMerge
);
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_supports_avg_merge() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT avg_merge(avg_state(number)) AS avg_num, ts FROM numbers_with_ts GROUP BY ts";
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(),
"avg_merge should be supported: {:?}",
analysis.unsupported_exprs
);
assert_eq!(analysis.merge_columns.len(), 1);
assert_eq!(
analysis.merge_columns[0].merge_op,
IncrementalAggregateMergeOp::AvgDeltaMerge
);
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_supports_duplicate_avg_projections() {
let analysis = analyze_test_sql(
"SELECT avg_state(number) AS avg_num, avg_state(number + 1) AS avg_num_plus, ts FROM numbers_with_ts GROUP BY ts",
)
.await;
assert!(analysis.unsupported_exprs.is_empty());
assert_eq!(analysis.merge_columns.len(), 2);
assert!(
analysis
.merge_columns
.iter()
.all(|column| { column.merge_op == IncrementalAggregateMergeOp::AvgDeltaMerge })
);
assert!(
analysis
.merge_columns
.iter()
.any(|column| column.output_field_name == "avg_num")
);
assert!(
analysis
.merge_columns
.iter()
.any(|column| column.output_field_name == "avg_num_plus")
);
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_supports_avg_with_native_aggregate() {
let analysis = analyze_test_sql(
"SELECT avg_state(number) AS avg_num, sum(number) AS total, ts FROM numbers_with_ts GROUP BY ts",
)
.await;
assert!(analysis.unsupported_exprs.is_empty());
assert_eq!(analysis.merge_columns.len(), 2);
assert!(analysis.merge_columns.iter().any(|column| {
column.output_field_name == "avg_num"
&& column.merge_op == IncrementalAggregateMergeOp::AvgDeltaMerge
}));
assert!(analysis.merge_columns.iter().any(|column| {
column.output_field_name == "total" && column.merge_op == IncrementalAggregateMergeOp::Sum
}));
}
#[tokio::test]
async fn test_analyze_incremental_aggregate_plan_rejects_distinct() {
let query_engine = create_test_query_engine();
@@ -1640,6 +1935,7 @@ async fn test_rewrite_incremental_aggregate_with_left_join() {
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table.clone(),
&sink_table_name,
None,
@@ -1701,6 +1997,7 @@ async fn test_rewrite_incremental_aggregate_filters_sink_dirty_time_window() {
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table.clone(),
&sink_table_name,
Some(sink_filter.clone()),
@@ -1751,7 +2048,9 @@ async fn test_rewrite_incremental_aggregate_rejects_empty_group_keys() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT max(number) AS number FROM numbers_with_ts";
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false)
.await
.unwrap();
let analysis = IncrementalAggregateAnalysis {
group_key_names: vec![],
merge_columns: vec![IncrementalAggregateMergeColumn::new(
@@ -1772,6 +2071,7 @@ async fn test_rewrite_incremental_aggregate_rejects_empty_group_keys() {
let err = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table,
&sink_table_name,
None,
@@ -1790,7 +2090,9 @@ async fn test_rewrite_incremental_aggregate_preserves_raw_aggregate_field_name()
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT max(number), number FROM numbers_with_ts GROUP BY number";
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false)
.await
.unwrap();
let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap();
assert!(analysis.unsupported_exprs.is_empty());
@@ -1804,6 +2106,7 @@ async fn test_rewrite_incremental_aggregate_preserves_raw_aggregate_field_name()
let rewritten = rewrite_incremental_aggregate_with_sink_merge(
&plan,
&analysis,
&query_engine,
sink_table.clone(),
&sink_table_name,
None,
@@ -1841,9 +2144,7 @@ async fn test_null_cast() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sql = "SELECT NULL::DOUBLE FROM numbers_with_ts";
let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false)
.await
.unwrap();
let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap();
let _sub_plan = DFLogicalSubstraitConvertor {}
.encode(&plan, DefaultSerializer)
@@ -1986,77 +2287,352 @@ async fn test_gen_plan_with_matching_schema_last_non_null_rejects_extra_flow_col
);
}
#[test]
fn test_df_plan_to_sql_quotes_colon_table_name() {
// Prometheus-style table names contain ':' (e.g.
// `kube_pod_cpu_cores:sum`). The unparser dialect must quote them,
// otherwise the re-parsed SQL is invalid (`keyword: :`).
let table = single_row_u32_table("kube_pod_cpu_cores:sum", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "kube_pod_cpu_cores:sum");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.build()
.unwrap();
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_unknown_attempt_column() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let values = BTreeMap::from([(String::from("unknown_attempt"), ScalarValue::Int64(Some(1)))]);
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
Some(&values),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("unknown_attempt"), "{err}");
assert!(err.contains("does not exist in sink schema"), "{err}");
}
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("\"kube_pod_cpu_cores:sum\""),
"expected quoted table name in {sql}"
);
// The only occurrence of `cores:sum` must be inside the quoted identifier.
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_wrong_attempt_column_type() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new("attempt", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let values = BTreeMap::from([(
String::from("attempt"),
ScalarValue::Utf8(Some("one".into())),
)]);
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
Some(&values),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("attempt"), "{err}");
assert!(err.contains("incompatible type"), "{err}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_matches_positional_alias_and_injects_attempt() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("renamed_number", ConcreteDataType::int64_datatype(), true),
ColumnSchema::new("attempt", ConcreteDataType::string_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let values = BTreeMap::from([(
String::from("attempt"),
ScalarValue::Utf8(Some("one".into())),
)]);
let plan = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
Some(&values),
)
.await
.unwrap();
let output_names = plan
.schema()
.fields()
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>();
assert_eq!(output_names, vec!["renamed_number", "attempt", "ts"]);
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_injects_ordinary_columns_after_auto_update_at() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let mut sink_columns = (0..16)
.map(|idx| {
ColumnSchema::new(
format!("state_{idx}"),
ConcreteDataType::int32_datatype(),
true,
)
})
.collect::<Vec<_>>();
sink_columns.push(ColumnSchema::new(
"update_at",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
));
sink_columns.extend([
ColumnSchema::new(
"__ee_checkpoint_epoch",
ConcreteDataType::uint32_datatype(),
false,
),
ColumnSchema::new(
"__ee_checkpoint_sequence",
ConcreteDataType::uint32_datatype(),
false,
),
ColumnSchema::new(
"__ee_checkpoint_region",
ConcreteDataType::uint32_datatype(),
false,
),
]);
let sink_schema = Arc::new(Schema::new(sink_columns));
let ordinary_values = BTreeMap::from([
(
"__ee_checkpoint_epoch".to_string(),
ScalarValue::UInt32(Some(1)),
),
(
"__ee_checkpoint_sequence".to_string(),
ScalarValue::UInt32(Some(2)),
),
(
"__ee_checkpoint_region".to_string(),
ScalarValue::UInt32(Some(3)),
),
]);
let flow_exprs = (0..16)
.map(|idx| format!("number AS state_{idx}"))
.collect::<Vec<_>>();
let sql = format!("SELECT {} FROM numbers_with_ts", flow_exprs.join(", "));
let plan = gen_plan_with_matching_schema_and_values(
&sql,
ctx,
query_engine,
sink_schema,
&[],
false,
Some(&ordinary_values),
)
.await
.unwrap();
let output_names = plan
.schema()
.fields()
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>();
assert_eq!(
sql.matches("cores:sum").count(),
1,
"colon should only appear inside quotes in {sql}"
output_names,
vec![
"state_0",
"state_1",
"state_2",
"state_3",
"state_4",
"state_5",
"state_6",
"state_7",
"state_8",
"state_9",
"state_10",
"state_11",
"state_12",
"state_13",
"state_14",
"state_15",
"update_at",
"__ee_checkpoint_epoch",
"__ee_checkpoint_sequence",
"__ee_checkpoint_region",
]
);
}
#[test]
fn test_df_plan_to_sql_does_not_quote_plain_lowercase() {
let table = single_row_u32_table("plain_table", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "plain_table");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.project(vec![datafusion_expr::col("value")])
.unwrap()
.build()
.unwrap();
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("plain_table") && !sql.contains("\"plain_table\""),
"plain lowercase table should stay unquoted in {sql}"
);
// `value` is not a reserved word, so the column stays unquoted.
assert!(
sql.contains("plain_table.value"),
"column unquoted in {sql}"
);
assert!(!sql.contains('`'), "no backtick quoting in {sql}");
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_no_attempt_strict_mismatch() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("attempt", ConcreteDataType::string_datatype(), true),
]));
let err = gen_plan_with_matching_schema(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("does not match sink table schema"), "{err}");
assert!(err.contains("attempt"), "{err}");
}
#[test]
fn test_df_plan_to_sql_quotes_digit_leading_table_name() {
// A table literally named `123metrics` starts with a digit and must be
// quoted, otherwise the re-parsed SQL is invalid.
let table = single_row_u32_table("123metrics", vec!["value"]);
let provider = Arc::new(DfTableProviderAdapter::new(table));
let table_source = Arc::new(DefaultTableSource::new(provider));
let table_ref = TableReference::full("catalog", "schema", "123metrics");
let plan = LogicalPlanBuilder::scan(table_ref, table_source, None)
.unwrap()
.project(vec![datafusion_expr::col("value")])
.unwrap()
.build()
.unwrap();
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_unknown_attempt_column_in_partial_mode() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("optional", ConcreteDataType::uint32_datatype(), true),
]));
let values = BTreeMap::from([(String::from("unknown_attempt"), ScalarValue::Int64(Some(1)))]);
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
true,
Some(&values),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("unknown_attempt"), "{err}");
assert!(err.contains("does not exist in sink schema"), "{err}");
}
let sql = df_plan_to_sql(&plan).unwrap();
assert!(
sql.contains("\"123metrics\""),
"expected digit-leading table name quoted in {sql}"
);
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_attempt_output_collision() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new("attempt", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let values = BTreeMap::from([(String::from("attempt"), ScalarValue::UInt32(Some(1)))]);
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, number AS attempt, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
Some(&values),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("collides with a flow output"), "{err}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_duplicate_original_outputs() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let err = gen_plan_with_matching_schema(
"SELECT * FROM numbers_with_ts AS lhs JOIN numbers_with_ts AS rhs ON lhs.ts = rhs.ts",
ctx,
query_engine,
sink_schema,
&[0],
false,
)
.await
.unwrap_err();
let diagnostic = format!("{err:?}");
assert!(diagnostic.contains("duplicate column"), "{diagnostic}");
assert!(diagnostic.contains("number"), "{diagnostic}");
}
#[tokio::test]
async fn test_gen_plan_with_matching_schema_rejects_attempt_output_collision_in_partial_mode() {
let query_engine = create_test_query_engine();
let ctx = QueryContext::arc();
let sink_schema = Arc::new(Schema::new(vec![
ColumnSchema::new("number", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new("attempt", ConcreteDataType::uint32_datatype(), true),
ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
]));
let values = BTreeMap::from([(String::from("attempt"), ScalarValue::UInt32(Some(1)))]);
let err = gen_plan_with_matching_schema_and_values(
"SELECT number, number AS attempt, ts FROM numbers_with_ts",
ctx,
query_engine,
sink_schema,
&[0],
true,
Some(&values),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("collides with a flow output"), "{err}");
}
+1 -9
View File
@@ -327,7 +327,6 @@ pub struct FlownodeBuilder {
/// receive a oneshot sender to send state size report
state_report_handler: Option<StateReportHandler>,
frontend_client: Arc<FrontendClient>,
batching_persistence_factory: Option<FactoryPlugin>,
}
impl FlownodeBuilder {
@@ -349,16 +348,9 @@ impl FlownodeBuilder {
heartbeat_task: None,
state_report_handler: None,
frontend_client,
batching_persistence_factory: None,
}
}
/// Inject the optional batching persistence collaborator.
pub fn with_batching_persistence_factory(mut self, factory: FactoryPlugin) -> Self {
self.batching_persistence_factory = Some(factory);
self
}
pub fn with_heartbeat_task(self, heartbeat_task: HeartbeatTask) -> Self {
let (sender, receiver) = SizeReportSender::new();
Self {
@@ -420,7 +412,7 @@ impl FlownodeBuilder {
self.table_meta.clone(),
self.catalog_manager.clone(),
self.opts.flow.batching_mode.clone(),
self.batching_persistence_factory.clone(),
self.plugins.get::<FactoryPlugin>(),
));
let dual = Arc::new(FlowDualEngine::new(
manager.clone(),