Skip to content

compute: two-runtime read isolation (interactive runtime) - #37770

Draft
antiguru wants to merge 37 commits into
MaterializeInc:mainfrom
antiguru:mh/two-runtime-stage2
Draft

compute: two-runtime read isolation (interactive runtime)#37770
antiguru wants to merge 37 commits into
MaterializeInc:mainfrom
antiguru:mh/two-runtime-stage2

Conversation

@antiguru

@antiguru antiguru commented Jul 21, 2026

Copy link
Copy Markdown
Member

Prerequisite: #37884 (ComputeRuntimeRole + role-labeled metrics). The role enum, the role metric label, and the serve/register_with role threading now live there with every call site passing Solo. Its commit appears in this diff until it merges. After it merges, rebase onto main and this PR is left flipping the call sites to Maintenance/Interactive.

Stacks on #37880 (typed s/u/t-prefixed global ids in clusterd-test-driver specs), which makes the driver's bare transient export flag moot: a transient export is now just a t-prefixed id. #37880's commits appear in this diff until it merges; rebase onto main after.

A second in-process "interactive" compute runtime that serves reads against the arrangements the maintenance runtime maintains, isolating latency-sensitive reads from CPU-bound maintenance. Implements the two-runtime design (#37747).

The cross-runtime arrangement-sharing primitive lives entirely in Materialize (mz-compute::shared_trace, mz-row-spine::ArcBatch), so it builds against released differential-dataflow with no fork or [patch.crates-io]. The diff also carries the production Rc→Arc spine migration that cross-thread sharing needs. The publisher follows the controller's compaction through AllowCompaction rather than reading trace internals.

How it works:

  • Maintained indexes publish their oks/errs arrangements into a per-process ArrangementSharingRegistry. A ComputeRuntimeRole { Solo, Maintenance, Interactive } distinguishes runtimes; Solo (default, single-runtime) is byte-behaviorally unchanged, metrics included.
  • A process-level multiplexer presents one controller endpoint, routing peeks and one-shot work to interactive, maintained work to maintenance, exactly one PeekResponse per uuid, and each collection's frontiers only from the runtime that owns it (both runtimes install the internal logging dataflows, so this keeps the interactive runtime's empty copies from regressing the controller's per-collection frontier).
  • The interactive runtime serves everything through the registry: fast-path index peeks read the published arrangement directly; slow-path query dataflows (e.g. introspection views) import the maintenance arrangements as real ArrangementFlavor::SharedTrace arrangements (not re-derived collections) and render joins/reduces over them; the query's own transient output is itself published so its result peek is served the same way. A spike verified a join over an imported shared arrangement is correct (the join cuts each trace at its own acknowledged batch boundary, so the shared handle's batches_through is always batch-aligned).
  • Introspection reads come from maintenance. Maintenance publishes its logging/introspection indexes into the registry too (gated strictly on the Maintenance role, since those indexes bypass the normal export_index publish path). The interactive runtime runs with logging disabled and serves introspection peeks from maintenance's published copies, so introspection queries during hydration return promptly (possibly stale) instead of blocking behind maintenance.
  • Notification-driven, no polling. The registry carries a per-interactive-worker coalescing SyncActivator + dirty-id inbox. A read whose dependency is not yet published/sealed is enqueued, not blocked; publication (insert) and seal (note_frontier, fired from the maintenance export's frontier probe) mark the id dirty and wake the interactive worker, which re-examines only the affected pending work. A query dataflow whose imported deps aren't published yet is deferred (not built) and built once all deps publish. The per-step process_peeks scan is removed on the interactive runtime.
  • Controller plumbing: ENABLE_TWO_RUNTIME_COMPUTE (dyncfg, off by default in production) makes the controller launch replicas with a second interactive runtime (--interactive-compute-timely-config, its own worker ports). Enabled by default in the variable CI system parameters so the suite exercises the two-runtime path broadly.
  • Failure model: shared-fate abort covers both runtimes (subprocess test); dropping a still-deferred interactive dataflow cancels cleanly rather than panicking.

Acceptance: a clusterd-test-driver workflow drives an interactive query dataflow that imports an unpublished maintenance index, scheduled before the index publishes (mirroring controller ordering), and its result peek resolves correctly only after publication — proving the defer→build→resolve read path is served off the maintenance worker.

A TwoRuntimeReadIsolation parallel-benchmark scenario measures read latency while the maintenance runtime is saturated by hydration churn. With two runtimes on, point-read p50 stays flat (~11ms) through the churn; the single-runtime baseline backlogs to tens of seconds.

Design and per-task plans: #37747, doc/developer/design/20260720_two_runtime_compute/stage2-detailed-plan.md.

Scope / follow-ups:

  • Subscribe is out of scope: all interactive work is single-time, so the shared import applies no until/as_of coalescing (correct for one-shot peeks; a future subscribe migration must add it).
  • A full SQL-level "introspection during hydration" acceptance test (versus the protocol-level clusterd-test-driver one here) is a follow-up; the mechanism it would exercise is in place.
  • The interactive serving loop is a single step loop, so its read throughput has a ceiling (heavy scans and light point reads share it); the read-isolation win holds below that drain rate. Raising it is future work.
  • Minor: the inline interactive peek records no index_peek_total_seconds; pre-existing println! debug lines in render.rs None arms.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DAfFVVJ8BwahdaAHkRtbf6

@antiguru antiguru changed the title compute: two-runtime read isolation (Stage 1 offload + Stage 2 second runtime) compute: two-runtime read isolation (interactive runtime) Jul 21, 2026
@antiguru
antiguru force-pushed the mh/two-runtime-stage2 branch 2 times, most recently from f90cdae to 6728fc8 Compare July 23, 2026 08:55
@antiguru
antiguru changed the base branch from claude/spines-differential-arc-j93mho to main July 23, 2026 09:02
Comment thread src/compute-client/src/multiplex.rs Outdated
Comment thread src/compute-client/src/multiplex.rs
Comment thread src/compute-client/src/multiplex.rs
@antiguru

Copy link
Copy Markdown
Member Author

This is an architectural commitment, not just a feature

Before we land this, it's worth being explicit that two-runtime is not a "sure, let's do it" change. It commits the compute layer to a new shape and is effectively a one-way door. This comment lays out the trade-off honestly so the decision is made with eyes open.

What it actually is

Two runtimes do not add CPU and do not magically "isolate" reads — both runtimes share the same cores. What the second runtime buys is one precise thing: a separate, OS-preemptible run loop, so an interactive read is no longer trapped behind a long, run-to-completion maintenance operator step on the same timely worker.

The right way to frame it is separation of concerns, not isolation:

  • The maintenance runtime stays a pure run-to-completion batch engine. Operators consume their inputs fully; the only sanctioned yield is pre-exchange, for downstream memory reduction. Yielding for interactivity is an anti-pattern we do not want in that runtime.
  • The interactive runtime is a pure, preemptible low-latency reader that reads the maintained arrangements zero-copy.

A single runtime cannot be both without compromising one of them. Two runtimes let each be pure. That is the real argument.

Why the obvious alternatives don't apply

"Just use a read replica." Fails on the use case that matters most: introspection. A replica's introspection describes that replica, so it fundamentally cannot be served from another one — and you need it exactly when the maintenance runtime is pinned (hydration, batchy work), which is when it blocks. Today we fly dark precisely then. Nothing but an in-process second runtime fixes that. Separately, replicas mostly redline on memory, not CPU, so a second replica doubles the binding resource; the shared-arrangement approach here duplicates no memory. And because those boxes are memory-bound, the spare CPU the interactive runtime needs is exactly what is already idle — the CPU-saturated case (where two runtimes help least) is not the common one.

"Just yield more finely in the one runtime." That is the anti-pattern above. It would degrade the maintenance runtime's core contract to buy interactivity it shouldn't be responsible for.

The costs we are accepting

  1. A permanent cross-runtime concurrency surface. Seal signals, lost-wakeups, placeholder/adopt races, delayed-capability panics, read-hold lifetimes. This class of bug is subtle and does not go away; several were found and fixed on the way here. This is the real price, not "more threads."
  2. The control plane remains a parallel, unsolved bottleneck. For non-introspection reads under load, peeks still serialize behind DDL on the single coordinator thread. Two-runtime fixes the data plane; it does not touch that. Landing this must not let the coordinator serialization problem fall off the roadmap — two-runtime is necessary but not sufficient for that second use case.

Why it's a one-way door

  • The off switch is not a clean exit. The Solo path keeps single-runtime deployments byte-identical, but once users are on the isolated low-latency reads, turning the feature off is a visible query-latency regression, not a no-op.
  • The capability we depend on will atrophy. Once reads live in the interactive runtime, the maintenance runtime no longer needs to accommodate interactivity at all — and it will be built to be maximally batchy because it was freed to be. Single-runtime interactive-read behavior will rot from disuse and hardened assumptions. Getting it back later is not a revert; it is a capability we will have to rebuild.

That irreversibility is the crux. It is acceptable only because the end-state — maintenance as a pure batch runtime, interactive as a pure reader — is the architecture we would choose deliberately, given the points above. The bar for merging is therefore not "we can back out." It is "we would design it this way on purpose." On the introspection-during-hydration and memory-bound-fleet facts, we would.

Bottom line

Worth doing, but as a deliberate architectural commitment. The durable, defensible assets are (a) zero-copy cross-thread arrangement sharing as a primitive and (b) introspection that survives hydration — both survive every counter-argument. The framing to avoid leaning on is "reads get faster," which a CPU-bound box or the unsolved coordinator cap can each undercut. Reviewers should weigh this as a commitment to the two-runtime shape, not just as an isolated feature.

Comment thread misc/python/materialize/mzcompose/__init__.py Outdated
Comment thread misc/python/materialize/parallel_workload/action.py Outdated
Comment thread src/clusterd-test-driver/src/script.rs Outdated
Comment on lines +150 to +160
/// Export `index_id` as [`GlobalId::Transient`] rather than [`GlobalId::User`].
///
/// The multiplexer fronting a two-runtime `clusterd` (see
/// `mz_compute_client::multiplex`) routes a `CreateDataflow` to the interactive
/// runtime only when every export id is transient and the dataflow has no
/// subscribe sink; a `User` export always stays on maintenance. Setting this
/// is how a script drives a dataflow onto the interactive runtime, e.g. to
/// exercise a query dataflow that imports a maintenance index and is itself
/// served by the interactive slow path.
#[serde(default)]
transient: bool,

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.

Alternative would be to allow setting transient global ids based on the name, using the canonical GloablId string representation.

Comment on lines +76 to +79
fn threshold_shared_trace<'scope, T: RenderTimestamp>(
arrangement: Arranged<'scope, SharedOksEnter<T>>,
name: &str,
) -> Arranged<'scope, RowRowAgent<T, Diff>> {

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 function be generic over traces surfacing rows?

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.

Spiked. This is the same reduce_abelian higher-ranked output-key normalization wall documented on threshold_local: the bound only normalizes when the input and output trace types are concrete through the function signature, so a generic Tr: TraceReader does not compile. The three helpers (_local, _trace, _shared_trace) share identical bodies but must stay concrete for that reason.

Unifying them needs mz_reduce_abelian's key-container bounds reworked (the same class of fix as differential #797/#798's named key-container parameter), which is a broader reduce-plumbing change than this file. A macro could remove the textual duplication but adds indirection for three ~10-line functions without touching the type constraint. Leaving concrete and tracking the genericization with the reduce_abelian work rather than here.

}
PeekStatus::NotReady => Some(peek),
PeekStatus::UsePeekStash => {
unreachable!("the interactive peek is never peek-stash eligible")

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.

This is a limitation that we shouldn't have (and don't document).

antiguru and others added 21 commits July 24, 2026 23:56
Script ids were raw u64s always mapped to GlobalId::User, so a spec
could not target the system or transient namespaces. Parse ids the
same way GlobalId's own Display does: a bare number defaults to the
user namespace, while an explicit s/si/u/t prefix (s42, u1000, t7)
selects the namespace directly. Spec and Command fields now carry
GlobalId end to end instead of wrapping a u64 at each call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…aring, interactive runtime)

Squashed work-log for the two-runtime compute branch. Migrates production
arrangements from Rc to Arc batches, adds cross-runtime arrangement sharing,
and stands up a second in-process compute runtime that serves ephemeral peeks
off the maintenance runtime's shared arrangements, fronted by a Multiplexer.

Includes fixes landed while stabilizing the branch:
- keep shared-trace snapshot import consistent so joins don't double-count
- guard the shared-index snapshot bound against a Timestamp::MAX as_of
- stop the multiplexer from dropping peek responses on non-zero processes
- release transient read holds so two-runtime read frontiers advance: forward
  the interactive runtime's terminal empty-Frontiers report for a dropped
  transient even after ownership eviction, and report frontiers only for the
  transient collections the interactive runtime exclusively hosts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Arc batches carry more fixed per-batch overhead than the Rc batches this
check was written against, so a single-record index arrangement now
exceeds the coarse `size < 16 * 1024` "not egregious" bound (the empty
arrangement still fits). Bump the bound to 32 KiB, which one Arc batch on
top of the empty spine stays under.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…w is dropped

A deferred interactive dataflow whose shared-index dependencies are not yet
published is cancelled from `pending_work` without ever building. The controller
had already created the collection, initialized its per-replica frontiers to the
as_of, and acquired read holds on its storage inputs. It releases those input
read holds only once the collection's frontiers all reach the empty antichain,
but the cancelled dataflow never reported any frontier, so the holds leaked and
pinned the inputs' read frontiers. This surfaced as an MV's `since` never
advancing under two-runtime (read_frontier_advancement).

Send empty `Frontiers` from `drop_collection` on the deferred-cancel path so the
controller can clean the collection up and drop the holds.

Also filter interactive-runtime frontier reports on `id.is_transient()` in the
multiplexer instead of `owner_of(id)` plus a terminal-empty special case. The
interactive runtime reports frontiers only for the transient collections it
hosts (see `report_frontiers`), so every such report must forward regardless of
`transient_owner`, whose eviction on `AllowCompaction{empty}` races ahead of a
dropped transient's trailing frontier reports. This replaces the earlier
terminal-transient hack and no longer depends on ownership state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Measures peek p50/p99/qps under a saturated maintenance runtime. Two closed-loop
`HydrationChurn` actions continuously build, hydrate, and drop heavy maintained
MVs, keeping the maintenance workers busy. Two open-loop peeks (a fast-path index
point lookup and a slower range-scan reduce) run at a fixed rate against a
pre-hydrated indexed table.

Run twice, once with `enable_two_runtime_compute=true` and once false, and
compare the SELECT latencies. With two runtimes on, the interactive runtime
serves the peeks off the shared arrangements, isolated from the maintenance
workers, so the open-loop peeks accumulate far less queue-wait latency than when
they contend with hydration on the same workers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Two indexes on the same key make the optimizer build the second as a
cross-dataflow re-export of the first: the second index's dataflow imports the
first's arrangement and re-exports it under its own id. On the maintenance
runtime, render's `ArrangementFlavor::Trace` arm publishes that re-export into
the sharing registry via `reexport`, but unlike the `Local` arm it builds no
streams of its own and so installs no seal-signal frontier tap.

An interactive peek on the re-exported id that arrives when the arrangement's
`upper` equals the peek `as_of` defers in `pending_work` waiting for the seal.
Nothing ever fires `note_frontier` for the re-exported id when it advances, so
the peek is never re-examined and hangs. A `DROP INDEX` of one of two same-key
indexes followed by a `SELECT` hit this deterministically.

Have the registry track re-export aliases. `reexport(from, to)` records `to` as
an alias of `from`, and `notify` marks an id together with the transitive
closure of ids that re-export it. The source's own dataflow outlives its catalog
drop while a re-export still imports its arrangement, so its live tap keeps
sealing the re-export. `remove` prunes an id only as a target, never as a source,
so that trailing seal signal survives the source's `DROP INDEX`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Records the sound architecture for cross-runtime index arrangement sharing: a
capture-based lifecycle with two synchronization barriers (seal-gated capture,
tombstone teardown), grounded in the invariant that the interactive runtime only
performs single-time reads. Removes the incremental replay machinery that caused
the row-doubling and delayed-capability-panic bugs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Replaces the v1 single-time/tombstone sketch with the converged design after
three adversarial review rounds and two feasibility spikes.

Three principles: build on a correct protocol and panic outside it (no local
safeguards, shared fate); the multiplexer splits the controller's one correct
protocol into two correct sub-protocols, making the interactive one well-formed
with an internal Import command; render in command arrival order with late-bound
imports for deterministic construction.

Keeps the frontier-tracked replay (deleting it reintroduces row-doubling) and
fixes the delayed-capability panic at its root by single-sourcing the replay
feed. Late-binding uses pre-allocated publication points (placeholder plus
adopt-in-place), spike-validated feasible. Teardown safety rests on the
controller's read-hold discipline rather than a compute-level lease, which review
showed to be inert under a correct controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
The interactive query's Get(id) is already a dataflow import that the interactive
runtime resolves from the registry by role, since it holds no traces of its own.
Formalize that: the interactive sub-protocol's index imports are shared imports,
a self-describing import kind that references a registry id with no prior local
creation. This makes the sub-protocol well-defined without a new command.

Placeholder teardown rides existing events, the maintenance publisher's
close-on-drop for an adopted slot and last-reader eviction for a never-adopted
one, so no import verb and no withdrawal are needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Nine staged TDD tasks from the committed design: single-source replay feed (F1),
placeholder plus adopt (F2), registry get-or-create, placeholder close/eviction,
bounded-read routing, arrival-order rendering with late-bound imports, since<=as_of
assert, delete dead live import(), and regression/concurrency suites. Tasks 1-2
seed from the validated spike branches spike/single-source-publish and
spike-f2-placeholder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…rontier

The publisher derived the published upper from the trace's map_batches, which
leads the arrangement stream within a worker step, so a Frontier instruction
could be enqueued before a Batch whose hint is below it and the importer's
caps.delayed panicked. Use the stream frontier, which never leads the delivered
batches, as the authoritative upper for both the published state and the importer
Frontier instructions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…place)

A SharedTrace placeholder can be created empty and handed to an importer as a
live handle before the arrangement is published. The maintenance publisher later
adopts the same Arc in place rather than constructing a fresh one. Publishing
becomes the degenerate case of adopting a publication point with no prior reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Whichever runtime touches an id first creates its placeholder slot; the other
adopts or reads the same Arc. Replaces create-fresh-and-overwrite so a placeholder
a reader already imports is filled in place and never overwritten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
A placeholder whose index creation is cancelled before it publishes must not
wedge its importers. Closing pushes a terminal empty frontier to them and the
registry evicts the slot when its last reader leaves. An adopted slot is closed
by the maintenance drop instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Route a CreateDataflow to the interactive runtime only when its until is bounded
and it has no subscribe or copy-to sink. Copy-to is finite-until but drives an S3
sink and is refused by reconciliation, so it belongs on maintenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…d imports

Remove the per-worker deferral, which built dataflows in each worker's own
publication order and so allocated timely channel ids in a worker-divergent
order. Every dataflow now builds immediately in command arrival order, and an
import of a not-yet-published dependency binds through a registry placeholder that
adopts later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Matches the assert the maintenance import path already makes. A since above the
requested as_of means the controller offered an unreadable as_of, a protocol
error, so it panics rather than reading coalesced data silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
The interactive runtime issues only bounded reads, so the unbounded live import
had no non-test callers. import_snapshot_at is the sole interactive import path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…s-first invariant

Add a debug_assert on the interactive CreateDataflow path as a tripwire for a
Multiplexer routing bug, matching the bounded-read predicate the multiplexer
enforces. Document that evict_unadopted consults only the oks adoption flag,
which is sound because every publish site adopts oks before errs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
… read-hold discipline

Record where correctness comes from for cleanup of a maintenance index an
interactive runtime imports. The controller holds a read hold on the imported
id for every reader and frees it only after the reader retires, so the index is
never compacted or dropped while an interactive import still reads it, with no
cross-runtime signal (Instance::finish_peek). A controller that violated this
would be an incorrect protocol instantiation, which the runtime panics on.

evict_unadopted is registry hygiene for a bounded, empty leaked placeholder, not
a correctness mechanism, and Published::close guards only a wedge that a correct
protocol never produces. Both are retained for a future hygiene path and have no
production caller today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
rustdoc under --document-private-items rejects these: SharedTraceHandle and
PublishArrangement are not in scope in the linking modules (use full crate
paths), Self::notify on the private Inner struct resolves to nothing (name
ArrangementSharingRegistry::notify), and public docs on get_or_create_placeholder
and evict_unadopted linked to the private insert_shared and adopted_and (demote
to plain code spans). cargo check does not run rustdoc, so these passed the
per-task build gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
antiguru and others added 16 commits July 25, 2026 00:09
The old `ArrangementSharingRegistry::insert`/`insert_shared` and the
`PublishArrangement::publish`/`publish_named` primitives are production-dead:
every maintenance publish path now binds through `get_or_create_placeholder`
plus `adopt`, which fills a placeholder in place rather than overwriting a slot
a reader may already have imported. `insert` reintroduced exactly that
placeholder-overwrite hazard, so remove it.

Delete the four items and port their remaining (test-only) callers to the
production pattern: registry-based sites route through
`get_or_create_placeholder` + `adopt` + `notify`, and the standalone
shared-trace primitive sites use `Published::placeholder` + `adopt` via a new
`adopt_fresh` test helper. Behavior is unchanged: `insert(publish, publish)`
into a fresh slot is equivalent to placeholder-then-fill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
`PendingWork` lost its `Dataflow` variant earlier in this branch, leaving a
single `Peek(SharedIndexPeek)` wrapper around every value in `pending_work`.
Store `SharedIndexPeek` directly in the map, drop the enum, and straight-line
the four single-arm match sites (`resolve_dirty`, `handle_cancel_peek`, and the
reconciliation drop loop in `server.rs`). No peek behavior changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
psycopg 3.3.3's Cursor.execute accepts LiteralString, bytes, sql.SQL,
sql.Composed, or Template, but not a plain str. An f-string with interpolated
attributes types as str, so pyright rejected the three HydrationChurn.execute
calls. Encode each f-string to bytes, matching the repo convention (for example
util.py PgConnInfo.connect and parallel_workload/action.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…ontier-filtered one

A late importer's initial snapshot came from state.chain, which the publisher
built by filtering map_batches to batch.upper() <= stream_frontier. The stream
frontier lags the trace by a scheduling round: the batch data is delivered, the
frontier notification catches up a round later. When the Spine merges an already
streamed batch with a leading one into a single batch whose upper leads the
frontier, that filter drops the whole batch, stranding its historical part. A
late importer registering in that window seeds an incomplete snapshot missing
rows, so its reducer later sees a retraction without its insert and reports a
non-positive DistinctBy multiplicity, or returns wrong rows.

Seed with the full map_batches instead. This costs only momentary memory, since
batches are Arc-shared, and cannot double-count: the stream emits each original
batch once and never re-emits a merged batch, so incremental batches never carry
what the seed already holds. The stream frontier still drives the published upper
and the incremental Frontier instructions, which is where it is authoritative.

Exposed by removing the interactive-dataflow deferral, which made interactive
reads register against an actively-updating arrangement rather than a settled one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…rators

The dataflow-operator introspection now shows the arrangement-sharing operators
(PublishShared and the InspectBatch seal taps) and the ArcBatch batch type name
(mz_row_spine::arc_batch::ArcBatch instead of alloc::rc::Rc) that the two-runtime
publish path installs. This is expected output, not a behavior change. Rewritten
with --rewrite-results under enable_two_runtime_compute; verified stable across
repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…ream tap

A fast-path peek parked on a shared index's seal is re-examined only when the
registry wakes its interactive worker. That wake came from an inspect_container
tap on the arrangement stream, placed upstream of the publisher sink. The tap
fired note_frontier when the frontier reached the tap, but the sink advances the
published state.upper a scheduling round later, downstream. So the peek woke,
read a stale state.upper, found itself NotReady, and parked. If that frontier
advance was the arrangement's last (a static index or view, the common case),
no further tap fired and the peek hung. The sink's own post-upper wakeups (the
importer activators and the upper_changed condvar) do not reach the peek server
loop, so nothing re-woke it. Flaky, because it depended on the tap-versus-sink
scheduling gap.

Move the seal signal into the publisher sink: adopt takes an on_seal callback
that the sink fires once per activation on which state.upper advances, after it
releases the state lock. Firing after the lock keeps the wakers lock strictly
after the trace state lock, matching the reader order (wakers then state) and
avoiding a deadlock. Firing after the advance means a peek the wake re-examines
reads the advanced upper and completes. This makes the seal notify program
ordered after the state.upper advance, so the lost-wakeup contract on
ArrangementSharingRegistry::notify covers the seal, not just publication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…nal move

Moving the seal signal from the upstream inspect_container taps into the publisher
sink removed the InspectBatch tap operators from the dataflow-operator
introspection, so relations.slt over-counted them. Rewrite the golden to match.
Also demote the intra-doc reference to ArrangementSharingRegistry::notify (a
pub(crate) item) from a link to a code span, since adopt is public and lint-doc
rejects a public-to-private doc link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
The cross-runtime arrangement publisher derived its writer-driven compaction
floor by reaching into `agent.trace_box_unstable()` (an upstream API documented
as unstable, with mutation undefined behavior) plus fork-only TraceBox
compaction getters, computing the meet of all other referees' holds minus its
own. That was the only remaining reason the differential-dataflow fork was
required.

Replace it with the authoritative sources Materialize already has. The
controller pushes logical compaction through `AllowCompaction`, and physical
compaction follows the trace upper (as `TraceManager::maintenance` does). So the
publisher now takes its logical floor from the last `AllowCompaction` frontier,
forwarded into the shared slot via
`ArrangementSharingRegistry::note_allow_compaction` from
`handle_allow_compaction`, and its physical floor from the stream `upper` it
already holds. `SharedTraceState` gains a `writer_logical` field, seeded `None`
so the publisher falls back to its own hold (the `as_of`) before the first
command arrives.

With the trace-box read gone, nothing links against the fork:
`mz_row_spine::ArcBatch` already supplies the cross-thread batch impls, and the
sharing primitive lives in `shared_trace.rs`. Remove the
`[patch.crates-io]` override for differential-dataflow and differential-dogs3,
which resolve to crates.io 0.25.1. This also clears the lint-and-rustfmt
dd-git-source deny.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
At 200+50 reads/s the combined rate exceeds both the two-runtime and the
single-runtime serving drain, so both runs overload and report queue-wait
latency growing to the run length. That masks the isolation the scenario means
to show.

Halve to 100+25/s. This sits above the baseline drain but below the
two-runtime drain, so the comparison separates cleanly: two-runtime holds reads
flat (p50 ~11ms, near-zero slope) through hydration churn while the baseline
backlogs to tens of seconds. The reported latency now reflects service time
rather than an undrained open-loop backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
…isher

Two `render::interactive_import_tests` drove compaction by advancing a writer
trace handle and relied on the publisher observing it through the trace-box
read, which no longer exists. Forward the same frontier through
`ArrangementSharingRegistry::note_allow_compaction`, the production
`handle_allow_compaction` path, so the publisher advances its `since`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
On the constrained CI runner the churn saturates every core, so even the
halved 100+25/s read probe backlogs: the interactive runtime cannot get CPU
while maintenance is pinned. Keep the churn heavy (that saturation is the point)
and shrink the read probe further to 50+12/s so it fits the interactive
runtime's CPU slice and its latency reflects serving under contention rather
than a coordinated-omission backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Replace the four planning and design docs (implementation-plan,
stage2-detailed-plan, arrangement-sharing-lifecycle-design and -plan) with one
design of record that reflects the current implementation and explains the
rationale behind the decisions.

The prior docs spanned two eras and carried stale mechanics: a differential
fork and Arc-batches base branch that no longer exist, a Stage-1 peek-offload
strategy that was abandoned for inline interactive serving, and an
ArrangementFlavor path once flagged known-broken that now ships as a real
SharedTrace flavor. The consolidated doc adds the AllowCompaction-driven
compaction mechanism (which postdated every prior doc), records why the
architecture is a deliberate commitment rather than a drop-in feature, and lists
the known limitations that remain, including the coordinator control-plane
bottleneck and the interactive serving-loop ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Add three known limitations to the two-runtime design: the interactive runtime
is an introspection blind spot (logging disabled, introspection forwarded from
maintenance), storage introspection remains patched into the maintenance
runtime's introspection, and the role metric label breaks exact-match dashboards
when the feature is enabled because the maintenance runtime's series gain a
role="maintenance" label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
The flag only gated whether a runtime publishes into the sharing registry, and
publication is already forced for the Maintenance and Interactive roles. So the
flag was a no-op with two-runtime on, and with it off a Solo process has no
registry peer to read what it would publish, so publishing there was dead. Its
only remaining use was as a test knob.

Publication is now decided purely by the role: `ComputeRuntimeRole::publishes`
(renamed from `publishes_unconditionally`, whose qualifier is meaningless with no
flag to be unconditional about) returns true for Maintenance and Interactive,
false for Solo. Drop the standalone dyncfg, inline the former `should_publish_index`
helper, and remove the flag from the system-parameter and parallel-workload
lists. Addresses the "unused" review comments on both Python files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
The multiplexer only ever records `Runtime::Interactive` as a transient owner,
with maintenance the default in `owner_of`, so the value was dead weight. Store
the interactive-owned transient ids as a `BTreeSet<GlobalId>` instead of a
`BTreeMap<GlobalId, Runtime>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CY7GHdrTBfAG4tgvSgPGJ9
Stacking on MaterializeInc#37880 (typed s/u/t-prefixed global ids in specs) makes the bare
`transient` export flag moot: an export id parses to `GlobalId::Transient`
directly from a `t` prefix. Drop the flag and its driver-side machinery (taken
from MaterializeInc#37880's rewrite of script.rs/text.rs), and mark the interactive query
dataflow's output `t4000` in two_runtime_query_dataflow.spec so the multiplexer
still routes it to the interactive runtime.

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

1 participant