Skip to content

Commit b5c6d72

Browse files
g-talbotclaude
andauthored
feat: enable page-level Parquet stats + add rg_partition_prefix_len marker (#6377)
* feat: enable page-level Parquet stats + add rg_partition_prefix_len marker Foundation for the streaming column-major merge engine workstream. Switches the writer's default from EnabledStatistics::Chunk to EnabledStatistics::Page so every newly-written file carries a Column Index and Offset Index in its footer. Without this, single-RG files produced by future PRs would have one min/max per file — useless for selective queries. The default is exposed as a knob (`ParquetWriterConfig::with_page_statistics`) so callers can opt out when the footer overhead isn't worth it. Adds a numeric marker `qh.rg_partition_prefix_len` in the file's KV metadata and a matching `rg_partition_prefix_len: u32` field on `ParquetSplitMetadata`. The marker records how many leading sort schema columns RG boundaries align with: 0 = no claim (legacy default), N = aligned with the first N sort columns. Single-RG files vacuously satisfy any prefix; future writers will set N = sort_schema.len(). Compaction scope now includes `rg_partition_prefix_len`. Splits with different prefix values land in different buckets; the merge engine validates input files agree on prefix and rejects mismatches at both the metastore-struct layer and the on-disk KV layer. Until the streaming engine lands, the merge writer demotes the output's prefix to 0 because it cannot enforce alignment. New developer tooling: - `quickwit_parquet_engine::storage::inspect_parquet_page_stats` library function returning a structured per-RG / per-column / per-page report, plus `verify_partition_prefix` for the strong-form alignment check. - `inspect_parquet` binary in the parquet-engine crate with `--json`, `--all-pages`, and `--verify-prefix` flags. Footer-size delta on a representative shape (100K rows × 6 cols): +19.5% (672 KB → 804 KB). The page index scales with column count, not data volume, so production-sized 50 MB splits show < 0.3% overhead. Test count: 367 → 382 (15 new). Clippy/doc/license/log/machete clean. * fix: preserve rg_partition_prefix_len on single-RG merge outputs Avoids a compaction-bucket leak that would otherwise appear once PR-3 ships single-RG ingest before PR-6 ships the streaming column-major merge engine. Previously, every merge unconditionally set the output's `rg_partition_prefix_len` to 0, even when the writer happened to produce a single-RG output that vacuously satisfies any alignment claim. With single-RG ingest active and merge demoting on every operation, post-PR-3 ingest splits would leak out of the `prefix = sort_len` bucket on their first merge and never rejoin it — newer ingests would not merge with merge outputs. New rule: predict the output's row group count via `num_rows.div_ceil(row_group_size)`. If ≤ 1 RG, propagate the inputs' prefix; otherwise demote to 0. Both the metastore split metadata (`merge_parquet_split_metadata`) and the file's KV metadata (`build_merge_kv_metadata`) follow the same rule, so they always agree about what's on disk. A `debug_assert!` checks that the prediction matches the actual row group count returned by `ArrowWriter::close()` — catches a future config change that adds a byte-based RG threshold and silently invalidates the KV claim. `MergeOutputFile` gains a `num_row_groups: usize` field so the metastore-side rule can be applied without re-parsing the file. Test changes: - Rename `test_output_prefix_len_demoted_to_zero` to `test_output_prefix_len_demoted_when_multi_rg`; pin the demotion to the `num_row_groups > 1` case. - New `test_output_prefix_len_preserved_when_single_rg` asserting the propagation case. - New `test_merge_demotes_prefix_when_output_is_multi_rg` exercising the real writer with `row_group_size = 2` and verifying the file's KV records 0 via the inspector. - Extend `test_merge_accepts_matching_rg_partition_prefix_len` to inspector-verify the single-RG output's KV preserves the prefix. Test count: 382 → 384. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: end-to-end page-index pruning through the metrics query path Gate-A verification before PR-3 (single-RG ingest cutover): proves that page-level statistics written by PR-1 are actually consumed by the production query path for pruning, not just embedded inertly in the footer. Findings: - The metrics read path at `MetricsParquetTableProvider::scan` already calls `ParquetSource::with_enable_page_index(true)`, so DataFusion loads the column index + offset index when reading. No new wiring needed on the reader side. - DataFusion's `PruningMetrics` (`page_index_rows_pruned`) counter on `DataSourceExec` is the testable signal — pruned > 0 means pages were eliminated using their min/max from the column index. The new integration test (`quickwit-datafusion/tests/metrics.rs::test_page_index_pruning_via_query`) builds a single split with two metric_names interleaved, forces the metric_name column into ~16 pages within one row group, runs `WHERE metric_name = 'cpu.usage'`, walks the executed plan, and asserts `page_index_rows_pruned >= 4096` (the rows from the *other* metric) plus correctness of the returned rows. Plumbing change: `ParquetWriterConfig::with_data_page_row_count_limit` exposes Parquet's per-page row count rollover threshold. The size-based `data_page_size` knob alone can't force multi-page output when dictionary-encoded columns RLE-compress to a handful of bytes regardless of row count. Default 0 = unbounded; production behavior unchanged. Tests: 14/14 metrics integration tests pass (was 13). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b68591e commit b5c6d72

15 files changed

Lines changed: 2136 additions & 27 deletions

File tree

quickwit/quickwit-datafusion/tests/metrics.rs

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ mod common;
3232
mod metrics_splits;
3333

3434
use common::{TestSandbox, create_metrics_index};
35-
use metrics_splits::{publish_split, publish_split_with_tag_metadata};
35+
use metrics_splits::{
36+
publish_split, publish_split_with_tag_metadata, publish_split_with_writer_config,
37+
};
3638

3739
// ── Setup ──────────────────────────────────────────────────────────
3840

@@ -99,6 +101,132 @@ async fn test_select_all() {
99101
assert_eq!(batches[0].num_columns(), 5);
100102
}
101103

104+
/// Gate-A end-to-end test: page-level pruning fires through the
105+
/// production read path.
106+
///
107+
/// PR-1 made the writer emit `EnabledStatistics::Page` (column index +
108+
/// offset index in the footer) and `MetricsParquetTableProvider::scan`
109+
/// already wires `ParquetSource::with_enable_page_index(true)`. Together
110+
/// they mean a query whose filter eliminates whole pages should report
111+
/// a non-zero `page_index_rows_pruned` metric on the `DataSourceExec`.
112+
///
113+
/// To make page-level pruning the *only* meaningful pruner, this test:
114+
/// - puts both metric_names in a single split (no split-level pruning)
115+
/// - forces a tiny `data_page_size` so each metric_name lives in its own pages within the same
116+
/// row group (so RG-level pruning can't fully resolve the filter; page-level must contribute)
117+
/// - asserts `page_index_rows_pruned`'s pruned count is greater than zero, and that the query
118+
/// still returns the right rows
119+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
120+
async fn test_page_index_pruning_via_query() {
121+
use datafusion::physical_plan::{collect, displayable};
122+
123+
let sandbox = start_sandbox().await;
124+
let metastore = sandbox.metastore.clone();
125+
let data_dir = &sandbox.data_dir;
126+
let builder = session_builder(&sandbox);
127+
128+
let index_uid = create_metrics_index(&metastore, "test-page-prune", data_dir.path()).await;
129+
130+
// Build a batch with both metric names. Rows are ordered by
131+
// metric_name (because the writer sorts by sort schema before
132+
// writing), so each metric ends up contiguous in the output —
133+
// exactly the layout that lets page-level stats prune the
134+
// unselected metric.
135+
let n_per = 4096_u32;
136+
let timestamps_a: Vec<u64> = (0..n_per).map(|i| 1_000 + i as u64).collect();
137+
let timestamps_b: Vec<u64> = (0..n_per).map(|i| 1_000 + (n_per + i) as u64).collect();
138+
let values_a: Vec<f64> = (0..n_per).map(|i| (i as f64) * 0.1).collect();
139+
let values_b: Vec<f64> = (0..n_per).map(|i| (i as f64) * 0.2).collect();
140+
let batch_a = make_batch("cpu.usage", &timestamps_a, &values_a, Some("web"));
141+
let batch_b = make_batch("memory.used", &timestamps_b, &values_b, Some("web"));
142+
let combined = arrow::compute::concat_batches(&batch_a.schema(), &[batch_a, batch_b])
143+
.expect("concat metric batches");
144+
145+
// Force the metric_name column into many pages. The size-based
146+
// `data_page_size` knob alone isn't enough because metric_name with
147+
// two distinct dictionary values RLE-compresses to a handful of
148+
// bytes regardless of row count. Use the row-count limit instead;
149+
// it's only checked at write-batch boundaries, so write_batch_size
150+
// must be at most the row-count limit to actually take effect.
151+
let writer_config = quickwit_parquet_engine::storage::ParquetWriterConfig::default()
152+
.with_data_page_row_count_limit(512)
153+
.with_write_batch_size(128);
154+
publish_split_with_writer_config(
155+
&metastore,
156+
&index_uid,
157+
data_dir.path(),
158+
"combined",
159+
&combined,
160+
writer_config,
161+
)
162+
.await;
163+
164+
let create_table = r#"
165+
CREATE OR REPLACE EXTERNAL TABLE "test-page-prune" (
166+
metric_name VARCHAR NOT NULL, metric_type TINYINT,
167+
timestamp_secs BIGINT NOT NULL, value DOUBLE NOT NULL, service VARCHAR
168+
) STORED AS metrics LOCATION 'test-page-prune'"#;
169+
let query = r#"SELECT value FROM "test-page-prune" WHERE metric_name = 'cpu.usage'"#;
170+
171+
let ctx = builder.build_session().unwrap();
172+
ctx.sql(create_table)
173+
.await
174+
.unwrap()
175+
.collect()
176+
.await
177+
.unwrap();
178+
179+
let df = ctx.sql(query).await.unwrap();
180+
let plan = df.create_physical_plan().await.unwrap();
181+
let result = collect(plan.clone(), ctx.task_ctx()).await.unwrap();
182+
let total_rows: usize = result.iter().map(|b| b.num_rows()).sum();
183+
184+
let pruned = collect_pruned_count(plan.as_ref(), "page_index_rows_pruned");
185+
186+
// The query keeps the 4096 cpu.usage rows; the other 4096
187+
// memory.used rows belong to pages whose min/max is "memory.used"
188+
// and are eliminable by page-level pruning. Asserting "≥ n_per"
189+
// catches the degenerate case where pruning does nothing AND any
190+
// future regression that loses pruning effectiveness.
191+
if pruned < n_per as usize {
192+
let plan_str = format!("{}", displayable(plan.as_ref()).indent(true));
193+
panic!(
194+
"expected page-level pruning to skip ≥ {n_per} rows; got {pruned}.\nPlan:\n{plan_str}",
195+
);
196+
}
197+
assert_eq!(
198+
total_rows, n_per as usize,
199+
"query should return exactly the cpu.usage rows; got {total_rows}"
200+
);
201+
}
202+
203+
/// Walk an executed `ExecutionPlan` tree and sum the `pruned` value of
204+
/// every `PruningMetrics` matching `metric_name`.
205+
fn collect_pruned_count(
206+
plan: &dyn datafusion::physical_plan::ExecutionPlan,
207+
metric_name: &str,
208+
) -> usize {
209+
use datafusion::physical_plan::metrics::MetricValue;
210+
211+
let mut total = 0;
212+
if let Some(metrics) = plan.metrics() {
213+
for metric in metrics.iter() {
214+
if let MetricValue::PruningMetrics {
215+
name,
216+
pruning_metrics,
217+
} = metric.value()
218+
&& name.as_ref() == metric_name
219+
{
220+
total += pruning_metrics.pruned();
221+
}
222+
}
223+
}
224+
for child in plan.children() {
225+
total += collect_pruned_count(child.as_ref(), metric_name);
226+
}
227+
total
228+
}
229+
102230
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
103231
async fn test_metric_name_pruning() {
104232
let sandbox = start_sandbox().await;

quickwit/quickwit-datafusion/tests/metrics_splits/mod.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,92 @@ pub async fn publish_split(
3838
split_name: &str,
3939
batch: &RecordBatch,
4040
) {
41-
publish_split_with_tag_metadata(metastore, index_uid, data_dir, split_name, batch, true).await;
41+
publish_split_with_options(
42+
metastore,
43+
index_uid,
44+
data_dir,
45+
split_name,
46+
batch,
47+
ParquetWriterConfig::default(),
48+
true,
49+
)
50+
.await;
4251
}
4352

53+
/// Same as [`publish_split`] but lets the caller suppress the
54+
/// low-cardinality tag metadata (`service`, `env`, `datacenter`,
55+
/// `region`, `host`). Tests use `false` to verify pruning paths that
56+
/// rely on writer-generated `zonemap_regexes` rather than exact tag
57+
/// sets.
58+
///
59+
/// Only referenced by `metrics.rs`; dead-code from the perspective of
60+
/// `null_columns.rs` / `distributed.rs`, both of which include this
61+
/// module via `mod metrics_splits;`.
62+
#[allow(dead_code)]
4463
pub(crate) async fn publish_split_with_tag_metadata(
4564
metastore: &MetastoreServiceClient,
4665
index_uid: &IndexUid,
4766
data_dir: &std::path::Path,
4867
split_name: &str,
4968
batch: &RecordBatch,
5069
include_low_cardinality_tags: bool,
70+
) {
71+
publish_split_with_options(
72+
metastore,
73+
index_uid,
74+
data_dir,
75+
split_name,
76+
batch,
77+
ParquetWriterConfig::default(),
78+
include_low_cardinality_tags,
79+
)
80+
.await;
81+
}
82+
83+
/// Same as [`publish_split`] but with a caller-supplied
84+
/// `ParquetWriterConfig`. Used by tests that need a specific on-disk
85+
/// layout — e.g., a small `data_page_row_count_limit` to force the
86+
/// metric_name column into multiple pages so page-level pruning has
87+
/// something to prune.
88+
///
89+
/// Only referenced by `metrics.rs`; dead-code from the perspective of
90+
/// `null_columns.rs` / `distributed.rs`.
91+
#[allow(dead_code)]
92+
pub async fn publish_split_with_writer_config(
93+
metastore: &MetastoreServiceClient,
94+
index_uid: &IndexUid,
95+
data_dir: &std::path::Path,
96+
split_name: &str,
97+
batch: &RecordBatch,
98+
writer_config: ParquetWriterConfig,
99+
) {
100+
publish_split_with_options(
101+
metastore,
102+
index_uid,
103+
data_dir,
104+
split_name,
105+
batch,
106+
writer_config,
107+
true,
108+
)
109+
.await;
110+
}
111+
112+
/// Inner helper combining both knobs. Kept private; the named
113+
/// entry points above (`publish_split`, `publish_split_with_tag_metadata`,
114+
/// `publish_split_with_writer_config`) cover the call patterns
115+
/// tests need.
116+
async fn publish_split_with_options(
117+
metastore: &MetastoreServiceClient,
118+
index_uid: &IndexUid,
119+
data_dir: &std::path::Path,
120+
split_name: &str,
121+
batch: &RecordBatch,
122+
writer_config: ParquetWriterConfig,
123+
include_low_cardinality_tags: bool,
51124
) {
52125
let (parquet_bytes, (row_keys_proto, zonemap_regexes)) =
53-
ParquetWriter::new(ParquetWriterConfig::default(), &TableConfig::default())
126+
ParquetWriter::new(writer_config, &TableConfig::default())
54127
.unwrap()
55128
.write_to_bytes(batch, None)
56129
.expect("parquet encode");

0 commit comments

Comments
 (0)