Skip to content

[X-3269] Empty-plan discriminator: RewriteReadiness + per-branch CandidateMetadata - #55

Merged
zhuqi-lucas merged 20 commits into
branch-54from
qizhu/x-3269-candidate-table-refs
Jul 24, 2026
Merged

[X-3269] Empty-plan discriminator: RewriteReadiness + per-branch CandidateMetadata#55
zhuqi-lucas merged 20 commits into
branch-54from
qizhu/x-3269-candidate-table-refs

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Empty candidate plans currently look indistinguishable to a cost function regardless of source. A fully-populated MV whose leading-sort-key stats prove absence of a rare literal via metadata alone (X-3269 case, e.g. WHERE composite_ticker = 'PQXX' on constituents_by_composite_ticker_v4) collapses to bare EmptyExec — same shape as a freshly-deployed MV whose ingest task hasn't run yet. Downstream discriminators can't tell them apart and end up either penalising both (X-3269: specialised MV loses to a 30s base-table scan) or trusting both (X-2174: unpopulated MVs silently return empty answers).

This PR fixes the semantics by splitting the two concerns across three layers:

  1. LP-time filter in ViewMatcher::f_down via a new
    Materialized::rewrite_readiness() -> RewriteReadiness trait method
    (Ready | NotReady | Unknown, default Unknown). MVs reporting
    NotReady are filtered out of the candidate set at LP rewrite time.

  2. Per-branch metadata that survives to the cost function via
    RewriteContext::candidates: Vec<CandidateMetadata>, aligned with
    OneOf::branches. CandidateMetadata::Materialized { table_ref, readiness }
    carries the provider's raw readiness verbatim, so cost functions can
    apply their own policy for the Unknown case (e.g. fall back to base
    scan cost) instead of being forced to trust every candidate equally.
    table_ref is stored as a TableReference (not stringified) so
    quoted / dotted identifiers round-trip losslessly through the
    physical-time catalog resolution.

  3. Physical-time readiness refresh in ViewExploitationPlanner::plan_extension
    re-consults each Materialized provider after DataFusion has invoked
    TableProvider::scan() on every candidate branch. Providers that opt
    into ReadinessAnnotatedExec (see below) get their readiness read
    straight off the plan tree — atomically captured at scan time.
    Providers that haven't opted in fall back to catalog sampling, which
    is best-effort and can be stale under concurrent snapshot swaps.
    drop_not_ready_after_refresh then removes any candidate that
    transitioned to NotReady, preserving the invariant that
    NotReady never reaches the cost function.

Architecture

The three layers wire together like this. The provider is consulted twice
— once at LP-rewrite time (cold, last_modified may still be None) and
again at physical-planning time (after scan() has run, so any lazy index is
warm and readiness is definitive). NotReady is filtered by two gates so the
cost function only ever sees Base | Ready | Unknown.

flowchart TD
    A["SQL"] --> B["Logical planning"]
    B --> C["ViewMatcher::f_down<br/>(LP time)"]
    C -->|"provider.rewrite_readiness()<br/>#1 · cold, pre-scan"| D{"Readiness?"}
    D -->|NotReady| E["🚫 filtered — never enters OneOf"]
    D -->|Ready / Unknown| F["OneOf {<br/>branches: [Base, MV_a, MV_b, ...],<br/>candidates: [Base, Materialized{ref, readiness}, ...]<br/>}"]
    F --> G["Physical planning"]
    G -->|"TableProvider::scan()<br/>· lazy index warms here<br/>· provider wraps output in<br/>ReadinessAnnotatedExec<br/>with atomically-captured readiness"| H["ViewExploitationPlanner::plan_extension"]
    H -->|"prefer annotated readiness on the plan<br/>OR fall back to sampling<br/>provider.rewrite_readiness() #2"| I["refresh_candidate_readiness<br/>· update metadata with fresh value"]
    I --> J["drop_not_ready_after_refresh<br/>· post-scan NotReady 🚫"]
    J -->|only Base left| K["Short-circuit:<br/>return base physical plan"]
    J -->|multiple candidates| L["OneOfExec::try_new(cost_fn)"]
    L -->|"CostContext.candidates:<br/>[Base, Ready, Unknown]<br/>(NotReady impossible)"| M["Cost function"]
    M --> N["PruneCandidates picks min"]
    K --> Z["Execute"]
    N --> Z
Loading

Atomic-readiness opt-in: Sampling rewrite_readiness() again after
scan() has returned is racy — a concurrent snapshot swap can publish
new state between the two calls, letting an EmptyExec from the old
snapshot be labelled with readiness from the new one. Providers close
that race by capturing readiness atomically at scan time and returning
Arc::new(ReadinessAnnotatedExec::new(scan_output, captured_readiness)).
The refresh walks each candidate's plan tree for a wrapper and reads
its readiness verbatim; providers that haven't opted in keep the
(racy) sampling fallback for backward compatibility.

The library ships layers 1–3 plus the atomic-readiness vehicle. The provider
(e.g. atlas's MaterializedView<Index>) implements rewrite_readiness however
fits its lifecycle model and optionally wraps its scan output in
ReadinessAnnotatedExec; the cost function branches on the readiness
value however fits its cost model. The atlas companion PR wires up both:

Layer Responsibility Owned by
Materialized::rewrite_readiness Report lifecycle state (cheap check) Provider
f_down NotReady filter Drop pre-scan NotReady from candidate set This PR
Per-branch CandidateMetadata Carry readiness value through to cost fn This PR
ReadinessAnnotatedExec wrapper Atomically propagate readiness with the scan output Provider opt-in; wrapper defined here
plan_extension refresh + filter Re-consult (annotated or sampled) after scan, drop post-scan NotReady This PR
deprioritize_empty_candidates Trust Ready+empty, penalise Unknown+empty Cost fn (atlas side)

File organisation

All readiness-related types and helpers live in src/rewrite/readiness.rs:
RewriteReadiness, CandidateMetadata, ReadinessAnnotatedExec,
readiness_from_plan, refresh_candidate_readiness,
drop_not_ready_after_refresh, resolve_current_readiness. The pre-move
locations (crate::materialized::RewriteReadiness,
crate::rewrite::exploitation::{CandidateMetadata, ReadinessAnnotatedExec, readiness_from_plan}) still resolve via pub use re-exports, so downstream
callers don't have to change their imports.

Changes

Materialized trait (in src/materialized.rs)

  • New RewriteReadiness enum (defined in src/rewrite/readiness.rs,
    re-exported here for backward compat): Ready / NotReady / Unknown.
  • New fn rewrite_readiness(&self) -> RewriteReadiness with Unknown
    default — backward-compatible for providers that don't distinguish
    lifecycle states.

CandidateMetadata + ReadinessAnnotatedExec (in src/rewrite/readiness.rs)

  • CandidateMetadata::Materialized { table_ref: TableReference, readiness }
    carries the provider's raw readiness verbatim. TableReference (not
    String) so quoted / dotted identifiers round-trip losslessly.
    Sorted deterministically by TableReference in f_down so alignment
    survives downstream LP rewrites via with_exprs_and_inputs.
  • ReadinessAnnotatedExec transparent wrapper carrying a
    RewriteReadiness value alongside an ExecutionPlan, so providers can
    publish the readiness that corresponds to the snapshot their scan()
    read from.
  • readiness_from_plan depth-first walker used by the refresh to
    extract annotated readiness from a candidate's physical input.

ViewMatcher::f_down

  • Filters NotReady MVs before they enter the candidate set.
  • Encodes Ready / Unknown values into CandidateMetadata::Materialized.
  • Pre-sorts MV candidates so OneOf::inputs() becomes a no-op.
  • Keeps the base branch pinned at branches[0].

ViewExploitationPlanner::plan_extension

  • Calls refresh_candidate_readiness — prefers ReadinessAnnotatedExec
    when a candidate's physical_input carries one; falls back to catalog
    sampling otherwise.
  • Filters NotReady post-scan transitions via drop_not_ready_after_refresh.
  • Short-circuits to the base physical plan directly when only Base remains.

Test coverage (61 lib tests, all passing)

New in this PR (organised by concern):

  • Metadata fieldf_down_encodes_ready_and_unknown_readiness_verbatim,
    candidate_metadata_partial_eq_discriminates_readiness,
    one_of_alignment_survives_rebuild_with_unknown_readiness.
  • LP-time filterf_down_excludes_not_ready_and_admits_ready_and_unknown,
    f_down_returns_original_lp_when_every_mv_is_not_ready,
    f_down_reflects_provider_readiness_on_each_rewrite.
  • Physical-time refresh — 5 tests covering post-scan Unknown → Ready,
    Unknown → NotReady when scan uncovers unpopulated, missing-provider
    fallback, Base variant preservation, and Base-at-index-0 invariant.
  • Atomic-readiness wrapperrefresh_prefers_annotated_readiness_over_catalog_sampling
    (intentional disagreement between annotated Ready and sampled Unknown,
    asserts annotated wins), refresh_falls_back_to_sampling_when_input_not_annotated,
    readiness_from_plan_walks_nested_children,
    readiness_from_plan_returns_none_when_no_annotation,
    readiness_annotated_exec_delegates_properties_and_children.
  • NotReady filter — 3 tests covering the happy path, backward-compat
    passthrough when metadata is missing, and the collapse-to-Base short-circuit.
  • End-to-end via plan_extension — 2 tests driving the actual planner
    method through a SessionContext, covering the cold-lazy Ready promotion
    and the short-circuit-to-base scenarios.

Affects

Downstream cost functions consuming RewriteContext::candidates() and MV
providers overriding rewrite_readiness(). The atlas repo has a companion
PR that:

  • Overrides MaterializedView<Index>::rewrite_readiness() to report Ready
    when Index::get_last_modified().is_some() and Unknown otherwise.
    Never returns NotReady — atlas cannot cheaply distinguish cold-lazy
    from unpopulated, so Unknown + cost-fn fallback handles both.
  • Modifies deprioritize_empty_candidates to skip the +INFINITY penalty
    when a candidate is Materialized { readiness: Ready } (trust the empty
    as predicate-pruned); the pre-readiness "penalize every empty" behaviour
    is retained as a fallback for Unknown and for older callsites that
    don't wire CandidateMetadata through.
  • Verified end-to-end against ny2 staging: PQXX-shape query on
    external.etfglobal.constituents_v1 picks the specialised MV, returns
    EmptyExec in microseconds instead of the previous 30 s base-table
    scan.

…lan discriminator

Empty candidate plans currently look indistinguishable to a cost function
regardless of source: a fully-populated materialized view whose leading
sort-key min/max stats proved absence of a rare query literal (X-3269
case, e.g. `WHERE composite_ticker = 'MFSI'` on
`constituents_by_ticker_date_v4` — 0 files after metadata prune, tree
collapses to `EmptyExec`) reads the same as a freshly-deployed MV whose
ingest task hasn't run yet (X-2174 case, 0 files because the MV was
never populated). Downstream discriminators (e.g. atlas's
`deprioritize_empty_candidates` in `crates/atlas/src/execution/cost_model.rs`)
can't tell which is which and end up either:

* penalizing both — the specialised MV loses to a 30s base-table scan
  (observed in prod: `/etf-global/v1/constituents?composite_ticker=MFSI`
  timing out at 30s across 2446 base files with `files_ranges_pruned_statistics=0`)
* or trusting both — routing queries to unpopulated MVs and silently
  returning empty results

This change threads two per-branch signals through `RewriteContext`,
aligned with candidate plan order at `OneOf` / `OneOfExec` construction:

* `candidate_table_refs: Vec<Option<String>>` — source MV `TableReference`
  for each branch (`None` for the base branch)
* `candidate_file_counts: Vec<Option<usize>>` — total MV file count at
  LP-rewrite time via a new `Materialized::file_count()` trait method
  (default `None`; providers with eagerly-loaded file indexes override
  it to return `Some(index.total_files())`)

Cost functions can now discriminate:
  file_count == Some(0) → unpopulated MV, safe to deprioritize (X-2174)
  file_count == Some(n>0) → populated MV, empty means predicate-pruned,
    keep the -inf so the MV wins (X-3269)
  file_count == None → base branch or provider without file_count → fall
    back to whatever discriminator the caller uses

Both fields are additive builder-style (`with_candidate_table_refs`,
`with_candidate_file_counts`) and default to empty, so pre-existing
callers of `RewriteContext::new` keep the same behavior.

Atlas end-to-end verification against staging data servers:
  MFSI (rare literal, prunes to zero across all MVs):
    before fix: 30.0s timeout, base scan of 2446 files
    after fix:  52.6ms, `SortExec -> EmptyExec` on `by_ticker_date_v4`
  SPY (populated ticker):
    plan unchanged — view matcher still picks `by_ticker_date_v4` with
    file_count=10, scan_direction=Reversed, fetch=1
Copilot AI review requested due to automatic review settings July 21, 2026 08:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the view-matching rewrite metadata so downstream cost functions can distinguish “populated MV pruned to empty by predicates” from “unpopulated MV (zero files)” when an empty physical plan collapses to EmptyExec.

Changes:

  • Add Materialized::file_count() -> Option<usize> (default None) to expose total MV file count (ignoring predicates).
  • Extend RewriteContext with per-branch candidate_table_refs and candidate_file_counts, plus builder/getter APIs.
  • Populate these per-branch signals when constructing OneOf candidates in ViewMatchingRewriter::f_down.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/rewrite/exploitation.rs Threads per-branch MV identity + file-count into RewriteContext during OneOf candidate construction.
src/materialized.rs Adds optional file_count() hook to the Materialized trait for populated-ness discrimination.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
@zhuqi-lucas
zhuqi-lucas marked this pull request as draft July 21, 2026 08:06
… fmt/clippy

Copilot review on massive-com PR #55 flagged a real ordering bug:
`OneOf::inputs()` sorts branches by `LogicalPlan::partial_cmp` before handing
them to DataFusion's planner. The initial X-3269 patch built the branches +
`candidate_table_refs` / `candidate_file_counts` vectors in insertion order,
so once `inputs()` reordered the branches the metadata would be
misattributed — silently swapping MV identity/populated-ness onto the wrong
physical candidate at cost-function time.

Fix: pre-sort branches AND both metadata vectors together in
`ViewMatchingRewriter::f_down`, using the same comparator `inputs()` uses.
`inputs()`'s sort is then a no-op on our stored order, `plan_extension`
receives `physical_inputs` in the same order, and index i of the metadata
lines up with index i of the physical candidate the cost function sees.

Also:

* Add `one_of_branches_and_candidate_metadata_share_ordering` regression
  test that assembles a `OneOf` in non-sorted insertion order, verifies
  the stored branches match `inputs()`'s exposed order, and confirms both
  metadata slices remain aligned. Guards against `inputs()` gaining a
  different comparator, or a future callsite forgetting to pre-sort.
* Add `rewrite_context_defaults_candidate_lists_to_empty` +
  `rewrite_context_builders_attach_candidate_metadata` covering the new
  public API surface (`RewriteContext::new` still empty by default,
  builders round-trip through the getters).
* Grammar polish per Copilot comment (`not un-population` →
  `not an unpopulated MV`).
* Reindent doc bullet lists to appease
  `clippy::doc_overindented_list_items`.
* `cargo fmt --all`.
@zhuqi-lucas
zhuqi-lucas marked this pull request as ready for review July 21, 2026 08:30
`EmbarkStudios/cargo-deny-action@v2` uses cargo-deny v0.14+, which renamed
the singular `license` enum value to plural `licenses` (breaking change).
The workflow config was still written against the v1 spelling, so every
recent PR — including main-branch pushes — has been failing with

    error: invalid value 'license' for '[WHICH]...'

One-word fix; unblocks the Cargo Deny check across the repo.
The per-branch `candidate_table_refs` / `candidate_file_counts` +
`Materialized::file_count()` changes were originally motivated by two
issues tracked in an internal Polygon project ("X-2174" for unpopulated
MVs winning cost, "X-3269" for populated predicate-pruned MVs losing).
Those ticket IDs meant nothing outside of that repo, so replace them
with plain-language descriptions of the semantics they refer to. The
upstream-ready doc reads:

* `file_count == 0` → unpopulated MV (should not win)
* `file_count > 0` + physical plan collapsed to `EmptyExec` →
  predicate-pruned populated MV (should win via `-inf` cost)

No behavioural change; comment rewording only.
@zhuqi-lucas
zhuqi-lucas requested a review from xudong963 July 22, 2026 03:16
Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
Xudong's review on the massive-com fork (PR #55) flagged three concerns
that all point at the same design gap: two parallel `Vec<Option<..>>`
metadata slices, positional alignment against a `branches` vector that
`OneOf::inputs()` was re-sorting on every call, and a `f_down` pre-sort
that moved the base branch off `branches[0]` — so `OneOf::schema()`
could accidentally expose an MV's schema rather than the query's.

Refactored per the review to fix all three at once:

1. Replace `candidate_table_refs: Vec<Option<String>>` and
   `candidate_file_counts: Vec<Option<usize>>` with a single
   `candidates: Vec<CandidateMetadata>`, where
   `CandidateMetadata::{Base, Materialized{table_ref,file_count}}`
   makes invalid combinations unrepresentable and matches the semantic
   shape (base has no MV identity; MV always has both).

2. `ViewMatchingRewriter::f_down` now keeps the base branch at index 0
   and sorts only the MV candidates. The sort key is each MV's
   `TableReference`, not its LP shape — this is stable across the
   downstream optimizer passes that change LP structure (identity
   Projection elimination, always-true Filter folding, etc.) but never
   change an MV's registered name.

3. `OneOf::inputs()` returns branches in stored order (no re-sort).
   Determinism now comes from the sort in `f_down`, and the alignment
   between `RewriteContext::candidates()` and the physical candidate
   the cost function receives survives every `with_exprs_and_inputs`
   rebuild.

Together these preserve the invariant "index 0 is the base branch, and
its schema is the query's schema" — which
`OneOf::schema() -> branches[0].schema()` depends on.

Test coverage added (4 new tests + 2 rewritten):

* `rewrite_context_defaults_candidates_to_empty` — backward compatibility
  for `RewriteContext::new` callers that don't attach metadata.
* `rewrite_context_builder_attaches_candidate_metadata` — enum
  round-trips through the builder/getter.
* `one_of_inputs_returns_stored_order_without_resort` — regression
  guard for the re-sort bug; deliberately stores branches in a
  non-sorted order and asserts `inputs()` doesn't reorder them.
* `one_of_schema_always_reports_base_branch_schema` — locks in the
  documented "index 0 is base, its schema is the query schema"
  invariant even for callers that construct OneOf directly.
* `one_of_alignment_survives_with_exprs_and_inputs_rebuild` — simulates
  an optimizer pass rewriting an MV branch, then rebuilds via
  `with_exprs_and_inputs`, and asserts metadata stays aligned to the
  transformed branch at the same index.

All 12 exploitation tests pass. cargo clippy clean.

@xudong963 xudong963 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought more, considering this is a lib, it might be better to reduce coupling with the storage layout.

The cost model only needs to know whether an MV is safe to use, while file_count is an unreliable proxy for that lifecycle state.

How about considering exposing an explicit Ready | NotReady | Unknown status from the same published snapshot used for scanning, and excluding NotReady candidates in ViewMatchingRewriter. This keeps lifecycle correctness in the provider instead of coupling the cost model to storage layout.

pub enum RewriteReadiness {
    Ready,
    NotReady,
    Unknown,
}

pub trait Materialized: ListingTableLike {
    fn rewrite_readiness(&self) -> RewriteReadiness {
        RewriteReadiness::Unknown
    }
}

On our side:

match self.index() {
    Some(index) if index.get_last_modified().is_some() => RewriteReadiness::Ready,
    Some(_) => RewriteReadiness::NotReady,
    None => RewriteReadiness::Unknown,
}

@zhuqi-lucas

zhuqi-lucas commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

I thought more, considering this is a lib, it might be better to reduce coupling with the storage layout.

The cost model only needs to know whether an MV is safe to use, while file_count is an unreliable proxy for that lifecycle state.

How about considering exposing an explicit Ready | NotReady | Unknown status from the same published snapshot used for scanning, and excluding NotReady candidates in ViewMatchingRewriter. This keeps lifecycle correctness in the provider instead of coupling the cost model to storage layout.

pub enum RewriteReadiness {
    Ready,
    NotReady,
    Unknown,
}

pub trait Materialized: ListingTableLike {
    fn rewrite_readiness(&self) -> RewriteReadiness {
        RewriteReadiness::Unknown
    }
}

On our side:

match self.index() {
    Some(index) if index.get_last_modified().is_some() => RewriteReadiness::Ready,
    Some(_) => RewriteReadiness::NotReady,
    None => RewriteReadiness::Unknown,
}

Thanks @xudong963 , great idea, so the emptyexec after this must be the prune cases, in progress.

Xudong's follow-up review on the massive-com fork PR #55 pointed out
that `Materialized::file_count()` was leaking storage-layout detail
into what is a library abstraction — the cost model only needs to
know whether an MV is "safe to use", and `file_count` is an unreliable
proxy for that (stale index, partial migration, unpublished snapshot,
etc. all present as `file_count > 0` but shouldn't route queries).

Refactor per his suggestion:

1. Replace `Materialized::file_count() -> Option<usize>` with
   `Materialized::rewrite_readiness() -> RewriteReadiness`. The
   returned enum is `Ready | NotReady | Unknown`, describing the
   lifecycle intent directly rather than through a file-count proxy.
   Default is `Unknown` (include as candidate, cost function decides)
   so existing providers keep working.

2. `ViewMatchingRewriter::f_down` now filters `NotReady` MVs upstream,
   before they enter the candidate set. The cost function only ever
   sees Ready + Unknown candidates, so it doesn't need to distinguish
   "unpopulated" from "predicate-pruned" any more — every empty MV
   plan reaching the cost function is predicate-pruned by construction.

3. `CandidateMetadata::Materialized` drops the `file_count` field;
   only `table_ref` remains (used as the transformation-invariant
   sort key that keeps per-branch metadata aligned with the physical
   candidate the cost function sees).

Test coverage:

* `tests_readiness::variants_are_distinct_and_comparable` — enum
  variants distinct, PartialEq works.
* `tests_readiness::readiness_is_copy_and_hashable` — trait bound
  guarantees so the rewrite path can match / store it cheaply.
* `rewrite_context_builder_attaches_candidate_metadata` — updated
  to new enum shape (no `file_count`).
* `multi_mv_candidates_sort_lexicographically_by_table_ref` — new,
  proves the deterministic ordering with 3 MVs in reverse-lex
  insertion order.
* Existing alignment tests (`one_of_inputs_returns_stored_order...`,
  `one_of_schema_always_reports_base_branch_schema`,
  `one_of_alignment_survives_with_exprs_and_inputs_rebuild`)
  updated to match the new `CandidateMetadata::Materialized{table_ref}`
  shape.

All 37 lib tests pass. `cargo clippy --workspace --all-targets`
clean.
Cover the actual invocation path of the NotReady filter (not just the
policy predicate) via a MockMv with configurable rewrite_readiness()
and a test-only ViewMatcher::from_mv_plans_for_test constructor that
skips the SessionContext / catalog walk.

Two new tests in tests_view_matcher_readiness_filter:

* `f_down_excludes_not_ready_and_admits_ready_and_unknown` — registers
  three MVs (Ready / NotReady / Unknown) all covering the same source
  table, drives the ViewMatchingRewriter over a TableScan LP, and
  asserts:
    - Ready MV appears among the OneOf candidates
    - Unknown MV appears among the OneOf candidates
    - NotReady MV is filtered out
    - Base branch is at index 0 (invariant re-verified)

* `f_down_returns_original_lp_when_every_mv_is_not_ready` — registers
  only a NotReady MV and asserts the rewriter returns the original
  TableScan LP untouched (no candidate-less OneOf wrapper).

Fixture: MockMv implements TableProvider + ListingTableLike +
Materialized with a caller-selectable RewriteReadiness. The added
`ViewMatcher::from_mv_plans_for_test` bypasses the full try_new_from_state
setup so tests inject arbitrary mv_plans directly.

Test count: 37 → 39 (+2 integration). cargo clippy clean.
CI runs on Rust 1.97 which added the `clippy::unnecessary_sort_by`
lint. Both `sort_by(|a, b| a.1.to_string().cmp(&b.1.to_string()))`
call sites (one in `ViewMatchingRewriter::f_down`, one in the
multi-MV sort test) are exact matches for the lint's suggested
`sort_by_key` form. Zero behavioural change.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread src/materialized.rs Outdated
Comment thread src/materialized.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
Two follow-ups from the latest Copilot review:

* `ViewMatchingRewriter::f_down` used `cast_to_materialized(..).ok().flatten()`
  which silently defaulted a real cast `DataFusionError` (e.g. static-partition
  invariant violation) to `RewriteReadiness::Unknown`. Switch to an explicit
  `match` that logs the error at `warn!` before defaulting, so catalog issues
  stay diagnosable while the rewrite path keeps functioning.

* Several rustdoc comments used `ViewMatchingRewriter` (private) as the visible
  link text with `ViewMatcher` (public) as the resolved target, producing
  confusing rendered docs. Standardise on `ViewMatcher` since it's the public
  optimizer rule; the rewriter is an implementation detail.

No behavioural change beyond the added warn log.
@zhuqi-lucas zhuqi-lucas changed the title [X-3269] Thread per-branch file_count into RewriteContext for empty-plan discriminator [X-3269] Empty-plan discriminator: RewriteReadiness + per-branch CandidateMetadata Jul 22, 2026
@zhuqi-lucas
zhuqi-lucas requested a review from xudong963 July 22, 2026 06:11
@zhuqi-lucas

Copy link
Copy Markdown
Collaborator Author

@xudong963 Updated the design now, and added rich tests.

// to `Unknown` so catalog issues stay debuggable, then treat the
// MV as Unknown (the pre-existing "include as candidate, cost
// function decides" default) so the rewrite path keeps functioning.
let readiness = match cast_to_materialized(table.as_ref()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This readiness check happens during logical rewriting, before the candidate scan is planned. In our internal case, lazy_index is enabled and a cold index has get_last_modified() == None until Index::query() is called from FileScanTable::scan(). With the proposed Atlas mapping, a valid but not-yet-loaded MV is therefore classified as NotReady and filtered here, so the scan that would initialize it is never planned.

Returning Unknown does not fully solve this either: Unknown is admitted below, but CandidateMetadata::Materialized retains only table_ref, so the cost function cannot distinguish it from Ready despite being documented as responsible for the unknown-state policy.

Could readiness be resolved after the physical inputs have been planned, when lazy index initialization has completed, or otherwise be propagated so Unknown must be handled explicitly? A cold-start test with lazy_index = true would catch this case later in our internal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

Comment thread src/materialized.rs Outdated
/// for unknown-lifecycle MVs. Default value returned by the trait's
/// blanket impl so backward-compatible providers keep the pre-existing
/// "always a candidate" behaviour.
Unknown,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unknown is documented as leaving the policy to the cost function, but this value is discarded before constructing RewriteContext. f_down admits both Ready and Unknown, and both are encoded as CandidateMetadata::Materialized { table_ref }, so a cost function cannot distinguish them through this API.

This also makes it unsafe to assume that every materialized EmptyExec is predicate-pruned, especially because Unknown is the default for existing providers. Could readiness be retained in CandidateMetadata, or could Unknown be handled explicitly rather than being erased?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Readiness be retained in CandidateMetadata is a good suggestion.

Two review comments from xudong on PR #55, both landing on the same design
gap in the RewriteReadiness plumbing:

* `f_down` admits both `Ready` and `Unknown`, but both were encoded into the
  same `CandidateMetadata::Materialized { table_ref }` shape — so a cost
  function that trusted every `Materialized` candidate as predicate-pruned
  would silently promote every default-`Unknown` provider to `Ready`. That
  regresses providers that don't yet distinguish lifecycle states.
* Providers that use a cold lazy-loaded index (`Index::query()` only runs
  from `FileScanTable::scan()`) cannot correctly report `NotReady` in the
  cold phase — doing so filters the MV out at LP rewrite time, the scan
  never plans, and the index stays cold forever. Reporting `Unknown` is
  the correct policy, but only if the cost function can tell it apart from
  `Ready` when it's making its policy decision.

Fix: give `CandidateMetadata::Materialized` a `readiness: RewriteReadiness`
field and thread the provider's raw readiness through `f_down` verbatim.
Cost functions can now branch on it — trust `Ready` as predicate-pruned,
fall back to a conservative estimate for `Unknown` — without needing any
extra plumbing.

Also derive `PartialOrd, Ord` on `RewriteReadiness` (needed by the
`PartialOrd` derive on `CandidateMetadata`).

Tests:
- Existing tests updated to construct `Materialized` with the new field.
- `f_down_encodes_ready_and_unknown_readiness_verbatim`: regression for
  the Ready/Unknown confusion in review comment 2.
- `f_down_metadata_length_matches_branch_count`: alignment invariant.
- `f_down_admits_unknown_provider_across_lifecycle_transition`:
  `MockMv` now has interior-mutable readiness (`AtomicU8`) so a single
  provider can be observed reporting `Unknown` first, then `Ready` after
  a simulated warm-up — locks in that reporting `Unknown` in the cold
  phase does not deadlock rewrite. Regression for review comment 1.
Three targeted tests filling gaps around the new readiness field:

* `candidate_metadata_partial_eq_discriminates_readiness`: same table_ref
  with different readiness must compare `!=`, otherwise a HashSet dedup
  or `matches!(..., readiness: Ready)` check would silently reintroduce
  the Ready/Unknown conflation this PR fixed.
* `multi_mv_sort_key_is_table_ref_not_readiness`: mixed-readiness set
  still sorts lexicographically by `TableReference` — the alignment
  invariant between branches and metadata must not depend on a field
  the cost function is also reading.
* `one_of_alignment_survives_rebuild_with_unknown_readiness`: mirrors
  the existing `Ready` rebuild test with `Unknown`, so `f_down`'s
  encoding still survives a `with_exprs_and_inputs` rebuild for the
  variant xudong's review specifically called out.

All 21 exploitation tests pass, clippy clean.
Xudong (@xudong963) asked whether readiness could be resolved after
physical inputs have been planned. This commit adds that hook and also
refreshes the docs / tests that the earlier commits left in a
half-committed state.

Physical-time resolution:
* ViewExploitationPlanner::plan_extension now re-consults each
  Materialized candidate's rewrite_readiness() before invoking the cost
  function. By the time this planner runs, DataFusion has already called
  TableProvider::scan() on every branch — so a provider whose readiness
  depends on scan-time side effects (lazy indexes, warmup jobs, snapshot
  swaps inside scan) surfaces its now-definitive value to the cost
  function instead of the stale LP-time snapshot. Fixes the cold-lazy
  scenario from the review comment.
* drop_not_ready_after_refresh filters any candidate whose refreshed
  readiness is NotReady, pair-wise with its physical_input. Keeps the
  invariant "NotReady never reaches the cost function" even when a
  provider transitions to NotReady between LP and physical planning.
* plan_extension short-circuits and returns the base physical plan
  directly when only Base remains after the filter — no OneOfExec
  wrapper for a single-branch case.

Doc updates:
* CandidateMetadata::Materialized::readiness now documents that its
  value is the *post-scan* readiness, not the LP-time snapshot.
* RewriteReadiness gains a source comment explaining why PartialOrd/Ord
  are derived (mechanical, not lifecycle-meaningful).

Test polish:
* Renamed f_down_admits_unknown_provider_across_lifecycle_transition
  to f_down_reflects_provider_readiness_on_each_rewrite. The old name
  overclaimed that the test proved the cold-lazy deadlock was solved;
  the docstring now honestly describes what the test proves (metadata
  channel + rewriter re-consults provider on each rewrite pass) vs.
  what it does not (end-to-end scan-triggered lifecycle proof lives in
  the downstream provider's own tests, per xudong's suggestion).
* MockMv now stores readiness in Mutex<RewriteReadiness> instead of
  AtomicU8 + encode/decode helpers. Same behavior, less ceremony.
* MockMv::scan promotes Unknown -> Ready to mimic a lazy-index provider
  whose scan warms the index. Existing tests don't call scan so are
  unaffected; the new physical-time tests use it to walk the scenario.

New tests (13 total in exploitation.rs):
* refresh_candidate_readiness: 5 scenarios (post-scan Ready, catalog
  lookup fallback, transition to NotReady, Base untouched, alignment)
* drop_not_ready_after_refresh: 3 scenarios (mixed, metadata missing
  fallback, collapse-to-Base short-circuit)
* plan_extension end-to-end: cold-lazy Unknown->Ready and
  short-circuit-to-base paths, driven through the actual planner
  method with a stub PhysicalPlanner
* Plus 3 supporting tests for readiness in CandidateMetadata sort key,
  PartialEq sensitivity, and Unknown surviving with_exprs_and_inputs
  rebuilds.

55/55 lib tests pass, cargo clippy --tests -D warnings clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/rewrite/exploitation.rs:609

  • drop_not_ready_after_refresh returns the (potentially misaligned) candidates slice unchanged when candidates.len() != physical_inputs.len(). If a caller accidentally provides a non-empty but mismatched metadata vector, this will preserve the misalignment into OneOfExec, where downstream cost functions may index by branch and panic or misattribute readiness. Safer behavior is to preserve the inputs but drop metadata (treat as backward-compat “no metadata”).
    if candidates.len() != physical_inputs.len() {
        return (physical_inputs.to_vec(), candidates.to_vec());
    }

Comment thread src/rewrite/exploitation.rs Outdated
Comment thread src/rewrite/exploitation.rs Outdated
@zhuqi-lucas
zhuqi-lucas requested a review from xudong963 July 23, 2026 03:57
Three items from the Copilot review pass on 66d965d:

* Doc: replace the intra-doc link `Self::Materialized::readiness` on
  `CandidateMetadata::Materialized` with prose + a link to
  `RewriteReadiness`. Struct-variant field paths don't resolve as
  intra-doc paths and were producing broken-link risk.

* Test doc: the `refresh_reflects_transition_to_not_ready` docstring
  used to say the refresh surfaces NotReady "so the cost function can
  penalize it", which contradicts the invariant that NotReady never
  reaches the cost function. Rewritten to say the refresh surfaces the
  value so `drop_not_ready_after_refresh` can filter the candidate
  before OneOfExec::try_new invokes the cost function.

* Safety: `drop_not_ready_after_refresh` used to return the caller's
  misaligned metadata slice unchanged on length mismatch, letting a
  bad slice flow into OneOfExec and mis-attribute readiness to the
  wrong branch inside the cost function. Now returns an empty
  metadata vector in the mismatch case (matches the pre-readiness
  code path shape). New regression test
  `drop_not_ready_filter_drops_misaligned_metadata_instead_of_propagating_it`.

56/56 lib tests pass, cargo clippy --tests -D warnings clean.
* rustfmt: wrap the long assert_eq! in
  drop_not_ready_filter_drops_misaligned_metadata_instead_of_propagating_it
  across four lines instead of one long single-line call.
* typos: rephrase 'mis-attribute readiness' to 'attribute readiness to
  the wrong branch' — 'mis-' was flagged as should-be 'miss' by the
  typos CI check (hyphenated compound isn't recognised).
Every query hitting the physical-time hook was doing one catalog walk
per candidate — 'stupid' on the warm hot path (once every MV has been
touched, LP-time readiness is Ready for every subsequent query).

Fast path: if no Materialized candidate is reporting Unknown at
LP-rewrite time, skip the refresh entirely and return the context
unchanged. Ready is monotone in the provider models we ship for
(a loaded index doesn't spontaneously unload between LP and physical
planning), so no post-scan value change can move a Ready back to
Unknown or NotReady. Providers whose readiness is not monotone should
opt out by never reporting Ready at LP time; documented on the fn.

Per-candidate short-circuit inside the join_all as well: Materialized
{ readiness: Ready } candidates skip their catalog walk even in the
mixed case, keeping the async work bounded to just the Unknown ones.

Test churn:
* refresh_reflects_transition_to_not_ready renamed and rescoped to
  refresh_promotes_unknown_candidate_to_not_ready_when_scan_uncovers_unpopulated
  — Ready -> NotReady is now explicitly unsupported by design, so the
  scenario that mattered (scan discovers unpopulated) is expressed as
  Unknown -> NotReady, which the fast path does refresh.
* plan_extension_short_circuits_to_base_when_all_mvs_go_not_ready
  same adjustment: LP-time Unknown -> post-scan NotReady exercises the
  refresh + filter + short-circuit chain end to end.
* New refresh_is_noop_when_every_candidate_already_ready pins the
  fast path so a future accidental removal reintroduces the perf tax
  the user called out.

57/57 lib tests pass, fmt/clippy clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread src/rewrite/exploitation.rs Outdated
/// folding, etc. change the LP but never the MV's registered name, so the
/// alignment between this metadata and the physical candidate the cost
/// function receives survives every optimizer pass.
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Hash)]
Comment thread src/rewrite/exploitation.rs Outdated
Comment on lines +684 to +688
let refreshed: Vec<CandidateMetadata> =
futures::future::join_all(context.candidates().iter().map(|c| async move {
match c {
CandidateMetadata::Base => CandidateMetadata::Base,
// Ready is monotone — skip the per-candidate catalog walk too.
Comment on lines +583 to +584
let filtered_context = RewriteContext::new(refreshed_context.root_table_refs().to_vec())
.with_candidates(kept_candidates);

@xudong963 xudong963 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put the Readiness-related struct/APIs into a seperate file?

Comment thread src/rewrite/exploitation.rs Outdated
}
)
});
if !any_unknown {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fast path makes the post-scan safety check depend on an undocumented monotonicity assumption. rewrite_readiness() currently describes the provider’s current state; its public contract does not promise that Ready can never transition to NotReady.

If f_down observes Ready and scan() subsequently discovers that the snapshot is unusable, this path retains the stale Ready value. drop_not_ready_after_refresh cannot remove the candidate, and the cost function may trust its EmptyExec, recreating the silent-empty-result failure this PR is intended to prevent.

Could we refresh every Materialized candidate after scan, or make monotonic readiness an explicit API capability?

Comment thread src/rewrite/exploitation.rs Outdated
Comment on lines +563 to +564
let refreshed_context =
refresh_candidate_readiness(one_of.rewrite_context().clone(), session_state).await;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reading readiness after scan() only observes the provider’s current state; it does not guarantee that this is the same snapshot that was used to construct the corresponding physical_input.

For example, scan() could produce an EmptyExec from an unknown or unpublished snapshot, and a concurrent refresh could publish a Ready snapshot before this lookup runs. The old EmptyExec would then be labeled Ready and trusted as predicate-pruned.

Could readiness, or a snapshot generation/token, be captured atomically during scan() and propagated with the physical input instead of being sampled independently afterward?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be captured atomically during scan() , this is a great idea, addressing this.

Comment thread src/rewrite/exploitation.rs Outdated
// `Full` variant renders as "catalog.schema.table". `TableReference::from`
// treats any &str as a `Bare` ref, so we need `parse_str` to split dotted
// names back into their component parts before catalog resolution.
let resolved = TableReference::parse_str(table_ref).resolve(default_catalog, default_schema);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TableReference::to_string() followed by parse_str() is not lossless for quoted or case-sensitive identifiers, or identifiers whose components contain dots. Display does not preserve quoting, while parse_str() normalizes and splits the resulting string.

This can make the physical-time refresh resolve the wrong provider or fail and silently retain stale LP-time readiness. Since TableReference already implements Clone, Eq, Hash, and ordering traits, could CandidateMetadata retain it directly, or at least store to_quoted_string()?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

@zhuqi-lucas

Copy link
Copy Markdown
Collaborator Author

Could we put the Readiness-related struct/APIs into a seperate file?

Good suggestion!

…+ drop fast path

Three items from the 2026-07-23 review, in the order they land in the diff:

* Comment 3 (fast-path monotonicity): the earlier fast path assumed
  `Ready` was monotone in every provider model. Xudong pointed out that
  the trait contract does not promise this — a provider that observes
  `Ready` at LP time and discovers the snapshot is unusable during
  scan() has no way to surface that if refresh short-circuits. Reverted
  to always-refresh so `drop_not_ready_after_refresh` sees the fresh
  value. Removed `refresh_is_noop_when_every_candidate_already_ready`.

* Comment 1 (TableReference round-trip loss): `CandidateMetadata::
  Materialized::table_ref` now stores `TableReference` directly instead
  of the result of `.to_string()`. `TableReference::from`/`parse_str`
  are not lossless for identifiers containing dots or quoting, so the
  round trip could resolve the wrong provider or silently retain a
  stale LP-time readiness. Storing the ref by value eliminates the trip
  entirely — `TableReference` is Clone+Eq+Hash+Ord which is everything
  `CandidateMetadata` needs. `resolve_current_readiness` now takes
  `&TableReference` and calls `.resolve()` directly, no string parsing.

* Comment 2 (snapshot atomicity race): sampling `rewrite_readiness()`
  after scan() has returned is racy — a concurrent snapshot swap can
  publish new state between the two calls, letting an EmptyExec from
  the old snapshot be labelled with readiness from the new one. Added
  `ReadinessAnnotatedExec`, a transparent wrapper that carries the
  readiness value captured at scan time on the ExecutionPlan itself.
  Providers opt in by returning `Arc::new(ReadinessAnnotatedExec::new(
  scan_output, captured_readiness))`. `refresh_candidate_readiness`
  now takes `physical_inputs` and walks each candidate's plan tree for
  an annotation via `readiness_from_plan`; when found, it uses that
  value verbatim instead of sampling. Providers that haven't opted in
  keep the (racy) sampling fallback for backward compatibility.

Test churn:
* All `CandidateMetadata::Materialized { table_ref: "...".to_string() }`
  literals switched to `TableReference::bare(...)` (bare-name tests) or
  `default_qualified_table_ref(&state, ...)` (SessionContext tests).
* Renamed `refresh_reflects_transition_to_not_ready` to
  `refresh_promotes_unknown_candidate_to_not_ready_when_scan_uncovers_unpopulated`
  and reshaped it around Unknown -> NotReady (Ready -> NotReady is now
  unsupported after removing the fast path, replaced by physical-time
  filtering when the provider opts into `ReadinessAnnotatedExec`).
* Four new tests for the annotation:
  * `refresh_prefers_annotated_readiness_over_catalog_sampling` —
    intentionally sets up disagreement (annotated Ready vs. sampled
    Unknown) and asserts annotated wins.
  * `refresh_falls_back_to_sampling_when_input_not_annotated` — pins
    the backward-compat path.
  * `readiness_from_plan_walks_nested_children` — RepartitionExec
    wrapping a ReadinessAnnotatedExec is still discovered.
  * `readiness_from_plan_returns_none_when_no_annotation` — negative.

60/60 lib tests pass, cargo clippy --tests -D warnings clean.
`RewriteReadiness`, `CandidateMetadata`, `ReadinessAnnotatedExec`, and
`readiness_from_plan` are now defined in a dedicated
`src/rewrite/readiness.rs` module, with the previous public paths
retained via re-exports so downstream callers don't have to change
their imports:

* `crate::materialized::RewriteReadiness` still resolves (re-exported).
* `crate::rewrite::exploitation::{CandidateMetadata,
  ReadinessAnnotatedExec, readiness_from_plan}` still resolve
  (re-exported).

The canonical home for every readiness-related type is now
`crate::rewrite::readiness`, so the surface a reader has to hold in
their head to understand the readiness contract lives in one place.

Test churn:
* `RewriteReadiness` sanity tests moved from materialized.rs to the
  new file's tests module.
* Added `readiness_annotated_exec_delegates_properties_and_children`
  in the new file to pin the wrapper's transparency (properties are
  the same Arc as the inner plan; the inner plan is the sole child).

Doc cross-references between modules use fully-qualified paths so the
rustdoc links still resolve after the move.

61/61 lib tests pass, fmt/clippy/doc clean.
Following comment 4 (readiness types moved to their own file), also
move the readiness *helpers* — refresh_candidate_readiness,
resolve_current_readiness, drop_not_ready_after_refresh — from
exploitation.rs into readiness.rs. All three operate exclusively on
CandidateMetadata / RewriteReadiness / ReadinessAnnotatedExec, so
keeping them next to the types they operate on makes readiness cohesive
in one file.

* refresh_candidate_readiness + drop_not_ready_after_refresh are
  pub(super) so exploitation.rs's plan_extension can still call them.
* resolve_current_readiness stays private to readiness.rs (only the
  refresh helper uses it).
* readiness.rs picks up the SessionState / CatalogProviderList /
  cast_to_materialized imports.
* readiness.rs imports RewriteContext from exploitation — one direction
  of the cross-file cycle, which is fine since Rust resolves module
  imports crate-wide.

exploitation.rs drops from 2481 to 2325 lines. readiness.rs grows from
317 to 480 lines and now hosts every readiness-related definition
(4 pub items, 3 helpers, 3 tests).

61/61 lib tests pass, fmt/clippy/doc clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/rewrite/exploitation.rs:396

  • f_down sorts MV candidates using table_ref.to_string(). Elsewhere (e.g. readiness resolution) the code explicitly avoids going through Display because it’s not lossless for quoted/dotted identifiers; using to_string() here can therefore collapse distinct identifiers and undermine the determinism/alignment invariant. Prefer ordering directly on TableReference instead of its display string (and avoid per-candidate allocations).
            let mut candidates = candidates;
            candidates.sort_by_key(|a| a.1.to_string());

Comment thread src/rewrite/readiness.rs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Comment thread src/rewrite/readiness.rs
if drop {
continue;
}
kept_inputs.push(Arc::clone(input));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadinessAnnotatedExec is not actually transparent to DataFusion. It delegates only properties, execution, and partition statistics, while ExecutionPlan also exposes optimizer-facing behavior such as input distribution/order requirements, maintains_input_order, repartitioning, limit/filter/sort pushdown, metrics, with_preserve_order, and downcast_delegate. The defaults are not necessarily equivalent to the wrapped scan.

Because drop_not_ready_after_refresh retains the original physical input, this wrapper survives into OneOfExec and eventually into the selected execution plan, where it can block optimizations or appear as an unknown node to physical-plan codecs.

Since the annotation is only needed during plan_extension, could we strip it from the physical input immediately after extracting readiness? Alternatively, the wrapper would need to proxy all relevant behavior/type identity and be covered by optimizer and serialization integration tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks @xudong963 , addressed in latest PR.

Xudong pointed out that `ReadinessAnnotatedExec` isn't actually
transparent to DataFusion: it only delegates `properties`, `execute`,
`partition_statistics`, and `children`/`with_new_children`, while
`ExecutionPlan` exposes many more optimizer-facing methods
(`required_input_distribution`, `maintains_input_order`,
`supports_limit_pushdown`, `try_swapping_with_projection`,
`with_preserve_order`, `metrics`, downcast identity for codecs, etc.).
Leaving the wrapper in place would silently block downstream
optimizations and confuse physical-plan codecs.

The wrapper is only needed during `plan_extension` as a signaling
device — once `readiness_from_plan` has extracted its value, the
wrapper's job is done. This commit adds `strip_readiness_annotation`,
a `TreeNode::transform`-based walker that collapses every
`ReadinessAnnotatedExec` back to its inner plan, and calls it from
`plan_extension` immediately after `refresh_candidate_readiness`. The
plan handed to `drop_not_ready_after_refresh` / `OneOfExec::try_new` /
downstream optimizers is wrapper-free.

New tests:
* `strip_returns_inner_for_bare_annotated_exec`
* `strip_replaces_annotated_exec_inside_nested_plan` (wrapper under a
  RepartitionExec, verifies the walker reaches nested positions)
* `strip_is_noop_when_no_annotation_present`
* `plan_extension_strips_readiness_annotation_before_returning` — end
  to end: wrap physical_input, drive plan_extension, assert no
  ReadinessAnnotatedExec remains anywhere in the returned tree.

65/65 lib tests pass, cargo clippy --tests -D warnings clean.
@zhuqi-lucas
zhuqi-lucas merged commit 39dcba5 into branch-54 Jul 24, 2026
8 checks passed
@zhuqi-lucas
zhuqi-lucas deleted the qizhu/x-3269-candidate-table-refs branch July 24, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants