Skip to content

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

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

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

Conversation

@antiguru

@antiguru antiguru commented Jul 21, 2026

Copy link
Copy Markdown
Member

Prerequisite: #37881 (row-spine Arc-batch migration). The production Rc→Arc spine migration and mz_row_spine::ArcBatch live there, and its commits appear in this diff until it merges. It is the only remaining prerequisite.

Cleared: #37884 (ComputeRuntimeRole + role-labeled metrics) and #37880 (typed s/u/t-prefixed global ids in clusterd-test-driver specs) have both merged, and this branch is rebased onto them, so neither appears in the diff any more. What #37884 left behind here is the flip of the call sites from Solo to Maintenance/Interactive, plus the Interactive variant itself, which arrived on main gated behind cfg(test) because nothing there could construct it.

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) takes the same code paths as before, with no registry, no second runtime, and no role metric label. It is not a byte-identical deployment: the Rc→Arc spine migration is unconditional and shows in the goldens.
  • A process-level multiplexer presents one controller endpoint, routing peeks and one-shot work to interactive, maintained work to maintenance, 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). It keeps no per-peek state: the exactly-one-PeekResponse-per-uuid contract is upheld below and above it, by each process's per-worker PartitionedComputeState and by the controller's per-process one.
  • 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.
  • 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 instead of blocking behind maintenance. The logging dataflows sit on the same stalled workers, so "promptly" means promptly with stale data, which is the useful property during an incident.
  • Notification-driven, no polling. The registry carries a per-interactive-worker coalescing SyncActivator + dirty-id inbox. A read whose dependency is not yet published or 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 imports are not yet published is still built immediately, binding a real but empty publication point that a publisher later adopts in place. Builds are never deferred or reordered, because every worker must construct dataflows in the same order. 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. Flipping it changes ServiceConfig::ports, so it rolls every compute replica in the environment.
  • Failure model: shared-fate abort covers both runtimes (subprocess test).

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 bind→fill→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 while the single-runtime baseline backlogs without bound. The scenario reads at strict_serializable=False, which is the population that benefits: an interactive peek still waits for maintenance to seal its read timestamp, so stale and serializable reads get the full win and strict serializable gets close to none.

Design: #37747 and doc/developer/design/20260720_two_runtime_compute/design.md (the single design of record).

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).
  • Peek routing is unconditional. Every peek goes to interactive today, which is what makes the observability regression below bite. Narrowing this to the reads that stand to benefit is the highest-leverage remaining change and turns the feature from a per-replica commitment into a per-read one. Worth making a policy knob rather than a fixed rule.
  • Replica-side peek observability goes dark: interactive runs with logging disabled and no IndexPeekMetrics, so mz_active_peeks, mz_peek_durations_histogram, and the index_peek_* histograms are empty while the feature is on.
  • Publishing an index doubles its reported arrangement size. Measured on a 16-worker replica against the same binary: 8740 bytes / 132 allocations with the feature on against 4370 / 66 with it off, records and batches unchanged. It is not the Rc→Arc migration (an unpublished materialized-view arrangement is byte-identical either way) and not a reader (it is present before anything imports the index). Whether it is a reporting artifact or real retention decides whether the feature carries a memory regression. test/testdrive/introspection-sources.td carries the raised bound and points at the design doc entry.
  • A full SQL-level "introspection during hydration" acceptance test (versus the protocol-level clusterd-test-driver one here) is a follow-up.
  • 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.
  • Reconciliation can drop and recreate a maintained index under the same GlobalId while the interactive runtime reconciles independently, so the slot binding wants either a written unreachability argument or an epoch.
  • 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_019G29DBfgE8LXpE5jamm2Zi

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

Comment thread src/compute/src/compute_state.rs Outdated
}
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).

Copy link
Copy Markdown
Member Author

Architectural review

Read against the design doc, the released differential-dataflow (not the fork), and the two-runtime discussion threads. Summary: the thesis is right and well argued, the skeleton (role split, multiplexer, placeholder-plus-adopt, notification-driven wakeups) is sound, but there is one place where a differential invariant is reimplemented with half of it dropped, and the form of the specialization is broader than the goal needs in ways that are cheap to narrow now and expensive to narrow later.

1. SharedTraceHandle::batches_through looks unsound

src/compute/src/shared_trace.rs:420-446 reimplements Spine::batches_through. The original (spine_fueled.rs:105-190) rests on two things this version does not have:

  • It asserts physical_frontier <= upper as a precondition.
  • It returns every batch in self.merging unconditionally, with no straddle check, and applies the straddle panic only to self.pending. That is sound because consider_merges (spine_fueled.rs:404) promotes a batch into the mergeable pile only once batch.upper() <= physical_frontier, so nothing merged can straddle a legal cut.

The shared handle sources its chain from agent.map_batches, which flattens merging and pending into one Vec and loses the distinction, then applies the straddle assert to all of it.

Failure sequence: an importer seeds chain [A=[0,3), B=[3,5)], the join advances acknowledged1 to 3 (mz_join_core.rs:197), the publisher meanwhile merges A and B into AB=[0,5), and the next cursor_through([3]) trips assert!(batch.upper() <= upper). Under shared fate that is a process abort and a replica restart.

Loosening the assert is not the fix, because returning AB would hand the join updates at times not before the cut and double count. The fix is to publish the maintenance trace's physical frontier into SharedTraceState alongside chain/since/upper and mirror the spine's rule, plus the precondition assert.

Compounding it: a reader's physical hold is not synchronously effective. A local TraceAgent::set_physical_compaction recomputes the TraceBox meet immediately. Here the hold lands in physical_holds and reaches the agent only on the publisher's next sink activation (shared_trace.rs:706), and it cannot undo merges that already happened. The window is real, not theoretical.

Provenance note: the fork existed to read trace_box_unstable() for this class of information. Replacing the compaction floor read with AllowCompaction plus the stream upper is a good trade. But batches_through's merged-versus-pending rule is a second consumer of trace internals and it was reimplemented without an equivalent.

Not covered by tests. Every join and reduce test in sharing.rs uses as_of = 0 with four updates and no merge pressure (sharing.rs:1144, :1305, :1462). Production always reads at an as_of inside the arrangement's history, so the untested regime is the only regime that ships.

2. Second-order correctness gaps

Cross-runtime index replacement. The lifetime story ("two closes, no withdrawal command", controller read-hold discipline) assumes a maintained index slot has one lifetime. Reconciliation breaks that: an incompatible or compacted-past dataflow is dropped and recreated under the same GlobalId (server.rs:768, :800). Maintenance's drop calls sharing_registry.remove(&id) and the re-render creates a fresh slot, while the interactive runtime reconciles concurrently on its own connection with no cross-runtime ordering. If interactive binds the old slot before maintenance removes it, the importer receives the publisher's terminal empty frontier and its dataflow completes against stale or empty data. Wrong results, not a panic. Wants either a written argument or an epoch on the slot.

The publisher pins superseded batches. state.chain is refreshed only on publisher activation (shared_trace.rs:619-620), and a sink activates on input data or frontier change, never on a spine merge. After a merge the pre-merge batches stay alive in the published chain until the next batch or frontier event on that arrangement, which for an idle-but-merging index is unbounded in time.

This is very likely what forced the ii_t4 bound bump from 16KB to 32KB in test/testdrive/introspection-sources.td, attributed to "Arc-batch overhead". Arc<T> and Rc<T> have identical layout, so that explanation does not hold. A clean 2x on a single-row arrangement looks much more like double counting in log_arrangement_size_inner, which sums over batches kept alive through Weak upgrade. If so, mz_arrangement_sizes and the public arrangement-size metric over-report whenever the feature is on. That needs an explanation rather than a threshold bump.

Memory is coupled in both directions. The doc argues maintenance progress is never coupled to a slow interactive reader and accepts unbounded replay queues on that basis. Maintenance memory is coupled both ways though: unbounded queues, plus interactive read holds forwarded into the maintenance trace's compaction. A heavy scan clogging the interactive step loop delays hold release and therefore maintenance compaction. Belongs in the doc next to the unbounded-queue non-goal.

3. Does the win match the claim?

This is the part that changes the decision, so it should be corrected in the doc before anyone signs off.

Two runtimes remove the serving cost from the maintenance step loop. They do not remove the sealing dependency. An interactive peek at T still waits for the maintenance arrangement's upper to pass T (compute_state.rs:2253-2264), and that upper is the maintenance stream frontier, which advances only when the maintenance worker steps. Therefore:

  • Stale and serializable reads at an already-sealed T: full win.
  • Strict serializable reads, the default isolation level: the read timestamp is the write frontier, so the peek waits for maintenance to seal it anyway. Close to no win.

The benchmark uses strict_serializable=False (scenarios.py:1360, :1373), so it measures exactly the component that benefits. That is a legitimate measurement of a real effect, but "99.9% reduction in query latency" reads as a general claim and is not one.

The same caveat applies to the flagship motivation. During hydration the logging dataflows sit on the same stalled maintenance workers, so their frontiers stall too. "Introspection returns promptly (possibly stale)" is true only in the stale sense. And the SQL-level introspection-during-hydration acceptance test is deferred to a follow-up, so the headline claim is not tested end to end. For a change this size and this hard to reverse, that test belongs in this PR.

On magnitude: the 80 second baseline p50 is open-loop queue collapse, so the ratio is an artifact of the offered rate rather than a 5000x speedup in per-query work. The honest framing is "reads hold about 10ms p50 while the single-runtime baseline backlogs without bound". Separately, the doc says a CPU-saturated runner makes both configurations backlog, but the reported run has one backlogging and one flat on what is presumably a saturated runner. Those two statements cannot both describe the same setup, and the resolution matters for sizing the real win.

4. Where the form of specialization does not match the goal

Each of these is separately walk-backable if scoped now, and each is part of the permanent tax if not.

All peeks route to interactive, unconditionally (multiplex.rs:201). That, not the second runtime, is what produces the regressions below. A narrower rule (interactive only when the read is stale enough to benefit, maintenance otherwise) preserves today's behavior for the reads that gain nothing and makes the feature reversible per read rather than per replica.

The interactive runtime publishes its own transient outputs and reads them back through the registry. ComputeRuntimeRole::publishes() admits Interactive (server.rs:100), and handle_peek routes every interactive index peek through shared_index_peek_response (compute_state.rs:872) even when the target is an arrangement this runtime maintains and already holds in its own TraceManager. That buys uniformity and costs an extra sink operator, a mutex, a full-chain clone per activation, and a notify plus wake round trip on the latency path the feature exists to shorten. Dropping Interactive from publishes() and letting local transient peeks use the existing PendingPeek::Index path removes a whole role behavior, the transient-id note_frontier wiring, and one of the two reasons SharedTrace exists.

Peek stash is silently disabled on the interactive path. drain_ok_iterator(..., false, 0, None) at compute_state.rs:2306 hard-codes stash-ineligible. Since every index peek now goes to interactive, a result that today streams through the persist stash instead returns result exceeds max size of .... That is a user-visible functional regression, it is not in the scope or follow-ups list, and CI will not catch it because test results never approach max_result_size. This one should block.

Peek observability goes dark. config.enable_logging = false on interactive (compute_state.rs:1082) makes the compute log replay empty (logging/compute.rs:315), and interactive is the only runtime serving peeks. So mz_active_peeks and mz_peek_durations_histogram become permanently empty, and every index_peek_* Prometheus histogram stops being recorded (metrics: None). The PR lists only the missing index_peek_total_seconds. Losing peek introspection in the change whose motivation is "today we fly dark at the moment we most need to see" is the wrong trade, and it is the strongest argument for the narrower routing rule above.

Rc to Arc is unconditional. Every spine in every deployment, feature on or off, now uses ArcBatch. Probably the right call, since a runtime-switchable spine type would be worse. But it means "Solo is byte-behaviorally unchanged" is not quite true, and the goldens confirm it (relations.slt, the ii_t4 bound). Worth saying plainly in the doc.

The debug_assert! tripwire is dead where it matters. compute_state.rs:710 guards a multiplexer routing bug, but [profile.optimized] (mzcompose and bin/environmentd) compiles debug assertions out, so it never fires in the suite that exercises two-runtime most broadly. Make it a real check that halts or errors.

5. Complexity tax: three pieces of unexercised machinery

Per src/compute/CLAUDE.md ("maintainability over complexity"), these read as things to cut before merge rather than carry:

  • snapshot_at / TraceSnapshot / the Condvar (shared_trace.rs:144-156, :333) have no production caller. A blocking read path inside a design whose premise is "never block the worker" is a trap for the next reader.
  • Published::close (shared_trace.rs:244) documents itself as having no production caller.
  • evict_unadopted (sharing.rs:252) documents itself as hygiene with no production caller, and carries the most delicate lock-ordering argument in the file plus the oks-before-errs adoption invariant (sharing.rs:262) that exists only to serve it.

That is roughly 300 lines of the most concurrency-subtle code here, live only in tests.

6. Doc and code drift

For a change this size the descriptions need to agree with the code.

  • The PR body says a query dataflow whose imports are unpublished is deferred and built later. The design doc says the opposite and correctly ("we render in command arrival order and never reorder or defer a build"), and no deferred-build code remains. two_runtime_query_dataflow.spec's header comments describe the removed design too.
  • The PR body describes multiplexer peek dedup with live_peeks state. The code explicitly does not dedupe and explains why (multiplex.rs:21-29). The design doc still asserts the live_peeks version.
  • ArrangementFlavor::SharedTrace lands in render/context.rs with a NOTE: that nothing prevents a multi-time dataflow from importing it, and nine (stream, lookup) join combinations now exist so mixed rows that never fire will type-check (linear_join.rs:425). src/compute/CLAUDE.md says rendering is generic and special-interest structures should be absorbed elsewhere. This is the generic rendering surface paying for a runtime-specific import kind, and it is the part that will be hardest to remove later.

7. On the one-way-door question

Agreed that it is close to one way, and the doc understates why. It is not mainly that users get used to fast reads. It is that peek serving now lives on the interactive path, and that path has dropped stash, logging, and per-phase metrics. Once the maintenance peek path stops being exercised it rots exactly as the doc predicts for interactivity. The reversibility that remains is proportional to how narrow the routing rule is.

Recommendation: the mechanism deserves to land, the current routing rule does not. A concrete ordered plan follows in the next comment.

Operational note for whenever the flag is documented: flipping ENABLE_TWO_RUNTIME_COMPUTE changes ServiceConfig::ports (clusters.rs:735), so enabling it rolls every compute replica in the environment.

Core affinity (server.rs:370 pins maintenance worker i and interactive worker i to the same core) came up during review and is deliberately not in the list above. Replicas run best-effort in k8s today, so affinity is not effective and there is nothing actionable here.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Directional follow-ups

Ordered work list derived from the review above, written so it can be picked up cold. Grouped by whether it blocks merge. File and line references are against 5abfb74.

Blocking

B1. Make SharedTraceHandle::batches_through mirror the spine's cut rule.
src/compute/src/shared_trace.rs:420-446.

  • Add a writer_physical: Antichain<Tr::Time> field to SharedTraceState next to chain/since/upper, written in the same critical section by the publisher (shared_trace.rs:639-701) from the value it forwards to agent.set_physical_compaction.
  • Rewrite batches_through to follow Spine::batches_through (differential-dataflow/src/trace/implementations/spine_fueled.rs:105-190): assert writer_physical <= upper as a precondition, return every batch whose upper() is at or below writer_physical unconditionally with no straddle check, and apply the straddle check only above that frontier.
  • Make a reader's physical hold effective at registration rather than on the publisher's next activation. SharedTraceHandle::register and import_snapshot_at currently write into physical_holds (shared_trace.rs:306, :809) and wait for the sink to forward. The publisher's TraceAgent clone would need to be reachable from the registration path, or registration needs to block until one forward has happened.
  • Regression test: publish an arrangement, keep writing past as_of, force spine merges across as_of (raise arrangement_exert_proportionality, or drive enough batches that consider_merges folds across the read time), then import at that stale as_of and run a join and a reduce over it. Every existing join and reduce test in sharing.rs uses as_of = 0 with four updates, which cannot reach this state.

B2. Restore peek stash on the interactive path, or stop routing stash-eligible peeks there.
src/compute/src/compute_state.rs:2306 hard-codes peek_stash_eligible = false and peek_stash_threshold_bytes = 0. Two options:

  • Thread the real ENABLE_PEEK_RESPONSE_STASH / PEEK_RESPONSE_STASH_THRESHOLD_BYTES / peek_stash_persist_location values into shared_index_peek_response and handle PeekStatus::UsePeekStash there the way process_peek does (compute_state.rs:1327-1340), removing the unreachable! at compute_state.rs:944.
  • Or make the multiplexer's peek routing conditional (see B4) so a peek whose finishing.is_streamable(..) holds stays on maintenance.

Add a test that a peek result above the stash threshold still returns rows with the feature on. Today CI cannot catch this because test results never approach max_result_size.

B3. Restore replica-side peek observability.
config.enable_logging = false at compute_state.rs:1082 empties the compute log replay (logging/compute.rs:315), and interactive is the only runtime serving peeks, so mz_active_peeks and mz_peek_durations_histogram are permanently empty and no index_peek_* histogram is recorded (metrics: None at compute_state.rs:2306).

The constraint from the design is that interactive must not run its own introspection indexes, since it serves introspection from maintenance's published copies. Peek events are a different thing. Either forward interactive's ComputeEvents into maintenance's event queue so its published logging arrangements see them, or give the interactive peek path its own IndexPeekMetrics so at least the Prometheus side stays populated. State whichever is chosen in the design doc's blind-spot section, which currently covers only dataflows, arrangement sizes, and scheduling.

B4. Narrow peek routing.
src/compute-client/src/multiplex.rs:201 sends every peek to interactive. Replace with a predicate that routes to interactive only when the read stands to benefit, meaning its timestamp is expected to be already sealed, and leaves everything else on maintenance. This is the single highest-leverage change for reversibility: it keeps today's behavior (stash, logging, metrics, all of it) for the reads that gain nothing, and turns the feature from a per-replica commitment into a per-read one. It also shrinks or removes B2 and B3.

Where the signal lives is the open design question. The peek carries its timestamp, and the runtime knows the target index's published upper, so the interactive side could in principle bounce a peek back, but a bounce needs a route that does not exist yet. Worth resolving with a short design note before coding.

B5. Explain the arrangement-size 2x.
test/testdrive/introspection-sources.td bumps the ii_t4 bound from 16KB to 32KB, attributed to "Arc-batch overhead". Arc<T> and Rc<T> have identical layout, so that cannot be the cause. Hypothesis to confirm or refute: the publisher's state.chain (shared_trace.rs:619-620) keeps pre-merge batches alive past a spine merge, and log_arrangement_size_inner sums over every batch its Weak map can still upgrade (extensions/arrange.rs:289-305), so merged and pre-merge batches are both counted.

If confirmed, the reporting bug and a real retention issue are the same bug, and the fix is to have the publisher refresh or drop its chain when the spine merges rather than only on stream activation. Revert the threshold bump once fixed.

Non-blocking but wanted before this is on by default

N1. Write down the reconciliation index-replacement argument, or add slot epochs.
Reconciliation can drop and recreate a maintained index under the same GlobalId (server.rs:768, :800), and maintenance's sharing_registry.remove(&id) races the interactive runtime's independent reconciliation on its own connection. If interactive binds the old slot first, its importer receives the terminal empty frontier and completes against stale or empty data. Either argue this is unreachable, or carry an epoch on the slot so a stale binding fails loudly instead of returning wrong rows.

N2. Land the SQL-level introspection-during-hydration acceptance test.
Currently a follow-up, which leaves the flagship motivation untested end to end. The protocol-level clusterd-test-driver scenario proves the read path, not the claim.

N3. Drop Interactive from ComputeRuntimeRole::publishes().
src/compute/src/server.rs:100. An interactive peek against an arrangement the interactive runtime itself maintains currently goes out through the registry and back (compute_state.rs:872), paying a sink operator, a mutex, a full-chain clone per activation, and a notify plus wake round trip, when the trace is already in the local TraceManager. Route local transient peeks through the existing PendingPeek::Index path. That deletes a role behavior, the transient-id note_frontier wiring, and one of the two reasons ArrangementFlavor::SharedTrace exists.

N4. Delete the machinery with no production caller.
snapshot_at / TraceSnapshot / upper_changed Condvar (shared_trace.rs:144-156, :333), Published::close (shared_trace.rs:244), evict_unadopted plus the oks-before-errs adoption invariant it needs (sharing.rs:252, :262). About 300 lines, all of it the most concurrency-subtle code in the change, live only in tests. If any of it must stay, say in the doc what future path requires it.

N5. Make the routing tripwire real.
compute_state.rs:710 is a debug_assert!, and [profile.optimized] (mzcompose, bin/environmentd) compiles debug assertions out, so it never fires in the suite that exercises two-runtime most broadly. Convert to a check that halts or logs an error unconditionally.

N6. Document the memory coupling.
Interactive read holds forward into the maintenance trace's compaction, so a clogged interactive step loop delays maintenance compaction. Add this next to the unbounded-replay-queue non-goal, which currently argues only that maintenance progress is decoupled.

Documentation corrections

D1. PR body says an unpublished-import query dataflow is deferred and built later. The design doc says the opposite and correctly, and no deferred-build code remains. Fix the PR body, and fix the header comments in test/clusterd-test-driver/scripts/two_runtime_query_dataflow.spec, which still narrate the removed defer-build-resolve design.

D2. PR body and design doc both describe multiplexer peek dedup with live_peeks state. The code deliberately does not dedupe and explains why (multiplex.rs:21-29). Remove the claim from both.

D3. Design doc: replace the "99.9% reduction in query latency" framing with the sealing-dependency scope. Two runtimes remove the serving cost from the maintenance step loop and do not remove the dependency on maintenance sealing the read timestamp (compute_state.rs:2253-2264). Say explicitly that the win lands on stale and serializable reads and is close to nil on strict serializable, and that the benchmark measures the former (scenarios.py:1360, :1373). Also reconcile the doc's claim that a CPU-saturated runner makes both configurations backlog with the reported result where only one does.

D4. Design doc: soften "Solo is byte-behaviorally unchanged". The unconditional Rc to Arc spine migration changes every deployment, and the goldens show it (relations.slt, the ii_t4 bound).

D5. Note wherever the flag is documented that flipping ENABLE_TWO_RUNTIME_COMPUTE changes ServiceConfig::ports (clusters.rs:735) and therefore rolls every compute replica in the environment.

D6. ArrangementFlavor::SharedTrace in render/context.rs carries a NOTE: that nothing prevents a multi-time dataflow from importing it, and linear_join.rs:425 now spells nine (stream, lookup) combinations so mixed rows that never fire will type-check. src/compute/CLAUDE.md asks that rendering stay generic and special-interest structures be absorbed elsewhere. Either state why this variant has to live on the generic surface, or absorb it behind the existing Trace flavor.

Explicitly not in scope

Core affinity pins maintenance worker i and interactive worker i to the same core (server.rs:370). Raised during review and dropped: replicas run best-effort in k8s today, so affinity is not effective and there is nothing actionable.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Addendum: SUBSCRIBE is a stronger motivation than the doc allows

Follow-up to the two comments above. SUBSCRIBE initial snapshots are a first-class reason to want this, and the design doc's non-goal understates both the value and how close the code already is.

The value. A subscribe's initial snapshot is a full arrangement walk at as_of, and today it runs on the maintenance runtime, so it queues behind whatever run-to-completion work is in flight. That is the same failure the feature exists to fix, on a read that is both large and interactive-facing (Console, dashboards, anything re-establishing a subscribe after a reconnect). Time-to-first-row on a subscribe is arguably a more visible symptom than point-read latency.

The non-goal text is wrong. The doc says "all interactive work is single-time, so the shared import applies no until or as_of coalescing. A future subscribe migration must add it." The import already does exactly that coalescing: import_snapshot_at builds TraceFrontier::make_from(handle, as_of, until) (shared_trace.rs:799) and wraps each emitted batch in BatchFrontier::make_from(batch, as_of, until) (shared_trace.rs:882). Add this to D1/D2 in the follow-up list, it is a third doc-versus-code drift.

What is actually missing is a live-following import, which was deleted as dead code in 740e4b7 ("remove the dead live import(), keep import_snapshot_at"). So the real prerequisite is reinstating an unbounded import whose capability tracks the publisher's frontier instead of dropping at until, plus subscribe response routing in the multiplexer.

A cheaper path that gets the stated win without that. Split the subscribe at as_of, which is a cut the subscribe implementation already makes internally:

  • Snapshot at as_of: a bounded read, served by the interactive runtime with import_snapshot_at exactly as it stands today. No new primitive.
  • Tail strictly after as_of: stays on maintenance, unchanged, no shared import involved.

The work becomes stitching the two into one ordered SubscribeResponse sequence rather than building a live cross-runtime import. Two things make this easier than it sounds: the cut is exact (the snapshot is the accumulation at as_of, the tail carries only times beyond it), and subscribes are already non-reconcilable (subscribe_free in server.rs:723), so they are always rebuilt on reconnect and sidestep the cross-runtime index-replacement hazard in N1 entirely.

The same shape applies to COPY TO, which the multiplexer excludes for reconciliation and S3-sink reasons rather than frontier ones (multiplex.rs:166-169). Its output is also a bounded read over a maintained arrangement.

Suggested doc change: reframe SUBSCRIBE from "out of scope" to "the next intended consumer, snapshot first", and record the split-at-as_of option alongside the live-import option so the next person picking this up sees both. It also strengthens the case for B4: once subscribe snapshots are in play, "which reads go to interactive" is a real policy surface rather than a single unconditional route.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

B4 design note captured

Wrote up the routing-policy question rather than leaving B4 as "resolve with a short design note first". On branch claude/pr-37770-review-sdjdic, at the path it should merge into:

doc/developer/design/20260720_two_runtime_compute/read-routing-policy.md

Cherry-pick or copy it into this branch, whichever is less friction. It says up front to fold the resolution into design.md and delete the note, so we do not re-accumulate planning documents alongside the single design of record.

Two things in it revise what I wrote in the follow-up list.

The signal already flows through the multiplexer. I claimed the multiplexer has no frontier view. It does: every ComputeResponse::Frontiers(id, frontiers) passes through filter_response (multiplex.rs:119). So the cheapest policy needs no protocol change and no controller change at all, just tracking write_frontier per non-transient id in the component already making the routing decision. The caveat is that it is process 0's view (only process 0 receives a Peek) while the peek is answered by every worker of every process, so the hint can be wrong on a multi-process replica. That is acceptable because both runtimes answer any peek correctly, so the bit is a hint and a wrong hint costs one extra thread hop or one lost isolation opportunity, never a wrong result.

The default direction matters more than the signal. Route to interactive only on a positive hint, and leave maintenance as the fallback. That keeps real traffic on the maintenance peek path, which is the concrete answer to the doc's own "the capability we lean on will atrophy" argument, and it means peek stash and the peek introspection relations keep working for the reads that use them. Gated on a dyncfg with maintenance / hinted / interactive so the rollout is stepwise and the revert is a config change rather than a deploy.

The note also sharpens why unconditional routing is the wrong default, beyond the observability losses: an unsealed read waits either way, but waiting on interactive is slightly worse than waiting on maintenance, because maintenance answers in the same step that advances the frontier past T while interactive needs the publisher's seal signal to cross a thread boundary first. Strict serializable reads are in that population by construction. So today's routing trades a small regression on the default isolation level for a large win on stale reads, and pays it on every read rather than the ones that benefit.

Same policy question applies to CreateDataflow routing, which uses the bounded-read predicate rather than a frontier signal (multiplex.rs:166), so a transient query dataflow whose as_of is unsealed sits on interactive holding a shared import. Worth deciding together so a peek and the dataflow feeding it do not land on opposite sides of the policy.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

Benchmark plan, and a correction to the routing note

Fair challenge on the previous comment. My last open question ("should introspection reads be forced to interactive") was posed as a judgement call and it is not one. Replaced it with experiments.

doc/developer/design/20260720_two_runtime_compute/benchmark-plan.md on claude/pr-37770-review-sdjdic, with read-routing-policy.md revised alongside it.

Seven experiments, each with a pre-registered prediction, plus a decision table mapping every outcome to a consequence. Highlights below, but the methodology section matters more than the individual experiments and is where the tail question lands.

On p99, p99.9, p99.99 and max

Three things worth flagging up front.

The harness will report tail percentiles it cannot support. test/parallel-benchmark/mzcompose.py already computes p99 through p99_999999 and stores raw durations, so nothing needs adding to compute the tail. The trap is that it computes all of them from any sample count. The current TwoRuntimeReadIsolation arms produce 6000 and 1440 samples, so p99_9 rests on six observations and everything past it is the maximum wearing a different label. A percentile needs roughly 10/(1-q) samples at minimum: p99.9 is reachable in a normal run, p99.99 needs about 33 minutes at 500 reads/s, and p99.999 is not honestly reachable in CI.

For the extreme tail, measure the mechanism rather than the percentile. The reason p99.99 and beyond matter here is specific: interactive is a single step loop, so a read arriving mid-scan waits for that step, and a parked read is re-examined only when the worker next reaches the top of its loop. The floor under the read tail is the interactive runtime's step duration distribution, and step duration has millions of samples per minute rather than thousands. So the tractable route to a claim about max latency is to measure step duration directly and treat it as the bound. That needs a per-runtime step-duration histogram, which does not exist. The role label this PR adds already provides the dimension. It is small, it partly addresses the interactive introspection blind spot, and it is the prerequisite for any credible tail claim, so it should land first.

Percentiles read off an overloaded open-loop arm are queueing artifacts. When the offered rate exceeds service capacity, latency grows roughly linearly with elapsed time and every percentile becomes a statement about where in the run the sample fell. That is the 80 second baseline p50. It establishes that the control was driven past capacity, and it cannot separate "isolation improves latency" from "we overloaded the baseline". Every latency arm should measure capacity first, then run at about 30% of it, and report utilization next to the percentiles.

Your race example, worked through

The two error directions turn out to be asymmetric, which narrows things considerably.

Reported frontiers never regress, so the multiplexer's tracked frontier is never ahead of that process's true frontier. Therefore false negatives come from report lag and are the only error on a single-process replica, and a false negative costs a lost isolation opportunity rather than a park. False positives come only from the cross-process meet, because only process 0 receives a Peek and its multiplexer tracks process 0's meet while every worker of every process answers. Clean experimental separation: measure false negatives at one process, false positives at four.

There is a stronger structural result. The multiplexer sees the same response stream the controller does, and sees it earlier being upstream. So for any read whose timestamp comes from the controller's read frontier (serializable, stale, introspection), the tracked frontier is at or beyond the controller's view, which is at or beyond the chosen T. The hint is positive by construction for exactly the population that benefits. Strict serializable takes its timestamp at the write frontier, ahead of what has been reported, so its hint is negative, which is also correct because it must wait for sealing anyway.

If that holds, the dynamic hint earns its keep only by catching strict-serializable reads whose timestamp got sealed during the flight from timestamp selection to the replica. That single fraction decides whether to build the hint or just classify statically, and it is a measurement. It also means the obvious worry, that a frontier hint self-defeats under saturation because the same stalled workers emit the reports, does not apply to the reads that matter, since their timestamps derive from the same stalled reports and so hint and timestamp move together.

The experiments

  • B-A. Where the win lands, by isolation level. Four read arms (strict serializable, serializable, stale, introspection) crossed with the feature on and off. Prediction: arms 2 through 4 improve at every percentile, arm 1 barely moves. If arm 1 improves materially my scoping critique is wrong and the doc's general framing is right.
  • B-B. Head-of-line blocking in the interactive lane. Point reads at fixed rate plus injected scans, sweeping scan cost across 50ms to 5s of step time. Deliverable is "a T-millisecond interactive scan puts a T-millisecond floor under the point-read max", which is what the doc's unquantified "read throughput has a ceiling" limitation needs. Interestingly falsifiable: interactive tails could come out worse than single-runtime, since maintenance has the same worker count but is not also the serving lane. If so the single-step-loop limitation is not deferrable.
  • B-C. What the routing hint actually costs. Three policies on identical workloads, with per-peek instrumentation for serving runtime, parked-or-not, and sealed-on-arrival ground truth. Yields the false-negative and false-positive rates separately, the latency penalty per misroute class at every percentile, and the flight-time fraction above.
  • B-D. Frontier-report staleness against load. Decides prediction versus observation, meaning P1/P2 versus P3 bounce-back.
  • B-E. Introspection during hydration, on both axes. Latency and staleness. "Returns promptly" only helps if the answer is fresh enough to act on, and a 5ms response describing state from 90 seconds ago does not resolve an incident. Prediction: latency improves dramatically and staleness degrades, because the logging dataflows sit on the same stalled workers. The staleness distribution is the real test of the flagship claim.
  • B-F. Cost when it cannot help. CPU-saturated hydration throughput at 1x/2x/4x oversubscription, plus steady-state publisher overhead on maintenance step time.
  • B-G. Memory. The write-then-idle-then-merge test that distinguishes real retention from double counting in log_arrangement_size_inner, which is also the test for the ii_t4 doubling.

Correction to my earlier recommendation

Two changes to read-routing-policy.md.

I wrote that waiting on interactive is "slightly worse" than waiting on maintenance for an unsealed read. More precisely the penalty is exactly one interactive step, which is small when the lane is quiet and unbounded when it is not, since the lane has no admission control. So that penalty is really a question about lane congestion rather than about routing, and it is a tail phenomenon rather than a median one. B-B measures it.

And the recommendation itself ("default to maintenance, route to interactive only on a positive hint") is now marked as a hypothesis the benchmarks are meant to overturn, with the case against it stated explicitly: if misroute penalties concentrate in the tail, all-interactive with the collateral losses fixed beats a hinted policy that is right on average. Reversibility is a real consideration but it does not outrank a measured tail regression.


Generated by Claude Code

antiguru commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Correction to B1: no differential change needed, and the fix is smaller

I said B1 needed the merged-versus-pending distinction reconstructed, and implied that might not be reachable from released differential since map_batches flattens it away. Worked it through against the source. It is reachable, the fix is three Materialize-side changes, and nothing upstream is required.

Why the distinction does not need reconstructing

The publisher cannot read the trace's physical frontier. TraceAgent::get_physical_compaction returns the agent's own hold, not the trace's effective frontier (agent.rs:62), and the effective frontier lives behind trace_box_unstable, which is the fork-era API this PR correctly dropped.

But it does not need to read it, because it can bound it from above. TraceBox::adjust_physical_compaction maintains a MutableAntichain over every agent's hold and pushes its meet into the spine (agent.rs:580-584). The publisher is one of those agents. So whatever value it last passed to set_physical_compaction, call it F_pub, the spine's physical frontier is at or below it.

Now chain that with the promotion rule. Spine::consider_merges moves a batch from pending into the mergeable pile only once batch.upper() <= physical_frontier (spine_fueled.rs:404). So:

every merged batch has upper <= physical_frontier <= F_pub

Add the precondition the spine itself asserts, F_pub <= upper (spine_fueled.rs:130), and every merged batch satisfies batch.upper() <= F_pub <= upper. It passes the existing straddle assert unchanged and is included as a batch entirely below the cut, which is exactly the semantics Spine::batches_through gives it by returning merging wholesale.

So the merged-versus-pending branch is unnecessary. The precondition alone makes it fall out. The current code's bug is not a missing branch, it is a missing precondition: the shared handle accepts cuts below the trace's physical frontier, which the local spine explicitly forbids.

The deferral is conservative in the right direction

The remaining worry was that a reader's physical hold reaches the agent only on the publisher's next activation, since the trace handle is Rc-based and can only be touched on the owning worker thread. That is inherent to cross-thread sharing and no upstream change fixes it. It turns out not to matter, because the deferral errs the safe way:

  • Publisher activation N enqueues Frontier(U_n) and forwards F_pub = meet(holds), where the reader's hold is still its old acknowledged frontier A_old, so F_pub <= A_old.
  • The reader then wakes, advances its acknowledged frontier to U_n, and cuts there. Acknowledged frontiers advance monotonically and A_old was an earlier U, so A_old <= U_n.
  • Precondition F_pub <= U_n holds, and any merge since activation N is bounded by physical_frontier <= F_pub <= U_n.

The publisher always forwards a hold at or below what the reader subsequently cuts at. The one-activation lag is a correctness margin, not a hazard.

Revised B1

  1. Store the forwarded physical target in SharedTraceState. The publisher already computes it at shared_trace.rs:672 and simply does not keep it.
  2. Assert F_pub <= upper at the top of batches_through (shared_trace.rs:420), mirroring spine_fueled.rs:130. Keep the existing straddle check for everything else. No merged-versus-pending branch.
  3. Fix the seed frontier. import_snapshot_at seeds Frontier(state.upper), the lagging stream frontier (shared_trace.rs:820), while the seeded chain comes from map_batches and can contain batches whose upper leads it. Differential's new_listener seeds the last batch's upper instead (agent.rs:122-127), which is consistent with the seeded chain by construction. Match that. This is needed independently of (1) and (2): a seeded pending batch above the seeded frontier is a genuine straddle that the assert should and would fire on.

Item 3 is the one that actually bites today, and it is roughly a one-line change. The commit that introduced it, 67c6ba0 ("seed shared-trace importers with the full snapshot, not a frontier-filtered one"), fixed a real data-loss bug by removing the batch.upper() <= stream_frontier gate on the batches. It kept the stream frontier as the seeded frontier, which is where the misalignment came in. Seeding the chain's own upper keeps the data fix and removes the misalignment.

The regression test from the original B1 still stands and is still the important part: publish, keep writing past as_of, force merges across as_of, then import at that stale as_of and run a join and a reduce. Every existing test uses as_of = 0 with four updates, which cannot reach the state.

What upstream would buy, if anyone is planning differential work anyway

Neither of these is needed and neither should gate this PR.

  • An arc_blanket_impls mirroring rc_blanket_impls would delete the ArcBatch newtype and its hand-written trait forwarding (src/row-spine/src/arc_batch.rs). Pure ergonomics. The newtype works and the orphan-rule rationale in its module doc is correct.
  • A public accessor for a trace's effective physical frontier would let the invariant above be asserted directly rather than inferred from "I forwarded X so the meet is at or below X". The inference is sound, but it is an invariant held in a comment across two crates, which is the kind of thing that rots. Defensive only.

Since the fix is local and the upstream items are optional, this should not need differential release planning.


Generated by Claude Code

@antiguru
antiguru force-pushed the mh/two-runtime-stage2 branch from 5abfb74 to 51e690e Compare August 8, 2026 20:31
@antiguru

Copy link
Copy Markdown
Member Author

Staging evaluation: what the numbers say, and three fixes that came out of it

Measured on a staging region across builds 140494e39a and 5708416d62. Short version: the peek offload is the part that pays, the second runtime is justified by temporary dataflows rather than by peeks, and the memory merge-blocker is cleared.

Fixes in this push

Kubernetes rejected the new port name. compute-interactive is 19 characters and the limit is 15, so every two-runtime replica was unprovisionable in cloud. Nothing local catches this: the process orchestrator accepts any port name, so a fully green CI run proves nothing about it. Now interactive, with a compile-time assert! on the length and a unit test, since the only other thing that validates it is the Kubernetes API server refusing to schedule.

enable_two_runtime_compute is now replica-scoped. It had been environment-wide on the reasoning that a scoped override is delivered to a replica while this value is needed before the replica exists. The second half is true and the conclusion does not follow: provisioning already takes caller-resolved booleans for exactly this reason (enable_worker_core_affinity, enable_storage_introspection_logs), and the coordinator holds the scoped working copy where it calls create_replica. Still a provisioning-time input rather than a live toggle, but flipping it now re-provisions only the replicas whose value changed instead of the whole environment.

An offloaded walk can divert to the peek response stash. should_offload_peek declined every peek the stash could take, because the size-based diversion happens partway through a walk. Production runs the stash on, so the offload was unreachable for ordinary traffic and the blocking it removes stayed in place.

The gate was avoidable. StashingPeek::start_upload already takes a boxed row iterator rather than a trace, and the offload already snapshots into owned cursors bounded on Send. The reason the stash pumps rows from the timely worker is that a trace cursor is not Send, and that does not apply to a walk which already owns its snapshot. upload_blocking drives the same do_upload from the walking thread, so the worker is out of the loop in both outcomes. Diversion re-walks from a spare cursor taken at snapshot time, which is what the inline path already does when it restarts over a fresh iterator.

Also added mz_index_peek_walks_total{substrate}, because the offload previously had no observable signature at all: "it changed nothing" and "it never ran" produced identical evidence, and staging produced exactly that ambiguity.

E1: the offload removes head-of-line blocking between peeks

Point-lookup latency behind three concurrent scans, one 400cc cluster per arm, walk cost set by index size. Run with the stash on, so this is a production configuration.

Scan walk inline p90 / max offload p90 / max
none 105.3 / 105.5 107.7 / 107.8
23 ms 121.7 / 142.2 105.2 / 105.9
190 ms 311.5 / 473.8 106.1 / 159.8
2170 ms 4025.9 / 6162.8 182.6 / 184.5

The inline tail tracks the walk it is queued behind; the offloaded tail is flat. 33x at the largest walk. Engagement is confirmed by the counter, offload=568 inline=0 on the offload arm against inline=2067 offload=0 on the inline arm, not inferred from latency.

E2: the offload alone captures it

The same sweep single-runtime gives max latencies of 107.5 / 109.2 / 180.4 / 167.6 ms. V1 equals V3 within noise at every scan cost, and V0 equals V2. The second runtime contributes nothing to peek tail latency. What removes the blocking is the substrate the walk runs on, and that is a dyncfg needing no restart, no new port and no fleet roll.

E7: temporary dataflows are what justify the second runtime

They cannot use the offload at all, since that is a peek walk substrate. Sampled during one 60M-row index hydration, both arms as replicas of one cluster sharing that hydration (possible because of the scoping fix above):

Probe Metric single runtime two runtime
Late materialization p90 2135.5 1346.3
Late materialization max 2835.1 1455.9
Introspection p90 776.6 114.9
Introspection max 778.4 115.3

Tail halves and introspection stops being collateral damage of hydration. p50 barely moves, which is what an isolation claim predicts.

So the two mechanisms are not competing: the offload for peeks, the second runtime for rendered temporary dataflows. The routing policy should follow that split.

One caveat worth more than the ratio: late materialization has an 850-950 ms floor in every cell, quiet or loaded, either runtime, for ~120 rows. That is temporary-dataflow creation and teardown. It is larger than the tail placement recovers, and it is the bigger prize.

E6: publication does not cost resident memory

Importing a published trace does not duplicate it. 48 concurrent interactive dataflows over a 95 MiB published index moved the resident set by 4.5 MiB on one replica and 1.4 MiB on the other. The arrangement-size doubling seen earlier on a 16-worker replica did not reproduce. Merge blocker cleared.

Methodology notes, including what went wrong

Recorded because each of these first produced a wrong answer.

  • Peeks ignore replica targeting. Instance::target_replica returns None for ComputeCommand::Peek, so a peek is broadcast to every replica and targeting only selects whose response is accepted. CreateDataflow does honour it. Load is therefore a property of the cluster: an idle control arm is impossible, and arms needing different load need a cluster each, not a replica each. A scan-cost sweep is exactly that case.
  • The stash gate made the offload arm inert, so the first E1 attempt measured a path that could not execute and returned a confident null.
  • An unsaturated load cannot demonstrate isolation. E7's first maintenance load, repeatedly building a 6M-row index, never saturated eight workers and moved nothing in either arm.
  • Two-runtime doubles timely worker threads at every replica size (interactive_compute_arg passes the same worker count), so 100cc is 2 cores / 4 threads and 400cc is 8 / 16. The ratio does not improve with size.
  • p50 under coarse contention lands on a discrete ladder set by queue position, so it is a three-valued statistic there and should not be averaged across repeats.

Still open

  • Swap behaviour is being measured now; a first attempt is inconclusive and written up as such, including two fixtures that failed for instructive reasons.
  • E4's in-flight cap sweep should be re-run, since a diverting walk now holds its batches for the upload as well as the walk, so index_peek_offload_max_inflight bounds a longer-lived compaction hold and a blocking thread.
  • B3, peek observability on the interactive runtime, is unchanged.

Full write-up, including the failed fixtures and the pre-registered predictions, is in doc/developer/design/20260720_two_runtime_compute/evaluation.md.

@antiguru

Copy link
Copy Markdown
Member Author

Swap: no regression, and the largest margin measured

Follow-up to the swap item left open above. The first attempt failed on the fixture rather than the question: building one huge index spikes the working set while it hydrates, which killed a replica and left the two arms at unequal swap depth (3.2 vs 6.4 GB), so it was not a matched control.

This environment runs compute_hydration_concurrency = 1, so several smaller indexes hydrate in sequence and the peak is one index rather than the whole set. Six indexes of repeat(l_comment, 8) over sf1.lineitem, ~1.3 GB each, plus a small resident index for the point probe, on M.1-nano (1 worker, 0.5 core, 4.07 GB memory).

That settles at ~3.8 GB resident and 5.05 GB of swap on one arm against 5.08 GB on the other, about 2.2x memory, with zero restarts on both. Equal depth is what the earlier run lacked.

Point-lookup latency on the resident index, two concurrent walks of a swap-resident index:

Cell Arm p50 p90 max
quiet inline 105.9 138.1 149.9
quiet offload 106.4 106.7 107.0
2 swapped walks inline 107.8 185.8 29151.7
2 swapped walks offload 105.5 141.6 152.4
2 swapped walks, repeat inline 2216.6 4429.1 4501.3
2 swapped walks, repeat offload 105.4 105.9 106.5

No regression under swap, and the margin is larger than anywhere else measured. Inline shows a 29 second worst case in one repeat and a 2.2 second median in the other; the offloaded arm stays at its quiet latency in both.

The walks themselves agree: one swapped walk takes 3.6, 4.7 and 56.4 seconds inline against 2.3 seconds three times over offloaded. Faster and far more predictable on the same data at the same depth.

Why the margin is largest here: a walk faulting on swapped pages spends its time blocked rather than computing, so the thread it blocks is not doing useful work either way. Moving it off the serving worker costs nothing and recovers everything, which is not true in the CPU-bound case where an offloaded walk still competes for a core. Disk pressure is the regime with the least to lose and the most to gain.

One correction to the earlier comment: the inconclusive first attempt reported an inline walk failing to complete while the offloaded one finished, at unequal swap depths. With depths matched that asymmetry persists in latency but neither arm restarts, so the restart in the first attempt should be attributed to the hydration spike rather than to the walk substrate.

@antiguru

Copy link
Copy Markdown
Member Author

The console under load: answers stay fast, and stay about a second stale

This is the case that motivates the second runtime for a UI. A console polls introspection, dataflow sizes and EXPLAIN ANALYZE; the question is whether those keep arriving while a replica hydrates, and how stale they are when they do.

Everything below runs in serializable, which is what lets a read pick an already-available timestamp instead of waiting for the newest one. Arms are replicas of one cluster (r-solo single-runtime, r-two two-runtime), so one 600M-row index hydration loads both simultaneously. Staleness is wall clock minus the query timestamp that EXPLAIN TIMESTAMP reports for the same query, with the EXPLAIN TIMESTAMP issued last so the two readings are adjacent.

Probe Idle Hydrating, r-solo Hydrating, r-two
Introspection query 161-167 ms 4445, 4824, 4959, 5262, 6695, 6945, 7518 ms 158-162 ms, one 1547
EXPLAIN ANALYZE CLUSTER MEMORY 297-356 ms 4935, 5224, 5991, 6107, 8692, 13301 ms 295-773 ms
EXPLAIN ANALYZE CLUSTER CPU 219-511 ms 4238, 5723, 6467, 7538, 10510 ms 174-525 ms
Staleness -120 to 427 ms 170 to 1589 ms -141 to 1456 ms

Two findings, and they answer different questions.

Latency splits the arms hard. On the single-runtime replica a console poll takes seconds while an index hydrates, up to 13.3 s for EXPLAIN ANALYZE CLUSTER MEMORY, well past where a UI has given up. On the two-runtime replica the same polls hold their idle cost: ~160 ms for introspection, ~300 ms for EXPLAIN ANALYZE. Thirty-fold on introspection, and the concrete reason to want the second runtime behind a console.

Staleness does not split them. Both arms answer from a timestamp about one second behind real time while hydrating, against roughly zero when idle, and they are indistinguishable on this axis (170-1589 ms vs -141 to 1456 ms). So the second runtime buys prompt answers at the same freshness, not fresher ones.

That is worth stating precisely in the design doc, because "a console showing data about a second old during hydration" is a weaker and more accurate claim than "a console showing live data". EXPLAIN TIMESTAMP is the right instrument for it.

Two reading notes: values straddling zero are clock skew between the probe host and the environment plus round trip, so idle staleness means "no measurable lag" rather than a negative number. And can respond immediately was false on two r-two probes during the heaviest hydration while the introspection query still returned in 161 ms — not a contradiction, since the statements are issued a moment apart and the flag describes the timestamp available at plan time.

Write-up in doc/developer/design/20260720_two_runtime_compute/evaluation.md as E9.

@antiguru

Copy link
Copy Markdown
Member Author

The skewed point lookup: one bad key stops stalling everyone

A real customer pattern, and the sharpest case for the offload — it needs none of the stash work to be reachable, because ORDER BY makes the finishing non-streamable so the stash never applied and the offload was always eligible.

Shape: SELECT ... WHERE key = <literal> ORDER BY ... LIMIT 1, where most keys hold one value and a few hold millions. Fixture: hot key 0 with 6,003,692 distinct values, 1.5M other keys with exactly one each, 7,503,698 records on 400cc. Both shapes plan as fast-path index lookups with literal constraints. Normal keys are drawn from the table rather than generated, since TPC-H order keys are sparse and generated keys mostly miss and measure an empty seek.

Isolated: a normal lookup is 105 ms (round trip), the hot key is 2020 ms — finding the minimum of six million values for one key is one worker's walk.

Load is open loop: arrivals fire on a fixed wall-clock schedule regardless of outstanding work, from a pre-opened pool. A closed loop throttles itself and hides exactly this queueing. Client-side queue delay stayed at 1.2 ms with zero drops in every run, so the pile-up is entirely server side.

Three skewed lookups injected into a steady 10/s stream:

Arm normal p50 p90 p99 max normals over 200 ms
inline 107.7 1223.0 2019.9 2024.2 58 of 261
offload 107.8 108.7 109.0 109.2 0 of 261

Same at 25/s, two injections:

Arm normal p50 p90 p99 max
inline 107.0 1027.1 1940.1 2024.2
offload 105.5 106.0 106.3 118.2

The inline timeline shows the mechanism, not just its size. After a skewed lookup arrives at t=5.0, the normals behind it complete at a fixed instant rather than after a fixed delay: 2019.9 ms for the arrival at 5.0, then 1921, 1821, 1721, 1622, 1522 … down to 235.8 ms for the arrival at 6.8. Each one waits out the remaining walk. Identical pattern at 12.0 and 19.0.

That is the complaint exactly: one lookup on a bad key stalls every lookup behind it, and the victim count is arrival rate times walk duration — which is why it presents as a cluster-wide latency spike rather than one slow query.

The offloaded arm has no such window. The skewed lookups still take ~2 s (1971, 2021, 2003 ms) because the work is unchanged; they just stop being in anyone else's way.

This is the strongest argument here for defaulting the offload on: the cost is a thread, and the benefit is that a single skewed key stops being a cluster-wide latency event.

Happy to encode this as a parallel benchmark so the pattern is regression-protected — say the word and I'll wire it into the existing harness rather than leaving it as a one-off script.

@antiguru
antiguru force-pushed the mh/two-runtime-stage2 branch 2 times, most recently from 2f73ff3 to 5826fea Compare August 13, 2026 12:13
antiguru added a commit to antiguru/materialize that referenced this pull request Aug 17, 2026
Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` carrying
differential's batch traits (the orphan rule forbids the blanket impl on a bare
`Arc<B>`), and switch the production spines and their builders (`RowRowSpine`,
`RowValSpine`, `RowSpine`, `ValRowSpine`, `ColValSpine`, `ColKeySpine`) from
`Rc`/`RcBuilder` to `ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents
are `Send + Sync` can be read from a thread other than the one maintaining the
trace, which `Rc` cannot do. Only the batch handle becomes atomic; the batch
contents are unchanged.

Also add generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers outside
`mz_compute`, adapt batch-size logging (`log_arrangement_size_inner`) to reach
through the newtype to the inner `Arc`, and switch the storage sink trace to the
`Arc`-backed spine.

Two consumers of the sink trace follow from that switch. The iceberg sink stashes
input batches while it waits for their batch description, so its `VecDeque` and
the `with_ready_batches` helper now hold `ArcBatch` rather than `Rc`. And the
arrangement-size operator's cache comment named the `RcBox` allocation its `Weak`
keeps reserved, which is an `ArcInner` once the batch handle is atomic. The
invariant it documents is unchanged.

This is the foundational primitive the two-runtime read-isolation work (MaterializeInc#37770)
builds on, extracted here for standalone review. It builds against released
differential-dataflow with no fork or `[patch.crates-io]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
@antiguru
antiguru force-pushed the mh/two-runtime-stage2 branch from 5826fea to 306799b Compare August 17, 2026 07:33
antiguru added a commit to antiguru/materialize that referenced this pull request Aug 17, 2026
Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` carrying
differential's batch traits (the orphan rule forbids the blanket impl on a bare
`Arc<B>`), and switch the production spines and their builders (`RowRowSpine`,
`RowValSpine`, `RowSpine`, `ValRowSpine`, `ColValSpine`, `ColKeySpine`) from
`Rc`/`RcBuilder` to `ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents
are `Send + Sync` can be read from a thread other than the one maintaining the
trace, which `Rc` cannot do. Only the batch handle becomes atomic; the batch
contents are unchanged.

Also add generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers outside
`mz_compute`, adapt batch-size logging (`log_arrangement_size_inner`) to reach
through the newtype to the inner `Arc`, and switch the storage sink trace to the
`Arc`-backed spine.

Two consumers of the sink trace follow from that switch. The iceberg sink stashes
input batches while it waits for their batch description, so its `VecDeque` and
the `with_ready_batches` helper now hold `ArcBatch` rather than `Rc`. And the
arrangement-size operator's cache comment named the `RcBox` allocation its `Weak`
keeps reserved, which is an `ArcInner` once the batch handle is atomic. The
invariant it documents is unchanged.

This is the foundational primitive the two-runtime read-isolation work (MaterializeInc#37770)
builds on, extracted here for standalone review. It builds against released
differential-dataflow with no fork or `[patch.crates-io]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
antiguru and others added 17 commits August 21, 2026 09:19
The spec described the design as drafted, which the implementation diverged from
three ways. A model describing a system nobody built reads as assurance, so it is
worse than no model.

Brought into line: the acquired hold follows its reader instead of sitting at the
as_of, reclaim is driven by an explicit release the rendering runtime records rather
than by watching a registration appear and go away, and the drop and the release are
two commands on that runtime's stream in that order rather than one.

Three things fell out of doing it.

I1 was wrong for the shipped design. It would have flagged a correct downgrade,
because once the reader advances the collection may legitimately compact past the
original as_of. It is now two windows: protected at the as_of before the dataflow is
built on a process, at the reader's current hold afterwards. Sabotaging the downgrade
to overshoot its reader violates it, so it discriminates.

The no-permanent-pin property cannot be an invariant. Reclaim and downgrade are
separate steps, so there is always a legal state where the release has been applied
and the hold is still there, and a safety version flags exactly that state. It is now
a liveness property against a fair spec.

Checking it surfaced a dependency nobody had written down: the release reaches the
owning runtime only through the rendering runtime applying a command, so a runtime
that stops draining its queue strands the hold forever. Fairness now states that the
runtimes drain, which is what each worker's server loop does.

MaintStep deliberately treats only the command-acquired hold as a bound and ignores
the reader's own registration. That registration is forwarded through one agent whose
setter joins, so it cannot represent a frontier below where the agent already sits.
Treating it as a bound would assume away the ratchet the acquired hold exists for.

HoldsReleaseOnMaint.cfg is new and keeps run one's refutation live: the release on the
owning runtime's stream, which the asymmetry in the code exists to avoid. Both
refutations still violate I1 and the runner fails if either stops.
Formatting only. The resolution of the upstream index-peek-rows metrics against the
peek offload restructure left one initializer wrapped that fits on a line.
Upstream MaterializeInc#38170 removed mz-compute-client from clusterd as an unused dependency,
which it was on main. This branch uses it: `lib.rs` constructs the two-runtime
`Multiplexer` from it.

A semantic conflict git cannot see. Both sides are individually correct and the
files do not overlap, so the rebase reported nothing and the breakage only showed
up building clusterd, which no per-crate check of the compute crates reaches.
`bin/doc --document-private-items` runs with `-D warnings`, so
`rustdoc::private-intra-doc-links` is an error: a public module doc may not link to a
private or crate-private item, because the link resolves only under
`--document-private-items` and breaks without it.

Both references become plain code spans. They point at internal mechanics that the
surrounding prose already explains, so nothing is lost by not linking.
A published arrangement forwarded the published `since` as its physical
compaction target. That conflates the two compaction kinds: logical
compaction decides which times stay distinguishable, physical compaction
decides which batches may merge. Holding physical down to `since` kept a
batch boundary at every frontier at or above the controller's read
frontier, so the spine stopped merging, and it did so for every published
index whether or not anything ever imported it. Measured on an index with
no importer at all: 5 to 7 batches where an unshared index reached 1.

A reader does not need a boundary at its `as_of`. `import_snapshot_at`
seeds it with the whole chain and wraps the handle in `TraceFrontier`,
which advances times rather than cutting, so a batch straddling `as_of` is
harmless. Batches are immutable, so a merge after a seed is captured
cannot disturb it, and `acknowledged` starts at that seed's coverage and
only rises.

What a reader does need is a boundary at each frontier it later passes to
`cursor_through`. `mz_join_core` already declares exactly that through
`set_physical_compaction`, as differential intends, but `SharedTraceHandle`
recorded the request in a local field and dropped it. Mirror it into the
publication point as `physical_holds` instead, and forward their meet,
falling back to the chain coverage when no reader is registered. A floor
starts at the coverage at registration, never at `since` (a logical
frontier, which is the conflation this commit removes) and never at
`upper`, which can lead the coverage and so sit above the seed a reader
registering now receives.

The straddle check in `SharedTraceHandle::batches_through` stays. It is
the detector for a reader that needs a boundary this forwarding merged
away, and deleting it would leave a consumer silently double counting
updates at times not before its cut.

`stale_as_of_import_over_merged_chain_matches_direct` asserted that no
batch straddles the `as_of`. That was a restatement of the old forwarding
rather than a property a reader depends on, so it now asserts the
straddle, which makes it the regression test for reintroducing a
collective floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
Every hold command in this design reconstructs an ordering that routing
destroyed: `CreateDataflow` goes only to the rendering runtime while
`AllowCompaction` goes to the owning one, so the two are no longer on a
single ordered stream. The alternative is to stop routing compaction and
hand it to both runtimes, which restores that ordering directly.

Model both variants so TLC decides between them rather than leaving the
argument in a comment.

`broadcast` hands the frontier to both runtimes and adds nothing else. It
violates I1 in five states: the controller creates a dataflow and
immediately drops it, as a cancelled peek does, so its own read hold is
gone before the rendering runtime has applied either command. Compaction
then advances on the owning runtime while the create is still queued, and
the dataflow is later built at an `as_of` the collection has compacted
past. Restoring the ordering within each stream does not restore it
between them.

`broadcast-standing` adds the standing per-collection hold: the rendering
runtime holds every shared collection at the last frontier it has applied,
so the owning runtime cannot realize a frontier that runtime has not
reached. It holds over 19940 distinct states. The invariant is that a
shared arrangement compacts only as fast as the slowest runtime's stream
position, which is derived from the importer rather than from the
controller's per-dataflow bookkeeping, and it needs no acquisition, no
release and no reclaim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
Records the conclusion the model reached and what follows from it. The
diagnosis in read-holds.md stands: splitting compute keeps the controller's
global floor and loses the single ordered command stream. The mechanism
does not. Every version of it reconstructs that ordering on top of the
routing that lost it, where sending compaction to both runtimes restores it
directly.

Adds broadcast-compaction.md with the design, the refutation of broadcast
without a hold, the invariant it buys, the list of what it deletes, and the
two unmeasured costs. Marks read-holds.md as superseded while keeping its
diagnosis and its record of what each attempt cost, since that is what
makes the simpler mechanism defensible rather than merely simpler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
An index's `since` must not pass the `as_of` of a dataflow importing it. A
single command stream ordered the create against every later compaction.
Splitting compute across two runtimes routed those two commands to different
streams, which lost that ordering.

Broadcast `AllowCompaction` for maintenance-owned collections to the
interactive runtime as well. It applies the frontier as a standing hold on the
shared arrangement, and the publisher bounds its logical target by that hold,
so a shared arrangement compacts only as fast as the slowest runtime's stream
position. An importing dataflow whose `CreateDataflow` is still queued on the
interactive runtime has registered no reader hold yet, so this is the only
thing keeping the arrangement at or below the `as_of` it is about to read at.

The hold is seeded at the publisher's compaction frontier at adoption rather
than at the minimum time. The controller offers no `as_of` below a
collection's own `since`, so no importer can need a frontier below it, and the
seed keeps a collection the controller never compacts again from being pinned
forever.

Tests at three levels, each verified by sabotaging the mechanism it covers:
the publication point, the registry and import path, and the multiplexer's
routing. The publisher also carries a `debug_assert` that the published
`since` never passes the hold.

The acquisition layer (`AcquireHolds`/`ReleaseHolds`) is untouched and now
redundant with this. Its deletion follows in a separate commit, so the
invariant is asserted before anything is removed on the strength of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
The standing hold makes every acquisition redundant. It bounds the publisher's
compaction by the importing runtime's own stream position, so the publisher's
agent never sits above an `as_of` that runtime can still present, which is the
ratchet the acquisition existed to escape.

Deleted: `ComputeCommand::AcquireHolds`/`ReleaseHolds` and `HoldRequest`,
`compute_state/command_hold.rs`, `ComputeState::command_holds` and its
maintenance pass, the registry's release records
(`release_holder`/`reclaim_holder`/`released_holders`/`clear_released`), the
multiplexer's `held_exports` and hold synthesis, and the `history.rs` arms.

Two more things fell out, both of which existed only because a command hold was
a second writer of the published `since`. `SharedTraceState::writer_since` and
`refresh_since` go, because with one writer left the publisher assigns `since`
directly. So do the four `Published` methods that recorded a hold the point did
not own, and with them the distinction between a hold that is a request
forwarded through the publisher's agent and a hold that is a grant backed by
someone else's handle. Every hold is a request again.

The replica no longer discards anything at a reconnection. A standing hold is
per collection, carries no dataflow identity, and only rises, so clearing it
would drop the bound to the minimum time until the replayed compactions raised
it again.

Four render tests covering the acquisition go. What they tested, a read at an
`as_of` below where the publisher's agent already sits, is covered at the same
level by `standing_hold_pins_until_the_importing_runtime_applies`. The
multiplexer's ordering test keeps its other half, that compaction is forwarded
uncapped, and its `Hello` test now asserts what `reset` still does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
The spec described an acquisition protocol the code no longer has. It now
models what the code does: compaction broadcast to both runtimes, and the
rendering runtime's applied frontier bounding the publisher.

Gone with the acquisition: the `acquire` and `release-on-maint` mechanisms and
their variables, the `cap` mechanism and the frontier report it retired on, and
the properties that had them as their subject. `Holds.cfg` is the shipped design
rather than a retired one, and `HoldsRouted.cfg` replaces `HoldsCap.cfg` as the
second refutation: compaction routed to the owning runtime alone, which is the
raw defect the runtime split introduced. It fails in the same six states as
broadcast-alone, so the pair shows that broadcasting the command restores the
ordering within each stream and buys nothing for that counterexample.

Applying a compaction and publishing one are now separate actions. The previous
spec recomputed the published frontier only when a command arrived, which models
a publisher that never notices its standing hold catching up. Splitting them is
what makes the liveness question expressible, and `CompactionNotStalled` states
it: the bound a lagging rendering runtime imposes is temporary. It holds only
because `Fairness` says the runtimes drain, which is the coupling this design
introduces.

The reader's own registration is a bound now. It could not be one under the
acquisition mechanism, since a registration forwarded through a single joining
agent cannot represent a frontier below where that agent sits. Bounding the
agent by the standing hold removes that ratchet, which is the same reason the
acquisition became unnecessary.

`Holds.cfg` holds over 7845 distinct states, depth 19. Both refutations violate
I1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
The broadcast reached the interactive runtime for every maintenance-owned
collection, including the transient ones. Maintenance renders its own transient
collections, its subscribes and copy-tos, and those are sinks with no
arrangement for anything to import. The interactive runtime has installed
nothing under such an id, so applying the frontier for one took the local path
and the drop in that sequence panicked in `drop_collection` with "dropped
untracked collection".

Two fixes, each of which is sufficient on its own.

Narrow the broadcast to the collections the interactive runtime can import,
which are the non-transient ones maintenance publishes. That is the truthful
scope: a standing hold exists to keep an importable arrangement readable.

Decide local work from `collections` rather than from the id. The id was the
wrong question in both directions: the interactive runtime holds empty local
copies of the peer's introspection indexes, whose ids are the peer's to publish,
and the peer renders transient collections this runtime has never seen. So a
stray broadcast can no longer reach the drop path at all.

Reproduced with `bin/sqllogictest --optimized --
--system-parameter-default=enable_two_runtime_compute=true` over
subscribe_outputs.slt and cursor.slt, which panic before either fix and pass
with either one. 836 assertions pass over the nine SUBSCRIBE-carrying files.

Also drop a rustdoc link from a public module doc to a private field, which
`ci/test/lint-doc.sh` rejects under `-D rustdoc::private-intra-doc-links`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
A refusal says the arrangement compacted past the dataflow's `as_of`, and the
existing diagnostics cannot say whether anything on the replica could have
prevented it. The standing hold is the value that decides: equal to the refusing
`since` means the importing runtime had already applied that compaction before
it built the dataflow, so the create was ordered behind it on that runtime's own
stream and the arrangement was legitimately compacted, with no replica-side hold
able to help. Below it means the publisher escaped its bound, which is a defect
here.

The publisher's `debug_assert` covers the second case but compiles out under
`--optimized`, which is what sqllogictest and mzcompose run, so the panic path
has to carry it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
`enable_two_runtime_compute` named a topology, two runtimes, rather than the
thing it turns on. What a reader wants from the name is which mechanism appears,
and the mechanism is an interactive runtime that serves reads off the
arrangements the maintenance runtime builds. The new name also joins the
established `enable_compute_*` family alongside `enable_compute_peek_response_stash`
and `enable_compute_replica_expiration`, so it sorts and reads with its siblings.

Renames the const and the parameter name together with all three Python
registration sites, since `check-test-flags.sh` requires the mzcompose variable
parameters and the parallel-workload flag list to agree with the compiled set.

NOTE: the parameter name is also the LaunchDarkly key. The old key carries a
staging rule enabling the feature for the read-placement evaluation environment,
and nothing reads that key after this commit, so the new key has to be created
before the next staging measurement or the environment silently falls back to the
compiled default of off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
Placement and preemption are independent: a walk off the timely worker can
still have yield points, which buys somewhere to observe a cancellation
request and a bound on how long one walk pins its batches. So S1 and S5 are
not two values of one setting, and the fourth cell of that grid is an
untried candidate rather than a contradiction.

Records what each cell has measured and names the two that decide the
question, both empty: S1 on M1 is predicted throughout and never measured,
and nothing at all is known about an off-worker walk that yields. The
deciding experiment runs S1 against S5 on E1, E11 and E8b, with the rule
registered up front, and needs neither the interactive runtime nor this
branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
The design of record, the evaluation record and the two machine-checked protocol
models land separately in MaterializeInc#38239, along with their CI checks. They have no
dependency on this code and reviewing 4,700 lines of prose against 7,000 lines of
compute is worse for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
The offload walks a fast-path index peek's cursor on a blocking task instead of on
the serving worker. It is orthogonal to the interactive runtime: it needs no second
runtime, the interactive runtime needs no offload, and the two were only ever in one
branch because they were written together.

Keeping them together also argues two things at once, and one of them is now a
question rather than a claim. Cooperative peek yielding is a second candidate for the
same defect, placement and preemption turn out to be independent axes so there is a
fourth untried combination, and the experiment that decides between them has not run.
Landing a mechanism that measurement may retire is the expensive order, since a merged
mechanism is harder to remove than an unmerged one.

What this leaves is the interactive runtime alone, whose case is E7 and E9: temporary
dataflows under maintenance load, and a replica that stays introspectable while it
hydrates. E1 and E11 go with the offload, which is where E2 showed they belonged.

Interactive peeks are unaffected. They resolve through `shared_index_peek_response`,
and the offload was a conditional in front of that rather than the path itself.

Removes `local_snapshot.rs`, `PendingPeek::IndexOffload` and its walk plumbing, the
two dyncfgs, `mz_index_peek_walks_total`, `PeekStash::upload_blocking`, and the
in-flight accounting. `static_assertions` goes with them, having been used only for
the `Send` assertion on the owned snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
…nct work

Resolutions for the rebase onto main after `ComputeRuntimeRole` and the
error-multiplicity collapse landed separately.

The role enum arrived on main with `Interactive` gated behind `cfg(test)`,
since nothing outside tests could construct it yet. This branch constructs
it, so the enum and its impl keep this branch's copy, which drops the gate
and adds `publishes`. Main's copy is removed rather than merged: the two
differ only in the gate and the accompanying notes about it.

`distinct_errs` and `bundle_errs` both match exhaustively on
`ArrangementFlavor`, and neither knew about `SharedTrace`. A shared trace is
an imported arrangement like `Trace`, so it takes the same treatment in
both: `distinct_errs` leaves it alone because this dataflow cannot rewrite
an imported error trace in place, and `bundle_errs` yields its error
collection so a delta join propagates those errors once rather than once per
path.
@antiguru
antiguru force-pushed the mh/two-runtime-stage2 branch from 0cfb53f to 3e03995 Compare August 21, 2026 07:39
antiguru added a commit to antiguru/materialize that referenced this pull request Aug 21, 2026
Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` carrying
differential's batch traits (the orphan rule forbids the blanket impl on a bare
`Arc<B>`), and switch the production spines and their builders (`RowRowSpine`,
`RowValSpine`, `RowSpine`, `ValRowSpine`, `ColValSpine`, `ColKeySpine`) from
`Rc`/`RcBuilder` to `ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents
are `Send + Sync` can be read from a thread other than the one maintaining the
trace, which `Rc` cannot do. Only the batch handle becomes atomic; the batch
contents are unchanged.

Also add generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers outside
`mz_compute`, adapt batch-size logging (`log_arrangement_size_inner`) to reach
through the newtype to the inner `Arc`, and switch the storage sink trace to the
`Arc`-backed spine.

Two consumers of the sink trace follow from that switch. The iceberg sink stashes
input batches while it waits for their batch description, so its `VecDeque` and
the `with_ready_batches` helper now hold `ArcBatch` rather than `Rc`. And the
arrangement-size operator's cache comment named the `RcBox` allocation its `Weak`
keeps reserved, which is an `ArcInner` once the batch handle is atomic. The
invariant it documents is unchanged.

This is the foundational primitive the two-runtime read-isolation work (MaterializeInc#37770)
builds on, extracted here for standalone review. It builds against released
differential-dataflow with no fork or `[patch.crates-io]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
antiguru added a commit that referenced this pull request Aug 21, 2026
Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` carrying
differential's batch traits (the orphan rule forbids the blanket impl on a bare
`Arc<B>`), and switch the production spines and their builders (`RowRowSpine`,
`RowValSpine`, `RowSpine`, `ValRowSpine`, `ColValSpine`, `ColKeySpine`) from
`Rc`/`RcBuilder` to `ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents
are `Send + Sync` can be read from a thread other than the one maintaining the
trace, which `Rc` cannot do. Only the batch handle becomes atomic; the batch
contents are unchanged.

Also add generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers outside
`mz_compute`, adapt batch-size logging (`log_arrangement_size_inner`) to reach
through the newtype to the inner `Arc`, and switch the storage sink trace to the
`Arc`-backed spine.

Two consumers of the sink trace follow from that switch. The iceberg sink stashes
input batches while it waits for their batch description, so its `VecDeque` and
the `with_ready_batches` helper now hold `ArcBatch` rather than `Rc`. And the
arrangement-size operator's cache comment named the `RcBox` allocation its `Weak`
keeps reserved, which is an `ArcInner` once the batch handle is atomic. The
invariant it documents is unchanged.

This is the foundational primitive the two-runtime read-isolation work (#37770)
builds on, extracted here for standalone review. It builds against released
differential-dataflow with no fork or `[patch.crates-io]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G29DBfgE8LXpE5jamm2Zi
pull Bot pushed a commit to Arstman/materialize that referenced this pull request Aug 21, 2026
…nt sharing (MaterializeInc#38396)

Replaces MaterializeInc#37881, whose head branch lives on a fork and so cannot be the
base of a stacked PR in this repository. Same commits, same tree, on an
upstream branch instead. This is the root of the stack MaterializeInc#38386 through
MaterializeInc#38393, which splits MaterializeInc#37770.

### Motivation

Cross-runtime arrangement sharing (the two-runtime read-isolation work,
MaterializeInc#37770) needs batches readable from a thread other than the one
maintaining the trace. Differential's default spines reference-count
batches with `Rc`, which is worker-local.

### Description

Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` that
carries differential's batch traits (the orphan rule forbids the blanket
impl on a bare `Arc<B>`), and switch the production spines and their
builders — `RowRowSpine`, `RowValSpine`, `RowSpine`, `ValRowSpine`,
`ColValSpine`, `ColKeySpine` — from `Rc`/`RcBuilder` to
`ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents are `Send
+ Sync` can be read across threads, which `Rc` cannot do. Only the batch
handle becomes atomic; the batch contents are unchanged, so the cost is
a marginally more expensive refcount.

Also adds generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers
outside `mz_compute`, adapts batch-size logging
(`log_arrangement_size_inner`) to reach through the newtype to the inner
`Arc`, and switches the storage sink trace to the `Arc`-backed spine.

Builds against released differential-dataflow 0.25 with no fork or
`[patch.crates-io]`.

### Verification

`cargo check --workspace` passes with no `Cargo.lock` churn.
`relations.slt`'s golden is rewritten because the spine type name
appears in operator names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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