Skip to content

compute: a per-process registry of published index arrangements - #38387

Open
antiguru wants to merge 3 commits into
mh/interactive-01-shared-tracefrom
mh/interactive-02-sharing
Open

compute: a per-process registry of published index arrangements#38387
antiguru wants to merge 3 commits into
mh/interactive-01-shared-tracefrom
mh/interactive-02-sharing

Conversation

@antiguru

@antiguru antiguru commented Aug 21, 2026

Copy link
Copy Markdown
Member

Second of eight PRs splitting #37770. Stacks on #38386. Tracked by CPU-215.

Publishing an arrangement produces handles, but a reader on another thread needs a way to find them. This adds a registry keyed by GlobalId and worker ordinal, shared across all timely workers of a process the way the persist client cache is. Worker i publishes into slot i and a reader on worker i of another runtime looks up the same slot, which is sound only because both sides shard keys by key.hashed() % peers.

A slot can be created before it is filled. get_or_create lets whichever side touches an id first create the slot, backed by unbacked publication points, and the other adopt it in place, so a reader never overwrites a publisher's arrangement nor imports over a slot a later publish replaces. publish is the one entry point for a publisher: it adopts the slot's points and marks the id dirty. Readers learn about publication and seal through a dirty-id inbox that unparks the worker's thread rather than by polling.

Inert: nothing constructs a registry.

@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 0b83619 to e4df1e7 Compare August 21, 2026 11:23
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from e4df1e7 to db592b9 Compare August 21, 2026 13:24
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from db592b9 to acaae62 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-02-sharing branch 2 times, most recently from e0c1fed to 7eeb13c Compare August 21, 2026 17:54
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 7eeb13c to 5c1ad88 Compare August 28, 2026 14:05
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 5c1ad88 to 882fe25 Compare September 3, 2026 08:48
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch 3 times, most recently from 1b830fc to 086d395 Compare September 3, 2026 15:56
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 086d395 to 9687c2c Compare September 4, 2026 15:44
@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 9687c2c to 16675c9 Compare September 4, 2026 17:38
Comment thread src/compute/src/sharing.rs Outdated
struct Waker {
/// Fires the interactive worker out of `step_or_park`. `Send`, minted by the worker via
/// `sync_activator_for`.
activator: SyncActivator,

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.

Do we want to wake a specific worker or thread? For the latter, just use a handle to the thread and call unpark.

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.

A specific worker, and switched to the thread as you suggest.

register_waker is called from handle_compute_command on the interactive worker's own thread (server.rs), so std::thread::current() is available at the registration site, exactly as at the peek-offload one. Waker now holds a Thread and mark calls unpark().

Two reasons beyond consistency. Every timely allocator's await_events bottoms out in std::thread::park, so the unpark is not a coincidence of the current allocator choice being the thread one. And, as the offload path's comment already notes, a root-path SyncActivator additionally marks the worker's dataflows schedulable, which is work this wake does not need: the server loop drains take_dirty on its own, it does not need an operator rescheduled.

What is lost is SyncActivationError as a "worker has gone away" signal, which the doc called a backstop. It is not a state we survive into: a panic on either runtime's worker aborts the process through the shared-fate hook, so there is no live registry with a departed worker behind it. That paragraph is gone.

This also removed the ParkedWorker harness in the tests, about 80 lines. It existed only to keep activate() on its non-erroring path, and unpark has no erroring path. The three dirty-set tests now register thread::current(), which the harness doc already said was sufficient for what they assert. sync_activator_fires_cross_thread stays, retargeted at shared_trace's ImportQueue, which is the remaining user of a real activation.

Posted by Claude Code.

Comment thread src/compute/src/sharing.rs Outdated
Comment on lines +257 to +264
/// Marks `id` dirty for interactive worker `worker_index` and fires its waker.
///
/// The seal signal: maintenance calls this from an export's frontier probe when the shared
/// trace's `upper` advances, so a fast-path peek waiting on the seal is re-examined. Delegates
/// to `notify`.
pub(crate) fn note_frontier(&self, id: GlobalId, worker_index: usize) {
self.notify(id, worker_index);
}

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.

Could this be inlined?

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.

Inlined. note_frontier was a bare delegation to notify, and its only other content was a doc sentence naming the caller. The frontier probes in render.rs and logging/initialize.rs now call notify directly, and the seal-signal sentence moved onto notify, where the rest of the caller contract already lives.

Posted by Claude Code.

Comment thread src/compute/src/sharing.rs Outdated
/// taken, so it stays outside the lost-wakeup argument.
pub(crate) fn notify(&self, id: GlobalId, worker_index: usize) {
let ids = self.notify_closure(id);
let mut wakers = self.inner.wakers.lock().expect("registry poisoned");

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 wakers be a read-write lock?

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.

No, every access to wakers mutates, so an RwLock would only ever take the write lock. notify and notify_all insert into the dirty set and set the coalescing flag, take_dirty takes the set and clears the flag, register_waker installs an entry. There is no read-only path to share.

The lock that could plausibly be an RwLock is map, which handles only reads. It stays a Mutex because get_or_create and reexport need the read and the write to be one critical section: the whole point is that whichever side touches a slot first creates it and the other observes the same Arc, which a read lock upgraded to a write lock would not give.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from 16675c9 to ccffa13 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 petrosagg September 4, 2026 19:39
@antiguru
antiguru requested a review from a team as a code owner September 4, 2026 19:39
@def-

def- commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. HIGH -- reexport overwrites a reader's placeholder slot, stranding its import forever

src/compute/src/sharing.rs:161

reexport writes slots[worker_index] = Some(arr) unconditionally, so when a reader has already created a placeholder for to and built an import over it, that point is dropped from the registry and never adopted. The import stays pinned at the minimum frontier for the life of the dataflow, which is a silent permanent stall, and it breaks exactly the convergence contract get_or_create documents two functions above.

Details

The placeholder path exists because the reader may touch an id before any publisher does. That applies to a re-exported id just as much as to a normally published one: from the interactive runtime's side, to is an ordinary index id, and it has no way to know the maintenance runtime will satisfy it by aliasing from. So the sequence is reachable whenever the interactive runtime processes a CreateDataflow importing to ahead of the maintenance runtime rendering the dataflow that exports it — the same command-arrival-order hazard join_over_point_adopted_late_matches_direct covers for the normal path.

The re-export producer is Context::export_index's ArrangementFlavor::Trace(gid, ..) arm (src/compute/src/render.rs:751), which today just clones the TraceBundle under the new id. Note that arm cannot be fixed by calling adopt instead: the flavor carries Arranged<RowRowEnter<..>>, not Arranged<TraceAgent<Tr>>, so registry-level aliasing really is the only mechanism available and the clobber has to be handled here.

Fix: reexport must not drop an occupied slot on the floor. Either back the reader's existing point in place — a Published-to-Published binding, the analogue of adopt, so handles already captured by value follow the alias — or, if that is too invasive for now, have reexport detect the occupied slot and surface it (return it to the caller, or assert) rather than swap the Arc. Worth a regression test mirroring get_or_create_converges_on_one_slot: mint a handle off get_or_create(to, ..), then reexport(&from, to, ..), and assert the handle observes from's rows.

2. MEDIUM -- Two aliased ids share one publication point's per-id compaction state

src/compute/src/sharing.rs:281

After reexport, from and to name the same SharedIndexArrangement, but each id has its own TraceAgent in the TraceManager and its own controller compaction frontier. note_allow_compaction assigns into the single writer_logical (last writer wins) and note_standing_hold joins into the single standing_hold, so the two ids' independent frontiers collide, and reads at an as_of the trace can still serve get refused.

Details

TraceBundle::clone at src/compute/src/render.rs:753 gives to an independent agent, so handle_allow_compaction drives allow_compaction(from, f1) and allow_compaction(to, f2) separately and the trace's real since is the meet of both. The published since is meet(publisher_after, writer_logical) with writer_logical holding only whichever of f1/f2 landed last, so it can sit strictly above the real since, and Published::handle_at then returns Err for an as_of the arrangement still supports. That Err is documented as a protocol-ordering failure callers report loudly, so the visible effect is a hard failure on a legitimate read.

The standing_hold join is the sharper half. Its whole purpose is to keep the publisher's agent at the frontier the importing runtime has applied for this id. Joining the two ids' applied frontiers lifts it to the higher one, so if the maintenance runtime has already advanced from's own agent, nothing holds the trace at the as_of the interactive runtime may still present for from, and it compacts past it. Both need per-id state: either keep writer_logical/standing_hold keyed by id inside the point, or give the alias its own point rather than sharing one.

@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch from ccffa13 to 0225ab1 Compare September 5, 2026 08:34
@antiguru

antiguru commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Both confirmed. Fixed in 0225ab1 by removing reexport and the alias map rather than repairing them.

A re-exported index now gets its own publication point. ArrangementSharingRegistry::publish is the single publisher entry point: it adopts the slot's points, so a placeholder a reader has already imported is backed in place exactly as on the primary path, and it notifies. #38389's re-export arms re-import the shared traces and call it, the same shape publish_logging_index already used. That gives each id its own writer_logical and standing hold, which settles the second finding as well, and it removes the alias fan-out from notify.

Posted by Claude Code.

@antiguru
antiguru force-pushed the mh/interactive-02-sharing branch 3 times, most recently from 74f1b05 to f2531cf Compare September 7, 2026 17:10
antiguru and others added 2 commits September 7, 2026 20:17
Publishing an arrangement produces handles, but a reader on another thread needs
a way to find them. This adds a registry keyed by `GlobalId` and worker ordinal,
shared across all timely workers of a process the way the persist client cache
is, so worker `i` publishes into slot `i` and a reader on worker `i` of another
runtime looks up the same slot. That is sound only because both sides shard keys
by `key.hashed() % peers`, which the equal-peer requirement guarantees.

A slot can be created before it is filled. `get_or_create_placeholder` lets
whichever side touches an id first create the slot and the other adopt it in
place, so a reader never overwrites a publisher's arrangement or imports over a
slot that a later publish replaces. Readers learn about publication and seal
through a dirty-id inbox with a coalescing `SyncActivator` rather than by
polling.

Nothing in the crate calls the registry yet, so it is inert in the same sense as
the shared-trace primitive it builds on. `published_logical_holds` is `pub`
because its callers arrive later and crate scoping would make it unreachable for
dead-code analysis.

Tests are out of line in `sharing/tests.rs`, per the convention in
`src/compute/AGENTS.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`note_allow_compaction` fed the publisher the controller's compaction frontier
so it could approximate the trace's `since`. The trace now publishes its own
`since`, so the registry only carries the standing hold. Tests keep a trace
agent alive for as long as they read, since the point closes with the trace.

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-02-sharing branch from f2531cf to 93cfbd9 Compare September 7, 2026 18:18
Every critical section is a few map operations and the publisher takes the lock
once per seal, so separate locks for the slots and the wakers bought nothing but
an acquisition-order rule. The lost-wakeup argument on `notify` is about the
publication and the mark being separate critical sections, and stands as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
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