@@ -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