Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 80 additions & 5 deletions src/query/catalog/src/runtime_filter_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use databend_common_expression::ColumnId;
use databend_common_expression::Expr;
use databend_common_expression::Scalar;
use databend_storages_common_table_meta::meta::ColumnStatistics;
use databend_storages_common_table_meta::meta::try_cmp_stat_scalars;
use parking_lot::RwLock;
use tokio::sync::watch;
use tokio::sync::watch::Receiver;
Expand Down Expand Up @@ -80,10 +81,15 @@ impl RuntimeScanOrder {
(RuntimeTopNRank::Best, _) => CmpOrdering::Less,
(_, RuntimeTopNRank::Best) => CmpOrdering::Greater,
(RuntimeTopNRank::Value(a), RuntimeTopNRank::Value(b)) => {
// Persisted bounds may carry a stale decimal precision after a metadata-only
// widening; comparing them raw would report every rank as equal. An
// incomparable pair carries no ordering information, so treat it as a tie.
let ordering = try_cmp_stat_scalars(&a.borrow().as_ref(), &b.borrow().as_ref())
.unwrap_or(CmpOrdering::Equal);
if self.asc {
a.borrow().cmp(b.borrow())
ordering
} else {
b.borrow().cmp(a.borrow())
ordering.reverse()
}
}
(RuntimeTopNRank::Value(_), RuntimeTopNRank::Unknown) => CmpOrdering::Less,
Expand Down Expand Up @@ -234,7 +240,9 @@ impl RuntimeTopNFilter {
fn tighter(&self, candidate: &Scalar, current: Option<&Scalar>) -> bool {
match current {
None => true,
Some(old) => match candidate.partial_cmp(old) {
// Boundaries are produced by the running query, so they normally share a size; an
// incomparable pair cannot be proven tighter, so keep the published one.
Some(old) => match try_cmp_stat_scalars(&candidate.as_ref(), &old.as_ref()) {
Some(CmpOrdering::Less) => self.asc,
Some(CmpOrdering::Greater) => !self.asc,
_ => false,
Expand Down Expand Up @@ -289,9 +297,11 @@ impl RuntimeTopNFilter {
}

if self.asc {
min.partial_cmp(boundary) == Some(CmpOrdering::Greater)
// A stale decimal precision makes the comparison inconclusive; keep the block rather
// than prune on an ordering we cannot establish.
try_cmp_stat_scalars(&min.as_ref(), &boundary.as_ref()) == Some(CmpOrdering::Greater)
} else {
max.partial_cmp(boundary) == Some(CmpOrdering::Less)
try_cmp_stat_scalars(&max.as_ref(), &boundary.as_ref()) == Some(CmpOrdering::Less)
}
}
}
Expand Down Expand Up @@ -529,6 +539,8 @@ mod tests {
use databend_common_expression::ColumnRef;
use databend_common_expression::Expr;
use databend_common_expression::types::DataType;
use databend_common_expression::types::DecimalScalar;
use databend_common_expression::types::DecimalSize;
use databend_common_expression::types::NumberDataType;
use databend_common_expression::types::NumberScalar;
use tokio::time::Duration;
Expand Down Expand Up @@ -726,4 +738,67 @@ mod tests {
assert!(ready.statistics_column_names().is_empty());
assert!(!ready.has_statistics_pruning());
}
fn decimal(value: i64, precision: u8, scale: u8) -> Scalar {
Scalar::Decimal(DecimalScalar::Decimal64(
value,
DecimalSize::new(precision, scale).unwrap(),
))
}

// A metadata-only decimal precision widening leaves persisted block bounds tagged with the
// previous `DecimalSize`. Raw comparison against the boundary collapses to `Equal`, so no
// block is ever excluded; comparing by scale and raw value restores the filter.
#[test]
fn runtime_top_n_filter_excludes_blocks_across_widened_precision() {
let asc = RuntimeTopNFilter::new(7, true, false);
asc.update(&decimal(500, 15, 2));

// Bounds tagged with the old precision but wholly above the boundary.
assert!(asc.boundary_excludes(&decimal(600, 10, 2), &decimal(700, 10, 2), 0));
// Straddling the boundary: must be kept.
assert!(!asc.boundary_excludes(&decimal(400, 10, 2), &decimal(700, 10, 2), 0));

let desc = RuntimeTopNFilter::new(7, false, false);
desc.update(&decimal(500, 15, 2));
assert!(desc.boundary_excludes(&decimal(100, 10, 2), &decimal(400, 10, 2), 0));
assert!(!desc.boundary_excludes(&decimal(100, 10, 2), &decimal(600, 10, 2), 0));
}

// A scale change is not a widening: the comparison is genuinely inconclusive, so the block
// must be kept rather than pruned on an ordering that cannot be established.
#[test]
fn runtime_top_n_filter_keeps_blocks_with_incomparable_bounds() {
let asc = RuntimeTopNFilter::new(7, true, false);
asc.update(&decimal(500, 15, 2));

assert!(!asc.boundary_excludes(&decimal(600, 10, 4), &decimal(700, 10, 4), 0));
}

// Ranking feeds the read order; mixed precisions must still sort by value, otherwise every
// part looks equally promising and the scheduling degrades.
#[test]
fn runtime_scan_order_ranks_across_widened_precision() {
let low = RuntimeTopNRank::Value(decimal(100, 10, 2));
let high = RuntimeTopNRank::Value(decimal(900, 15, 2));

let asc = RuntimeScanOrder {
column_id: 7,
asc: true,
nulls_first: false,
};
assert_eq!(asc.compare_ranks(&low, &high), CmpOrdering::Less);
assert_eq!(asc.compare_ranks(&high, &low), CmpOrdering::Greater);

let desc = RuntimeScanOrder {
column_id: 7,
asc: false,
nulls_first: false,
};
assert_eq!(desc.compare_ranks(&low, &high), CmpOrdering::Greater);
assert_eq!(desc.compare_ranks(&high, &low), CmpOrdering::Less);

// A scale change is inconclusive, so neither rank is preferred.
let other_scale = RuntimeTopNRank::Value(decimal(900, 10, 4));
assert_eq!(asc.compare_ranks(&low, &other_scale), CmpOrdering::Equal);
}
}
34 changes: 29 additions & 5 deletions src/query/storages/common/index/src/range_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use databend_common_expression::types::DataType;
use databend_common_expression::types::DateType;
use databend_common_expression::types::Decimal64Type;
use databend_common_expression::types::DecimalScalar;
use databend_common_expression::types::DecimalSize;
use databend_common_expression::types::NumberDataType;
use databend_common_expression::types::NumberType;
use databend_common_expression::types::TimestampType;
Expand All @@ -48,6 +49,7 @@ use databend_common_functions::BUILTIN_FUNCTIONS;
use databend_storages_common_table_meta::meta::ColumnStatistics;
use databend_storages_common_table_meta::meta::StatisticsOfColumns;
use databend_storages_common_table_meta::meta::StatisticsOfSpatialColumns;
use databend_storages_common_table_meta::meta::retag_stat_scalar;
use geo::Point;
use geo::Rect;

Expand Down Expand Up @@ -248,6 +250,16 @@ impl RangeIndex {
}
}

/// The decimal size persisted statistics must be retagged to before use, if any.
///
/// Only decimal columns need this; every other type is already comparable as persisted.
fn target_decimal_size(data_type: &DataType) -> Option<DecimalSize> {
match data_type.remove_nullable() {
DataType::Decimal(size) => Some(size),
_ => None,
}
}

pub fn statistics_to_domain(mut stats: Vec<&ColumnStatistics>, data_type: &DataType) -> Domain {
if stats.len() != data_type.num_leaf_columns() {
return Domain::full(data_type);
Expand Down Expand Up @@ -299,8 +311,23 @@ pub fn statistics_to_domain(mut stats: Vec<&ColumnStatistics>, data_type: &DataT
DataType::Vector(_) => Domain::full(data_type),
_ => {
let stat = stats[0];
let min = stat.min();
let max = stat.max();
// A metadata-only decimal precision widening leaves persisted bounds tagged with the
// previous `DecimalSize`, so retag them to the current schema before building a
// domain. Bounds that cannot be retagged did not come from such a widening, and
// constructing a domain from them would be unsound, so fall back to the full domain.
let (min, max) = match target_decimal_size(data_type) {
None => (Cow::Borrowed(stat.min()), Cow::Borrowed(stat.max())),
Some(size) => {
let Some(min) = retag_stat_scalar(stat.min(), size) else {
return Domain::full(data_type);
};
let Some(max) = retag_stat_scalar(stat.max(), size) else {
return Domain::full(data_type);
};
(Cow::Owned(min), Cow::Owned(max))
}
};
let (min, max) = (min.as_ref(), max.as_ref());

with_number_mapped_type!(|NUM_TYPE| match data_type {
DataType::Number(NumberDataType::NUM_TYPE) => {
Expand All @@ -322,9 +349,6 @@ pub fn statistics_to_domain(mut stats: Vec<&ColumnStatistics>, data_type: &DataT
max: DateType::try_downcast_scalar(&max.as_ref()).unwrap(),
}),
DataType::Decimal(size) => {
debug_assert_eq!(*size, min.as_decimal().unwrap().size());
debug_assert_eq!(*size, max.as_decimal().unwrap().size());

let domain = match min.as_decimal().unwrap() {
DecimalScalar::Decimal64(_, _) => {
let domain = SimpleDomain {
Expand Down
61 changes: 61 additions & 0 deletions src/query/storages/common/index/tests/it/range_pruner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,15 @@ use databend_common_expression::TableSchema;
use databend_common_expression::type_check::check_function;
use databend_common_expression::types::ArgType;
use databend_common_expression::types::DataType;
use databend_common_expression::types::DecimalScalar;
use databend_common_expression::types::DecimalSize;
use databend_common_expression::types::Int32Type;
use databend_common_expression::types::NumberDataType;
use databend_common_expression::types::decimal::DecimalDomain;
use databend_common_functions::BUILTIN_FUNCTIONS;
use databend_storages_common_index::RangeIndex;
use databend_storages_common_index::eliminate_cast;
use databend_storages_common_index::statistics_to_domain;
use databend_storages_common_table_meta::meta::ColumnStatistics;
use databend_storages_common_table_meta::meta::SpatialStatistics;
use databend_storages_common_table_meta::meta::StatisticsOfColumns;
Expand Down Expand Up @@ -552,3 +556,60 @@ fn build_spatial_stats(
};
[(column_id, stats)].into_iter().collect()
}

// A metadata-only decimal precision widening leaves persisted bounds tagged with the previous
// `DecimalSize`. The domain must be built at the current schema size so pruning still works;
// previously this hit a `debug_assert_eq!` on the size instead.
#[test]
fn test_statistics_to_domain_retags_widened_decimal() {
let current = DecimalSize::new(15, 2).unwrap();
let stale = ColumnStatistics::new(
Scalar::Decimal(DecimalScalar::Decimal64(
100,
DecimalSize::new(10, 2).unwrap(),
)),
Scalar::Decimal(DecimalScalar::Decimal64(
200,
DecimalSize::new(10, 2).unwrap(),
)),
0,
0,
None,
);

let domain = statistics_to_domain(vec![&stale], &DataType::Decimal(current));

// The bounds keep their raw values and are reported at the current precision.
match domain {
Domain::Decimal(DecimalDomain::Decimal64(inner, size)) => {
assert_eq!(size, current);
assert_eq!(inner.min, 100);
assert_eq!(inner.max, 200);
}
other => panic!("expected a Decimal64 domain, got {other:?}"),
}
}

// Bounds that cannot be retagged did not come from a metadata-only widening, so no sound domain
// can be derived from them and pruning must fall back to the full domain.
#[test]
fn test_statistics_to_domain_falls_back_for_incompatible_decimal() {
let current = DataType::Decimal(DecimalSize::new(15, 2).unwrap());
// A different scale means the raw value denotes a different number.
let other_scale = ColumnStatistics::new(
Scalar::Decimal(DecimalScalar::Decimal64(
100,
DecimalSize::new(10, 4).unwrap(),
)),
Scalar::Decimal(DecimalScalar::Decimal64(
200,
DecimalSize::new(10, 4).unwrap(),
)),
0,
0,
None,
);

let domain = statistics_to_domain(vec![&other_scale], &current);
assert_eq!(domain, Domain::full(&current));
}
Loading
Loading