fix: preserve global limit for multi-partition fetch - #23800
Conversation
1229760 to
4b30ece
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #23800 +/- ##
=======================================
Coverage 81.17% 81.17%
=======================================
Files 1109 1109
Lines 388038 388116 +78
Branches 388038 388116 +78
=======================================
+ Hits 314992 315057 +65
- Misses 54507 54513 +6
- Partials 18539 18546 +7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
neilconway
left a comment
There was a problem hiding this comment.
Thanks for your work on this, @discord9! This PR is overall very good: thorough explanation of the problem, very good test coverage, and well-written implementation. I spent a while working through it and talking to Claude and the basic approach makes sense to me and clears up a clear semantic shortcoming of the current representation.
| /// Scope of a semantic cap that remains pending independently of its numeric | ||
| /// `skip` and `fetch` payload. |
There was a problem hiding this comment.
I found this comment confusing (e.g., whose skip and fetch are we referring to? What is a "semantic cap", and is a "non-semantic cap" a thing?). Calling it PendingScope only makes sense if you know the field in GlobalRequirements is named pending, which isn't obvious.
What about:
/// The scope of a row limit: what the limited row count applies to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LimitScope {
/// The limit caps each output partition independently, as enforced by
/// [`LocalLimitExec`].
Local,
/// The limit caps the combined output of all partitions, as enforced by
/// [`GlobalLimitExec`] over a single-partition input. A fetch on a
/// multi-partition operator cannot satisfy this scope; the partitions
/// must first be merged into one stream.
Global,
}| && global_limit.fetch().is_none() | ||
| { | ||
| // Remove this no-op wrapper without clearing inherited state, which may | ||
| // have been promoted from local to global scope at a one-output boundary. |
There was a problem hiding this comment.
I found this comment confusing. What about
// A GlobalLimitExec with no skip and no fetch enforces nothing, so
// remove it and keep the carried state untouched. Falling through to
// the general limit handling would instead re-derive `pending` from
// this node, which could reopen an already-satisfied requirement.
// The local-to-global promotion above is kept as well: it relied only
// on this node having one output partition, and it remains correct
// after the node is removed.| if pushdown_plan.fetch().is_some() { | ||
| if global_state.skip == 0 { | ||
| global_state.satisfied = true; | ||
| global_state.pending = None; | ||
| } | ||
| (global_state.skip, global_state.fetch) = combine_limit( | ||
| global_state.skip, |
There was a problem hiding this comment.
Why if we owe fetch=5 but the fetch on the operator is > 5?
We rebuild the operator below via pushdown_plan.with_fetch(skip_and_fetch) below, but some operators might support fetch but not with_fetch (e.g., PartialSort).
| let new_children = children | ||
| .into_iter() | ||
| .map(|child: &Arc<dyn ExecutionPlan>| { | ||
| let new_child = pushdown_limits( | ||
| Arc::<dyn ExecutionPlan>::clone(child), | ||
| global_state.clone(), | ||
| )?; | ||
| // Tracking if any of the children changed | ||
| changed |= !Arc::ptr_eq(child, &new_child); | ||
| Ok(new_child) | ||
| }) | ||
| .collect::<Result<_>>()?; |
There was a problem hiding this comment.
Is copying the global state into every child correct, for an operator with multiple children? e.g., if we have an operator with n children and a single output partition, wouldn't this result in copying the fetch limit into n different operators, so we'd produce too many rows?
There was a problem hiding this comment.
Is copying the global state into every child correct, for an operator with multiple children? e.g., if we have an operator with
nchildren and a single output partition, wouldn't this result in copying the fetch limit intondifferent operators, so we'd produce too many rows?
The final results is still comes from a single partition output CoalescePartitionsExec/SortPreservingMergeExec so the result will only be like what limit=N needs, as the cloned global state to every child should be like this anyway:
pending = None
skip = 0
fetch = Some(N) -- just a early stop hint for child
There was a problem hiding this comment.
This is true of the operators in the tree but I don't think it's true in general. Suppose there's a custom operator with n children, a single output partition, and supports_limit_pushdown() == true: at that node the materialization branch doesn't fire (one partition) and the blocker branch doesn't run (pushdown supported), so the helper returns with pending == Some(LimitScope::Global) still open, and pushdown_limits clones that open requirement into all of its children.
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
37d5ff9 to
c56d310
Compare
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
Which issue does this PR close?
Rationale for this change
An operator-level
fetchdoes not necessarily enforce a query-wide limit. On aplan with multiple output partitions, each partition can apply the fetch
independently:
Replacing the global limit with only a scan fetch is incorrect:
The scan can return five rows from each partition, or ten rows in total. Its
fetch is still useful for stopping each partition early, but the plan also
needs one limit across their combined output:
SortPreservingMergeExecis used instead when input ordering must bepreserved.
LimitPushdownremoves limit nodes while descending the plan, so a numericfetchalone is not enough to describe what remains to be enforced. The samenumber can mean:
enforced.
The rule therefore tracks the outstanding scope separately:
The states mean:
pendingNonefetchis only for early stopping.Some(LimitScope::Local)Some(LimitScope::Global)A pending global limit is enforced before descending into a multi-partition
plan. The rule merges the partitions with
CoalescePartitionsExecorSortPreservingMergeExec, applies the global limit there, and may continue tocopy the fetch to children only as an early-stop value.
This also makes cloning the state for child plans safe:
UnionExec; andpendingisNoneandskipis cleared beforevisiting children, so descendants cannot apply the same OFFSET again.
An existing operator fetch can satisfy the current limit only when there is no
OFFSET and the existing fetch is at least as strict as the requested one. For
example,
fetch=10does not enforceLIMIT 5:If the operator supports
with_fetch, the rule lowers its fetch to 5. If itcannot be changed, the explicit limit remains or is enforced between that
operator and its child. An existing fetch never satisfies an OFFSET by itself.
The rule also handles single-output operators that hide multi-partition
children. A local limit on one output partition is equivalent to a global limit
at that point, so the scope is promoted before descending. If recursion later
reaches a multi-partition child, the global limit is enforced before continuing.
What changes are included in this PR?
Option<LimitScope>, with separateLocalandGlobalscopes.per-partition fetch as a global limit.
preserving order when required.
skip == 0andexisting_fetch <= required_fetch.with_fetchwhere supported; otherwisepreserve or insert the explicit limit.
descending into child plans.
GlobalLimitExecnodes without changing the limit state beingcarried through the plan.
ExecutionPlan::apply_expressionsmethod on the twotest execution plans used by this regression suite.
Are these changes tested?
Yes. The
physical_optimizer::limit_pushdownintegration tests cover 37 planshapes, including:
fetch;
SortExecfetch throughwith_fetch; andVerified on the rebased PR branch with:
The focused integration suite passed with 37 tests and no failures.
Are there any user-facing changes?
Yes. Queries using these physical plan shapes now preserve the requested global
LIMIT/OFFSET instead of returning too many rows or applying a limit or offset
more than once. There are no intended public API changes.