|
30 | 30 | //! because batch-arrival timing affects how soon the TopK heap fills, |
31 | 31 | //! and we don't want this test to become flaky. |
32 | 32 |
|
| 33 | +use std::io::Write; |
33 | 34 | use std::sync::Arc; |
34 | 35 |
|
35 | 36 | use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray}; |
36 | 37 | use arrow_schema::{DataType, Field, Schema}; |
37 | 38 |
|
38 | | -use datafusion::prelude::SessionConfig; |
| 39 | +use datafusion::physical_plan::collect; |
| 40 | +use datafusion::prelude::{ParquetReadOptions, SessionConfig, SessionContext}; |
| 41 | +use datafusion_common::ScalarValue; |
| 42 | +use parquet::arrow::ArrowWriter; |
| 43 | +use parquet::file::metadata::{ParquetMetaData, ParquetMetaDataWriter}; |
| 44 | +use parquet::file::properties::{EnabledStatistics, WriterProperties}; |
| 45 | +use parquet::file::statistics::Statistics; |
| 46 | +use parquet::file::writer::TrackedWrite; |
| 47 | +use tempfile::NamedTempFile; |
39 | 48 |
|
40 | 49 | use crate::parquet::Unit::RowGroup; |
| 50 | +use crate::parquet::utils::MetricsFinder; |
41 | 51 | use crate::parquet::{ContextWithParquet, Scenario}; |
42 | 52 |
|
| 53 | +/// Keep min/max but omit the middle row group's null count. Negate the |
| 54 | +/// values for DESC so the first group always establishes the winning bound. |
| 55 | +fn file_with_missing_null_count(descending: bool, has_null: bool) -> NamedTempFile { |
| 56 | + let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, true)])); |
| 57 | + let props = WriterProperties::builder() |
| 58 | + .set_statistics_enabled(EnabledStatistics::Chunk) |
| 59 | + .build(); |
| 60 | + let mut bytes = Vec::new(); |
| 61 | + let mut writer = |
| 62 | + ArrowWriter::try_new(&mut bytes, schema.clone(), Some(props)).unwrap(); |
| 63 | + for values in [ |
| 64 | + vec![Some(0), Some(1), Some(2)], |
| 65 | + vec![ |
| 66 | + if has_null { None } else { Some(100) }, |
| 67 | + Some(100), |
| 68 | + Some(101), |
| 69 | + ], |
| 70 | + vec![Some(200), Some(201), Some(202)], |
| 71 | + ] { |
| 72 | + let values: Int64Array = values |
| 73 | + .into_iter() |
| 74 | + .map(|v| v.map(|v| if descending { -v } else { v })) |
| 75 | + .collect(); |
| 76 | + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(values)]).unwrap(); |
| 77 | + writer.write(&batch).unwrap(); |
| 78 | + writer.flush().unwrap(); |
| 79 | + } |
| 80 | + let metadata = writer.close().unwrap(); |
| 81 | + let mut groups = metadata.row_groups().to_vec(); |
| 82 | + if has_null { |
| 83 | + let column = groups[1].column(0).clone(); |
| 84 | + let Statistics::Int64(stats) = column.statistics().unwrap() else { |
| 85 | + panic!("expected Int64 statistics"); |
| 86 | + }; |
| 87 | + let stats = Statistics::int64( |
| 88 | + stats.min_opt().copied(), |
| 89 | + stats.max_opt().copied(), |
| 90 | + None, |
| 91 | + None, |
| 92 | + false, |
| 93 | + ); |
| 94 | + let column = column.into_builder().set_statistics(stats).build().unwrap(); |
| 95 | + groups[1] = groups[1] |
| 96 | + .clone() |
| 97 | + .into_builder() |
| 98 | + .set_column_metadata(vec![column]) |
| 99 | + .build() |
| 100 | + .unwrap(); |
| 101 | + } |
| 102 | + let metadata = ParquetMetaData::new(metadata.file_metadata().clone(), groups); |
| 103 | + |
| 104 | + // Replace the footer, leaving the encoded rows and their offsets intact. |
| 105 | + let footer_len = |
| 106 | + u32::from_le_bytes(bytes[bytes.len() - 8..bytes.len() - 4].try_into().unwrap()); |
| 107 | + bytes.truncate(bytes.len() - 8 - footer_len as usize); |
| 108 | + let mut file = tempfile::Builder::new() |
| 109 | + .suffix(".parquet") |
| 110 | + .tempfile() |
| 111 | + .unwrap(); |
| 112 | + let mut output = TrackedWrite::new(file.as_file_mut()); |
| 113 | + output.write_all(&bytes).unwrap(); |
| 114 | + ParquetMetaDataWriter::new_with_tracked(output, &metadata) |
| 115 | + .finish() |
| 116 | + .unwrap(); |
| 117 | + file |
| 118 | +} |
| 119 | + |
| 120 | +#[tokio::test] |
| 121 | +async fn missing_null_count_preserves_null_filter_and_count() { |
| 122 | + let file = file_with_missing_null_count(false, true); |
| 123 | + let ctx = SessionContext::new(); |
| 124 | + ctx.register_parquet( |
| 125 | + "t", |
| 126 | + file.path().to_str().unwrap(), |
| 127 | + ParquetReadOptions::default(), |
| 128 | + ) |
| 129 | + .await |
| 130 | + .unwrap(); |
| 131 | + for (sql, expected) in [ |
| 132 | + ("SELECT COUNT(v) FROM t", 8), |
| 133 | + ("SELECT COUNT(*) FROM t WHERE v IS NULL", 1), |
| 134 | + ] { |
| 135 | + let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap(); |
| 136 | + assert_eq!( |
| 137 | + ScalarValue::try_from_array(batches[0].column(0), 0).unwrap(), |
| 138 | + ScalarValue::Int64(Some(expected)), |
| 139 | + "{sql}", |
| 140 | + ); |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +#[tokio::test] |
| 145 | +async fn dynamic_rg_pruning_preserves_missing_null_count() { |
| 146 | + for descending in [false, true] { |
| 147 | + for has_null in [false, true] { |
| 148 | + let file = file_with_missing_null_count(descending, has_null); |
| 149 | + for nulls_first in [false, true] { |
| 150 | + for pushdown in [false, true] { |
| 151 | + let mut config = SessionConfig::new() |
| 152 | + .with_target_partitions(1) |
| 153 | + .with_batch_size(1) |
| 154 | + .with_parquet_page_index_pruning(false); |
| 155 | + // Keep the live filter on the row-group path: file-level |
| 156 | + // statistics can otherwise prune the entire remaining file. |
| 157 | + config.options_mut().execution.collect_statistics = false; |
| 158 | + config.options_mut().optimizer.enable_sort_pushdown = false; |
| 159 | + config |
| 160 | + .options_mut() |
| 161 | + .optimizer |
| 162 | + .enable_topk_dynamic_filter_pushdown = pushdown; |
| 163 | + config.options_mut().execution.parquet.pushdown_filters = false; |
| 164 | + let ctx = SessionContext::new_with_config(config); |
| 165 | + ctx.register_parquet( |
| 166 | + "t", |
| 167 | + file.path().to_str().unwrap(), |
| 168 | + ParquetReadOptions::default(), |
| 169 | + ) |
| 170 | + .await |
| 171 | + .unwrap(); |
| 172 | + let order = if descending { "DESC" } else { "ASC" }; |
| 173 | + let null_order = if nulls_first { "FIRST" } else { "LAST" }; |
| 174 | + let sql = format!( |
| 175 | + "SELECT v FROM t ORDER BY v {order} NULLS {null_order} LIMIT 1" |
| 176 | + ); |
| 177 | + let plan = ctx |
| 178 | + .sql(&sql) |
| 179 | + .await |
| 180 | + .unwrap() |
| 181 | + .create_physical_plan() |
| 182 | + .await |
| 183 | + .unwrap(); |
| 184 | + let batches = collect(plan.clone(), ctx.task_ctx()).await.unwrap(); |
| 185 | + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 1); |
| 186 | + let expected = if has_null && nulls_first { |
| 187 | + None |
| 188 | + } else { |
| 189 | + Some(0) |
| 190 | + }; |
| 191 | + assert_eq!( |
| 192 | + ScalarValue::try_from_array(batches[0].column(0), 0).unwrap(), |
| 193 | + ScalarValue::Int64(expected), |
| 194 | + "{sql}, has_null={has_null}, pushdown={pushdown}", |
| 195 | + ); |
| 196 | + let metrics = MetricsFinder::find_metrics(plan.as_ref()).unwrap(); |
| 197 | + let pruned = metrics |
| 198 | + .sum_by_name("row_groups_pruned_dynamic_filter") |
| 199 | + .unwrap() |
| 200 | + .as_usize(); |
| 201 | + if pushdown { |
| 202 | + assert!( |
| 203 | + pruned > 0, |
| 204 | + "runtime pruning must run: {sql}, has_null={has_null}\n{metrics}\n{}", |
| 205 | + datafusion::physical_plan::displayable(plan.as_ref()) |
| 206 | + .indent(true), |
| 207 | + ); |
| 208 | + } else { |
| 209 | + assert_eq!(pruned, 0); |
| 210 | + } |
| 211 | + } |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | +} |
| 216 | + |
43 | 217 | /// Build five `RecordBatch`es whose `v` column ranges are disjoint: |
44 | 218 | /// batch `i` carries `v` values `[i*100, (i+1)*100)`. When written with |
45 | 219 | /// `max_row_group_row_count = 100` each batch lands in its own row group. |
|
0 commit comments