Skip to content

Commit 7c20e0b

Browse files
adriangbclaude
andcommitted
feat(physical-expr): add DynamicFilterTracker for cheap dynamic-filter change detection
Introduce a consumer-side counterpart to the producer API on `DynamicFilterPhysicalExpr` (`update`/`mark_complete`/`wait_*`), living in a new `expressions::dynamic_filters` module (`dynamic_filters.rs` becomes `dynamic_filters/mod.rs`, with the tracker in `dynamic_filters/tracker.rs`). `DynamicFilterPhysicalExpr::subscribe()` returns a `DynamicFilterSubscription` that observes one filter through its existing `watch` channel: steady-state polling is a single atomic load, the lock is taken only when the filter actually moved, and a bare `mark_complete()` (which re-broadcasts the current generation) is distinguished from a real expression change. This subscription plumbing is `pub(crate)` — the public surface is the tracker. `DynamicFilterTracker` walks a (possibly composite) predicate once, subscribing to every still-incomplete dynamic filter, then answers `changed()` by polling only that shrinking set. `DynamicFilterTracking::classify` distinguishes Static / AllComplete / Watching in a single traversal. Test-only constructors are gated behind `#[cfg(test)]`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 971cf99 commit 7c20e0b

4 files changed

Lines changed: 405 additions & 1 deletion

File tree

datafusion/physical-expr/src/expressions/dynamic_filters.rs renamed to datafusion/physical-expr/src/expressions/dynamic_filters/mod.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ use datafusion_common::{
2929
use datafusion_expr::ColumnarValue;
3030
use datafusion_physical_expr_common::physical_expr::DynHash;
3131

32+
mod tracker;
33+
pub use tracker::{DynamicFilterTracker, DynamicFilterTracking};
34+
3235
/// State of a dynamic filter, tracking both updates and completion.
3336
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3437
enum FilterState {
@@ -326,6 +329,31 @@ impl DynamicFilterPhysicalExpr {
326329
.await;
327330
}
328331

332+
/// Returns `true` if this filter has been marked complete via
333+
/// [`Self::mark_complete`] and will therefore never change again.
334+
pub(crate) fn is_complete(&self) -> bool {
335+
self.inner.read().is_complete
336+
}
337+
338+
/// Subscribe to this filter's updates for cheap, synchronous change
339+
/// detection.
340+
///
341+
/// The returned [`DynamicFilterSubscription`] lets a consumer poll whether
342+
/// the filter's expression has advanced since it last looked, without
343+
/// re-walking a predicate tree or re-deriving a generation on every check.
344+
/// This is the building block used by [`DynamicFilterTracker`](tracker::DynamicFilterTracker)
345+
/// to watch every dynamic filter inside a (possibly composite) predicate.
346+
pub(crate) fn subscribe(&self) -> DynamicFilterSubscription {
347+
let mut receiver = self.state_watch.subscribe();
348+
// Mark the current state as already-seen so the first `observe()` only
349+
// reports updates that happen *after* subscription.
350+
let last_generation = receiver.borrow_and_update().generation();
351+
DynamicFilterSubscription {
352+
receiver,
353+
last_generation,
354+
}
355+
}
356+
329357
/// Check if this dynamic filter is being actively used by any consumers.
330358
///
331359
/// Returns `true` if there are references beyond the producer (e.g., the HashJoinExec
@@ -522,6 +550,66 @@ impl PhysicalExpr for DynamicFilterPhysicalExpr {
522550
}
523551
}
524552

553+
/// The result of polling a [`DynamicFilterSubscription`].
554+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555+
pub(crate) struct DynamicFilterChange {
556+
/// The filter's expression advanced since the previous observation.
557+
pub(crate) changed: bool,
558+
/// The filter has been marked complete; it will never change again and the
559+
/// subscription can be dropped.
560+
pub(crate) complete: bool,
561+
}
562+
563+
/// A cheap, synchronous handle for observing updates to a single
564+
/// [`DynamicFilterPhysicalExpr`].
565+
///
566+
/// Obtained via [`DynamicFilterPhysicalExpr::subscribe`]. Steady-state polling
567+
/// via [`Self::observe`] is a single atomic load (the underlying
568+
/// [`tokio::sync::watch`] version counter); the lock is only taken when the
569+
/// filter has actually been updated.
570+
#[derive(Debug)]
571+
pub(crate) struct DynamicFilterSubscription {
572+
receiver: watch::Receiver<FilterState>,
573+
/// Last generation we reported as "seen". Used to distinguish a real
574+
/// expression update from a bare [`DynamicFilterPhysicalExpr::mark_complete`]
575+
/// (which re-broadcasts the current generation without changing the
576+
/// expression).
577+
last_generation: u64,
578+
}
579+
580+
impl DynamicFilterSubscription {
581+
/// Observe the latest state of the filter.
582+
///
583+
/// Reports whether the filter's expression advanced since the previous call
584+
/// and whether it has since been marked complete. Cheap when nothing has
585+
/// changed: a single atomic comparison with no lock acquisition.
586+
pub(crate) fn observe(&mut self) -> DynamicFilterChange {
587+
match self.receiver.has_changed() {
588+
Ok(true) => {
589+
let state = *self.receiver.borrow_and_update();
590+
let changed = state.generation() > self.last_generation;
591+
if changed {
592+
self.last_generation = state.generation();
593+
}
594+
DynamicFilterChange {
595+
changed,
596+
complete: matches!(state, FilterState::Complete { .. }),
597+
}
598+
}
599+
Ok(false) => DynamicFilterChange {
600+
changed: false,
601+
complete: false,
602+
},
603+
// The sender was dropped: no further updates are possible, so treat
604+
// the subscription as complete.
605+
Err(_) => DynamicFilterChange {
606+
changed: false,
607+
complete: true,
608+
},
609+
}
610+
}
611+
}
612+
525613
/// An atomic counter used to generate monotonic u64 ids.
526614
struct ExpressionIdAtomicCounter {
527615
inner: AtomicU64,

0 commit comments

Comments
 (0)