You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/08/10 11:56:43 UTC

(datafusion) branch main updated: test(proto): add missing physical plan round-trip coverage (#24172)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 0f68e23bde test(proto): add missing physical plan round-trip coverage (#24172)
0f68e23bde is described below

commit 0f68e23bde46c19d2bc944132cc37bf9055c6f13
Author: Adrian Garcia Badaracco <17...@users.noreply.github.com>
AuthorDate: Mon Aug 10 07:40:13 2026 -0400

    test(proto): add missing physical plan round-trip coverage (#24172)
    
    ## Which issue does this PR close?
    
    - Part of #24171.
    
    This addresses items (a), (b) and (c) of that issue. It does **not**
    attempt (d) the colocated test tier or (e) splitting up
    `roundtrip_physical_plan.rs`, both of which are larger and independent,
    so the issue stays open.
    
    Split out of #24167 at the reviewer's prompting: @andygrove pointed out
    on that PR that the "covered by existing round-trip tests" claim did not
    hold — there is no round-trip test for `SortPreservingMergeExec`. He was
    right. Rather than bundle new tests with a mechanical refactor, they are
    here on their own so the two can be reviewed and merged independently.
    
    ## Rationale for this change
    
    `datafusion/proto/tests/cases/roundtrip_physical_plan.rs` is the safety
    net for physical-plan serialization. A field that is silently dropped on
    the wire produces a plan that still *runs* — it just returns different
    rows. Checking turned up two real holes:
    
    1. **`SortPreservingMergeExec` had no round-trip test at all.** The
    string `SortPreservingMerge` did not appear anywhere in the file.
    Nothing constructed one, so nothing on the encode or decode path for
    that plan was exercised.
    
    2. **`SortExec::fetch` is serialized but was never exercised.**
    `roundtrip_sort` and `roundtrip_sort_preserve_partitioning` both leave
    `fetch` as `None`, so the `Some(..)` state had no coverage. `fetch` is
    what makes a `SortExec` a top-k sort; dropping it on the wire silently
    widens the result set.
    
    Both are cases where a serialization gap changes query results rather
    than causing a visible failure.
    
    ## What changes are included in this PR?
    
    Two new tests in `roundtrip_physical_plan.rs`. No production code
    changes.
    
    - **`roundtrip_sort_preserving_merge`** — covers everything actually on
    the wire for `SortPreservingMergeExecNode`: the input, the sort
    expressions, and `fetch` in both its `None` (encoded as `-1`) and
    `Some(11)` states.
    - **`roundtrip_sort_with_fetch`** — `SortExec` with `fetch: Some(7)`,
    plus `fetch: Some(3)` combined with `preserve_partitioning: true`, since
    both live in the same proto node.
    
    ### Which tests assert on accessors rather than the helper, and why
    
    The file's `roundtrip_test` helper compares `format!("{plan:?}")` before
    and after. That comparison is only as good as the plan's `Debug` impl —
    it is blind to any field `Debug` does not print, which is how #24165
    (`HashJoinExec::fetch`) survived.
    
    `SortExec` and `SortPreservingMergeExec` both currently *derive*
    `Debug`, so the helper does in fact observe `expr`, `fetch` and
    `preserve_partitioning` today. I checked rather than assumed, and the
    deliberate-break results below confirm it — the helper is what fires
    first under each break.
    
    But that coverage is incidental: it would vanish the day either plan
    grows a hand-written `Debug`. So both new tests go through
    `roundtrip_test_and_return`, downcast, and assert on `fetch()`,
    `expr()`, `preserve_partitioning()` and the input schema directly, with
    the helper's string comparison still running as a backstop.
    
    ### What is deliberately *not* asserted
    
    `SortPreservingMergeExec::enable_round_robin_repartition` is **not**
    serialized — `SortPreservingMergeExecNode` has only `input`, `expr` and
    `fetch`, so decode always restores the `true` default from
    `SortPreservingMergeExec::new`. A round-trip equality assertion would
    pass whether or not that field were on the wire, so asserting on it
    would advertise coverage that does not exist. The test carries a comment
    saying so instead.
    
    The same applies to `Global/LocalLimitExec::required_ordering`, which is
    set by the `enforce_sorting` rule and starts as `None` on a decoded
    plan.
    
    If either field *should* be on the wire, that is a separate change with
    a wire-format bump, not something to paper over with a test that cannot
    tell the difference.
    
    ### Plans I checked and decided needed nothing
    
    I went through the rest of the plans touched by #24167 looking for state
    that is on the wire but exercised by no test. These already have
    adequate coverage and I did not add to them:
    
    | Plan | Existing coverage |
    |---|---|
    | `GlobalLimitExec` | `roundtrip_global_limit` (skip 0 / limit 25) and
    `roundtrip_global_skip_no_limit` (skip 10 / limit `None`) — both `skip`
    and `fetch` states |
    | `LocalLimitExec` | `roundtrip_local_limit` |
    | `FilterExec` | `roundtrip_filter_with_fetch` already asserts
    `default_selectivity`, `batch_size` and `fetch` on the accessors;
    `roundtrip_filter_projection_states` covers the projection |
    | `ProjectionExec` | `roundtrip_projection_source`,
    `roundtrip_empty_projection` |
    | `RepartitionExec` | `roundtrip_repartition_preserve_order`
    (round-robin + `preserve_order`), `roundtrip_range_partitioning`, plus
    hash-partitioning cases |
    | `UnionExec` / `InterleaveExec` | `roundtrip_union`,
    `roundtrip_interleave` — nothing on the wire beyond the children |
    | `CoalesceBatchesExec` | `roundtrip_coalesce_batches_with_fetch` covers
    `target_batch_size` and `fetch` in both states |
    | `CoalescePartitionsExec` | `roundtrip_coalesce_partitions_with_fetch`,
    both `fetch` states |
    | `CooperativeExec` | `roundtrip_cooperative` — only the input is on the
    wire |
    | `BufferExec` | `roundtrip_buffer` asserts `capacity()` on the accessor
    |
    | `EmptyExec` / `PlaceholderRowExec` |
    `roundtrip_empty_with_partitions`,
    `roundtrip_placeholder_row_with_partitions` |
    | `ExplainExec` | `roundtrip_explain` asserts schema, stringified plans
    and `verbose` on the accessors |
    | `ScalarSubqueryExec` | `roundtrip_scalar_subquery_exec` and the
    executing variant |
    
    Per the issue, this is not a push for 100% field coverage. These two
    were the cases where the gap was real and the test was cheap; past them
    it got contrived fast.
    
    ## Are these changes tested?
    
    This PR *is* tests. To confirm they are not vacuous, I broke the encode
    side on purpose and checked each one fails:
    
    | Deliberate break | Result |
    |---|---|
    | `SortPreservingMergeExec` encode: hardcode `fetch: -1` |
    `roundtrip_sort_preserving_merge` **FAILS** |
    | `SortPreservingMergeExec` encode: `.take(1)` on the sort expressions |
    `roundtrip_sort_preserving_merge` **FAILS** |
    | `SortExec` encode: hardcode `fetch: -1` | `roundtrip_sort_with_fetch`
    **FAILS** |
    
    Under all three breaks the pre-existing `roundtrip_sort` and
    `roundtrip_sort_preserve_partitioning` kept passing — a direct
    demonstration that the gap was real. All breaks reverted; the diff here
    is test-only.
    
    Checks run:
    
    - `cargo fmt --all`
    - `cargo clippy --all-targets --workspace --features
    avro,integration-tests,extended_tests -- -D warnings` — clean
    - `cargo test -p datafusion-proto --test proto_integration` — 216
    passed, 0 failed (214 before this PR)
    - `cargo test -p datafusion-physical-plan` — 1641 passed, 0 failed
    
    ### Why this is based on `main` rather than on #24167
    
    These tests pass on unmodified `main` — verified, not assumed. They
    describe serialization behaviour that already exists, and none of them
    depends on #24167's changes.
    
    That gives a useful property: merged first, they pin the current wire
    behaviour independently, which makes #24167's "wire format unchanged"
    claim something CI verifies rather than something the PR description
    asserts.
    
    ## Are there any user-facing changes?
    
    No. Test-only; no production code touched.
    
    Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
 .../proto/tests/cases/roundtrip_physical_plan.rs   | 156 +++++++++++++++++++++
 1 file changed, 156 insertions(+)

diff --git a/datafusion/proto/tests/cases/roundtrip_physical_plan.rs b/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
index 19249cf97b..3f03e58656 100644
--- a/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
+++ b/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
@@ -88,6 +88,7 @@ use datafusion::physical_plan::scalar_subquery::{
     ScalarSubqueryExec, ScalarSubqueryLink,
 };
 use datafusion::physical_plan::sorts::sort::SortExec;
+use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
 use datafusion::physical_plan::union::{InterleaveExec, UnionExec};
 use datafusion::physical_plan::unnest::{ListUnnest, UnnestExec};
 use datafusion::physical_plan::windows::{
@@ -1199,6 +1200,161 @@ fn roundtrip_sort_preserve_partitioning() -> Result<()> {
     ))
 }
 
+/// `SortExec::fetch` turns a sort into a top-k sort. Losing it during serde
+/// would silently widen the result set, so exercise the `Some(..)` state
+/// explicitly (`roundtrip_sort` only covers `None`).
+///
+/// `SortExec` currently derives `Debug`, so `roundtrip_test`'s
+/// `format!("{plan:?}")` comparison does observe `fetch`. The assertions below
+/// go through the accessor instead so that this coverage does not silently
+/// disappear if `SortExec` ever grows a hand-written `Debug` impl.
+#[test]
+fn roundtrip_sort_with_fetch() -> Result<()> {
+    let field_a = Field::new("a", DataType::Boolean, false);
+    let field_b = Field::new("b", DataType::Int64, false);
+    let schema = Arc::new(Schema::new(vec![field_a, field_b]));
+    let sort_exprs: LexOrdering = [
+        PhysicalSortExpr {
+            expr: col("a", &schema)?,
+            options: SortOptions {
+                descending: true,
+                nulls_first: false,
+            },
+        },
+        PhysicalSortExpr {
+            expr: col("b", &schema)?,
+            options: SortOptions {
+                descending: false,
+                nulls_first: true,
+            },
+        },
+    ]
+    .into();
+
+    let ctx = SessionContext::new();
+    let codec = DefaultPhysicalExtensionCodec {};
+    let proto_converter = DefaultPhysicalProtoConverter {};
+
+    let roundtripped = roundtrip_test_and_return(
+        Arc::new(
+            SortExec::new(
+                sort_exprs.clone(),
+                Arc::new(EmptyExec::new(Arc::clone(&schema))),
+            )
+            .with_fetch(Some(7)),
+        ),
+        &ctx,
+        &codec,
+        &proto_converter,
+    )?;
+    let roundtripped = roundtripped
+        .downcast_ref::<SortExec>()
+        .expect("should decode back into a SortExec");
+    assert_eq!(roundtripped.fetch(), Some(7));
+    assert_eq!(roundtripped.expr(), &sort_exprs);
+
+    // `fetch` combined with `preserve_partitioning`, since both share the same
+    // proto node.
+    let roundtripped = roundtrip_test_and_return(
+        Arc::new(
+            SortExec::new(sort_exprs.clone(), Arc::new(EmptyExec::new(schema)))
+                .with_fetch(Some(3))
+                .with_preserve_partitioning(true),
+        ),
+        &ctx,
+        &codec,
+        &proto_converter,
+    )?;
+    let roundtripped = roundtripped
+        .downcast_ref::<SortExec>()
+        .expect("should decode back into a SortExec");
+    assert_eq!(roundtripped.fetch(), Some(3));
+    assert!(roundtripped.preserve_partitioning());
+    Ok(())
+}
+
+/// Round trip a [`SortPreservingMergeExec`], which had no dedicated round trip
+/// test at all.
+///
+/// Covers everything that is actually on the wire for this plan: the input, the
+/// sort expressions and `fetch` in both its `None` and `Some(..)` states.
+///
+/// Note that `SortPreservingMergeExec::enable_round_robin_repartition` is
+/// deliberately *not* asserted on here: it has no field in
+/// `SortPreservingMergeExecNode`, so it is not serialized and decoding always
+/// restores the `true` default from `SortPreservingMergeExec::new`. Asserting
+/// round trip equality on it would give a false sense of coverage.
+///
+/// `SortPreservingMergeExec` derives `Debug`, so `roundtrip_test`'s
+/// `format!("{plan:?}")` comparison does observe `expr` and `fetch`. The
+/// assertions below use the accessors so the coverage survives a future
+/// hand-written `Debug` impl.
+#[test]
+fn roundtrip_sort_preserving_merge() -> Result<()> {
+    let field_a = Field::new("a", DataType::Boolean, false);
+    let field_b = Field::new("b", DataType::Int64, false);
+    let schema = Arc::new(Schema::new(vec![field_a, field_b]));
+    let sort_exprs: LexOrdering = [
+        PhysicalSortExpr {
+            expr: col("a", &schema)?,
+            options: SortOptions {
+                descending: true,
+                nulls_first: false,
+            },
+        },
+        PhysicalSortExpr {
+            expr: col("b", &schema)?,
+            options: SortOptions {
+                descending: false,
+                nulls_first: true,
+            },
+        },
+    ]
+    .into();
+
+    let ctx = SessionContext::new();
+    let codec = DefaultPhysicalExtensionCodec {};
+    let proto_converter = DefaultPhysicalProtoConverter {};
+
+    // No fetch: `fetch` is encoded as -1 and must decode back to `None`.
+    let roundtripped = roundtrip_test_and_return(
+        Arc::new(SortPreservingMergeExec::new(
+            sort_exprs.clone(),
+            Arc::new(EmptyExec::new(Arc::clone(&schema))),
+        )),
+        &ctx,
+        &codec,
+        &proto_converter,
+    )?;
+    let roundtripped = roundtripped
+        .downcast_ref::<SortPreservingMergeExec>()
+        .expect("should decode back into a SortPreservingMergeExec");
+    assert_eq!(roundtripped.fetch(), None);
+    assert_eq!(roundtripped.expr(), &sort_exprs);
+    assert_eq!(roundtripped.input().schema(), schema);
+
+    // With a fetch: dropping it would turn a bounded merge into an unbounded
+    // one and change the query result.
+    let roundtripped = roundtrip_test_and_return(
+        Arc::new(
+            SortPreservingMergeExec::new(
+                sort_exprs.clone(),
+                Arc::new(EmptyExec::new(Arc::clone(&schema))),
+            )
+            .with_fetch(Some(11)),
+        ),
+        &ctx,
+        &codec,
+        &proto_converter,
+    )?;
+    let roundtripped = roundtripped
+        .downcast_ref::<SortPreservingMergeExec>()
+        .expect("should decode back into a SortPreservingMergeExec");
+    assert_eq!(roundtripped.fetch(), Some(11));
+    assert_eq!(roundtripped.expr(), &sort_exprs);
+    Ok(())
+}
+
 #[test]
 fn roundtrip_coalesce_batches_with_fetch() -> Result<()> {
     let field_a = Field::new("a", DataType::Boolean, false);


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org