mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-12 06:52:34 +00:00
feat(perf): add nullable_wave distribution and nullable range-fn case
Add a nullable_wave value distribution to the direct-SST fixture
generator: every null_every-th sample of each series is NULL, counted
per series so every series carries the same null ratio on every layout.
New case tests/perf/query_cases/promql_nullable_range_fn: 128 series x
780 timestamps at 15s cadence, nullable_table (25% NULL, null_every=4)
and dense_table (identical, 0 NULL) as control. Seven TQL queries cover
rate/increase/delta and count/last_over_time at [5m], rate at [1m],
and a dense control query. Every window of every series takes the
nullable branch (no window is entirely NULL or NULL-free).
Regression evidence for #9234. The case is selectable by explicit path;
not added to DEFAULT_CASES.
Review: .e-agent/greptimedb-query-optimization-review-2026-09-17.md §4.4.
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
(cherry picked from commit 5a73f6f21f)
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
@@ -548,6 +548,19 @@ pub(super) enum Distribution {
|
||||
},
|
||||
#[serde(rename = "deterministic_wave")]
|
||||
DeterministicWave { min: f64, max: f64 },
|
||||
/// Wave values with periodic NULLs: every `null_every`-th sample of each
|
||||
/// series is NULL, and `0` writes no NULLs.
|
||||
///
|
||||
/// The cadence is counted in samples of the series rather than in generated
|
||||
/// rows, so every series carries the same null ratio on every layout; a row
|
||||
/// cadence stays constant per series whenever `series_count` is a multiple of
|
||||
/// `null_every`.
|
||||
#[serde(rename = "nullable_wave")]
|
||||
NullableWave {
|
||||
min: f64,
|
||||
max: f64,
|
||||
null_every: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
|
||||
@@ -214,6 +214,27 @@ fn wave_value(min: f64, max: f64, row: usize) -> f64 {
|
||||
min + span * (0.5 - 0.5 * (std::f64::consts::TAU * phase).cos())
|
||||
}
|
||||
|
||||
/// Position of the generated row inside its own series sample sequence.
|
||||
///
|
||||
/// [Distribution::NullableWave] counts its null cadence in these positions so that
|
||||
/// the null pattern is deterministic per series instead of per generated row.
|
||||
fn series_sample_index(
|
||||
layout: &LayoutConfig,
|
||||
sst_idx: usize,
|
||||
base_row: usize,
|
||||
row: usize,
|
||||
) -> usize {
|
||||
let series_count = layout.series_count.get();
|
||||
match layout.series_layout.as_str() {
|
||||
// Rows of one series are `series_count` generated rows apart.
|
||||
"round_robin" | "timestamp_major" => (base_row + row) / series_count,
|
||||
// One series per SST, repeated across SSTs when there are more SSTs than
|
||||
// series.
|
||||
"per_sst" => (sst_idx / series_count) * layout.rows_per_sst + row,
|
||||
other => panic!("unsupported series_layout {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_record_batch(
|
||||
table: &TableConfig,
|
||||
metadata: &RegionMetadataRef,
|
||||
@@ -237,17 +258,33 @@ fn generate_record_batch(
|
||||
}
|
||||
columns.push(Arc::new(b.finish()) as ArrayRef);
|
||||
}
|
||||
("field", "DOUBLE") => {
|
||||
let (min, max) = match col.distribution.as_ref() {
|
||||
Some(Distribution::DeterministicWave { min, max }) => (*min, *max),
|
||||
_ => (0.0, 1.0),
|
||||
};
|
||||
columns.push(Arc::new(Float64Array::from(
|
||||
(0..rows)
|
||||
.map(|r| wave_value(min, max, base_row + r))
|
||||
.collect::<Vec<_>>(),
|
||||
)) as ArrayRef);
|
||||
}
|
||||
("field", "DOUBLE") => match col.distribution.as_ref() {
|
||||
Some(Distribution::NullableWave {
|
||||
min,
|
||||
max,
|
||||
null_every,
|
||||
}) => {
|
||||
columns.push(Arc::new(Float64Array::from_iter((0..rows).map(|r| {
|
||||
let sample_index = series_sample_index(layout, sst_idx, base_row, r);
|
||||
if *null_every > 0 && sample_index.is_multiple_of(*null_every) {
|
||||
None
|
||||
} else {
|
||||
Some(wave_value(*min, *max, base_row + r))
|
||||
}
|
||||
}))) as ArrayRef);
|
||||
}
|
||||
distribution => {
|
||||
let (min, max) = match distribution {
|
||||
Some(Distribution::DeterministicWave { min, max }) => (*min, *max),
|
||||
_ => (0.0, 1.0),
|
||||
};
|
||||
columns.push(Arc::new(Float64Array::from(
|
||||
(0..rows)
|
||||
.map(|r| wave_value(min, max, base_row + r))
|
||||
.collect::<Vec<_>>(),
|
||||
)) as ArrayRef);
|
||||
}
|
||||
},
|
||||
("field", "UINT64") => columns.push(Arc::new(UInt64Array::from(
|
||||
(0..rows).map(|r| (base_row + r) as u64).collect::<Vec<_>>(),
|
||||
)) as ArrayRef),
|
||||
@@ -736,4 +773,234 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nullable range-function case must write NULL on every fourth sample of
|
||||
/// every series and wave values elsewhere, while its dense control table stays
|
||||
/// NULL-free.
|
||||
#[test]
|
||||
fn nullable_wave_nulls_one_sample_in_every_four_per_series() {
|
||||
let case: CaseFile = toml::from_str(include_str!(
|
||||
"../../../../../tests/perf/query_cases/promql_nullable_range_fn/case.toml"
|
||||
))
|
||||
.expect("nullable range-function case should parse");
|
||||
let scenario = match &case.scenario {
|
||||
Scenario::DirectReadableSst(scenario) => scenario,
|
||||
_ => unreachable!("nullable range-function case is a direct SST case"),
|
||||
};
|
||||
assert_eq!(2, scenario.tables.len());
|
||||
let sst_idx = 3;
|
||||
let sequence = 1003;
|
||||
let base_row = sst_idx * scenario.layout.rows_per_sst;
|
||||
let series_count = scenario.layout.series_count.get();
|
||||
let samples_per_series = scenario.layout.rows_per_sst / series_count;
|
||||
for table in &scenario.tables {
|
||||
let metadata = Arc::new(build_region_metadata(table, RegionId::from(43)));
|
||||
let batch =
|
||||
generate_record_batch(table, &metadata, &scenario.layout, sst_idx, sequence);
|
||||
let value = table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.name == "value")
|
||||
.expect("fixture case has a value field");
|
||||
let (min, max, null_every) = match value.distribution.as_ref() {
|
||||
Some(Distribution::NullableWave {
|
||||
min,
|
||||
max,
|
||||
null_every,
|
||||
}) => (*min, *max, *null_every),
|
||||
Some(Distribution::DeterministicWave { min, max }) => (*min, *max, 0),
|
||||
other => panic!("unexpected value distribution in {}: {other:?}", table.name),
|
||||
};
|
||||
let host = table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.name == "host")
|
||||
.expect("fixture case has a host tag");
|
||||
let instance = table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.name == "instance")
|
||||
.expect("fixture case has an instance tag");
|
||||
|
||||
let mut expected = (0..scenario.layout.rows_per_sst)
|
||||
.map(|row| {
|
||||
let series = series_for_row(&scenario.layout, sst_idx, base_row, row);
|
||||
let mut tags = HashMap::new();
|
||||
tags.insert(host.name.clone(), tag_value(host, series));
|
||||
tags.insert(instance.name.clone(), tag_value(instance, series));
|
||||
let is_null = null_every > 0
|
||||
&& series_sample_index(&scenario.layout, sst_idx, base_row, row)
|
||||
.is_multiple_of(null_every);
|
||||
(
|
||||
encode_dense_primary_key(table, &tags),
|
||||
timestamp_for_row(&scenario.layout, base_row, row),
|
||||
sequence,
|
||||
tag_value(host, series),
|
||||
tag_value(instance, series),
|
||||
(!is_null).then(|| wave_value(min, max, base_row + row)),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort_by(|left, right| {
|
||||
left.0
|
||||
.cmp(&right.0)
|
||||
.then_with(|| left.1.cmp(&right.1))
|
||||
.then_with(|| right.2.cmp(&left.2))
|
||||
});
|
||||
|
||||
let timestamps = batch
|
||||
.column(batch.num_columns() - 4)
|
||||
.as_any()
|
||||
.downcast_ref::<TimestampNanosecondArray>()
|
||||
.expect("nanosecond fixture timestamp")
|
||||
.values()
|
||||
.to_vec();
|
||||
let sequences = batch
|
||||
.column(batch.num_columns() - 2)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.expect("fixture sequence column")
|
||||
.values()
|
||||
.to_vec();
|
||||
let hosts = string_values(&batch, 0);
|
||||
let instances = string_values(&batch, 1);
|
||||
let values = batch
|
||||
.column(2)
|
||||
.as_any()
|
||||
.downcast_ref::<Float64Array>()
|
||||
.expect("fixture value column")
|
||||
.iter()
|
||||
.collect::<Vec<Option<f64>>>();
|
||||
let actual = encoded_primary_keys(&batch)
|
||||
.into_iter()
|
||||
.zip(timestamps)
|
||||
.zip(sequences)
|
||||
.zip(hosts)
|
||||
.zip(instances)
|
||||
.zip(values.iter().cloned())
|
||||
.map(
|
||||
|(((((primary_key, timestamp), sequence), host), instance), value)| {
|
||||
(primary_key, timestamp, sequence, host, instance, value)
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(expected, actual, "{}", table.name);
|
||||
|
||||
// Sorted by primary key, every series owns `samples_per_series`
|
||||
// consecutive rows ordered by timestamp, so its NULLs must sit on a
|
||||
// single residue class of its own sample positions.
|
||||
assert_eq!(series_count, values.len() / samples_per_series);
|
||||
for (chunk_index, chunk) in values.chunks(samples_per_series).enumerate() {
|
||||
if null_every == 0 {
|
||||
assert!(
|
||||
chunk.iter().all(Option::is_some),
|
||||
"{} series chunk {chunk_index} must stay dense",
|
||||
table.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let residue = chunk.iter().position(Option::is_none).unwrap_or_else(|| {
|
||||
panic!("{} series chunk {chunk_index} has no NULL", table.name)
|
||||
});
|
||||
assert_eq!(
|
||||
samples_per_series / null_every,
|
||||
chunk.iter().filter(|value| value.is_none()).count(),
|
||||
"{} series chunk {chunk_index} NULL count",
|
||||
table.name
|
||||
);
|
||||
for (position, value) in chunk.iter().enumerate() {
|
||||
assert_eq!(
|
||||
value.is_none(),
|
||||
position % null_every == residue,
|
||||
"{} series chunk {chunk_index} sample {position}",
|
||||
table.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The case's window arithmetic counts the NULL slots of every window, so the
|
||||
/// cadence must also stay on one residue class of a series across SSTs, not
|
||||
/// only inside a single SST.
|
||||
#[test]
|
||||
fn nullable_wave_keeps_one_null_residue_per_series_across_ssts() {
|
||||
let case: CaseFile = toml::from_str(include_str!(
|
||||
"../../../../../tests/perf/query_cases/promql_nullable_range_fn/case.toml"
|
||||
))
|
||||
.expect("nullable range-function case should parse");
|
||||
let scenario = match &case.scenario {
|
||||
Scenario::DirectReadableSst(scenario) => scenario,
|
||||
_ => unreachable!("nullable range-function case is a direct SST case"),
|
||||
};
|
||||
let table = scenario
|
||||
.tables
|
||||
.iter()
|
||||
.find(|table| table.name == "nullable_table")
|
||||
.expect("nullable range-function case has a nullable_table");
|
||||
let value = table
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| column.name == "value")
|
||||
.expect("fixture case has a value field");
|
||||
let null_every = match value.distribution.as_ref() {
|
||||
Some(Distribution::NullableWave { null_every, .. }) => *null_every,
|
||||
other => panic!("nullable_table must use nullable_wave, found {other:?}"),
|
||||
};
|
||||
// Without aligned SST boundaries the per-series cadence would shift by one
|
||||
// slot per SST, and the expected window counts documented in the case would
|
||||
// stop holding.
|
||||
let samples_per_series = scenario.layout.rows_per_sst / scenario.layout.series_count.get();
|
||||
assert_eq!(0, samples_per_series % null_every);
|
||||
|
||||
let metadata = Arc::new(build_region_metadata(table, RegionId::from(43)));
|
||||
let mut null_positions: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for sst_idx in 0..scenario.layout.sst_count {
|
||||
let batch = generate_record_batch(
|
||||
table,
|
||||
&metadata,
|
||||
&scenario.layout,
|
||||
sst_idx,
|
||||
1003 + sst_idx as u64,
|
||||
);
|
||||
let values = batch
|
||||
.column(2)
|
||||
.as_any()
|
||||
.downcast_ref::<Float64Array>()
|
||||
.expect("fixture value column")
|
||||
.iter()
|
||||
.collect::<Vec<Option<f64>>>();
|
||||
let hosts = string_values(&batch, 0);
|
||||
let instances = string_values(&batch, 1);
|
||||
for (chunk_index, chunk) in values.chunks(samples_per_series).enumerate() {
|
||||
let series = (
|
||||
hosts[chunk_index * samples_per_series].clone(),
|
||||
instances[chunk_index * samples_per_series].clone(),
|
||||
);
|
||||
let positions = null_positions.entry(series).or_default();
|
||||
for (position, value) in chunk.iter().enumerate() {
|
||||
if value.is_none() {
|
||||
positions.push(sst_idx * samples_per_series + position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
scenario.layout.series_count.get(),
|
||||
null_positions.len(),
|
||||
"every series must be represented"
|
||||
);
|
||||
let expected_nulls = scenario.layout.sst_count * samples_per_series / null_every;
|
||||
for (series, positions) in &null_positions {
|
||||
assert_eq!(expected_nulls, positions.len(), "{series:?} null count");
|
||||
assert!(
|
||||
positions
|
||||
.iter()
|
||||
.all(|position| position % null_every == positions[0] % null_every),
|
||||
"{series:?} nulls leave their residue class: {:?}",
|
||||
&positions[..positions.len().min(6)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,34 @@ warmup = 0
|
||||
iterations = 1
|
||||
```
|
||||
|
||||
### Value distributions
|
||||
|
||||
Tag columns use `cardinality` and produce one label per series. Double field
|
||||
columns accept either a NULL-free wave:
|
||||
|
||||
```toml
|
||||
distribution = { kind = "deterministic_wave", min = 0.0, max = 100.0 }
|
||||
```
|
||||
|
||||
or a wave with periodic NULLs:
|
||||
|
||||
```toml
|
||||
distribution = { kind = "nullable_wave", min = 0.0, max = 100.0, null_every = 4 }
|
||||
```
|
||||
|
||||
`nullable_wave` also accepts `null_every = 0` to disable NULLs. It writes NULL on
|
||||
every `null_every`-th sample of each series and wave values everywhere else, so a
|
||||
NULL is one missing sample of one series rather than a missing row. The cadence
|
||||
is counted in samples of the series, not in generated rows, so every series
|
||||
carries the same null ratio under any `series_layout`; when one SST holds a
|
||||
multiple of `null_every` samples per series (`rows_per_sst / series_count`), the
|
||||
pattern also repeats with the same period in every SST. Counting the cadence in
|
||||
generated rows instead would make it constant per series whenever `series_count`
|
||||
is a multiple of `null_every` (128 and 4, for example), leaving series either
|
||||
entirely NULL or entirely NULL-free. Counting per series keeps NULL and non-NULL
|
||||
samples mixed inside range-function windows, which is what the nullable branches
|
||||
of the PromQL range functions observe.
|
||||
|
||||
### Query kinds
|
||||
|
||||
`kind = "prom_http"` runs a Prometheus range query by POSTing form fields to
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# PromQL nullable range-function benchmark: regression evidence for the indexed
|
||||
# valid-sample lookup in nullable range functions (#9234).
|
||||
#
|
||||
# `rate`/`increase`/`delta` and the presence evaluator behind
|
||||
# `count_over_time`/`last_over_time` fall back to a per-window validity scan once
|
||||
# any sample of a series is NULL. `nullable_table` writes NULL on every fourth
|
||||
# sample of every series, so 25% of its samples are missing (one missing sample
|
||||
# per minute at the 15s cadence) and every window of every series contains both
|
||||
# NULL and non-NULL samples instead of being entirely NULL or entirely dense.
|
||||
# `dense_table` is the same fixture without NULLs; it keeps the no-NULL fast path
|
||||
# measured next to the nullable path.
|
||||
#
|
||||
# 128 series x 780 timestamps at 15s cadence = 99,840 rows per table, written as
|
||||
# 13 SSTs x 7,680 rows. Data starts one hour before evaluation; 481 evaluations
|
||||
# span two hours. Range windows are left-open and every series is dense on the
|
||||
# 15s grid, so a fully covered 5m window holds 20 slots and a 1m window holds 4.
|
||||
# Five of the 20 slots and one of the 4 slots are NULL, which makes
|
||||
# `count_over_time(nullable_table[5m])` return exactly 15 and
|
||||
# `count_over_time(nullable_table[1m])` return exactly 3 at every one of the 481
|
||||
# evaluations: no window is entirely NULL and none is NULL-free, so every window
|
||||
# of every series takes the nullable branch.
|
||||
|
||||
[case]
|
||||
name = "promql_nullable_range_fn"
|
||||
description = "PromQL range functions over partially NULL series at 15-second scrape and evaluation cadence"
|
||||
|
||||
[scenario]
|
||||
kind = "direct_readable_sst"
|
||||
seed = 9234
|
||||
|
||||
[[scenario.tables]]
|
||||
database = "public"
|
||||
name = "nullable_table"
|
||||
engine = "mito"
|
||||
append_mode = true
|
||||
sst_format = "flat"
|
||||
primary_key = ["host", "instance"]
|
||||
time_index = "ts"
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "host"
|
||||
type = "STRING"
|
||||
semantic = "tag"
|
||||
distribution = { kind = "cardinality", values = 16, prefix = "host" }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "instance"
|
||||
type = "STRING"
|
||||
semantic = "tag"
|
||||
distribution = { kind = "cardinality", values = 128, prefix = "instance" }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "value"
|
||||
type = "DOUBLE"
|
||||
semantic = "field"
|
||||
distribution = { kind = "nullable_wave", min = 0.0, max = 1000.0, null_every = 4 }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "ts"
|
||||
type = "TIMESTAMP(9)"
|
||||
semantic = "timestamp"
|
||||
|
||||
[[scenario.tables]]
|
||||
database = "public"
|
||||
name = "dense_table"
|
||||
engine = "mito"
|
||||
append_mode = true
|
||||
sst_format = "flat"
|
||||
primary_key = ["host", "instance"]
|
||||
time_index = "ts"
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "host"
|
||||
type = "STRING"
|
||||
semantic = "tag"
|
||||
distribution = { kind = "cardinality", values = 16, prefix = "host" }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "instance"
|
||||
type = "STRING"
|
||||
semantic = "tag"
|
||||
distribution = { kind = "cardinality", values = 128, prefix = "instance" }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "value"
|
||||
type = "DOUBLE"
|
||||
semantic = "field"
|
||||
distribution = { kind = "deterministic_wave", min = 0.0, max = 1000.0 }
|
||||
|
||||
[[scenario.tables.columns]]
|
||||
name = "ts"
|
||||
type = "TIMESTAMP(9)"
|
||||
semantic = "timestamp"
|
||||
|
||||
[scenario.layout]
|
||||
regions = 1
|
||||
sst_count = 13
|
||||
rows_per_sst = 7680
|
||||
row_group_size = 1920
|
||||
series_count = 128
|
||||
start_unix_nanos = 1704067200000000000
|
||||
step_nanos = 15000000000
|
||||
time_range_layout = "non_overlapping_per_sst"
|
||||
series_layout = "timestamp_major"
|
||||
|
||||
# Twenty slots per window, five of them NULL: the counter correction and the
|
||||
# valid-sample lookup both walk a mixed window on every series.
|
||||
[[scenario.queries]]
|
||||
name = "rate_nullable_5m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(nullable_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
[[scenario.queries]]
|
||||
name = "increase_nullable_5m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') increase(nullable_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
# `delta` shares the nullable window-bounds path but skips the counter correction.
|
||||
[[scenario.queries]]
|
||||
name = "delta_nullable_5m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') delta(nullable_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
# The presence evaluator counts valid slots per window instead of scanning bounds.
|
||||
[[scenario.queries]]
|
||||
name = "count_over_time_nullable_5m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') count_over_time(nullable_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
# `last_over_time` searches backwards for the last valid slot of every window,
|
||||
# which the NULL at the window end forces for one evaluation in every four.
|
||||
[[scenario.queries]]
|
||||
name = "last_over_time_nullable_5m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') last_over_time(nullable_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
# Four slots per window, one of them NULL: fixed overhead and short-window control
|
||||
# for the same nullable path.
|
||||
[[scenario.queries]]
|
||||
name = "rate_nullable_1m"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(nullable_table{host=~'host.*'}[1m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
|
||||
# Identical shape and values without NULLs: the fast path must not move.
|
||||
[[scenario.queries]]
|
||||
name = "rate_dense_5m_control"
|
||||
kind = "tql"
|
||||
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') rate(dense_table{host=~'host.*'}[5m])"
|
||||
warmup = 3
|
||||
iterations = 9
|
||||
|
||||
[scenario.queries.thresholds]
|
||||
max_candidate_latency_regression_pct = 20
|
||||
Reference in New Issue
Block a user