[X-3269] Empty-plan discriminator: RewriteReadiness + per-branch CandidateMetadata - #55
Conversation
…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
There was a problem hiding this comment.
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>(defaultNone) to expose total MV file count (ignoring predicates). - Extend
RewriteContextwith per-branchcandidate_table_refsandcandidate_file_counts, plus builder/getter APIs. - Populate these per-branch signals when constructing
OneOfcandidates inViewMatchingRewriter::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.
… 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`.
`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.
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
left a comment
There was a problem hiding this comment.
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.
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.
|
@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()) { |
There was a problem hiding this comment.
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.
| /// 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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_refreshreturns the (potentially misaligned)candidatesslice unchanged whencandidates.len() != physical_inputs.len(). If a caller accidentally provides a non-empty but mismatched metadata vector, this will preserve the misalignment intoOneOfExec, 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());
}
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.
| /// 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)] |
| 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. |
| let filtered_context = RewriteContext::new(refreshed_context.root_table_refs().to_vec()) | ||
| .with_candidates(kept_candidates); |
xudong963
left a comment
There was a problem hiding this comment.
Could we put the Readiness-related struct/APIs into a seperate file?
| } | ||
| ) | ||
| }); | ||
| if !any_unknown { |
There was a problem hiding this comment.
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?
| let refreshed_context = | ||
| refresh_candidate_readiness(one_of.rewrite_context().clone(), session_state).await; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Be captured atomically during scan() , this is a great idea, addressing this.
| // `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); |
There was a problem hiding this comment.
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()?
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.
There was a problem hiding this comment.
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_downsorts MV candidates usingtable_ref.to_string(). Elsewhere (e.g. readiness resolution) the code explicitly avoids going throughDisplaybecause it’s not lossless for quoted/dotted identifiers; usingto_string()here can therefore collapse distinct identifiers and undermine the determinism/alignment invariant. Prefer ordering directly onTableReferenceinstead of its display string (and avoid per-candidate allocations).
let mut candidates = candidates;
candidates.sort_by_key(|a| a.1.to_string());
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| if drop { | ||
| continue; | ||
| } | ||
| kept_inputs.push(Arc::clone(input)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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'onconstituents_by_composite_ticker_v4) collapses to bareEmptyExec— 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:
LP-time filter in
ViewMatcher::f_downvia a newMaterialized::rewrite_readiness() -> RewriteReadinesstrait method(
Ready | NotReady | Unknown, defaultUnknown). MVs reportingNotReadyare filtered out of the candidate set at LP rewrite time.Per-branch metadata that survives to the cost function via
RewriteContext::candidates: Vec<CandidateMetadata>, aligned withOneOf::branches.CandidateMetadata::Materialized { table_ref, readiness }carries the provider's raw readiness verbatim, so cost functions can
apply their own policy for the
Unknowncase (e.g. fall back to basescan cost) instead of being forced to trust every candidate equally.
table_refis stored as aTableReference(not stringified) soquoted / dotted identifiers round-trip losslessly through the
physical-time catalog resolution.
Physical-time readiness refresh in
ViewExploitationPlanner::plan_extensionre-consults each
Materializedprovider after DataFusion has invokedTableProvider::scan()on every candidate branch. Providers that optinto
ReadinessAnnotatedExec(see below) get their readiness readstraight 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_refreshthen removes any candidate thattransitioned to
NotReady, preserving the invariant thatNotReadynever reaches the cost function.Architecture
The three layers wire together like this. The provider is consulted twice
— once at LP-rewrite time (cold,
last_modifiedmay still beNone) andagain at physical-planning time (after
scan()has run, so any lazy index iswarm and readiness is definitive).
NotReadyis filtered by two gates so thecost 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 --> ZThe library ships layers 1–3 plus the atomic-readiness vehicle. The provider
(e.g. atlas's
MaterializedView<Index>) implementsrewrite_readinesshoweverfits its lifecycle model and optionally wraps its scan output in
ReadinessAnnotatedExec; the cost function branches on thereadinessvalue however fits its cost model. The atlas companion PR wires up both:
Materialized::rewrite_readinessf_downNotReady filterCandidateMetadatareadinessvalue through to cost fnReadinessAnnotatedExecwrapperplan_extensionrefresh + filterdeprioritize_empty_candidatesReady+empty, penaliseUnknown+emptyFile 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-movelocations (
crate::materialized::RewriteReadiness,crate::rewrite::exploitation::{CandidateMetadata, ReadinessAnnotatedExec, readiness_from_plan}) still resolve viapub usere-exports, so downstreamcallers don't have to change their imports.
Changes
Materializedtrait (insrc/materialized.rs)RewriteReadinessenum (defined insrc/rewrite/readiness.rs,re-exported here for backward compat):
Ready/NotReady/Unknown.fn rewrite_readiness(&self) -> RewriteReadinesswithUnknowndefault — backward-compatible for providers that don't distinguish
lifecycle states.
CandidateMetadata+ReadinessAnnotatedExec(insrc/rewrite/readiness.rs)CandidateMetadata::Materialized { table_ref: TableReference, readiness }carries the provider's raw readiness verbatim.
TableReference(notString) so quoted / dotted identifiers round-trip losslessly.Sorted deterministically by
TableReferenceinf_downso alignmentsurvives downstream LP rewrites via
with_exprs_and_inputs.ReadinessAnnotatedExectransparent wrapper carrying aRewriteReadinessvalue alongside anExecutionPlan, so providers canpublish the readiness that corresponds to the snapshot their
scan()read from.
readiness_from_plandepth-first walker used by the refresh toextract annotated readiness from a candidate's physical input.
ViewMatcher::f_downNotReadyMVs before they enter the candidate set.Ready/Unknownvalues intoCandidateMetadata::Materialized.OneOf::inputs()becomes a no-op.branches[0].ViewExploitationPlanner::plan_extensionrefresh_candidate_readiness— prefersReadinessAnnotatedExecwhen a candidate's
physical_inputcarries one; falls back to catalogsampling otherwise.
NotReadypost-scan transitions viadrop_not_ready_after_refresh.Test coverage (61 lib tests, all passing)
New in this PR (organised by concern):
f_down_encodes_ready_and_unknown_readiness_verbatim,candidate_metadata_partial_eq_discriminates_readiness,one_of_alignment_survives_rebuild_with_unknown_readiness.f_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.Unknown → NotReady when scan uncovers unpopulated, missing-provider
fallback, Base variant preservation, and Base-at-index-0 invariant.
refresh_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.passthrough when metadata is missing, and the collapse-to-Base short-circuit.
plan_extension— 2 tests driving the actual plannermethod through a
SessionContext, covering the cold-lazy Ready promotionand the short-circuit-to-base scenarios.
Affects
Downstream cost functions consuming
RewriteContext::candidates()and MVproviders overriding
rewrite_readiness(). The atlas repo has a companionPR that:
MaterializedView<Index>::rewrite_readiness()to reportReadywhen
Index::get_last_modified().is_some()andUnknownotherwise.Never returns
NotReady— atlas cannot cheaply distinguish cold-lazyfrom unpopulated, so
Unknown+ cost-fn fallback handles both.deprioritize_empty_candidatesto skip the+INFINITYpenaltywhen a candidate is
Materialized { readiness: Ready }(trust the emptyas predicate-pruned); the pre-readiness "penalize every empty" behaviour
is retained as a fallback for
Unknownand for older callsites thatdon't wire
CandidateMetadatathrough.external.etfglobal.constituents_v1picks the specialised MV, returnsEmptyExecin microseconds instead of the previous 30 s base-tablescan.