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
@@ -258,6 +258,295 @@ WHERE greptime_timestamp = (
);
}
#[apply(both_instances_cases)]
async fn test_metric_logical_scan_tag_order(instance: Arc<dyn MockInstance>) {
let frontend = instance.frontend();
execute_sql(
&frontend,
r#"CREATE TABLE f04_scan_phy (
host STRING PRIMARY KEY,
val DOUBLE,
ts TIMESTAMP TIME INDEX
) ENGINE = metric WITH (physical_metric_table = 'true')"#,
)
.await;
execute_sql(
&frontend,
r#"CREATE TABLE f04_scan_logical (
host STRING PRIMARY KEY,
val DOUBLE,
ts TIMESTAMP TIME INDEX
) ENGINE = metric WITH (on_physical_table = 'f04_scan_phy')"#,
)
.await;
execute_sql(
&frontend,
"INSERT INTO f04_scan_logical (host, val, ts) VALUES \
('a', 1, 0), ('b', 2, 0), ('c', 3, 0), ('d', 4, 0), \
('e', 5, 0), ('f', 6, 0), ('g', 7, 0), ('h', 8, 0)",
)
.await;
// The ordered rows are compared exactly as printed. Sorting them before
// comparing (or comparing them unordered) would accept a scan that returns
// the physical table's tag order instead of the requested `host` order.
async fn ordered_rows(instance: &Arc<Instance>, sql: &str, parallelism: usize) -> String {
execute_sql_with_query_parallelism(instance, sql, parallelism)
.await
.data
.pretty_print()
.await
}
let all_hosts = "\
+------+---------------------+-----+
| host | ts | val |
+------+---------------------+-----+
| a | 1970-01-01T00:00:00 | 1.0 |
| b | 1970-01-01T00:00:00 | 2.0 |
| c | 1970-01-01T00:00:00 | 3.0 |
| d | 1970-01-01T00:00:00 | 4.0 |
| e | 1970-01-01T00:00:00 | 5.0 |
| f | 1970-01-01T00:00:00 | 6.0 |
| g | 1970-01-01T00:00:00 | 7.0 |
| h | 1970-01-01T00:00:00 | 8.0 |
+------+---------------------+-----+";
let first_three_hosts = "\
+------+---------------------+-----+
| host | ts | val |
+------+---------------------+-----+
| a | 1970-01-01T00:00:00 | 1.0 |
| b | 1970-01-01T00:00:00 | 2.0 |
| c | 1970-01-01T00:00:00 | 3.0 |
+------+---------------------+-----+";
// `host` is NOT NULL, so `NULLS FIRST` and the SQL default `NULLS LAST` return the same
// rows. The `NULLS FIRST` queries spell out the ordering the scan used to claim, so the
// optimizer may drop the sort and expose a wrong tag order; the `NULLS LAST` queries
// keep the sort. Both are replayed in memory and after flushing the physical table.
let mut mismatches = Vec::new();
for flushed in [false, true] {
if flushed {
execute_sql(&frontend, "ADMIN FLUSH_TABLE('f04_scan_phy')").await;
}
for parallelism in [1, 4] {
for (query, expected) in [
(
"SELECT host, ts, val FROM f04_scan_logical ORDER BY host NULLS FIRST",
all_hosts,
),
(
"SELECT host, ts, val FROM f04_scan_logical ORDER BY host NULLS FIRST LIMIT 3",
first_three_hosts,
),
(
"SELECT host, ts, val FROM f04_scan_logical ORDER BY host NULLS LAST",
all_hosts,
),
(
"SELECT host, ts, val FROM f04_scan_logical ORDER BY host NULLS LAST LIMIT 3",
first_three_hosts,
),
] {
let actual = ordered_rows(&frontend, query, parallelism).await;
if actual.trim() != expected.trim() {
mismatches.push(format!(
"query `{query}` with query_parallelism={parallelism} flushed={flushed} \
returned:\n{actual}"
));
}
}
}
}
assert!(
mismatches.is_empty(),
"logical metric table rows are not ordered by host, expected `a`..`h`:\n{}",
mismatches.join("\n---\n")
);
}
/// The uppercase tag `"A"` sorts before the physical `__table_id`/`__tsid` row key prefix
/// by name, but it is not a prefix of the row key. A scan that claims it as ordering or
/// partitioning lets the optimizer skip the sort or the repartition the query needs, so the
/// per-host count comes back split across scan partitions in physical tsid order.
#[apply(both_instances_cases)]
async fn test_metric_logical_scan_uppercase_tag_order(instance: Arc<dyn MockInstance>) {
let frontend = instance.frontend();
execute_sql(
&frontend,
r#"CREATE TABLE f04_upper_phy (
"A" STRING,
marker STRING,
val DOUBLE,
ts TIMESTAMP TIME INDEX,
PRIMARY KEY ("A", marker)
) ENGINE = metric WITH (physical_metric_table = 'true')"#,
)
.await;
execute_sql(
&frontend,
r#"CREATE TABLE f04_upper_logical (
"A" STRING,
marker STRING,
val DOUBLE,
ts TIMESTAMP TIME INDEX,
PRIMARY KEY ("A", marker)
) ENGINE = metric WITH (on_physical_table = 'f04_upper_phy')"#,
)
.await;
// 8 hosts x 4 markers x 2 timestamps = 64 rows. Every host spans four series, so a count
// computed per scan partition would return several rows per host with partial counts.
let mut rows = Vec::with_capacity(64);
for host in 0..8 {
for marker in ["a", "b", "c", "d"] {
for ts in [1000, 2000] {
rows.push(format!("('h{host}', '{marker}', {host}, {ts})"));
}
}
}
execute_sql(
&frontend,
&format!(
"INSERT INTO f04_upper_logical (\"A\", marker, val, ts) VALUES {}",
rows.join(", ")
),
)
.await;
let expected = "\
+----+---+
| A | n |
+----+---+
| h0 | 8 |
| h1 | 8 |
| h2 | 8 |
| h3 | 8 |
| h4 | 8 |
| h5 | 8 |
| h6 | 8 |
| h7 | 8 |
+----+---+";
// The rows are compared exactly as printed: sorting them before comparing would accept
// a wrong count or a wrong row order. `NULLS FIRST` spells out the ordering the scan
// used to claim, so the optimizer may drop the sort; the default `NULLS LAST` keeps it
// and a failure there can only come from a wrong partitioning claim. Both orders are
// replayed in memory and after flushing the physical table.
let queries = [
"SELECT \"A\", count(*) AS n FROM f04_upper_logical GROUP BY \"A\" ORDER BY \"A\"",
"SELECT \"A\", count(*) AS n FROM f04_upper_logical GROUP BY \"A\" ORDER BY \"A\" NULLS FIRST",
];
let mut mismatches = Vec::new();
for flushed in [false, true] {
if flushed {
execute_sql(&frontend, "ADMIN FLUSH_TABLE('f04_upper_phy')").await;
}
for parallelism in [1, 4] {
for query in queries {
let actual = execute_sql_with_query_parallelism(&frontend, query, parallelism)
.await
.data
.pretty_print()
.await;
if actual.trim() != expected.trim() {
mismatches.push(format!(
"query `{query}` with query_parallelism={parallelism} flushed={flushed} \
returned:\n{actual}"
));
}
}
}
}
assert!(
mismatches.is_empty(),
"logical metric table must return one row of eight per host in `A` order:\n{}",
mismatches.join("\n---\n")
);
}
/// The small shape of the same problem: four rows and two groups. Each host has two series,
/// so the count must not be computed per scan partition, and `ORDER BY "A"` pins the exact
/// row order at query_parallelism 4 as well as at 1.
#[apply(both_instances_cases)]
async fn test_metric_logical_scan_small_group_count(instance: Arc<dyn MockInstance>) {
let frontend = instance.frontend();
execute_sql(
&frontend,
r#"CREATE TABLE f04_upper_min_phy (
"A" STRING,
marker STRING,
val DOUBLE,
ts TIMESTAMP TIME INDEX,
PRIMARY KEY ("A", marker)
) ENGINE = metric WITH (physical_metric_table = 'true')"#,
)
.await;
execute_sql(
&frontend,
r#"CREATE TABLE f04_upper_min_logical (
"A" STRING,
marker STRING,
val DOUBLE,
ts TIMESTAMP TIME INDEX,
PRIMARY KEY ("A", marker)
) ENGINE = metric WITH (on_physical_table = 'f04_upper_min_phy')"#,
)
.await;
execute_sql(
&frontend,
"INSERT INTO f04_upper_min_logical (\"A\", marker, val, ts) VALUES \
('h0', 'a', 1, 1000), ('h0', 'b', 2, 1000), \
('h1', 'a', 3, 1000), ('h1', 'b', 4, 1000)",
)
.await;
let expected = "\
+----+---+
| A | n |
+----+---+
| h0 | 2 |
| h1 | 2 |
+----+---+";
// `NULLS FIRST` spells out the ordering the scan used to claim and is replayed next to
// the default `NULLS LAST` order, in memory and after flushing the physical table.
let queries = [
"SELECT \"A\", count(*) AS n FROM f04_upper_min_logical GROUP BY \"A\" ORDER BY \"A\"",
"SELECT \"A\", count(*) AS n FROM f04_upper_min_logical GROUP BY \"A\" ORDER BY \"A\" NULLS FIRST",
];
let mut mismatches = Vec::new();
for flushed in [false, true] {
if flushed {
execute_sql(&frontend, "ADMIN FLUSH_TABLE('f04_upper_min_phy')").await;
}
for parallelism in [1, 4] {
for query in queries {
let actual = execute_sql_with_query_parallelism(&frontend, query, parallelism)
.await
.data
.pretty_print()
.await;
if actual.trim() != expected.trim() {
mismatches.push(format!(
"query `{query}` with query_parallelism={parallelism} flushed={flushed} \
returned:\n{actual}"
));
}
}
}
}
assert!(
mismatches.is_empty(),
"small logical metric grouping must return [h0, 2] and [h1, 2]:\n{}",
mismatches.join("\n---\n")
);
}
#[apply(both_instances_cases)]
async fn test_partitioned_merge_scan_left_join_repartitions(instance: Arc<dyn MockInstance>) {
let frontend = instance.frontend();