Skip to content

Commit 92b0861

Browse files
antiguruclaude
andcommitted
compute-client: multiplex one command stream across two runtimes
A replica running two compute runtimes still speaks one compute protocol to the controller. This adds the multiplexer that fans a single command stream out to both and merges their responses back into one, so neither the controller nor the protocol learns that the replica is split. Routing follows collection identity rather than command kind. Lifecycle commands go to both runtimes, a dataflow goes to the runtime that will host it, and `AllowCompaction` for a maintained collection is broadcast to both because the interactive runtime may be reading a published copy of it. Frontier reports are forwarded only from the runtime that owns the collection, since the controller keeps one frontier stream per collection and two reporters would race and regress it. Peek responses are forwarded verbatim without dedup, because a peek is answered by exactly one runtime. Nothing constructs a multiplexer yet, so the module is inert. Its tests cover the routing table, the broadcast, ownership eviction, and that `recv` loses no message when both sides are ready. Tests are out of line in `multiplex/tests.rs`, per the convention in `src/compute/AGENTS.md`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e0c1fed commit 92b0861

3 files changed

Lines changed: 1183 additions & 0 deletions

File tree

src/compute-client/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ pub mod as_of_selection;
1515
pub mod controller;
1616
pub mod logging;
1717
pub mod metrics;
18+
pub mod multiplex;
1819
pub mod protocol;
1920
pub mod service;
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
//! A process-level command/response multiplexer over two compute runtimes.
11+
//!
12+
//! A clusterd process can host two compute runtimes: a `Maintenance` runtime that renders durable,
13+
//! maintained work, and an `Interactive` runtime that serves ephemeral peeks. The compute
14+
//! controller still connects to a single endpoint. [`Multiplexer`] bridges the two: it presents one
15+
//! [`ComputeClient`] to the controller, routes each command to the runtime that owns the referenced
16+
//! work, and merges the two response streams back into one.
17+
//!
18+
//! Routing is derived entirely from command contents (see [`Multiplexer::send`]).
19+
//!
20+
//! The split would otherwise lose one invariant: an index's `since` must not pass the `as_of` of a
21+
//! dataflow importing it. A single command stream ordered the create against every later compaction.
22+
//! Routing the two commands to different runtimes loses that, so `AllowCompaction` for a
23+
//! maintenance-owned collection is *broadcast*: interactive sees it too, applies it as a standing hold
24+
//! on the shared arrangement, and the publisher compacts only as far as the slower of the two runtimes
25+
//! has applied. Interactive therefore has the create and the compactions that follow it back on one
26+
//! ordered stream. See `doc/developer/design/20260720_two_runtime_compute/broadcast-compaction.md`.
27+
//!
28+
//! The multiplexer therefore does not modify compaction frontiers, and it holds no per-dataflow
29+
//! state for the invariant. What keeps the arrangement readable is derived from the importing
30+
//! runtime's own stream position rather than from anything tracked here, so a runtime that is
31+
//! arbitrarily behind, or that never processes the create at all, cannot break it.
32+
//!
33+
//! State is therefore only which runtime renders each transient collection (`transient_owner`). It is
34+
//! per-connection and discarded by `Hello`, see `Multiplexer::reset`.
35+
//!
36+
//! The multiplexer does not deduplicate peek responses. The exactly-one-`PeekResponse`-per-uuid
37+
//! contract is already upheld below and above it: the per-worker `PartitionedComputeState` inside
38+
//! each process collapses a cancel-versus-complete split across that process's workers into one
39+
//! response, and the controller's per-process `PartitionedComputeState` merges one response per
40+
//! process. Peeks route only to the interactive runtime, so the multiplexer receives exactly one
41+
//! `PeekResponse` per uuid and forwards it verbatim. A multiplexer on a non-zero process never
42+
//! observes the originating `Peek` command anyway (commands other than `Hello`/`UpdateConfiguration`
43+
//! are sent to process 0 only, reaching other processes' workers through the intra-runtime command
44+
//! channel), so it cannot gate responses on having seen the command.
45+
46+
use std::collections::BTreeSet;
47+
48+
use async_trait::async_trait;
49+
use mz_repr::GlobalId;
50+
use mz_service::client::GenericClient;
51+
52+
use crate::protocol::command::ComputeCommand;
53+
use crate::protocol::response::ComputeResponse;
54+
use crate::service::ComputeClient;
55+
56+
/// Which of a process's two compute runtimes a piece of work lives on.
57+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58+
enum Runtime {
59+
/// The runtime that renders durable, maintained collections.
60+
Maintenance,
61+
/// The runtime that serves ephemeral, interactive peeks.
62+
Interactive,
63+
}
64+
65+
/// A single [`ComputeClient`] presented to the controller over two compute runtimes.
66+
///
67+
/// See the module documentation for the routing and merge policy.
68+
#[derive(Debug)]
69+
pub struct Multiplexer {
70+
/// The runtime that renders durable, maintained collections.
71+
maintenance: Box<dyn ComputeClient>,
72+
/// The runtime that serves ephemeral, interactive peeks.
73+
interactive: Box<dyn ComputeClient>,
74+
/// The transient collections rendered by the interactive runtime, learned from `CreateDataflow`.
75+
///
76+
/// Only interactive-owned transient ids are recorded. Maintenance is the default in `owner_of`,
77+
/// so this is a set rather than a map. An entry is evicted when the collection's
78+
/// `AllowCompaction` reaches the empty frontier, so the set does not grow without bound.
79+
transient_owner: BTreeSet<GlobalId>,
80+
}
81+
82+
impl Multiplexer {
83+
/// Wraps a maintenance and an interactive compute client into one multiplexed client.
84+
pub fn new(maintenance: Box<dyn ComputeClient>, interactive: Box<dyn ComputeClient>) -> Self {
85+
Self {
86+
maintenance,
87+
interactive,
88+
transient_owner: BTreeSet::new(),
89+
}
90+
}
91+
92+
/// Discards all per-connection routing state.
93+
///
94+
/// A `Hello` opens a new protocol epoch: the controller then replays its command history, which
95+
/// re-establishes ownership from the replayed `CreateDataflow`s.
96+
fn reset(&mut self) {
97+
self.transient_owner.clear();
98+
}
99+
100+
/// The runtime that owns `id`. A recorded transient owner wins, otherwise maintenance.
101+
fn owner_of(&self, id: GlobalId) -> Runtime {
102+
if self.transient_owner.contains(&id) {
103+
Runtime::Interactive
104+
} else {
105+
Runtime::Maintenance
106+
}
107+
}
108+
109+
/// A mutable handle to the client for `runtime`.
110+
fn client_mut(&mut self, runtime: Runtime) -> &mut Box<dyn ComputeClient> {
111+
match runtime {
112+
Runtime::Maintenance => &mut self.maintenance,
113+
Runtime::Interactive => &mut self.interactive,
114+
}
115+
}
116+
117+
/// Decides whether a response received from `source` is forwarded to the controller.
118+
///
119+
/// Only `Frontiers` reports are filtered; every other response forwards verbatim.
120+
///
121+
/// Each runtime reports frontiers only for collections it exclusively hosts, so the two streams
122+
/// never overlap on a collection id:
123+
///
124+
/// * The maintenance runtime hosts every durable, maintained collection, plus the internally
125+
/// created logging/introspection indexes, and owns their frontiers. Its transient collections
126+
/// are subscribes and copy-tos, which do not emit `Frontiers` (they report through
127+
/// `SubscribeResponse`/`CopyToResponse`). So maintenance reports frontiers only for
128+
/// non-transient ids.
129+
/// * The interactive runtime hosts only wholly-transient query dataflows. It installs empty
130+
/// copies of maintenance's introspection indexes but does not report their frontiers (see
131+
/// `report_frontiers`, which reports only transient collections on the interactive runtime). So
132+
/// interactive reports frontiers only for transient ids.
133+
///
134+
/// Filtering on `id.is_transient()` for the interactive source captures that split exactly. It
135+
/// deliberately does not consult `transient_owner`: that map is evicted when a collection's
136+
/// `AllowCompaction{empty}` drop is forwarded, which races ahead of the collection's final
137+
/// (empty) frontier reports. Gating on ownership would drop those trailing reports, so the
138+
/// controller would never observe the collection's frontiers reach the empty antichain, would
139+
/// never run `cleanup_collections` for it, and would strand its read holds on its inputs (a stale
140+
/// `since` on any upstream index/MV the transient read). Forwarding on `is_transient()` delivers
141+
/// every frontier report for the collections interactive owns, terminal or not.
142+
fn filter_response(
143+
&self,
144+
source: Runtime,
145+
response: ComputeResponse,
146+
) -> Option<ComputeResponse> {
147+
match response {
148+
ComputeResponse::Frontiers(id, frontiers) => {
149+
let forward = match source {
150+
Runtime::Maintenance => true,
151+
Runtime::Interactive => id.is_transient(),
152+
};
153+
forward.then_some(ComputeResponse::Frontiers(id, frontiers))
154+
}
155+
other => Some(other),
156+
}
157+
}
158+
}
159+
160+
#[async_trait]
161+
impl GenericClient<ComputeCommand, ComputeResponse> for Multiplexer {
162+
async fn send(&mut self, command: ComputeCommand) -> Result<(), anyhow::Error> {
163+
use ComputeCommand::*;
164+
165+
match command {
166+
// Lifecycle commands drive both runtimes. Send to maintenance first, then interactive.
167+
// A failure on either surfaces via `?` rather than being swallowed.
168+
cmd @ Hello { .. } => {
169+
self.reset();
170+
self.maintenance.send(cmd.clone()).await?;
171+
self.interactive.send(cmd).await?;
172+
}
173+
cmd @ (CreateInstance(_) | InitializationComplete | UpdateConfiguration(_)) => {
174+
self.maintenance.send(cmd.clone()).await?;
175+
self.interactive.send(cmd).await?;
176+
}
177+
CreateDataflow(desc) => {
178+
// Interactive serves a dataflow only when it is wholly transient, has a bounded
179+
// (non-empty) `until`, and carries no subscribe or copy-to sink. Transience is
180+
// required, not just a finite `until`: a durable dataflow can also get a finite
181+
// `until` (a `REFRESH AT` materialized view sets it to the last refresh, see
182+
// `create_materialized_view.rs`), and `filter_response` forwards interactive's
183+
// frontier reports only for transient ids. Routing such a dataflow to interactive
184+
// would make its frontier reports get dropped by that gate, so it must stay on
185+
// maintenance regardless of `until`. A finite `until` alone marks the dataflow as
186+
// an ephemeral read that stops on its own, safe to render outside the durable,
187+
// reconciled maintenance runtime. Subscribes stay on maintenance regardless of
188+
// `until`. Copy-to is transient and finite-until too, but it drives an S3 sink and
189+
// is refused by reconciliation, so it is excluded here for that reason, not a
190+
// frontier one.
191+
let to_interactive = desc.is_transient()
192+
&& !desc.until.is_empty()
193+
&& desc.subscribe_ids().next().is_none()
194+
&& desc.copy_to_ids().next().is_none();
195+
if to_interactive {
196+
for id in desc.export_ids() {
197+
self.transient_owner.insert(id);
198+
}
199+
self.interactive.send(CreateDataflow(desc)).await?;
200+
} else {
201+
self.maintenance.send(CreateDataflow(desc)).await?;
202+
}
203+
}
204+
Schedule(id) => {
205+
let runtime = self.owner_of(id);
206+
self.client_mut(runtime).send(Schedule(id)).await?;
207+
}
208+
AllowWrites(id) => {
209+
let runtime = self.owner_of(id);
210+
self.client_mut(runtime).send(AllowWrites(id)).await?;
211+
}
212+
AllowCompaction { id, frontier } => {
213+
let runtime = self.owner_of(id);
214+
// The empty frontier drops the collection. Evict its ownership after forwarding so
215+
// `transient_owner` does not grow without bound.
216+
let dropping = frontier.is_empty();
217+
let evict = dropping && self.transient_owner.contains(&id);
218+
219+
// Forwarded verbatim. The frontier is never modified: an importing dataflow's read is
220+
// protected by the standing hold the broadcast below advances, not by withholding
221+
// compaction here. That is also what removes the regression hazard a cap carries,
222+
// since the command history derives a dataflow's effective `as_of` from the last
223+
// frontier seen per export.
224+
self.client_mut(runtime)
225+
.send(AllowCompaction {
226+
id,
227+
frontier: frontier.clone(),
228+
})
229+
.await?;
230+
231+
// Broadcast to interactive as well, where the frontier advances the standing hold on
232+
// the shared arrangement rather than compacting a local trace. This is what puts the
233+
// create and the compactions that follow it on one ordered stream for the runtime that
234+
// renders the importing dataflow, so a compaction interactive has not applied cannot
235+
// advance the arrangement's `since` past the `as_of` of a create still queued there.
236+
//
237+
// Only for the collections interactive can import, which are the non-transient ones
238+
// maintenance publishes. Maintenance also owns transient collections, its subscribes
239+
// and copy-tos, and those are sinks with no arrangement for anything to import. Sending
240+
// one to interactive would hand it a frontier for a collection it has never installed,
241+
// and the drop in that sequence would ask it to drop what it does not have.
242+
if runtime == Runtime::Maintenance && !id.is_transient() {
243+
self.interactive
244+
.send(AllowCompaction { id, frontier })
245+
.await?;
246+
}
247+
248+
if evict {
249+
self.transient_owner.remove(&id);
250+
}
251+
}
252+
Peek(peek) => {
253+
// Every peek is served by interactive.
254+
self.interactive.send(Peek(peek)).await?;
255+
}
256+
CancelPeek { uuid } => {
257+
// The peek lives on interactive, so its cancellation goes there too.
258+
self.interactive.send(CancelPeek { uuid }).await?;
259+
}
260+
}
261+
262+
Ok(())
263+
}
264+
265+
/// # Cancel safety
266+
///
267+
/// This method is cancel safe. It `select!`s over the two inner `recv`s, each of which is
268+
/// cancel safe: dropping the non-selected branch loses no message, and dropping the whole
269+
/// future (the caller cancelling us) drops both inner futures without loss. The only value
270+
/// taken from an inner client is returned or dropped synchronously, with no intervening await,
271+
/// so a cancellation can never strand a response.
272+
///
273+
/// This method never sends, so nothing here can be stranded half-done by a cancellation.
274+
async fn recv(&mut self) -> Result<Option<ComputeResponse>, anyhow::Error> {
275+
loop {
276+
let (source, response) = tokio::select! {
277+
r = self.maintenance.recv() => (Runtime::Maintenance, r?),
278+
r = self.interactive.recv() => (Runtime::Interactive, r?),
279+
};
280+
match response {
281+
// Either runtime terminating ends the multiplexed endpoint. The caller must then
282+
// drop this client, matching the process's all-or-nothing runtime lifecycle.
283+
None => return Ok(None),
284+
Some(response) => {
285+
if let Some(forward) = self.filter_response(source, response) {
286+
return Ok(Some(forward));
287+
}
288+
// A dropped duplicate `PeekResponse` or a non-owner frontier report. Poll again
289+
// for the next response.
290+
}
291+
}
292+
}
293+
}
294+
}
295+
296+
#[cfg(test)]
297+
mod tests;

0 commit comments

Comments
 (0)