Skip to content

compute: import published indexes as a shared arrangement - #38390

Open
antiguru wants to merge 3 commits into
mh/interactive-04-publishfrom
mh/interactive-05-import
Open

compute: import published indexes as a shared arrangement#38390
antiguru wants to merge 3 commits into
mh/interactive-04-publishfrom
mh/interactive-05-import

Conversation

@antiguru

Copy link
Copy Markdown
Member

Fifth of eight PRs splitting #37770. Stacks on #38389. Tracked by CPU-215.

ArrangementFlavor gains a SharedTrace variant carrying arrangements backed by TraceFrontier<SharedTraceHandle>, and import_index_shared constructs it on the interactive runtime instead of reading the local TraceManager, which holds nothing there. The shared handle shares the RowRow/Err batch and cursor types of a maintenance TraceAgent, so the variant flows through the same generic bodies as Trace and a downstream Get receives a real arrangement with its key and permutation intact, rather than a collection the plan re-derives. Joins, delta joins, thresholds, and hydration logging gain the corresponding arm.

The import is a static snapshot at as_of, bounded one step past it rather than by self.until: interactive work is single-time, so the capability must drop once the shared trace seals past as_of for the one-shot result to complete, and an unbounded until never gets there.

The read hold is the returned Arranged's own trace rather than a separate token, so consumers can downgrade it as their frontiers advance. A token nobody downgrades would be a floor under every hold that is downgraded, since the publisher forwards the meet.

The variant and its constructor land together because a never-constructed variant of an enum inside the private render module trips dead_code, and CI lints with -D warnings. Reachable only when a runtime holds the Interactive role, which nothing constructs yet.

@antiguru
antiguru force-pushed the mh/interactive-05-import branch from cf8f1f6 to 5c374d3 Compare August 21, 2026 11:23
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 5c374d3 to 1bce309 Compare August 21, 2026 13:24
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 1bce309 to 35cb94e Compare August 21, 2026 13:42
@antiguru
antiguru requested a review from DAlperin August 21, 2026 13:46
@antiguru
antiguru force-pushed the mh/interactive-05-import branch 2 times, most recently from f92f260 to aa7c8eb Compare August 21, 2026 17:54
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from aa7c8eb to db731c1 Compare August 28, 2026 14:06
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from db731c1 to 7f75767 Compare September 3, 2026 08:48
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 7f75767 to ecc45ca Compare September 3, 2026 16:03
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from ecc45ca to 00d66cc Compare September 4, 2026 16:02
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 00d66cc to c4afb21 Compare September 4, 2026 17:38
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from c4afb21 to 5bb8e79 Compare September 4, 2026 19:37
@antiguru
antiguru marked this pull request as ready for review September 4, 2026 19:40
@antiguru
antiguru requested a review from petrosagg September 4, 2026 19:40
@antiguru
antiguru requested a review from a team as a code owner September 4, 2026 19:40
@def-

def- commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Delta join's SharedTrace arms re-emit input errors that bundle_errs already seeded

src/compute/src/render/join/delta_join.rs:431 (and :785)

Details

The two new SharedTrace arms concatenate the input arrangement's own error collection into the errors they return, but render_delta_join already seeds inner_errs once per input from bundle_errs, which this PR extended with a SharedTrace arm too. An error carried by an imported shared index is therefore emitted 1 + (number of delta paths that read it) times instead of once, which is exactly the per-path multiplication the surrounding code is written to avoid.

The sibling arms deliberately drop the bundle's errors: Some(ArrangementFlavor::Local(oks, _errs)) at :359 and Some(ArrangementFlavor::Trace(_, oks, _errs)) at :383, likewise at :760/:768 in build_update_stream. The contract is stated on both helpers ("The returned error collection holds only the errors this stage produces. The errors bundle already carries are the caller's to propagate, once") and on bundle_errs itself at :294, which spells out the consequence: "Propagating them per path would multiply their multiplicities by the number of paths, and because a join's output is another join's input, those factors compound multiplicatively through a nested plan until the Diff overflows."

bundle_errs gained the matching arm at :323, so both sources are live for this flavor. Concretely, a three-way delta join over imported indexes on the interactive runtime reads each input as one path's source and two paths' lookup, so each input's pre-existing errors land in the output four times; the inflation is visible in mz_compute_error_counts for an index export and compounds through nested joins.

Fix: bind the errors away, as the Trace arms do.

-        Some(ArrangementFlavor::SharedTrace(_, oks, errs)) => {
+        Some(ArrangementFlavor::SharedTrace(_, oks, _errs)) => {
             let (oks, errs2) = if source_precedes_lookup {
...
-            (oks, errs2.concat(errs.as_collection(|k, _v| k.clone())))
+            (oks, errs2)
         }

and the same in build_update_stream:

-        Some(ArrangementFlavor::SharedTrace(_, oks, errs)) => {
-            let (oks, errs2) = build_update_stream_trace::<_, SharedOksEnter<_>>(
-                oks,
-                as_of,
-                source_relation,
-                initial_closure,
-            );
-            (oks, errs2.concat(errs.as_collection(|k, _v| k.clone())))
-        }
+        Some(ArrangementFlavor::SharedTrace(_, oks, _errs)) => {
+            build_update_stream_trace::<_, SharedOksEnter<_>>(
+                oks,
+                as_of,
+                source_relation,
+                initial_closure,
+            )
+        }

2. LOW -- import_shared_index's contract is attached to report_compacted_past

src/compute/src/render.rs:563

The doc comment block that opens at :563 runs straight into report_compacted_past's own summary at :605 without a break, so the entire import contract, including the "caller MUST retain [the slot] for as long as the import is alive" requirement, renders as documentation for the panic helper, and import_shared_index itself is left undocumented.

Details

Split the block so that /// Reports a publication point refusing to serve as_of, and aborts. starts report_compacted_past's own comment and the preceding paragraphs move onto import_shared_index.

Related: the NOTE: on the new variant at src/compute/src/render/context.rs:244 says "the imported handles carry no TraceFrontier/until bound". They do: SharedOksEnter<T> = TraceEnter<TraceFrontier<SharedOksHandle>, T>, structurally identical to RowRowEnter, and import_index_shared passes a real until (as_of stepped forward). The accurate caveat is that the bound is derived from as_of rather than from the dataflow's self.until, which is what a multi-time import would need.

@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 5bb8e79 to 6807118 Compare September 5, 2026 08:34
@antiguru

antiguru commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

All three fixed in 6807118. The delta join's SharedTrace arms bind the input errors away as the Trace arms do, so bundle_errs remains the one place that propagates them. The import contract now sits on import_shared_index, and report_compacted_past has its own doc. The NOTE on the variant says what is true: the bound is derived from as_of rather than from until, so the variant serves single-time dataflows only.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 6807118 to fb79274 Compare September 5, 2026 11:47
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from fb79274 to 8a82082 Compare September 5, 2026 16:59
@def-

def- commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- interactive_import_holds_after_construction passes with the import's read hold deleted

src/compute/src/render/tests.rs:406

Both assertions in this test are satisfied by the standing hold alone, so the test passes unchanged when the import registers no read hold at all. I verified this by deleting the hold import_snapshot_at owns and re-running: all 35 tests across render::tests, sharing::tests and shared_trace::tests still pass. The regression the test names, a publisher compacting past a live import's as_of and feeding silently wrong rows to every consumer that keeps only the stream, currently has no coverage anywhere in the crate.

Details

SharedTrace::new_empty seeds logical_compaction with the standing hold's own contribution (MutableAntichain::from_elem(minimum), shared_trace/state.rs:208), and adopt advances it to the writer's frontier at adoption, which is {0} here. This test never calls note_standing_hold, so 0 stays in the accumulation, and published_logical_holds returns logical_compaction.frontier(), that is {0}, whether or not the import ever registered its own hold at as_of = {5}. Both !holds.is_empty() and less_equal(holds, as_of) are therefore true either way.

The sibling tests do not cover it either: interactive_import_hold_releases_on_drop and interactive_import_hold_downgrades_while_live retain the returned Arranged's trace, which registers a hold of its own, so they pass with the operator-owned hold gone. That hold is what protects the as_collection and reduce consumers, which drop the trace at build time, so it is exactly the path with no test.

Reproduction: patch let mut hold = Some(self.clone()); (shared_trace/handle.rs:293) to None, then cargo nextest run -p mz-compute --lib 'render::tests::' 'sharing::tests::' 'shared_trace::' → 35 passed.

Advancing the standing hold past as_of first leaves the import's own hold as the only thing the accumulation can report:

// The standing hold sits at the minimum and is itself one of the accumulated holds, so
// advance it past `as_of` first.
registry.note_standing_hold(id, 0, &Antichain::from_elem(Timestamp::from(10_u64)));
let holds = registry
    .published_logical_holds(&id, 0)
    .expect("still published");

With that line the test fails on the patched build (the import's hold must not have released past its own as_of: Antichain { elements: [10] }) and passes on this head; I ran both. Published::physical_holds() is an alternative measure, since it starts empty and only readers contribute to it.

Comment on lines +431 to +445
// The nine `(stream flavor) x (lookup flavor)` combinations differ only in the two trace
// types handed to the generic `differential_join_inner` and the two arrangement values
// consumed. This local macro spells one combination. The `SharedTrace` rows exist so an
// interactive-runtime join over imported indexes type-checks. At runtime a dataflow's
// arrangements are all one runtime's flavor, so the mixed rows never fire, but exhaustive
// matching requires them.
macro_rules! join {
($stream:expr, $stream_tr:ty, $lookup:expr, $lookup_tr:ty, $errs1:expr) => {{
let (oks, errs2) = self
.differential_join_inner::<$stream_tr, $lookup_tr>($stream, $lookup, closure);
errors.push($errs1.as_collection(|k, _v| k.clone()));
errors.extend(errs2);
oks
}};
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Macro is fine; but could the also be a vcall instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not without a branch in the per-record loop. TraceReader and Cursor carry GATs (Key<'a>, Val<'a>, Cursor<'a>), so neither is dyn-compatible. The alternative is an enum over the three trace types plus an enum over their cursors, with a match in every key, val, step_key, and so on, which is a dispatch per cursor operation inside the join's inner loop. The macro costs monomorphisation only, three new combinations of the nine. Keeping it.

Posted by Claude Code.

Comment thread src/compute/src/render/context.rs Outdated
Comment on lines +244 to +246
/// NOTE: the `TraceFrontier` bound on the imported handles is derived from the dataflow's
/// `as_of`, not from its `until`, so this variant serves single-time dataflows only. Nothing
/// checks that at the type level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't follow the reasoning: The bound should always be derived from the as-of, as we're interested in the data in (as_of, until[, right? What implies that this can only serve single-time dataflows?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are right, and the reasoning was wrong. The maintenance import passes (as_of, until) to import_frontier_core and follows the upper when until is empty. Nothing about the shared import needs a different bound, and the single-time restriction lives in the router, which admits only is_single_time() dataflows. So snapshot_until equalled self.until for every dataflow that reached this import, and the hardcoding bought nothing while forbidding a long-lived dependent later.

import_index_shared now passes self.until. The NOTE on the variant, the "serves single-time dataflows only" sentence, and the try_step_forward special case for Timestamp::MAX are gone. is_single_time() at MAX yields an empty until, so the import follows the trace to its terminal frontier there, the same as the maintenance import.

Posted by Claude Code.

Comment thread src/compute/src/render.rs
/// permuted as the plan expects, so a `Get` of `idx.on_id` and the joins and reduces below it
/// consume an arrangement rather than re-deriving one. The import is a snapshot at `as_of`, so
/// it serves single-time dataflows only.
fn import_index_shared<'outer>(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We have fn import_index_shared, and fn import_shared_index, which can be misread. Do we have more than one use of the import_shared_index free function? It's fine to keep if this is the cleanest, and the free function isn't pub.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

One production caller (import_index_shared) and the render tests. It is not pub. Renamed the free function to import_published_index, which is the registry's vocabulary and no longer reads as a permutation of the method's name.

Posted by Claude Code.

Comment thread src/compute/src/render.rs Outdated
Comment on lines +934 to +943
Some(ArrangementFlavor::SharedTrace(..)) => {
// Only the interactive runtime produces `SharedTrace`, and only for imports it reads
// from the sharing registry. Its exports are transient query outputs, which are
// freshly rendered `Local` arrangements (a join/reduce output), never a direct
// re-export of an imported shared arrangement. The maintenance runtime's imports are
// `Local`/`Trace`. So an export can never observe a `SharedTrace` input.
unreachable!(
"interactive runtime does not re-export an imported shared arrangement"
);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this encodes an unwritten invariant: A peek against a re-export dataflow would be totally valid right now, but now wouldn't be anymore. I do not think the optimizer guarantees that it doesn't do no-op re-exports. What'd be the cost of re-exporting a shared trace?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed that nothing guaranteed it. Both unreachable! arms, here and in export_index_iterative, are now the shared-arrangement analogue of the Trace arm: alias_shared_reexport registers idx_id as an alias of gid's publication point through publish_alias, so a peek on idx_id reads the imported arrangement. Only import_published_index creates slots and a dataflow routed here imports no transient id, so no reader can have created idx_id's slot first, and the alias always registers, which is asserted.

The one thing the Trace arm has that this cannot is a TraceBundle to install, and report_frontiers reads the write frontier from it. The alias has no trace of its own, so report_frontiers falls back to the published point's upper through a new ArrangementSharingRegistry::published_upper, which is the same frontier the maintenance re-export reports. Everything else already worked: TraceManager::allow_compaction is a no-op on a missing id, Aliases::note defers to the target while it lives, and drop_collection removes the alias.

Cost of the re-export itself is a map insert. A protocol invariant would have to mirror what ArrangeBy does over the imported Get, and any simpler rule over-rejects a filter followed by a re-arrange under an import's key, so I did not pursue it.

#38393 gains two_runtime_reexport_interactive.spec, a query dataflow whose export is the arrangement it imports, which passes locally along with the four existing two-runtime specs.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 8a82082 to 99777c2 Compare September 7, 2026 09:35
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from 99777c2 to ae38969 Compare September 7, 2026 11:39
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from ae38969 to de38369 Compare September 7, 2026 17:10
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from de38369 to afa26ed Compare September 7, 2026 18:19
antiguru and others added 3 commits September 7, 2026 20:38
`ArrangementFlavor` gains a `SharedTrace` variant carrying arrangements backed by
`TraceFrontier<SharedTraceHandle>`, and `import_index_shared` constructs it on the
interactive runtime instead of reading the local `TraceManager`, which holds
nothing there. Because the shared handle shares the `RowRow`/`Err` batch and
cursor types of a maintenance `TraceAgent`, the variant flows through the same
generic bodies as `Trace`, so a downstream `Get` receives a real arrangement with
its key and permutation intact rather than a collection the plan has to
re-derive. Joins, delta joins, thresholds, and hydration logging gain the
corresponding arm.

The import is a static snapshot at `as_of`, bounded one step past it rather than
by `self.until`. Interactive work is single-time, so the capability must drop once
the shared trace seals past `as_of` for the one-shot result to complete, and an
unbounded `until` never gets there. An `as_of` at `Timestamp::MAX` yields the
empty bound, which is the right reading of "the final state".

The read hold is the returned `Arranged`'s own trace rather than a separate token,
so consumers can downgrade it as their frontiers advance. A token nobody
downgrades would be a floor under every hold that is downgraded, because the
publisher forwards the meet.

The two export paths reject `SharedTrace` as unreachable. Only the interactive
runtime produces the variant, and its exports are freshly rendered query outputs,
never a re-export of an import.

Both `import_index_shared` and the variant are reachable only when a runtime holds
the `Interactive` role, which nothing constructs yet.

The two `sharing` tests that build this variant return here, along with the
`consolidate_capture` helper they share. `render`'s tests move out of line to
`render/tests.rs`, per the convention in `src/compute/AGENTS.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xports

The shared import synthesised its own bound one step past `as_of` instead of
passing the dataflow's `until`, and documented the result as serving
single-time dataflows only. The maintenance import passes `(as_of, until)` and
follows the trace when `until` is empty, and nothing about the shared import
needs a different bound. The router already sends only single-time dataflows to
the interactive runtime, so the two bounds were equal for every dataflow that
reached the import, and the narrowing bought nothing. The variant's single-time
note and the `Timestamp::MAX` special case go with it.

An export whose arrangement is an imported shared arrangement was
`unreachable!`. Nothing in the optimizer promises that a peek dataflow never
re-exports its import under the export key, and a panic on either runtime aborts
the process. Both export paths now alias the export's id to the imported
publication point, the shared analogue of the `Trace` arm. The alias has no
`TraceBundle`, so `report_frontiers` reads the frontier through the registry.

`import_shared_index` is renamed `import_published_index` so it no longer reads
as a permutation of the `import_index_shared` method that calls it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
The refusal report named the controller's last compaction frontier, which the
registry no longer records. The published `since` already says how far the trace
compacted, so the report keeps `since`, `as_of`, and the standing hold. Tests
compact through the writer's own agents where they used to note the controller's
frontier on the registry, and keep the agents alive for as long as they read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
@antiguru
antiguru force-pushed the mh/interactive-05-import branch from afa26ed to 511f795 Compare September 7, 2026 18:42
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.

2 participants