Skip to content

Commit 5ff5d36

Browse files
committed
[X-2935] Cherry-pick missing dedup with_new_children block from apache#21807
When this fork ported apache#21807 in commit 47f263d, the 'with_new_children on cache hit' block in DeduplicatingDeserializer was accidentally omitted. The simpler 'return Ok(Arc::clone(cached))' version we landed is correct only when every occurrence of a given Inner has the same outer wrapper shape. In production this fails: FilterPushdown clones a SortExec's DynamicFilterPhysicalExpr and rewrites its children's column refs to match the downstream FileScan's file schema. Both outer wrappers share the same Inner Arc (same expression_id, same wire expr_id) but DIFFERENT children. On decode the simpler cache hit returns the first decode's outer wrapper, silently discarding the second occurrence's children. prune_by_statistics then resolves column refs against the wrong positions and pruning becomes a no-op. Observed end-to-end on ny2 staging 2026-06-15 after walker removal: row_groups_pruned_statistics=0 total bytes_scanned=91 MB time_elapsed_processing=31 s (was 1.99K pruned / 136 KB / ~100ms with the walker still active) Cherry-picks the missing block: parse the proto body first, then on cache hit return Arc::clone(cached).with_new_children(parsed.children()). This keeps the cached Inner (so TopK heap-max updates propagate) but installs the proto body's children on a fresh outer wrapper (so each occurrence keeps its own column refs). Adds a regression test that fails without the fix and passes with it, with assertion messages pointing at the exact root cause.
1 parent 641f176 commit 5ff5d36

2 files changed

Lines changed: 198 additions & 20 deletions

File tree

datafusion/proto/src/physical_plan/mod.rs

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4072,24 +4072,49 @@ impl PhysicalProtoConverterExtension for DeduplicatingDeserializer {
40724072
where
40734073
Self: Sized,
40744074
{
4075-
if let Some(expr_id) = proto.expr_id {
4076-
// Check cache first
4077-
if let Some(cached) = self.cache.borrow().get(&expr_id) {
4078-
return Ok(Arc::clone(cached));
4079-
}
4080-
// Deserialize and cache
4081-
let expr = parse_physical_expr_with_converter(
4075+
let Some(expr_id) = proto.expr_id else {
4076+
return parse_physical_expr_with_converter(
40824077
proto,
40834078
ctx,
40844079
input_schema,
40854080
codec,
40864081
self,
4087-
)?;
4088-
self.cache.borrow_mut().insert(expr_id, Arc::clone(&expr));
4089-
Ok(expr)
4090-
} else {
4091-
parse_physical_expr_with_converter(proto, ctx, input_schema, codec, self)
4082+
);
4083+
};
4084+
4085+
// Always parse the proto body first. The cache hit path below uses
4086+
// `parsed.children()` (which carry this occurrence's column refs as
4087+
// they appeared in the proto -- e.g. file-schema indices after
4088+
// FilterPushdown rewrote them), so we can't short-circuit before
4089+
// parsing.
4090+
let parsed =
4091+
parse_physical_expr_with_converter(proto, ctx, input_schema, codec, self)?;
4092+
4093+
let mut cache = self.cache.borrow_mut();
4094+
if let Some(cached) = cache.get(&expr_id) {
4095+
// Since expressions may manage their own internal state when
4096+
// deriving expressions via `with_new_children`, we use
4097+
// `with_new_children` to opt into the same behavior.
4098+
//
4099+
// For example, one `DynamicFilterPhysicalExpr` may be derived
4100+
// from another resulting in shared references (e.g. a SortExec
4101+
// dynamic filter at the parent schema and a pushed-down FileScan
4102+
// predicate at the file schema, sharing the same `Inner` Arc
4103+
// but with different children). Using `with_new_children`
4104+
// preserves those references: the cached `Arc<Inner>` is kept,
4105+
// while this occurrence's children (parsed from the proto body)
4106+
// are installed onto a fresh outer wrapper.
4107+
//
4108+
// Ported from apache/datafusion#21807; without this the cache
4109+
// hit path returned the entire cached outer Arc and silently
4110+
// discarded the proto body's children, breaking column ref
4111+
// alignment in plans where FilterPushdown rewrote them.
4112+
let children: Vec<_> = parsed.children().into_iter().cloned().collect();
4113+
return Arc::clone(cached).with_new_children(children);
40924114
}
4115+
4116+
cache.insert(expr_id, Arc::clone(&parsed));
4117+
Ok(parsed)
40934118
}
40944119

40954120
fn physical_expr_to_proto(

datafusion/proto/tests/cases/roundtrip_physical_plan.rs

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3215,22 +3215,32 @@ fn dynamic_filter_dedup_with_deduplicating_codec() -> Result<()> {
32153215
let d1 = deserializer.proto_to_physical_expr(&proto1, &ctx, &schema, &codec)?;
32163216
let d2 = deserializer.proto_to_physical_expr(&proto2, &ctx, &schema, &codec)?;
32173217

3218-
assert!(
3219-
Arc::ptr_eq(&d1, &d2),
3220-
"DeduplicatingDeserializer must return the same Arc for two refs with the same expr_id"
3221-
);
3222-
3218+
// d1 and d2 may be distinct OUTER Arcs because the cache-hit path
3219+
// rewraps via `with_new_children` to preserve each occurrence's
3220+
// children. The invariant that matters is shared INNER (same
3221+
// expression_id, TopK updates visible to both).
32233222
let d1_df = d1
32243223
.as_any()
32253224
.downcast_ref::<DynamicFilterPhysicalExpr>()
32263225
.expect(
32273226
"decoded expr must be DynamicFilterPhysicalExpr; snapshot path is bypassed",
32283227
);
3228+
let d2_df = d2
3229+
.as_any()
3230+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3231+
.expect(
3232+
"decoded expr must be DynamicFilterPhysicalExpr; snapshot path is bypassed",
3233+
);
32293234
assert_eq!(
32303235
d1_df.inner().expression_id,
32313236
id_before,
32323237
"expression_id must survive proto roundtrip"
32333238
);
3239+
assert_eq!(
3240+
d1_df.inner().expression_id,
3241+
d2_df.inner().expression_id,
3242+
"DeduplicatingDeserializer must share Inner for two refs with the same expr_id"
3243+
);
32343244

32353245
Ok(())
32363246
}
@@ -3300,9 +3310,152 @@ fn dynamic_filter_dedup_distinct_outer_arcs_same_inner() -> Result<()> {
33003310
let deserializer = DeduplicatingDeserializer::new();
33013311
let d1 = deserializer.proto_to_physical_expr(&proto1, &ctx, &schema, &codec)?;
33023312
let d2 = deserializer.proto_to_physical_expr(&proto2, &ctx, &schema, &codec)?;
3303-
assert!(
3304-
Arc::ptr_eq(&d1, &d2),
3305-
"Distinct-outer same-Inner refs must reconstruct to one Arc"
3313+
// d1 and d2 may be distinct OUTER Arcs because the cache-hit path
3314+
// rewraps via `with_new_children` to preserve each occurrence's
3315+
// children. The invariant that matters is shared INNER.
3316+
let d1_inner_id = d1
3317+
.as_any()
3318+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3319+
.unwrap()
3320+
.inner()
3321+
.expression_id;
3322+
let d2_inner_id = d2
3323+
.as_any()
3324+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3325+
.unwrap()
3326+
.inner()
3327+
.expression_id;
3328+
assert_eq!(
3329+
d1_inner_id, d2_inner_id,
3330+
"Distinct-outer same-Inner refs must share Inner after decode"
3331+
);
3332+
assert_eq!(d1_inner_id, expected_id);
3333+
3334+
Ok(())
3335+
}
3336+
3337+
/// Two outer `Arc<DynamicFilterPhysicalExpr>` sharing the same Inner but
3338+
/// carrying DIFFERENT children must roundtrip to two outer Arcs whose
3339+
/// children match their original occurrence's children. This is the shape
3340+
/// `FilterPushdown` actually produces: it clones a SortExec's dyn filter and
3341+
/// rewrites the children column refs to match the FileScan's schema (e.g.
3342+
/// `ticker@0` at SortExec input → `ticker@12` at file schema). Both outer
3343+
/// wrappers share the same `Inner` Arc (so TopK heap-max updates propagate)
3344+
/// but the column-ref indices differ.
3345+
///
3346+
/// Without `DeduplicatingDeserializer::proto_to_physical_expr` applying
3347+
/// `with_new_children(parsed.children())` on cache hits, the second decode
3348+
/// silently returns the FIRST occurrence's children and discards the proto
3349+
/// body's children -- `prune_by_statistics` then resolves column refs against
3350+
/// the wrong file-schema positions and pruning becomes a no-op. Observed on
3351+
/// X-2935 ny2 staging 2026-06-15: `row_groups_pruned_statistics=0`,
3352+
/// `bytes_scanned=91 MB`, `time_elapsed_processing=31 s`.
3353+
#[test]
3354+
fn dynamic_filter_dedup_distinct_children_via_with_new_children() -> Result<()> {
3355+
use datafusion::physical_plan::expressions::Column;
3356+
use datafusion_physical_expr::expressions::DynamicFilterPhysicalExpr;
3357+
use datafusion_proto::physical_plan::{
3358+
DeduplicatingDeserializer, DeduplicatingSerializer,
3359+
PhysicalProtoConverterExtension,
3360+
};
3361+
3362+
// Schema with the SAME column name at TWO positions, mimicking a
3363+
// FilterPushdown step where SortExec sees `a@0` (input schema) but the
3364+
// FileScan below it has `a@1` (file schema after some projection).
3365+
let schema = Schema::new(vec![
3366+
Field::new("extra", DataType::Int64, false), // physical position 0
3367+
Field::new("a", DataType::Int64, false), // physical position 1
3368+
]);
3369+
3370+
let initial: Arc<dyn PhysicalExpr> = lit(true);
3371+
let col_a_idx0: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3372+
let col_a_idx1: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 1));
3373+
3374+
// outer A: SortExec.filter-style (children at index 0).
3375+
let df_arc1: Arc<dyn PhysicalExpr> = Arc::new(DynamicFilterPhysicalExpr::new(
3376+
vec![Arc::clone(&col_a_idx0)],
3377+
Arc::clone(&initial),
3378+
));
3379+
// outer B: FileScan.predicate-style (children at index 1) sharing Inner.
3380+
let df_arc2 =
3381+
Arc::clone(&df_arc1).with_new_children(vec![Arc::clone(&col_a_idx1)])?;
3382+
assert_eq!(
3383+
df_arc1
3384+
.as_any()
3385+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3386+
.unwrap()
3387+
.inner()
3388+
.expression_id,
3389+
df_arc2
3390+
.as_any()
3391+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3392+
.unwrap()
3393+
.inner()
3394+
.expression_id,
3395+
"shared Inner must produce identical expression_id"
3396+
);
3397+
3398+
let codec = DefaultPhysicalExtensionCodec {};
3399+
let serializer = DeduplicatingSerializer::new();
3400+
let proto1 = serializer.physical_expr_to_proto(&df_arc1, &codec)?;
3401+
let proto2 = serializer.physical_expr_to_proto(&df_arc2, &codec)?;
3402+
assert_eq!(
3403+
proto1.expr_id, proto2.expr_id,
3404+
"same Inner must stamp same wire expr_id"
3405+
);
3406+
3407+
let ctx = SessionContext::new().task_ctx();
3408+
let deserializer = DeduplicatingDeserializer::new();
3409+
let d1 = deserializer.proto_to_physical_expr(&proto1, &ctx, &schema, &codec)?;
3410+
let d2 = deserializer.proto_to_physical_expr(&proto2, &ctx, &schema, &codec)?;
3411+
3412+
// Inner sharing: an update via d1 must be visible from d2 (and vice
3413+
// versa). Without inner-level dedup, d1 and d2 would each have their
3414+
// own Inner and updates wouldn't propagate.
3415+
let d1_df = d1
3416+
.as_any()
3417+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3418+
.expect("d1 must remain a DynamicFilterPhysicalExpr after roundtrip");
3419+
let d2_df = d2
3420+
.as_any()
3421+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3422+
.expect("d2 must remain a DynamicFilterPhysicalExpr after roundtrip");
3423+
assert_eq!(
3424+
d1_df.inner().expression_id,
3425+
d2_df.inner().expression_id,
3426+
"Inner must be shared (same expression_id) after deduplicating decode"
3427+
);
3428+
3429+
// The bug: each outer wrapper must carry its OCCURRENCE's children, not
3430+
// get clobbered by whichever side decoded first. d1's child Column index
3431+
// must be 0; d2's child Column index must be 1.
3432+
fn child_column_index(expr: &Arc<dyn PhysicalExpr>) -> usize {
3433+
let dyn_filter = expr
3434+
.as_any()
3435+
.downcast_ref::<DynamicFilterPhysicalExpr>()
3436+
.unwrap();
3437+
dyn_filter
3438+
.children()
3439+
.first()
3440+
.unwrap()
3441+
.as_any()
3442+
.downcast_ref::<Column>()
3443+
.unwrap()
3444+
.index()
3445+
}
3446+
assert_eq!(
3447+
child_column_index(&d1),
3448+
0,
3449+
"decoded d1 must carry its OCCURRENCE's children (Column at index 0); \
3450+
if this is 1, dedup-by-Inner is missing and the cache hit clobbered \
3451+
the children with d2's"
3452+
);
3453+
assert_eq!(
3454+
child_column_index(&d2),
3455+
1,
3456+
"decoded d2 must carry its OCCURRENCE's children (Column at index 1); \
3457+
if this is 0, dedup-by-Inner is missing and the cache hit returned \
3458+
d1's children unchanged"
33063459
);
33073460

33083461
Ok(())

0 commit comments

Comments
 (0)