Skip to content

compute: publish maintained indexes into the sharing registry - #38389

Open
antiguru wants to merge 3 commits into
mh/interactive-03-multiplexfrom
mh/interactive-04-publish
Open

compute: publish maintained indexes into the sharing registry#38389
antiguru wants to merge 3 commits into
mh/interactive-03-multiplexfrom
mh/interactive-04-publish

Conversation

@antiguru

@antiguru antiguru commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fourth of eight PRs splitting #37770. Stacks on #38388. Tracked by CPU-215.

Both export paths publish their oks/errs arrangements into the per-process registry when the runtime's role publishes. A re-export arm, where an index reuses another index's arrangement, has no streams of its own, so it registers its id as an alias of the arrangement's existing publication point, which leaves the re-export's dataflow without operators, as it is on a runtime that does not publish. A reader that bound the re-export's id before the render holds its own unbacked point, which only a publisher into it can back, so that case re-imports the shared traces and publishes them under the new id, and logs the imported errors because mz_compute_error_counts forwards a dependency's counts only to a re-export whose dataflow has no operators. Logging indexes publish the same way, gated strictly on Maintenance: an interactive runtime reads maintenance's slot and its own copy would clobber it, while Solo has no registry peer.

An alias shares its target's frontiers while the target lives: the alias dataflow imports the target, so the controller never advances the target's since past an alias's, and the target's frontier bounds every reader of the shared point. Once the target drops, the meet of the remaining aliases' frontiers governs the point, since the shared trace then compacts to exactly that meet. Seal notifications fan out from the target to its aliases, since a reader waits under the id it imported.

ComputeRuntimeRole::Interactive stops being test-only, because publishes() has to name it. pub mod server keeps the variant reachable, so no #[allow] is needed even though nothing constructs it yet.

No behavior change: Solo is the only role anything constructs and it does not publish, so every added block is skipped and no dataflow gains an operator. That is also why no goldens move here. They move in the last PR of the stack, which turns the flag on in CI.

@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from d31414b to d9ddff4 Compare August 21, 2026 11:23
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch 2 times, most recently from d754ecc to 1303035 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-04-publish branch from 1303035 to dffb179 Compare August 21, 2026 14:31
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from dffb179 to 7d70e54 Compare August 21, 2026 17:54
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 7d70e54 to 6e430c4 Compare August 28, 2026 14:06
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 6e430c4 to 8ba6dce Compare September 3, 2026 08:48
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 8ba6dce to 3f24fa0 Compare September 3, 2026 15:58
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 3f24fa0 to d3a7b3d Compare September 4, 2026 15:57
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from d3a7b3d to 188d168 Compare September 4, 2026 17:38
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 188d168 to aac9441 Compare September 4, 2026 19:37
@antiguru
antiguru marked this pull request as ready for review September 4, 2026 19:39
@antiguru
antiguru requested a review from a team as a code owner September 4, 2026 19:40
@antiguru
antiguru requested a review from petrosagg September 4, 2026 19:40
@def-

def- commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The re-export arm overwrites a registry slot an interactive import has already bound to

src/compute/src/render.rs:801

ArrangementSharingRegistry::reexport assigns slots[worker_index] = Some(arr) unconditionally (src/compute/src/sharing.rs:161), so when the interactive runtime has already created a placeholder for idx_id and built its import over it, that placeholder is orphaned. Nothing ever adopts it, its chain stays empty and its upper stays at minimum, so the importing dataflow never produces and the peek behind it hangs until cancelled.

Details

Every other publish path goes through get_or_create + PublishArrangement::adopt precisely so that a point a reader has already imported is backed in place "rather than being overwritten by a second, disconnected arrangement" (sharing.rs:111). The comment on the new call site claims that same property for the alias, but reexport does not have it.

The reader binds once and never re-resolves: import_shared_index takes slot = registry.get_or_create(idx_id, ..), mints slot.oks/slot.errs handles, builds import_snapshot_at over them and keeps the Arc in tokens for the life of the dataflow. A later map replacement is invisible to it.

The ordering that triggers this is the one get_or_create is built for ("whichever side ... touches id first creates the slot"): a peek dataflow routed to interactive renders as soon as it arrives, while maintenance's own CreateDataflow for idx_id may still be queued behind other render work. Reaching the re-export arm at all needs only a second index on an already-indexed key, e.g. CREATE INDEX i2 ON t (k) while i1 ON t (k) exists: i2's dataflow imports i1 and re-exports its arrangement.

Either make reexport fill only an unoccupied slot (and report loudly otherwise, since silently orphaning a live reader is worse than a panic), or resolve the alias on the read side so an importer of idx_id reaches gid's slot rather than a second map entry that can be swapped underneath it.

2. MEDIUM -- Aliasing two ids onto one publication point makes them share per-collection compaction state, refusing reads the arrangement can still serve

src/compute/src/render.rs:801

reexport stores the same Arc<SharedIndexArrangement> under gid and idx_id, so the two collections share one Published/SharedTrace. The controller's compaction frontier is per collection, but the registry funnels both ids into that one point: note_writer_logical assigns (shared_trace/publish.rs:111) so the last id the controller sent wins, and advance_standing_hold joins (shared_trace/state.rs:158) so the standing hold becomes the max of the two. When the two frontiers diverge, the publisher's since can settle at the faster id's frontier, and a read on the slower id at a timestamp the arrangement still holds is refused.

Details

The refusal surfaces two ways. On the fast path, the peek's handle is registered at the published since, gate_peek compares it to peek.timestamp and returns PeekResponse::Error("Arrangement compaction frontier ... is beyond the time of the attempted read") -- a spurious user-visible error on a valid read. On the slow path, import_shared_index's handle_at(as_of) fails and report_compacted_past panics the replica.

The underlying trace is not actually over-compacted: compute_state.traces.set(idx_id, trace) clones the TraceBundle, so gid and idx_id keep distinct TraceAgents and the TraceBox meet still protects the data. That is why this shows up as a false refusal rather than wrong results, and why it cannot be caught by watching for corruption.

Divergence between two indexes on the same key is ordinary: a freshly created duplicate starts at its create as_of while the original sits a compaction lag behind, and a read hold (a SUBSCRIBE, an explicit AS OF) on one and not the other keeps them apart for as long as it is held. writer_logical then flips between the two ids' frontiers as the controller's AllowCompaction messages interleave, so the failure is intermittent rather than sticky.

The publication point's writer_logical and standing_hold are properties of a collection, not of an arrangement. If two collections are going to share one point, those two fields need to be tracked per id and combined by meet, not overwritten and joined.

@antiguru

antiguru commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Both confirmed and fixed in aea40da, together with the registry change in #38387 (0225ab1).

The re-export arms no longer alias gid's slot. They re-import the shared traces (TraceBundle::import_named) and publish them under idx_id through ArrangementSharingRegistry::publish, which adopts the slot rather than replacing it. A placeholder an interactive import already bound to is therefore backed in place, and each id carries its own writer frontier and standing hold.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-04-publish branch 2 times, most recently from 5911208 to 6d76ee5 Compare September 5, 2026 16:59

antiguru commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Revised in 6d76ee5: the re-import per re-export cost 125 to 185 KB of clusterd memory each (five operators and a trace listener per re-export, measured in the nightly's ManyReexportsIdle), so re-exports alias again, with both findings addressed in the registry rather than in the render arm.

  1. publish_alias fills only an unoccupied slot. When a reader already created the alias's slot, it returns false and the render arm falls back to re-importing and publishing under the new id, which backs that reader's point in place.
  2. While the target lives, only its AllowCompaction and standing-hold notes reach the shared point. That is sound because the alias dataflow imports the target, so the controller never advances the target's since past an alias's. Once the target drops, the point takes the meet of the remaining aliases' noted frontiers. Tests: alias_refused_once_a_reader_holds_its_own_point, alias_frontiers_follow_the_target_then_the_aliases_meet.

Posted by Claude Code.

@def-

def- commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- publish_alias decides per worker but records the alias relation process-wide, so a worker that falls back to publishing never receives a frontier note

src/compute/src/sharing.rs:254

publish_alias aliases or falls back per (id, worker), but it writes target_of/aliases_of with no worker ordinal. When the reader race resolves differently on two workers, the worker that fell back owns a real, independent publication point for idx_id, yet Aliases::note still routes it to the target and returns None, so that point never receives note_allow_compaction or note_standing_hold. Its standing hold stays at the seed adopt installed, which pins the target's trace and stops that arrangement compacting on that worker for as long as the re-export lives.

Details

The slot check is per worker (sharing.rs:249): publish_alias returns false only when this worker's slot for alias is already taken, which is what happens when the interactive reader ran get_or_create(idx_id, worker, peers) before maintenance rendered the re-export. Interactive and maintenance workers are independent threads racing on independent slots, so worker 0 can alias while worker 1 falls back to registry.publish (render.rs:909-916).

allowed and holds are correctly keyed (GlobalId, usize) (sharing.rs:97-99), but target_of/aliases_of are not. On the fallback worker Aliases::note computes target = target_of[idx_id] = gid, finds gid live, and returns None because id != target (sharing.rs:121-124). So the fallback point's writer_logical stays None and its standing_hold stays at the initial_logical seeded in adopt. The publisher forwards that accumulated hold to its agent on every activation (shared_trace/publish.rs:270-278, :317), so the meet over the trace's agents never rises above the frontier at which the re-export was rendered. Reads stay correct, since the published since is pinned just as low, but the target index's arrangement grows without bound on that worker.

The global relation also misdirects the remove handover: with one fallback alias and one true alias on the same worker, remaining.iter().find_map(...) (sharing.rs:289-295) can apply the aliases' meet to the fallback's private point and leave the genuinely shared point untouched.

A milder consequence of the same split: the fallback adds import and publisher operators that outlive the dropped import token, so the re-export's dataflow no longer empties out and stays in mz_dataflows. mz_compute_error_counts_per_worker derives index_reuses from mz_dataflows, which is worker 0 only, so if worker 0 falls back while the others alias, forwarding from the dependency is suppressed and only worker 0 ever logged direct counts, leaving the reported error count at one worker's share.

Fix: key target_of and aliases_of by (GlobalId, usize) the way allowed and holds already are, so the relation is recorded exactly where publish_alias established it and a fallback worker keeps being its own target.

Comment thread src/compute/src/sharing.rs Outdated
@@ -133,9 +196,6 @@
/// an error carries its data on the errs arrangement, whose frontier is held back until the
/// error is emitted, so an oks-only signal would leave that peek parked.
///

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.

Suggested change
///

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.

Removed.

Posted by Claude Code.

Comment on lines 77 to +83
struct Inner {
map: Mutex<BTreeMap<GlobalId, Vec<Option<Arc<SharedIndexArrangement>>>>>,
/// Indexed by worker ordinal; `None` until that interactive worker registers its waker.
wakers: Mutex<Vec<Option<Waker>>>,
/// Taken after `map` and before `wakers`, never the other way around.
aliases: Mutex<Aliases>,
}

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.

Is it important we have three mutexes, or could the whole Inner be protected by one Mutex?

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 important. Inner is now one Mutex. Every critical section is a few map operations and the publisher takes the lock once per seal, not per record, so nothing measurable is lost, and the acquisition-order rule goes away.

The lost-wakeup argument on notify survives unchanged in substance. It was never about the locks being distinct: the publication and the mark are separate critical sections on the publisher side, and take_dirty and the slot re-read are separate on the worker side, so the same four-step ordering argument applies. Its wording now says that instead of "two independent locks".

Lock nesting is registry then shared-trace state, in remove and the two note_* methods. The publisher's on_seal callback runs after the state lock is released, so there is no path in the other direction.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 6d76ee5 to 6a74e15 Compare September 7, 2026 09:35
@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 6a74e15 to 4b688a8 Compare September 7, 2026 11:39
@def-

def- commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- publish_alias records the immediate target rather than the publication point's root, so a re-export of a re-export never receives seal notifications

src/compute/src/sharing.rs:521

target_of/aliases_of are one level deep: publish_alias(alias, target) records target verbatim (sharing.rs:259), even when target is itself an alias. A publisher's on_seal notifies under the id it published (sharing.rs:211-217), and notify marks that id plus its direct aliases, so an alias two hops from the publication point is never marked dirty. A reader waiting under that id is never re-examined and the work behind it stalls.

Details

Reachable through ordinary DDL on duplicate indexes. CREATE INDEX i2 ON t (k) renders a real arrangement and publishes it; CREATE INDEX i3 ON t (k) re-exports it, so publish_reexport aliases i3 onto i2's point (render.rs:909). DROP INDEX i2 leaves the point alive under i3, which is what the remove handover is for. A later CREATE INDEX i5 ON t (k) must now import i3, since indexes_on(t) no longer offers i2 (src/adapter/src/coord/indexes.rs:88, and the key match takes the first offered index in src/transform/src/dataflow.rs:1116). That gives target_of[i5] = i3 while the live publisher still notifies under i2, whose alias set is {i3, i4}. i5 is on the same point and is never marked.

Fix by flattening the recorded relation to the point's root, after taking the slot from the immediate target (the slot lookup has to stay on target, since a dropped root is no longer in map):

slots[worker_index] = Some(shared);
// Record the point's root, not the immediate target: the publisher notifies under the
// id it published, so that id's alias set must name every id sharing the point.
let mut root = target;
while let Some(&next) = aliases.target_of.get(&root) {
    root = next;
}
aliases.target_of.insert(alias, root);
aliases.aliases_of.entry(root).or_default().insert(alias);

A loop rather than a single hop because a root can accumulate aliases across several drops. It terminates because publish_alias refuses an alias that already holds a slot on this worker and ids are never reused, so target_of cannot cycle. Recording a root that has already dropped is fine: Aliases::note and remove both key off the target's alias set and handle a dead target.

@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from 4b688a8 to c9e3686 Compare September 7, 2026 17:10
Comment on lines 459 to -296
pub(crate) fn notify(&self, id: GlobalId, worker_index: usize) {
let mut wakers = self.inner.wakers.lock().expect("registry poisoned");
let mut inner = self.lock();
let Inner {
wakers, aliases, ..
} = &mut *inner;
if let Some(waker) = wakers.get_mut(worker_index).and_then(|w| w.as_mut()) {
Self::mark(waker, id);
}
}

/// Marks `id` dirty for every registered worker and fires each coalescing waker. Used by
/// `remove`, which is not worker-specific. Per worker, the lost-wakeup argument on
/// [`Self::notify`] applies unchanged.
fn notify_all(&self, id: GlobalId) {
let mut wakers = self.inner.wakers.lock().expect("registry poisoned");
for waker in wakers.iter_mut().flatten() {
Self::mark(waker, id);

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.

Should this be in this PR or further up the stack?

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.

Fair question. note_standing_hold has no production caller until #38392 wires handle_allow_compaction on the interactive runtime; in this PR it is reached only by adopt's seeding and by tests. This block is the alias half of that method: when a target with aliases drops, the point's standing hold moves to the meet of what the aliases noted, the same rule note_standing_hold applies while the target lives.

I kept it here so the alias rules stay in one place with publish_alias and Aliases, rather than splitting them across this PR and #38392. If you would rather see the standing hold arrive with its caller, I can move note_standing_hold, the holds table, and this block to #38392 as one unit.

Posted by Claude Code.

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.

Misread the anchor, sorry: you meant the registry changing shape here right after #38387 introduced it. Agreed. The one-lock form now lands in #38387 itself, so this PR only adds the alias table to Inner and touches nothing the registry already had. The fixup that remains here is a two-line doc trim on the logging gate.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-04-publish branch from c9e3686 to 6746cde Compare September 7, 2026 18:19
antiguru and others added 3 commits September 7, 2026 20:37
Both export paths now publish their `oks`/`errs` arrangements into the per-process
registry when the runtime's role publishes. A re-export arm has no streams of its
own, so it registers its id as an alias of the arrangement's existing publication
point, which leaves the re-export's dataflow without operators, as it is on a
runtime that does not publish. A reader that bound the re-export's id before the
render holds its own unbacked point, which only a publisher into it can back, so
that case re-imports the shared traces and publishes them under the new id, and
logs the imported errors because `mz_compute_error_counts` forwards a dependency's
counts only to a re-export whose dataflow has no operators. Logging indexes
publish the same way, gated strictly on `Maintenance`: an interactive runtime
reads maintenance's slot, and its own copy would clobber it, while `Solo` has no
registry peer at all.

An alias shares its target's frontiers while the target lives: the alias dataflow
imports the target, so the controller never advances the target's `since` past an
alias's, and the target's frontier bounds every reader of the shared point. Once
the target drops, the meet of the remaining aliases' frontiers governs the point,
since the shared trace then compacts to exactly that meet. Seal notifications fan
out from the target to its aliases, since a reader waits under the id it imported.

`ComputeRuntimeRole::Interactive` stops being test-only. Nothing constructs it
yet, but `publishes()` has to name it, and `pub mod server` keeps the variant
reachable so dead-code analysis is satisfied without an attribute. The stale
`owns_process_globals` note claiming every constructible role owns the globals
goes with it.

Carrying the role and the registry to the render path is what the rest of this
change is: `Config` and `Worker` gain both, `ComputeState` stores them and
exposes `role()`, and clusterd builds one registry per process. Per process, not
per runtime, because a reader on one runtime looks up the slot a publisher on
another filled.

No behavior change. `Solo` is the only role anything constructs and it does not
publish, so every added block is skipped and no dataflow gains an operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
With the trace publishing its own `since`, the registry no longer forwards the
controller's compaction frontier through aliases. The alias table keeps only the
standing-hold frontiers, and the alias test exercises those.

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-04-publish branch from 6746cde to 8c44e85 Compare September 7, 2026 18:42
@def-

def- commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. HIGH -- Publishing one arrangement under two ids kills the first publication point, because attach keeps only the newest attachment

src/compute/src/logging/initialize.rs:407

Every logging index shares a single errs arrangement (initialize.rs:207), and publish_logging_index adopts that one trace once per log id. SharedSpine::attach replaces any earlier attachment, so on the Maintenance runtime every logging index except the last one processed is left with a dead errs publication point: empty chain, upper pinned at the minimum time, on_seal never firing. A reader that waits for the errs half to seal, which ArrangementSharingRegistry::publish's own contract says a peek does, parks forever, so introspection peeks over ~32 of the 33 log ids never answer.

Details

The chain is mechanical. TraceAgent::import_named returns an Arranged whose trace is self.clone(), and TraceAgent::clone is Rc::clone(&self.trace), so both re-imports in publish_logging_index (initialize.rs:404) carry the same TraceBox. registry.publishArranged::adopt reaches that box's spine (shared_trace/publish.rs:202) and attach ends with *self.attachment.borrow_mut() = Some(..) (src/timely-util/src/shared_trace.rs:313), overwriting the previous point. publish_frontiers, publish_chain and apply_holds all read that single Option, so the displaced point stops receiving chain, frontiers and seals, and its readers' holds stop reaching the inner trace.

Confirmed by driving two registry.publish calls over one shared errs arrangement to completion in a single-worker dataflow: the second id's errs point reaches upper = [], the first stays at upper = [0].

src/compute/src/render.rs:916 has the same defect with a worse blast radius. The re-export fallback re-imports gid's TraceBundle and publishes it under idx_id, which detaches gid's own point. gid's chain and upper freeze at that instant and notify(gid, ..) never fires again, so every reader already importing gid silently stops seeing new data and is never woken, while a new peek on gid at a current timestamp parks on a frontier that can no longer advance. Results are not corrupted, since the frozen chain's batches stay pinned, which is why this fails as an indefinite hang rather than something a consistency check would catch.

Fix: let a spine mirror into more than one point. Turn SharedSpine::attachment into a Vec<Attachment<..>> that attach appends to, fan publish_chain/publish_frontiers/on_seal out over it, and take the meet across all points' remote_logical/remote_physical in apply_holds. Both call sites then work as written. The narrower alternative, giving each publication its own arrangement, is cheap for the empty logging errs but means re-arranging the data for the re-export fallback.

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