Skip to content

compute: prune the source imports no export reads - #38319

Open
antiguru wants to merge 3 commits into
aljoscha/sql-635-expiration-unread-importsfrom
claude/compute-derive-time-dependence-s2rb9j
Open

compute: prune the source imports no export reads#38319
antiguru wants to merge 3 commits into
aljoscha/sql-635-expiration-unread-importsfrom
claude/compute-derive-time-dependence-s2rb9j

Conversation

@antiguru

@antiguru antiguru commented Aug 18, 2026

Copy link
Copy Markdown
Member

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 the
dependency 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_imports tightens the
index imports at the end of the pipeline, and
DataflowBuilder::import_into_dataflow says that deferral is by design, but
source_imports never had a counterpart: the three passes that touch it after
optimization only annotate. So a transform that drops the last Get of an
imported collection leaves the import behind, and that costs three things, not
one:

  • render.rs builds a persist_source for every entry, so a dangling one
    decodes a shard into a stream nobody consumes, on every worker.
  • ComputeController::create_dataflow acquires a read hold per imported source,
    so a dangling one pins that collection's since for the lifetime of the
    dataflow.
  • The dataflow reports wall-clock dependence its exports do not have, which earns
    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_imports runs at the end of optimize_dataflow, beside
    the 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_dataflow computes used_import_ids once and uses it
    twice: determine_time_dependence counts through it rather than over the raw
    import 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_dependence takes the read set and filters both import loops
through 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!, not
soft_assert_no_log!. The walk is paid for above either way, so reporting a loose
list 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. EXPLAIN builds a peek
description without its index export (the conditional export_index in
optimize::peek, which carries a standing TODO). Such a description has no answer
to "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 EXPLAIN of a one-shot SELECT still lists imports the real dataflow
would prune; EXPLAIN of a materialized view, index, or subscribe is unaffected,
since those always export.

A note on one-sidedness: depends_on cannot tell which index on a collection a
plan will use, so the export walk reports every index imported on a collection it
reaches. That makes used a superset on the index side, so the check can miss a
loose 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_reqs collects, which is why the two prunes are not unified.

Verification

  • New unit tests for the prune in mz_transform::dataflow: the import no export
    reads is dropped, a constant export drops every import, and an export-less
    description is left alone.
  • Two EXPLAIN MATERIALIZED VIEW goldens in
    test/sqllogictest/explain/materialized_view.slt, each pinning that a folded
    import stops showing up as a source, and each paired with an EXPLAIN LOCALLY OPTIMIZED PLAN golden asserting the table is still read at that stage. Without
    that 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 new
    assertion. 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:

dataflow materialize.public.mv_join imports collections no export reads: imports [User(1)], read {}

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 0 behind a view does not qualify; nor does a
contradictory range split across a view boundary, which inlines but does not fold.


🤖 Generated with Claude Code

https://claude.ai/code/session_018eEZgU1VhJF9jgnjhCHGYc

@linear-code

linear-code Bot commented Aug 18, 2026

Copy link
Copy Markdown

SQL-635

@antiguru
antiguru marked this pull request as ready for review August 18, 2026 12:28
@antiguru
antiguru requested review from a team as code owners August 18, 2026 12:28
@antiguru
antiguru requested review from DAlperin and aljoscha August 18, 2026 12:28
Comment thread src/adapter/src/frontend_read_then_write.rs Outdated
@antiguru
antiguru force-pushed the claude/compute-derive-time-dependence-s2rb9j branch 2 times, most recently from bd73a81 to 7b5ebc5 Compare August 19, 2026 07:47
@antiguru
antiguru changed the base branch from main to aljoscha/sql-635-expiration-unread-imports August 19, 2026 07:48
@antiguru
antiguru marked this pull request as draft August 19, 2026 07:48
claude added 2 commits August 19, 2026 08:24
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.
@antiguru
antiguru force-pushed the claude/compute-derive-time-dependence-s2rb9j branch from 7b5ebc5 to 27f8929 Compare August 19, 2026 08:24
@antiguru
antiguru marked this pull request as ready for review August 19, 2026 08:24
@aljoscha

Copy link
Copy Markdown
Contributor

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 since, every worker decodes a shard into a stream nobody consumes, and the time dependence is wrong. Filtering inside determine_time_dependence fixes the third and leaves the other two, which makes a broken description look tolerable. One invariant, maintained by the producer, asserted once where it is consumed, is the better shape, and used_import_ids earns its keep as that assertion's vocabulary rather than as a filter.

Severity is the part I would push on. soft_assert_no_log! is a no-op in release, so once the filter is gone, production rests entirely on the prune covering every path that installs a dataflow. If one ever slips past, the output frontier is pinned at the expiration, the collection never reports final, and nothing says so anywhere. That is the exact silence SQL-635 presented as, and what it took to find it was a hung environment. The perf argument for avoiding the logging variant is sound, the check walks the plan and this runs per dataflow creation, but the trade deserves to be said out loud: a debug-only assertion buys a failing test, and what the filter bought was production behavior.

Two cheap ways to get that back:

  • soft_panic_or_log! on the check, so a loose list is a log line in production rather than nothing.
  • Have determine_time_dependence iterate used_import_ids() regardless. One set per call, and it is the one consumer whose wrong answer hangs an environment rather than merely wasting a read hold.

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 EXPLAIN, and it is also the one shape where a description reaching create_dataflow without exports trips the new assertion rather than being pruned. I believe every installed dataflow has an index or sink export, subscribes and copy-to included, so this is latent rather than live, but the prune and the assertion disagree about that case and only one of them is load-bearing.

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. used being a superset on the index side means the check can miss a loose list but never invent one, which is the direction you want for something that soft panics.

— 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.

Copy link
Copy Markdown
Member Author

Took the second, and your reasoning for it makes the first free, so the change does both. Pushed in 1e8bb94.

create_dataflow computes used_import_ids once. determine_time_dependence takes it and counts through it, so the consumer whose wrong answer hangs an environment is correct whether or not the prune ran. That was the part worth having: the prune reclaims the read hold and the persist source, which are read off the import list directly, and the frontier answer stops depending on the prune's coverage being complete.

The bit your comment set up without claiming: once that filter is in, the walk is unconditional in production anyway, since determine_time_dependence is called about thirty lines below the check in the same function. So the perf argument for soft_assert_no_log! evaporates, and the check can be soft_assert_or_log! against the already-computed set for the price of the comparison. Production gets the correct time dependence and a log line if the list goes loose. Your two options each bought one of those; together they cost less than either alone.

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: test/sqllogictest/explain/ passes 872/872 with no firing of either assertion, across peeks, materialized views, indexes and subscribes. That is consistent with your belief that every installed dataflow exports something, but it is one suite, not a proof. Full CI is the real test of it, and if it ever does fire the message now points at the right thing.

Nothing to change on the one-sidedness note. used being a superset on the index side means the check can miss a loose list but never invent one, and that is the direction you want now that it logs in production rather than only failing a test.


Generated by Claude Code

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