mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 23:06:59 +00:00
perf(mito2): prefilter single-column dynamic filters (#10340)
* perf(mito2): prefilter dynamic join membership Read the narrow dynamic-membership column before the full projection and use a stable per-row-group mask to select rows. The live filter above the scan stays authoritative; bounds-only and unsupported shapes keep all rows. Signed-off-by: discord9 <discord9@163.com> * test: cover dynamic prefilter cache and generations Prove the prefilter-result cache keeps the static mask but never reuses a dynamic mask across producer generations. Document why a superseded dynamic mask is discarded rather than assumed monotone. Signed-off-by: discord9 <discord9@163.com> --------- Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
@@ -5341,14 +5341,16 @@ async fn test_reset_state_discards_dynamic_field_pruning() {
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
// Dynamic pruning skips the first row group but does not filter individual
|
||||
// rows in the second. Drop before consuming the third row group.
|
||||
// Dynamic pruning skips the first row group, and the dynamic prefilter prunes the
|
||||
// row the producer rejects in the second. Only the row above the producer's
|
||||
// threshold is left; the live filter above the scan still owns the final result.
|
||||
// Drop before consuming the third row group.
|
||||
let mut stream = scanner
|
||||
.scan_partition(&Default::default(), &ExecutionPlanMetricsSet::default(), 0)
|
||||
.unwrap();
|
||||
let first_batch = stream.try_next().await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
vec![2.0, 3.0],
|
||||
vec![3.0],
|
||||
scan_field_values(std::slice::from_ref(&first_batch))
|
||||
);
|
||||
drop(stream);
|
||||
|
||||
@@ -24,6 +24,9 @@ use std::sync::Arc;
|
||||
|
||||
use api::v1::SemanticType;
|
||||
use common_recordbatch::filter::SimpleFilterEvaluator;
|
||||
use common_telemetry::debug;
|
||||
use datafusion::physical_expr::utils::collect_columns;
|
||||
use datafusion::physical_plan::PhysicalExpr;
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_expr::Expr;
|
||||
use datatypes::arrow::array::{Array, BinaryArray, BooleanArray, BooleanBufferBuilder};
|
||||
@@ -53,8 +56,8 @@ use crate::sst::parquet::file_range::PreFilterMode;
|
||||
use crate::sst::parquet::flat_format::FlatReadFormat;
|
||||
use crate::sst::parquet::format::{PrimaryKeyArray, StatValues};
|
||||
use crate::sst::parquet::reader::{
|
||||
MaybeFilter, PhysicalFilterContext, RowGroupBuildContext, RowGroupReaderBuilder,
|
||||
SimpleFilterContext,
|
||||
DynamicFilterSnapshot, MaybeFilter, PhysicalFilterContext, RowGroupBuildContext,
|
||||
RowGroupReaderBuilder, SimpleFilterContext,
|
||||
};
|
||||
|
||||
pub(crate) fn matching_row_ranges_by_primary_key(
|
||||
@@ -401,6 +404,12 @@ pub(crate) fn build_bulk_filter_plan(
|
||||
/// the prefilter pass. A caller can postpone simple timestamp filters to the normal
|
||||
/// precise-filter path when the scan time range covers the SST. When predicate
|
||||
/// prefiltering is disabled, all simple filters remain on the normal path instead.
|
||||
///
|
||||
/// Producer-owned dynamic filters, such as the membership filter a single-key `HashJoin`
|
||||
/// publishes, are admitted the same best-effort way, from their published physical
|
||||
/// expression instead of a logical [`Expr`]: see [`PhysicalFilterContext::new_dynamic`].
|
||||
/// Their masks are never cached, because the producer may republish the expression
|
||||
/// between row groups.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn build_reader_filter_plan(
|
||||
predicate: Option<&Predicate>,
|
||||
@@ -517,6 +526,20 @@ pub(crate) fn build_reader_filter_plan(
|
||||
};
|
||||
}
|
||||
|
||||
// Producer-owned dynamic filters are physical expressions the producer republishes
|
||||
// while the scan runs. Evaluate their current snapshot as a prefilter pass so the row
|
||||
// selection shrinks before the full projection is read. A dynamic filter never
|
||||
// replaces the live filter above the scan, and its mask is never cached.
|
||||
let dyn_filters = predicate.dyn_filters();
|
||||
for handle in dyn_filters.iter() {
|
||||
if let Some(filter) =
|
||||
PhysicalFilterContext::new_dynamic(metadata, expected_metadata, read_format, handle)
|
||||
&& can_direct_prefilter(filter.semantic_type())
|
||||
{
|
||||
prefilter_physical_filters.push(filter);
|
||||
}
|
||||
}
|
||||
|
||||
let pk_filter_expr_strs = (!pk_filter_contexts.is_empty()).then(|| {
|
||||
let mut expr_strs = pk_filter_contexts
|
||||
.iter()
|
||||
@@ -567,6 +590,12 @@ pub(crate) struct PrefilterContext {
|
||||
/// Physical filters that can be evaluated directly from the prefilter batch.
|
||||
/// Physical expressions are only applied in the prefilter phase.
|
||||
physical_filters: Vec<PhysicalFilterContext>,
|
||||
/// Producer-owned dynamic filter snapshots, aligned with `physical_filters`.
|
||||
///
|
||||
/// Captured when this row group's prefilter starts and evaluated for the whole pass.
|
||||
/// `None` for static filters and for dynamic filters this pass cannot use, which
|
||||
/// keeps every row instead.
|
||||
dynamic_snapshots: Vec<Option<DynamicFilterSnapshot>>,
|
||||
/// Region schema version used in per-filter cache keys.
|
||||
schema_version: u64,
|
||||
/// Sorted expression strings for the encoded-PK filter group.
|
||||
@@ -577,6 +606,41 @@ pub(crate) struct PrefilterContext {
|
||||
proven_simple_filters: Vec<bool>,
|
||||
}
|
||||
|
||||
impl PrefilterContext {
|
||||
/// Returns true when the dynamic filter at `physical_filters[idx]` contributes no mask
|
||||
/// for this row group: no snapshot was captured, or the snapshot references no column.
|
||||
///
|
||||
/// A producer that has not published yet still holds its `true` placeholder, and
|
||||
/// reading the column for this row group would only cost I/O. A constant that rejects
|
||||
/// every row is left to the row-group statistics pruning, which skips the whole row
|
||||
/// group before any read.
|
||||
fn dynamic_filter_is_noop(&self, idx: usize) -> bool {
|
||||
let Some(filter) = self.physical_filters.get(idx) else {
|
||||
return false;
|
||||
};
|
||||
if !filter.is_dynamic() {
|
||||
return false;
|
||||
}
|
||||
match self.dynamic_snapshots.get(idx) {
|
||||
Some(Some(snapshot)) => collect_columns(&snapshot.expr).is_empty(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the producer has not republished the dynamic filter at
|
||||
/// `physical_filters[idx]` since this pass captured its snapshot.
|
||||
fn dynamic_snapshot_is_current(&self, idx: usize) -> bool {
|
||||
let (Some(filter), Some(Some(snapshot))) = (
|
||||
self.physical_filters.get(idx),
|
||||
self.dynamic_snapshots.get(idx),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
filter.dynamic_snapshot_is_current(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-built state for constructing [PrefilterContext] per row group.
|
||||
///
|
||||
/// Fields invariant across row groups (projection mask, codec, metadata, filters)
|
||||
@@ -675,6 +739,10 @@ impl PrefilterContextBuilder {
|
||||
}
|
||||
|
||||
/// Builds a [PrefilterContext] for a specific row group.
|
||||
///
|
||||
/// This is where a row group's prefilter starts, so producer-owned dynamic filters
|
||||
/// are snapshotted here: one stable expression per filter, evaluated for the whole
|
||||
/// pass and rechecked against the producer before its mask is committed.
|
||||
pub(crate) fn build(&self, row_group_idx: usize) -> PrefilterContext {
|
||||
let pk_filter = self
|
||||
.build_primary_key_filter()
|
||||
@@ -683,6 +751,11 @@ impl PrefilterContextBuilder {
|
||||
pk_filter,
|
||||
filters: self.filters.clone(),
|
||||
physical_filters: self.physical_filters.clone(),
|
||||
dynamic_snapshots: self
|
||||
.physical_filters
|
||||
.iter()
|
||||
.map(|filter| filter.capture_dynamic_snapshot())
|
||||
.collect(),
|
||||
schema_version: self.schema_version,
|
||||
pk_filter_expr_strs: self.pk_filter_expr_strs.clone(),
|
||||
arrow_schema: self.arrow_schema.clone(),
|
||||
@@ -935,8 +1008,10 @@ async fn execute_prefilter_with_result_cache(
|
||||
.copied()
|
||||
.map(|idx| PrefilterEntry::without_cache(PrefilterEntryKind::Physical(idx))),
|
||||
);
|
||||
let (uncached_mask, read_rows) =
|
||||
let masks =
|
||||
build_prefilter_masks(prefilter_ctx, reader_builder, build_ctx, &uncached_entries).await?;
|
||||
let read_rows = masks.rows;
|
||||
let uncached_mask = masks.commit(prefilter_ctx);
|
||||
|
||||
let final_mask = match (hit_mask, uncached_mask) {
|
||||
(Some(hit_mask), Some(uncached_mask)) => hit_mask.bitand(&uncached_mask),
|
||||
@@ -960,16 +1035,90 @@ fn non_cacheable_physical_filters(prefilter_ctx: &PrefilterContext) -> Vec<usize
|
||||
.physical_filters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, filter)| (!filter.is_immutable()).then_some(idx))
|
||||
.filter_map(|(idx, filter)| {
|
||||
if filter.is_immutable() || prefilter_ctx.dynamic_filter_is_noop(idx) {
|
||||
return None;
|
||||
}
|
||||
Some(idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Masks produced by one prefilter pass over a row group.
|
||||
struct PrefilterMasks {
|
||||
/// Mask over the entries whose result may be committed as it is, or `None` when the
|
||||
/// pass evaluated no such entry.
|
||||
static_mask: Option<BooleanBuffer>,
|
||||
/// Masks of producer-owned dynamic filters, keyed by their `physical_filters` index.
|
||||
/// Only commit them while the producer stays at the generation they were snapshotted
|
||||
/// at.
|
||||
dynamic_masks: Vec<(usize, BooleanBuffer)>,
|
||||
/// Number of rows this pass read.
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl PrefilterMasks {
|
||||
/// ANDs the masks that may be committed for this row group.
|
||||
///
|
||||
/// A producer may publish a new expression while this pass runs. The committed mask
|
||||
/// must never be tighter than the live filter, and nothing here orders one generation
|
||||
/// against another, so a dynamic mask whose generation moved on is discarded and the
|
||||
/// incoming selection of the row group is kept as it was. Only the live filter above
|
||||
/// the scan decides which rows a query returns.
|
||||
///
|
||||
/// # Producer contract
|
||||
///
|
||||
/// Committing a dynamic mask takes two properties from the producer:
|
||||
///
|
||||
/// * **Stable snapshot.** The mask was computed from the single expression captured
|
||||
/// at `generation` and is only committed while the handle is still at that
|
||||
/// generation (see [`PhysicalFilterContext::capture_dynamic_snapshot`]).
|
||||
/// * **A publication is a necessary condition for the same execution.** A committed
|
||||
/// mask removes every row that generation rejects, so a publication is only allowed
|
||||
/// to reject rows the enclosing join or limit rejects too. The supported producers
|
||||
/// publish completed state: a `HashJoin` publishes its immutable, completed
|
||||
/// build-side map, and `TopK` publishes a monotone (only ever tightening) threshold.
|
||||
/// Arbitrary independently mutable nested expressions are not promised, and nothing
|
||||
/// here compares two generations: a newer publication is simply re-evaluated by the
|
||||
/// next pass, while the live filter above the scan stays authoritative.
|
||||
///
|
||||
/// # Why a stale mask is dropped instead of retained
|
||||
///
|
||||
/// For a monotone tightening producer such as `TopK`, the newer expression accepts a
|
||||
/// subset of the rows the snapshotted one accepts, so keeping the older mask would
|
||||
/// stay correct and prune more. Nothing observable here tells that producer apart from
|
||||
/// one whose next publication widens, reverses, or rewrites its expression, and
|
||||
/// keeping a mask across such a publication would return rows the live filter rejects.
|
||||
/// A mask whose generation moved on is therefore treated as untrusted rather than
|
||||
/// assumed safe: the mask is dropped, the incoming selection of the row group is kept,
|
||||
/// and the next prefilter pass evaluates the new expression from scratch. Retaining a
|
||||
/// stale mask needs an explicit monotone/tightening contract published by the producer
|
||||
/// next to its generation; no producer exposes one today, so it can be added together
|
||||
/// with the producer that relies on it.
|
||||
fn commit(self, prefilter_ctx: &PrefilterContext) -> Option<BooleanBuffer> {
|
||||
let mut mask = self.static_mask;
|
||||
for (idx, dynamic_mask) in self.dynamic_masks {
|
||||
// The producer moved on. Keeping this mask would assume the new expression only
|
||||
// tightens the snapshotted one, which nothing here proves.
|
||||
if !prefilter_ctx.dynamic_snapshot_is_current(idx) {
|
||||
continue;
|
||||
}
|
||||
mask = Some(match mask {
|
||||
Some(mask) => mask.bitand(&dynamic_mask),
|
||||
None => dynamic_mask,
|
||||
});
|
||||
}
|
||||
|
||||
mask
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_prefilter_masks(
|
||||
prefilter_ctx: &mut PrefilterContext,
|
||||
reader_builder: &RowGroupReaderBuilder,
|
||||
build_ctx: &RowGroupBuildContext<'_>,
|
||||
entries: &[PrefilterEntry],
|
||||
) -> Result<(Option<BooleanBuffer>, usize)> {
|
||||
) -> Result<PrefilterMasks> {
|
||||
let prefilter_column_names = prefilter_column_names_for_entries(prefilter_ctx, entries);
|
||||
let parquet_schema = reader_builder
|
||||
.parquet_metadata()
|
||||
@@ -994,7 +1143,14 @@ async fn build_prefilter_masks(
|
||||
.iter()
|
||||
.map(|entry| entry.key.is_some().then(|| BooleanBufferBuilder::new(0)))
|
||||
.collect::<Vec<_>>();
|
||||
let mut combined_builder = (!entries.is_empty()).then(|| BooleanBufferBuilder::new(0));
|
||||
// Dynamic filter masks are kept per filter, so the commit step can still drop one
|
||||
// whose generation moved on without discarding the others.
|
||||
let mut dynamic_builders = prefilter_ctx
|
||||
.dynamic_snapshots
|
||||
.iter()
|
||||
.map(|snapshot| snapshot.is_some().then(|| BooleanBufferBuilder::new(0)))
|
||||
.collect::<Vec<_>>();
|
||||
let mut combined_builder = BooleanBufferBuilder::new(0);
|
||||
let mut rows_before_filter = 0usize;
|
||||
|
||||
while let Some(batch_result) = stream.next().await {
|
||||
@@ -1013,14 +1169,21 @@ async fn build_prefilter_masks(
|
||||
entry.kind,
|
||||
reader_builder.file_path(),
|
||||
)?;
|
||||
batch_mask = batch_mask.bitand(&mask);
|
||||
let dynamic_builder = match entry.kind {
|
||||
PrefilterEntryKind::Physical(physical_idx) => dynamic_builders
|
||||
.get_mut(physical_idx)
|
||||
.and_then(Option::as_mut),
|
||||
PrefilterEntryKind::Simple(_) | PrefilterEntryKind::PkGroup => None,
|
||||
};
|
||||
match dynamic_builder {
|
||||
Some(builder) => builder.append_buffer(&mask),
|
||||
None => batch_mask = batch_mask.bitand(&mask),
|
||||
}
|
||||
if let Some(Some(builder)) = cache_builders.get_mut(idx) {
|
||||
builder.append_buffer(&mask);
|
||||
}
|
||||
}
|
||||
if let Some(builder) = &mut combined_builder {
|
||||
builder.append_buffer(&batch_mask);
|
||||
}
|
||||
combined_builder.append_buffer(&batch_mask);
|
||||
}
|
||||
|
||||
for (entry, builder) in entries.iter().zip(cache_builders) {
|
||||
@@ -1031,10 +1194,18 @@ async fn build_prefilter_masks(
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
combined_builder.map(|mut builder| builder.finish()),
|
||||
rows_before_filter,
|
||||
))
|
||||
Ok(PrefilterMasks {
|
||||
static_mask: Some(combined_builder.finish())
|
||||
.filter(|mask: &BooleanBuffer| !mask.is_empty()),
|
||||
dynamic_masks: dynamic_builders
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, builder)| builder.map(|mut builder| (idx, builder.finish())))
|
||||
// A dynamic filter without a usable snapshot reads no row for this pass.
|
||||
.filter(|(_, mask)| !mask.is_empty())
|
||||
.collect(),
|
||||
rows: rows_before_filter,
|
||||
})
|
||||
}
|
||||
|
||||
fn prefilter_column_names_for_entries(
|
||||
@@ -1073,8 +1244,9 @@ async fn execute_prefilter_by_reading_columns(
|
||||
if entries.is_empty() {
|
||||
return Ok(identity_prefilter_result(reader_builder, build_ctx));
|
||||
}
|
||||
let (mask, rows_before_filter) =
|
||||
build_prefilter_masks(prefilter_ctx, reader_builder, build_ctx, &entries).await?;
|
||||
let masks = build_prefilter_masks(prefilter_ctx, reader_builder, build_ctx, &entries).await?;
|
||||
let rows_before_filter = masks.rows;
|
||||
let mask = masks.commit(prefilter_ctx);
|
||||
|
||||
let final_mask = mask.unwrap_or_else(|| BooleanBuffer::new_set(rows_before_filter));
|
||||
let rows_selected = final_mask.count_set_bits();
|
||||
@@ -1111,6 +1283,7 @@ fn all_prefilter_entries(prefilter_ctx: &PrefilterContext) -> Vec<PrefilterEntry
|
||||
.physical_filters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !prefilter_ctx.dynamic_filter_is_noop(*idx))
|
||||
.map(|(idx, _)| PrefilterEntry::without_cache(PrefilterEntryKind::Physical(idx))),
|
||||
);
|
||||
entries
|
||||
@@ -1263,7 +1436,36 @@ fn eval_entry_mask(
|
||||
eval_simple_filter_mask(batch, &prefilter_ctx.filters[idx], file_path)
|
||||
}
|
||||
PrefilterEntryKind::Physical(idx) => {
|
||||
eval_physical_filter_mask(batch, &prefilter_ctx.physical_filters[idx], file_path)
|
||||
let filter_ctx = &prefilter_ctx.physical_filters[idx];
|
||||
let Some(snapshot) = prefilter_ctx
|
||||
.dynamic_snapshots
|
||||
.get(idx)
|
||||
.and_then(Option::as_ref)
|
||||
else {
|
||||
// A static filter; a dynamic filter without a usable snapshot keeps all
|
||||
// rows instead of being evaluated at its live, ever-changing generation.
|
||||
let Some(filter) = filter_ctx.filter() else {
|
||||
return Ok(BooleanBuffer::new_set(batch.num_rows()));
|
||||
};
|
||||
return evaluate_physical_mask(batch, filter_ctx, filter, file_path);
|
||||
};
|
||||
// A producer-owned expression is only checked against the prefilter schema
|
||||
// when it is snapshotted. Keep every row if evaluating it fails, exactly like
|
||||
// any other unsupported prefilter candidate.
|
||||
match evaluate_physical_mask(batch, filter_ctx, &snapshot.expr, file_path) {
|
||||
Ok(mask) => Ok(mask),
|
||||
Err(error) => {
|
||||
// The producer owns the expression, so a snapshot that cannot be
|
||||
// evaluated is a miss for this pass, not a scan error.
|
||||
debug!(
|
||||
"Ignored dynamic prefilter snapshot of {} that failed to evaluate: {}, file: {}",
|
||||
filter_ctx.column_name(),
|
||||
error,
|
||||
file_path
|
||||
);
|
||||
Ok(BooleanBuffer::new_set(batch.num_rows()))
|
||||
}
|
||||
}
|
||||
}
|
||||
PrefilterEntryKind::PkGroup => {
|
||||
let pk_filter = prefilter_ctx.pk_filter.as_mut().context(UnexpectedSnafu {
|
||||
@@ -1322,13 +1524,18 @@ fn eval_simple_filter_mask(
|
||||
filter.evaluate_array(&column).context(RecordBatchSnafu)
|
||||
}
|
||||
|
||||
fn eval_physical_filter_mask(
|
||||
/// Evaluates a physical prefilter expression against the single filter column of `batch`.
|
||||
///
|
||||
/// `NULL` results count as filtered out. This is safe because the published predicate is
|
||||
/// already a necessary condition for the enclosing operation; legacy consumer-side NULL
|
||||
/// guards may admit additional rows that the operation does not need. No `IS NULL` guard
|
||||
/// is added here because the producer owns the null semantics of its expression.
|
||||
pub(crate) fn evaluate_physical_mask(
|
||||
batch: &RecordBatch,
|
||||
filter_ctx: &PhysicalFilterContext,
|
||||
filter: &Arc<dyn PhysicalExpr>,
|
||||
file_path: &str,
|
||||
) -> Result<BooleanBuffer> {
|
||||
let filter = filter_ctx.filter();
|
||||
|
||||
let (idx, _) = batch
|
||||
.schema()
|
||||
.column_with_name(filter_ctx.column_name())
|
||||
@@ -1371,12 +1578,15 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use bytes::Bytes;
|
||||
use common_recordbatch::filter::SimpleFilterEvaluator;
|
||||
use common_recordbatch::filter::{SimpleFilterEvaluator, batch_filter};
|
||||
use datafusion::physical_plan::expressions::{
|
||||
Column, DynamicFilterPhysicalExpr, lit as physical_lit,
|
||||
};
|
||||
use datafusion_common::ScalarValue;
|
||||
use datafusion_expr::{col, lit};
|
||||
use datatypes::arrow::array::{
|
||||
ArrayRef, DictionaryArray, Int32Array, TimestampMillisecondArray, UInt8Array, UInt32Array,
|
||||
UInt64Array,
|
||||
ArrayRef, DictionaryArray, Float64Array, Int32Array, TimestampMillisecondArray, UInt8Array,
|
||||
UInt32Array, UInt64Array,
|
||||
};
|
||||
use datatypes::arrow::datatypes::{DataType, Field, Schema, UInt32Type};
|
||||
use datatypes::arrow::record_batch::RecordBatch;
|
||||
@@ -2011,6 +2221,7 @@ mod tests {
|
||||
pk_filter: None,
|
||||
filters,
|
||||
physical_filters: Vec::new(),
|
||||
dynamic_snapshots: Vec::new(),
|
||||
schema_version: metadata.schema_version,
|
||||
pk_filter_expr_strs: None,
|
||||
arrow_schema: metadata.schema.arrow_schema().clone(),
|
||||
@@ -2027,6 +2238,102 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A generation change discards the dynamic mask even when the new expression is a
|
||||
/// monotone tightening of the snapshotted one.
|
||||
///
|
||||
/// A `TopK` only ever tightens its threshold, so the mask of the generation a pass
|
||||
/// snapshotted would still be implied by the newer expression and keeping it would
|
||||
/// prune more. Nothing here can tell such a producer apart from one whose next
|
||||
/// publication widens, so a mask from a superseded generation is treated as untrusted
|
||||
/// and dropped: the row group keeps its incoming selection and the next pass evaluates
|
||||
/// the new expression from scratch.
|
||||
#[test]
|
||||
fn test_commit_discards_dynamic_mask_after_monotone_tightening_publication() {
|
||||
let metadata: RegionMetadataRef = Arc::new(sst_region_metadata());
|
||||
let read_format = FlatReadFormat::new(
|
||||
metadata.clone(),
|
||||
ReadColumns::new(metadata.column_metadatas.iter().map(|c| c.column_id)),
|
||||
None,
|
||||
"memtable",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let producer_schema = Arc::new(Schema::new(vec![Field::new(
|
||||
"field_0",
|
||||
DataType::UInt64,
|
||||
true,
|
||||
)]));
|
||||
let publish = |members: Vec<u64>| {
|
||||
let expr = col("field_0").in_list(members.into_iter().map(lit).collect(), false);
|
||||
Predicate::to_physical_expr(&expr, &producer_schema).unwrap()
|
||||
};
|
||||
let key = Arc::new(Column::new("field_0", 0)) as Arc<dyn PhysicalExpr>;
|
||||
// The producer starts from `{1, 2}` and only ever tightens the membership below.
|
||||
let handle = Arc::new(DynamicFilterPhysicalExpr::new(
|
||||
vec![key],
|
||||
physical_lit(true),
|
||||
));
|
||||
handle.update(publish(vec![1, 2])).unwrap();
|
||||
|
||||
// A prefilter pass of three rows that snapshots the current generation.
|
||||
let new_context = || {
|
||||
let filter =
|
||||
PhysicalFilterContext::new_dynamic(&metadata, None, &read_format, &handle).unwrap();
|
||||
let snapshot = filter.capture_dynamic_snapshot();
|
||||
PrefilterContext {
|
||||
pk_filter: None,
|
||||
filters: Vec::new(),
|
||||
physical_filters: vec![filter],
|
||||
dynamic_snapshots: vec![snapshot],
|
||||
schema_version: metadata.schema_version,
|
||||
pk_filter_expr_strs: None,
|
||||
arrow_schema: read_format.arrow_schema().clone(),
|
||||
proven_simple_filters: Vec::new(),
|
||||
}
|
||||
};
|
||||
let mask_bits = |mask: BooleanBuffer| mask.iter().collect::<Vec<bool>>();
|
||||
let keep_all = BooleanBuffer::new_set(3);
|
||||
// `{1, 2}` keeps rows 1 and 2 of the three rows the pass read.
|
||||
let membership_12 = BooleanBuffer::from(vec![false, true, true]);
|
||||
|
||||
let context = new_context();
|
||||
let committed = PrefilterMasks {
|
||||
static_mask: Some(keep_all.clone()),
|
||||
dynamic_masks: vec![(0, membership_12.clone())],
|
||||
rows: 3,
|
||||
}
|
||||
.commit(&context);
|
||||
assert_eq!(
|
||||
vec![false, true, true],
|
||||
mask_bits(committed.unwrap()),
|
||||
"a mask of the snapshotted generation is committed"
|
||||
);
|
||||
|
||||
// Tightening `{1, 2}` to `{2}` supersedes the generation the pass snapshotted.
|
||||
handle.update(publish(vec![2])).unwrap();
|
||||
let committed = PrefilterMasks {
|
||||
static_mask: Some(keep_all.clone()),
|
||||
dynamic_masks: vec![(0, membership_12.clone())],
|
||||
rows: 3,
|
||||
}
|
||||
.commit(&context);
|
||||
assert_eq!(
|
||||
vec![true, true, true],
|
||||
mask_bits(committed.unwrap()),
|
||||
"the superseded mask must be dropped, not retained as a tightening"
|
||||
);
|
||||
|
||||
// The next pass snapshots the tightened generation and prunes with it.
|
||||
let context = new_context();
|
||||
let committed = PrefilterMasks {
|
||||
static_mask: Some(keep_all),
|
||||
dynamic_masks: vec![(0, BooleanBuffer::from(vec![false, false, true]))],
|
||||
rows: 3,
|
||||
}
|
||||
.commit(&context);
|
||||
assert_eq!(vec![false, false, true], mask_bits(committed.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_row_selection_preserves_input() {
|
||||
let sparse = RowSelection::from(vec![
|
||||
@@ -2242,6 +2549,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates the dynamic filter a single-key `HashJoin` publishes for `members`.
|
||||
fn new_dynamic_field_filter(
|
||||
read_format: &FlatReadFormat,
|
||||
members: Vec<u64>,
|
||||
) -> Arc<DynamicFilterPhysicalExpr> {
|
||||
let arrow_schema = read_format.arrow_schema();
|
||||
let key = Arc::new(Column::new("field_0", 0)) as Arc<dyn PhysicalExpr>;
|
||||
let handle = Arc::new(DynamicFilterPhysicalExpr::new(
|
||||
vec![key],
|
||||
Predicate::to_physical_expr(&lit(true), arrow_schema).unwrap(),
|
||||
));
|
||||
let expr = col("field_0").in_list(members.into_iter().map(lit).collect(), false);
|
||||
handle
|
||||
.update(Predicate::to_physical_expr(&expr, arrow_schema).unwrap())
|
||||
.unwrap();
|
||||
handle
|
||||
}
|
||||
|
||||
fn dynamic_filter_count(builder: &PrefilterContextBuilder) -> usize {
|
||||
builder
|
||||
.physical_filters
|
||||
.iter()
|
||||
.filter(|filter| filter.is_dynamic())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A dynamic filter on a field column is admitted only where field pruning is safe.
|
||||
/// `SkipFields` preserves the dedup semantics of a multi-source `LastRow` /
|
||||
/// `LastNonNull` merge, so the dynamic filter must not prune fields there either.
|
||||
#[test]
|
||||
fn test_build_reader_filter_plan_gates_dynamic_filters_by_prefilter_mode() {
|
||||
let metadata: RegionMetadataRef = Arc::new(sst_region_metadata());
|
||||
let read_format = FlatReadFormat::new(
|
||||
metadata.clone(),
|
||||
ReadColumns::new(metadata.column_metadatas.iter().map(|c| c.column_id)),
|
||||
None,
|
||||
"test",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let codec = build_primary_key_codec(metadata.as_ref());
|
||||
let parquet_metadata = stats_metadata(&[vec![1]], None);
|
||||
let predicate = || {
|
||||
Predicate::with_dyn_filters(
|
||||
vec![],
|
||||
vec![new_dynamic_field_filter(&read_format, vec![1])],
|
||||
)
|
||||
};
|
||||
|
||||
let all_plan = build_reader_filter_plan(
|
||||
Some(&predicate()),
|
||||
None,
|
||||
PreFilterMode::All,
|
||||
true,
|
||||
false,
|
||||
&read_format,
|
||||
&codec,
|
||||
&parquet_metadata,
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
dynamic_filter_count(all_plan.prefilter_builder.as_ref().unwrap())
|
||||
);
|
||||
|
||||
// The dynamic filter is the only prefilter candidate, and it is gated out.
|
||||
let skip_fields_plan = build_reader_filter_plan(
|
||||
Some(&predicate()),
|
||||
None,
|
||||
PreFilterMode::SkipFields,
|
||||
true,
|
||||
false,
|
||||
&read_format,
|
||||
&codec,
|
||||
&parquet_metadata,
|
||||
);
|
||||
assert!(skip_fields_plan.prefilter_builder.is_none());
|
||||
|
||||
// With a tag filter the prefilter still runs, without the dynamic field filter.
|
||||
let skip_fields_plan = build_reader_filter_plan(
|
||||
Some(&Predicate::with_dyn_filters(
|
||||
vec![col("tag_0").eq(lit("a"))],
|
||||
vec![new_dynamic_field_filter(&read_format, vec![1])],
|
||||
)),
|
||||
None,
|
||||
PreFilterMode::SkipFields,
|
||||
true,
|
||||
false,
|
||||
&read_format,
|
||||
&codec,
|
||||
&parquet_metadata,
|
||||
);
|
||||
assert_eq!(
|
||||
0,
|
||||
dynamic_filter_count(skip_fields_plan.prefilter_builder.as_ref().unwrap())
|
||||
);
|
||||
|
||||
// Disabled predicate prefiltering keeps dynamic filters on the normal path.
|
||||
let disabled_plan = build_reader_filter_plan(
|
||||
Some(&predicate()),
|
||||
None,
|
||||
PreFilterMode::All,
|
||||
false,
|
||||
false,
|
||||
&read_format,
|
||||
&codec,
|
||||
&parquet_metadata,
|
||||
);
|
||||
assert!(disabled_plan.prefilter_builder.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pk_filter_expr_strings_are_stable_under_expr_order() {
|
||||
let metadata: RegionMetadataRef = Arc::new(sst_region_metadata_with_encoding(
|
||||
@@ -2348,10 +2765,177 @@ mod tests {
|
||||
let pk = new_primary_key(&["a", "x"]);
|
||||
let batch = new_raw_batch(&[pk.as_slice(), pk.as_slice(), pk.as_slice()], &[9, 10, 11]);
|
||||
|
||||
let mask = eval_physical_filter_mask(&batch, &physical_filters[0], "test").unwrap();
|
||||
let filter = physical_filters[0].filter().unwrap();
|
||||
let mask = evaluate_physical_mask(&batch, &physical_filters[0], filter, "test").unwrap();
|
||||
assert_eq!(mask.count_set_bits(), 1);
|
||||
}
|
||||
|
||||
/// The producer's input schema, where `field_0` is its fourth column rather than the
|
||||
/// only column of a prefilter batch.
|
||||
fn producer_schema(field_type: DataType) -> SchemaRef {
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("tag_0", DataType::Utf8, true),
|
||||
Field::new("tag_1", DataType::Utf8, true),
|
||||
Field::new("ts", DataType::UInt64, true),
|
||||
Field::new("field_0", field_type, true),
|
||||
]))
|
||||
}
|
||||
|
||||
fn full_read_format(metadata: &RegionMetadataRef) -> FlatReadFormat {
|
||||
FlatReadFormat::new(
|
||||
metadata.clone(),
|
||||
ReadColumns::new(metadata.column_metadatas.iter().map(|c| c.column_id)),
|
||||
None,
|
||||
"test",
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Evaluates a producer-owned dynamic filter over `batch` both ways: as a prefilter
|
||||
/// mask, and as the live `batch_filter` that runs above the scan.
|
||||
///
|
||||
/// `batch` carries the filter column plus a `row` column holding row indexes, so the
|
||||
/// rows the upper filter keeps come back as values. Both evaluations see the same
|
||||
/// rows, so the returned row lists are directly comparable.
|
||||
fn prefilter_and_upper_rows(
|
||||
metadata: &RegionMetadataRef,
|
||||
read_format: &FlatReadFormat,
|
||||
producer_schema: &SchemaRef,
|
||||
published: &Expr,
|
||||
batch: &RecordBatch,
|
||||
) -> (Vec<usize>, Vec<usize>) {
|
||||
let key = Arc::new(Column::new("field_0", 3)) as Arc<dyn PhysicalExpr>;
|
||||
let handle = Arc::new(DynamicFilterPhysicalExpr::new(
|
||||
vec![key],
|
||||
Predicate::to_physical_expr(&lit(true), producer_schema).unwrap(),
|
||||
));
|
||||
handle
|
||||
.update(Predicate::to_physical_expr(published, producer_schema).unwrap())
|
||||
.unwrap();
|
||||
let filter_ctx =
|
||||
PhysicalFilterContext::new_dynamic(metadata, None, read_format, &handle).unwrap();
|
||||
let snapshot = filter_ctx.capture_dynamic_snapshot().unwrap();
|
||||
|
||||
let mask = evaluate_physical_mask(batch, &filter_ctx, &snapshot.expr, "test").unwrap();
|
||||
let prefilter_rows = mask
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(row, keep)| keep.then_some(row))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let filtered = batch_filter(batch, &snapshot.expr).unwrap();
|
||||
let upper_rows = filtered
|
||||
.column(1)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap()
|
||||
.values()
|
||||
.iter()
|
||||
.map(|row| *row as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(prefilter_rows, upper_rows)
|
||||
}
|
||||
|
||||
/// A batch of `field_0` values plus a `row` column holding their indexes.
|
||||
fn indexed_batch(field: ArrayRef, data_type: DataType) -> RecordBatch {
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("field_0", data_type, true),
|
||||
Field::new("row", DataType::UInt64, false),
|
||||
]));
|
||||
let rows = Arc::new(UInt64Array::from_iter_values(0..field.len() as u64)) as ArrayRef;
|
||||
RecordBatch::try_new(schema, vec![field, rows]).unwrap()
|
||||
}
|
||||
|
||||
/// A bare `col IN (...)` leaves a NULL comparison UNKNOWN, and the prefilter must
|
||||
/// reject that row exactly like the live filter above the scan: it adds no NULL guard
|
||||
/// of its own, because the producer owns the NULL semantics of its expression.
|
||||
#[test]
|
||||
fn test_dynamic_filter_mask_matches_upper_filter_for_nullable_bare_in() {
|
||||
let metadata: RegionMetadataRef = Arc::new(sst_region_metadata());
|
||||
let read_format = full_read_format(&metadata);
|
||||
let published = col("field_0").in_list(vec![lit(1_u64), lit(3_u64)], false);
|
||||
// Row 1 is NULL, and `NULL IN (1, 3)` is neither a member nor a non-member.
|
||||
let batch = indexed_batch(
|
||||
Arc::new(UInt64Array::from(vec![Some(1), None, Some(2), Some(3)])) as ArrayRef,
|
||||
DataType::UInt64,
|
||||
);
|
||||
|
||||
let (prefilter_rows, upper_rows) = prefilter_and_upper_rows(
|
||||
&metadata,
|
||||
&read_format,
|
||||
&producer_schema(DataType::UInt64),
|
||||
&published,
|
||||
&batch,
|
||||
);
|
||||
|
||||
assert_eq!(vec![0, 3], prefilter_rows);
|
||||
assert_eq!(
|
||||
prefilter_rows, upper_rows,
|
||||
"the prefilter must keep exactly the rows the upper filter keeps"
|
||||
);
|
||||
}
|
||||
|
||||
/// Float membership compares with a total order, so `NaN` is a member of a list that
|
||||
/// holds `NaN` while `-0.0` is not a member of a list that holds `0.0`. The prefilter
|
||||
/// must reproduce those semantics exactly, which it can only do by evaluating the
|
||||
/// producer's whole expression instead of substituting an IEEE `==` or a re-derived
|
||||
/// comparison.
|
||||
#[test]
|
||||
fn test_dynamic_filter_mask_matches_upper_filter_for_float_nan_membership() {
|
||||
let metadata = metadata_with_field_type(ConcreteDataType::float64_datatype());
|
||||
let read_format = full_read_format(&metadata);
|
||||
let published = col("field_0").in_list(vec![lit(0.0_f64), lit(f64::NAN)], false);
|
||||
// Rows: `0.0` and `NaN` are members, `-0.0` is not (total order, not IEEE `==`),
|
||||
// and the NULL row is UNKNOWN.
|
||||
let batch = indexed_batch(
|
||||
Arc::new(Float64Array::from(vec![
|
||||
Some(0.0),
|
||||
Some(-0.0),
|
||||
Some(f64::NAN),
|
||||
None,
|
||||
])) as ArrayRef,
|
||||
DataType::Float64,
|
||||
);
|
||||
let unlisted = indexed_batch(
|
||||
Arc::new(Float64Array::from(vec![
|
||||
Some(1.5),
|
||||
Some(f64::NAN),
|
||||
Some(0.0),
|
||||
None,
|
||||
])) as ArrayRef,
|
||||
DataType::Float64,
|
||||
);
|
||||
|
||||
let (prefilter_rows, upper_rows) = prefilter_and_upper_rows(
|
||||
&metadata,
|
||||
&read_format,
|
||||
&producer_schema(DataType::Float64),
|
||||
&published,
|
||||
&batch,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
prefilter_rows, upper_rows,
|
||||
"the prefilter must keep exactly the rows the upper filter keeps"
|
||||
);
|
||||
assert_eq!(vec![0, 2], prefilter_rows);
|
||||
|
||||
let (unlisted_prefilter_rows, unlisted_upper_rows) = prefilter_and_upper_rows(
|
||||
&metadata,
|
||||
&read_format,
|
||||
&producer_schema(DataType::Float64),
|
||||
&published,
|
||||
&unlisted,
|
||||
);
|
||||
assert_eq!(
|
||||
unlisted_prefilter_rows, unlisted_upper_rows,
|
||||
"the prefilter must keep exactly the rows the upper filter keeps"
|
||||
);
|
||||
assert_eq!(vec![1, 2], unlisted_prefilter_rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eval_pk_group_mask_finds_pk_column_by_name() {
|
||||
let metadata = Arc::new(sst_region_metadata());
|
||||
|
||||
+1041
-43
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user