fix(table): stop claiming tag order for logical metric scans (#9522)

* test: cover metric logical scan tag ordering

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(table): only claim projected primary key prefix ordering

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(table): keep physical key ordering for metric projections

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(table): compare unknown partition counts explicitly

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(table): model scoped internal TSID scan properties

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
discord9
2026-10-14 03:35:58 +00:00
committed by GitHub
parent caefcfd012
commit 75bc4ce46d
2 changed files with 554 additions and 7 deletions
+265 -7
View File
@@ -118,21 +118,34 @@ impl RegionScanExec {
}
let metadata = scanner.metadata();
let mut pk_names = metadata
// Keep the primary key columns in the order reported by the metadata (for a metric
// region that is the physical row key order). Reordering them would let a projected
// logical tag look like a prefix of the row key, which yields ordering and
// partitioning claims the scanner cannot honour.
let pk_names = metadata
.primary_key_columns()
.map(|col| col.column_schema.name.clone())
.collect::<Vec<_>>();
// workaround for logical table
if scanner.properties().is_logical_region() {
pk_names.sort_unstable();
}
// A region emits rows ordered by its primary key columns and then by the time
// index column. A primary key column that is not projected is absent from the
// arrow schema, and it also breaks the ordering of every primary key column that
// follows it (e.g. the physical `__table_id`/`__tsid` prefix of a metric region
// when reading a logical table). Thus only the longest fully projected prefix of
// the primary key can describe the output ordering, and that prefix can be empty.
let projected_pk_prefix_len = pk_names
.iter()
.position(|name| Column::new_with_schema(name, &arrow_schema).is_err())
.unwrap_or(pk_names.len());
// Whether the projected prefix covers the whole row key.
let all_pk_projected = projected_pk_prefix_len == pk_names.len();
let projected_pk_names = &pk_names[..projected_pk_prefix_len];
let pk_columns = pk_names
.iter()
.filter_map(
|col| Some(Arc::new(Column::new_with_schema(col, &arrow_schema).ok()?) as _),
)
.collect::<Vec<_>>();
let pk_sort_columns: Vec<PhysicalSortExpr> = pk_names
let pk_sort_columns: Vec<PhysicalSortExpr> = projected_pk_names
.iter()
.filter_map(|col| {
Some(PhysicalSortExpr::new(
@@ -165,7 +178,9 @@ impl RegionScanExec {
let mut orderings = Vec::with_capacity(2);
let mut pk_time_ordering = pk_sort_columns.clone();
if let Some(ts) = ts_col.clone() {
// The time index column extends the ordering only when the whole row key
// precedes it; otherwise rows of different series can interleave.
if all_pk_projected && let Some(ts) = ts_col.clone() {
pk_time_ordering.push(ts);
}
orderings.push(pk_time_ordering);
@@ -694,9 +709,11 @@ mod test {
use datatypes::vectors::{Int32Vector, TimestampMillisecondVector, VectorRef};
use futures::TryStreamExt;
use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
use store_api::metric_engine_consts::DATA_SCHEMA_TABLE_ID_COLUMN_NAME;
use store_api::region_engine::{
PrepareRequest, QueryScanContext, RegionScanner, ScannerProperties, SinglePartitionScanner,
};
use store_api::storage::consts::ReservedColumnId;
use store_api::storage::{RegionId, TimeSeriesDistribution};
use super::*;
@@ -953,6 +970,247 @@ mod test {
}
}
/// Returns whether the scan claims the given output ordering.
fn claims_ordering(plan: &RegionScanExec, names: &[&str]) -> bool {
plan.properties()
.equivalence_properties()
.ordering_satisfy(names.iter().map(|name| {
PhysicalSortExpr::new(
Arc::new(Column::new_with_schema(name, &plan.arrow_schema).unwrap()),
SortOptions {
descending: false,
nulls_first: true,
},
)
}))
.unwrap()
}
/// Column schema used by the ordering fixtures below.
fn column_schema_of(name: &str) -> ColumnSchema {
let data_type = match name {
"ts" => ConcreteDataType::timestamp_millisecond_datatype(),
DATA_SCHEMA_TABLE_ID_COLUMN_NAME => ConcreteDataType::uint32_datatype(),
DATA_SCHEMA_TSID_COLUMN_NAME => ConcreteDataType::uint64_datatype(),
_ => ConcreteDataType::string_datatype(),
};
ColumnSchema::new(name, data_type, false)
}
/// The scan must describe its output ordering with the primary key order reported by
/// the metadata. Sorting the names (the removed logical-table workaround) would let a
/// projected column look like a row key prefix and claim an ordering the scanner does
/// not produce; the time index only extends a fully projected row key.
#[test]
fn test_per_series_ordering_follows_metadata_pk_order() {
// Deliberately not alphabetical: sorting these names would give `[a, b, z]`.
let mut builder = RegionMetadataBuilder::new(RegionId::new(1234, 5690));
for (index, name) in ["z", "a", "b", "ts"].into_iter().enumerate() {
builder.push_column_metadata(ColumnMetadata {
column_schema: column_schema_of(name),
semantic_type: if name == "ts" {
SemanticType::Timestamp
} else {
SemanticType::Tag
},
column_id: index as u32 + 1,
});
}
builder.primary_key(vec![1, 2, 3]);
let metadata = Arc::new(builder.build().unwrap());
// The claims must not depend on the scanner reporting a logical region.
for logical_region in [false, true] {
for (projection, claimed, unclaimed) in [
// Missing the first key: neither the remaining keys nor the time index are
// ordered.
(
vec!["a", "b", "ts"],
vec![],
vec![vec!["a"], vec!["b"], vec!["ts"], vec!["a", "b"]],
),
// Missing a key in the middle: only the prefix before it is ordered.
(
vec!["z", "b", "ts"],
vec![vec!["z"]],
vec![vec!["b"], vec!["z", "b"], vec!["z", "ts"]],
),
// A valid but incomplete prefix: the time index is not ordered after it.
(
vec!["z", "a", "ts"],
vec![vec!["z"], vec!["z", "a"]],
vec![vec!["a", "z"], vec!["z", "a", "ts"]],
),
// The whole row key followed by the time index.
(
vec!["z", "a", "b", "ts"],
vec![vec!["z", "a", "b"], vec!["z", "a", "b", "ts"]],
vec![vec!["a"], vec!["a", "b", "z"]],
),
] {
let schema = Arc::new(Schema::new(
projection
.iter()
.map(|name| column_schema_of(name))
.collect(),
));
let mut properties = ScannerProperties::new(vec![vec![], vec![]], false, 0);
properties.set_logical_region(logical_region);
let scanner = Box::new(RepeatableScanner {
batches: RecordBatches::try_new(schema, vec![]).unwrap(),
properties,
metadata: metadata.clone(),
dynamic_filters: Arc::new(Mutex::new(Vec::new())),
});
let plan = RegionScanExec::new(
scanner,
ScanRequest {
distribution: Some(TimeSeriesDistribution::PerSeries),
..Default::default()
},
None,
)
.unwrap();
for ordering in claimed {
assert!(
claims_ordering(&plan, &ordering),
"logical_region={logical_region} projection {projection:?} must claim \
ordering {ordering:?}"
);
}
for ordering in unclaimed {
assert!(
!claims_ordering(&plan, &ordering),
"logical_region={logical_region} projection {projection:?} must not \
claim ordering {ordering:?}"
);
}
}
}
}
/// Metric TSID properties require projecting `__tsid` and restricting the scan to one
/// `__table_id`; hiding the physical row-key prefix must not claim a visible tag or time as
/// order or hash partition columns.
#[test]
fn test_metric_projection_claims_only_projected_row_key_prefix() {
let mut builder = RegionMetadataBuilder::new(RegionId::new(1234, 5691));
for (name, column_id) in [
(
DATA_SCHEMA_TABLE_ID_COLUMN_NAME,
ReservedColumnId::table_id(),
),
(DATA_SCHEMA_TSID_COLUMN_NAME, ReservedColumnId::tsid()),
("A", 1),
("marker", 2),
("ts", 3),
] {
builder.push_column_metadata(ColumnMetadata {
column_schema: column_schema_of(name),
semantic_type: if name == "ts" {
SemanticType::Timestamp
} else {
SemanticType::Tag
},
column_id,
});
}
// The physical row key order: `__table_id`, `__tsid`, then the tag columns.
builder.primary_key(vec![
ReservedColumnId::table_id(),
ReservedColumnId::tsid(),
1,
2,
]);
let metadata = Arc::new(builder.build().unwrap());
for (projection, partitioned_by_tsid, claimed, unclaimed) in [
// The logical projection drops the row key prefix, so `A` is not a prefix of the
// row key and neither `A` nor `ts` is ordered.
(
vec!["A", "marker", "ts"],
false,
vec![],
vec![vec!["A"], vec!["marker"], vec!["ts"], vec!["A", "ts"]],
),
// Projecting internal `__tsid` plus the single-table filter preserves metric TSID properties.
(
vec![DATA_SCHEMA_TSID_COLUMN_NAME, "A", "marker", "ts"],
true,
vec![
vec![DATA_SCHEMA_TSID_COLUMN_NAME],
vec![DATA_SCHEMA_TSID_COLUMN_NAME, "ts"],
],
vec![vec!["A"], vec!["ts"], vec!["A", "ts"]],
),
] {
let schema = Arc::new(Schema::new(
projection
.iter()
.map(|name| column_schema_of(name))
.collect(),
));
let mut properties = ScannerProperties::new(vec![vec![], vec![]], false, 0);
properties.set_logical_region(true);
let scanner = Box::new(RepeatableScanner {
batches: RecordBatches::try_new(schema, vec![]).unwrap(),
properties,
metadata: metadata.clone(),
dynamic_filters: Arc::new(Mutex::new(Vec::new())),
});
let plan = RegionScanExec::new(
scanner,
ScanRequest {
distribution: Some(TimeSeriesDistribution::PerSeries),
filters: if partitioned_by_tsid {
vec![
datafusion_expr::col(DATA_SCHEMA_TABLE_ID_COLUMN_NAME)
.eq(datafusion_expr::lit(42u32)),
]
} else {
vec![]
},
..Default::default()
},
None,
)
.unwrap();
let expected_partitioning = if partitioned_by_tsid {
Partitioning::Hash(
vec![Arc::new(
Column::new_with_schema(DATA_SCHEMA_TSID_COLUMN_NAME, &plan.arrow_schema)
.unwrap(),
) as _],
2,
)
} else {
Partitioning::UnknownPartitioning(2)
};
match (&plan.properties.partitioning, &expected_partitioning) {
(
Partitioning::UnknownPartitioning(actual_count),
Partitioning::UnknownPartitioning(expected_count),
) => assert_eq!(actual_count, expected_count, "projection {projection:?}"),
(actual, expected) => assert_eq!(actual, expected, "projection {projection:?}"),
}
for ordering in claimed {
assert!(
claims_ordering(&plan, &ordering),
"projection {projection:?} must claim ordering {ordering:?}"
);
}
for ordering in unclaimed {
assert!(
!claims_ordering(&plan, &ordering),
"projection {projection:?} must not claim ordering {ordering:?}"
);
}
}
}
#[test]
fn test_count_statistics_require_exact_source_rows() {
for (append_mode, exact, expected) in [