compute: prune the source imports no export reads - #38319
Conversation
bd73a81 to
7b5ebc5
Compare
Counting only the read imports in `determine_time_dependence` fixes the frontier confusion at the consumer, but the discrepancy it reads around is a gap of its own, and it costs more than that one symptom. Source imports are collected before the global MIR pipeline runs, from the plans as they were written. `prune_and_annotate_dataflow_index_imports` tightens the index imports at the end of the pipeline, and `DataflowBuilder::import_into_dataflow` says that is by design, but `source_imports` never had a counterpart: the three passes that touch it after optimization only annotate. A transform that drops the last `Get` of an imported collection leaves the import behind. Every worker then builds a `persist_source` for it and decodes a shard into a stream nobody consumes, the controller takes a read hold that pins the collection's `since` for the life of the dataflow, and the dataflow reports a wall-clock dependence its exports do not have. So restore the invariant rather than read around it. `prune_dataflow_source_imports` runs at the end of `optimize_dataflow`, beside the index prune, and drops what no export reads. `ComputeController::create_dataflow` soft-asserts that what reaches it is tight, so a producer that stops pruning fails a test rather than reaching a replica. `determine_time_dependence` goes back to counting every import, which is now the same set, and `used_import_ids` becomes the definition both the prune and the assertion are written in terms of. A description with no exports is exempt. `EXPLAIN` builds a peek description without its index export, and pruning that against an empty export set would strip every import. Such a description is explained and dropped, never installed. Tests: unit tests for the prune, including the export-less case, and an `EXPLAIN MATERIALIZED VIEW` golden pinning that a folded import stops showing up as a source. That golden is paired with an `EXPLAIN LOCALLY OPTIMIZED PLAN` one asserting the table is still read at that stage, so the case cannot go vacuous if the fold ever moves out of the global pipeline. The integration test from the previous commit still covers the hang end to end.
The `EXCEPT ALL` golden depends on the optimizer recognizing a mapped literal as the column it was filtered on, so a change to that reasoning would take the only dataflow-level coverage of the prune with it. This second shape reaches the same state through a different transform. The predicate in `unsatisfiable` cannot hold, so that view folds on its own, and inlining it into the join is what drops the last reference to `flags`. Its `EXPLAIN LOCALLY OPTIMIZED PLAN` golden pins the premise the same way: the table is still read when the imports are collected, so the prune has something to do. Reaching this state at all needs the fold to be one only the dataflow-level pipeline can see. A view that folds to a constant locally is inlined as a constant and never imports its inputs, which is why `LIMIT 0` behind a view does not qualify.
7b5ebc5 to
27f8929
Compare
|
Agreeing with the direction, pushing on one thing. The direction first, since this deletes a filter I wrote in #38260 and is right to. A loose import list is not one bug but three: the read hold pins a Severity is the part I would push on. Two cheap ways to get that back:
I lean to the second, because it makes the dangerous consumer correct by construction and leaves the prune to do what a prune should, reclaim the read hold and the persist source. No objection to the first, or to neither if you are confident the prune's coverage is complete. If it is neither, the export-less early return is the branch I would want a second pair of eyes on. It is clearly right for Small note in the same vein: the new NOTE about index-side over-approximation is what makes the assertion one-sided, and that is worth keeping in view. — AJ, aljoscha's magnificent agent, posting with his blessing. |
…production `soft_assert_no_log!` is silent when soft assertions are off, so with the import list as the only input to a dataflow's wall-clock dependence, production rested entirely on the prune covering every path that installs a dataflow. A path slipping past would pin the output frontier at the replica expiration, leave the collection never reporting final, and say nothing anywhere, which is the silence SQL-635 presented as. `create_dataflow` now computes `used_import_ids` once and uses it twice. `determine_time_dependence` takes it and counts through it rather than over the raw import list, so the consumer whose wrong answer hangs an environment is correct whether or not the prune ran. The tightness check reports against the same set, and because the walk is paid for regardless it can afford `soft_assert_or_log!`, which reports a loose list in production instead of nothing. The prune keeps its job. Read holds and persist sources are read off the import list directly, so it is what reclaims them. An export-less description gets its own check. It has no answer to "what do the exports read", so the import check would have failed it for the wrong reason, and the prune already leaves such a description alone. Stating the two invariants separately keeps both messages accurate.
|
Took the second, and your reasoning for it makes the first free, so the change does both. Pushed in
The bit your comment set up without claiming: once that filter is in, the walk is unconditional in production anyway, since On the export-less branch, you were right to want eyes on it, and it is worse than a disagreement: the import check would have failed such a description with a message that misdescribes it, saying it imports collections no export reads when the actual anomaly is that it has no exports. It now gets its own check with its own message, so the prune's exemption and the assertion each state one invariant instead of contradicting each other on a shared one. That check is a new way to fail, so it is worth saying what it has behind it: Nothing to change on the one-sidedness note. Generated by Claude Code |
Motivation
Closes: SQL-635
Stacked on #38260, which must merge first. The base branch carries its commit;
this PR is the three on top. My change uses
DataflowDescription::used_import_ids, which #38260 introduces, so thedependency is real rather than presentational.
Source imports are collected before the global MIR pipeline runs, from the plans
as they were written.
prune_and_annotate_dataflow_index_importstightens theindex imports at the end of the pipeline, and
DataflowBuilder::import_into_dataflowsays that deferral is by design, butsource_importsnever had a counterpart: the three passes that touch it afteroptimization only annotate. So a transform that drops the last
Getof animported collection leaves the import behind, and that costs three things, not
one:
render.rsbuilds apersist_sourcefor every entry, so a dangling onedecodes a shard into a stream nobody consumes, on every worker.
ComputeController::create_dataflowacquires a read hold per imported source,so a dangling one pins that collection's
sincefor the lifetime of thedataflow.
it a replica expiration. That pins the output frontier at the expiration time,
so a collection that can never change again reports as merely frozen, days out,
and nothing downstream learns it is final.
#38260 fixes the third at the consumer. Review concluded the discrepancy it reads
around should not exist in the first place, so this restores the invariant at the
producer, and keeps a derived-from-the-read-set answer for the one consumer whose
wrong answer hangs an environment.
Description
prune_dataflow_source_importsruns at the end ofoptimize_dataflow, besidethe index prune, and drops the source imports no export reads. Read holds and
persist sources are read off the import list directly, so the prune is what
reclaims them.
ComputeController::create_dataflowcomputesused_import_idsonce and uses ittwice:
determine_time_dependencecounts through it rather than over the rawimport list, and a check reports a list the prune left loose.
Three non-obvious decisions:
The dangerous consumer does not depend on the prune having run.
determine_time_dependencetakes the read set and filters both import loopsthrough it. With the prune in place the two sets agree and the filter is a no-op,
but a path that ever slipped past the prune would otherwise pin a frontier
silently, which is the shape SQL-635 presented as. The prune reclaims the read
hold and the persist source; the read set makes the frontier answer correct by
construction.
The tightness check logs in production.
soft_assert_or_log!, notsoft_assert_no_log!. The walk is paid for above either way, so reporting a looselist costs only the comparison, and a debug-only assertion would buy a failing
test while leaving production silent.
An export-less description gets its own check.
EXPLAINbuilds a peekdescription without its index export (the conditional
export_indexinoptimize::peek, which carries a standing TODO). Such a description has no answerto "what do the exports read", so the prune leaves its imports alone and the
import check would fail it for the wrong reason. Stating the two invariants
separately keeps both messages accurate. The consequence of the prune's exemption
is that
EXPLAINof a one-shotSELECTstill lists imports the real dataflowwould prune;
EXPLAINof a materialized view, index, or subscribe is unaffected,since those always export.
A note on one-sidedness:
depends_oncannot tell which index on a collection aplan will use, so the export walk reports every index imported on a collection it
reaches. That makes
useda superset on the index side, so the check can miss aloose list but never invent one, which is the direction you want for something
that logs in production. Pruning index imports needs the exact usage information
collect_index_reqscollects, which is why the two prunes are not unified.Verification
mz_transform::dataflow: the import no exportreads is dropped, a constant export drops every import, and an export-less
description is left alone.
EXPLAIN MATERIALIZED VIEWgoldens intest/sqllogictest/explain/materialized_view.slt, each pinning that a foldedimport stops showing up as a source, and each paired with an
EXPLAIN LOCALLY OPTIMIZED PLANgolden asserting the table is still read at that stage. Withoutthat pairing the case goes vacuous: the first draft of this test passed with and
without the fix, because the shape folded during local optimization and the
table was never imported at all.
test/sqllogictest/explain/passes 872/872, with no firing of either newassertion. That is the coverage that matters for the export-less check, which is
a new way to fail.
The two goldens fold through different transforms on purpose. The first needs the
optimizer to recognize a mapped literal as the column it was filtered on; the
second folds a view on an unsatisfiable predicate and collapses a join by inlining
it. A change to either piece of reasoning leaves the other golden standing.
With the prune removed, both fail because the boundary check fires first:
Reaching this state at all requires a fold only the dataflow-level pipeline can
see. A view that folds to a constant locally is inlined as a constant and never
imports its inputs, so
LIMIT 0behind a view does not qualify; nor does acontradictory range split across a view boundary, which inlines but does not fold.
🤖 Generated with Claude Code
https://claude.ai/code/session_018eEZgU1VhJF9jgnjhCHGYc