Skip to content

Commit 287fc37

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 0225ab1 commit 287fc37

4 files changed

Lines changed: 1258 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: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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, and the multiplexer never modifies a frontier.
27+
//!
28+
//! The only state is which runtime renders each transient collection (`transient_owner`). It is
29+
//! per-connection and discarded by `Hello`, see `Multiplexer::reset`.
30+
//!
31+
//! The multiplexer does not deduplicate peek responses. The exactly-one-`PeekResponse`-per-uuid
32+
//! contract is already upheld below and above it: the per-worker `PartitionedComputeState` inside
33+
//! each process collapses a cancel-versus-complete split across that process's workers into one
34+
//! response, and the controller's per-process `PartitionedComputeState` merges one response per
35+
//! process. Peeks route only to the interactive runtime, so the multiplexer receives exactly one
36+
//! `PeekResponse` per uuid and forwards it verbatim. A multiplexer on a non-zero process never
37+
//! observes the originating `Peek` command anyway (commands other than `Hello`/`UpdateConfiguration`
38+
//! are sent to process 0 only, reaching other processes' workers through the intra-runtime command
39+
//! channel), so it cannot gate responses on having seen the command.
40+
41+
use std::collections::BTreeSet;
42+
43+
use async_trait::async_trait;
44+
use mz_repr::GlobalId;
45+
use mz_service::client::GenericClient;
46+
47+
use crate::protocol::command::ComputeCommand;
48+
use crate::protocol::response::ComputeResponse;
49+
use crate::service::ComputeClient;
50+
51+
/// Which of a process's two compute runtimes a piece of work lives on.
52+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53+
enum Runtime {
54+
/// The runtime that renders durable, maintained collections.
55+
Maintenance,
56+
/// The runtime that serves ephemeral, interactive peeks.
57+
Interactive,
58+
}
59+
60+
/// A single [`ComputeClient`] presented to the controller over two compute runtimes.
61+
///
62+
/// See the module documentation for the routing and merge policy.
63+
#[derive(Debug)]
64+
pub struct Multiplexer {
65+
/// The runtime that renders durable, maintained collections.
66+
maintenance: Box<dyn ComputeClient>,
67+
/// The runtime that serves ephemeral, interactive peeks.
68+
interactive: Box<dyn ComputeClient>,
69+
/// The transient collections rendered by the interactive runtime, learned from `CreateDataflow`.
70+
///
71+
/// Only interactive-owned transient ids are recorded. Maintenance is the default in `owner_of`,
72+
/// so this is a set rather than a map. An entry is evicted when the collection's
73+
/// `AllowCompaction` reaches the empty frontier, so the set does not grow without bound.
74+
transient_owner: BTreeSet<GlobalId>,
75+
}
76+
77+
impl Multiplexer {
78+
/// Wraps a maintenance and an interactive compute client into one multiplexed client.
79+
pub fn new(maintenance: Box<dyn ComputeClient>, interactive: Box<dyn ComputeClient>) -> Self {
80+
Self {
81+
maintenance,
82+
interactive,
83+
transient_owner: BTreeSet::new(),
84+
}
85+
}
86+
87+
/// Discards all per-connection routing state.
88+
///
89+
/// A `Hello` opens a new protocol epoch: the controller then replays its command history, which
90+
/// re-establishes ownership from the replayed `CreateDataflow`s.
91+
fn reset(&mut self) {
92+
self.transient_owner.clear();
93+
}
94+
95+
/// The runtime that owns `id`. A recorded transient owner wins, otherwise maintenance.
96+
fn owner_of(&self, id: GlobalId) -> Runtime {
97+
if self.transient_owner.contains(&id) {
98+
Runtime::Interactive
99+
} else {
100+
Runtime::Maintenance
101+
}
102+
}
103+
104+
/// A mutable handle to the client for `runtime`.
105+
fn client_mut(&mut self, runtime: Runtime) -> &mut Box<dyn ComputeClient> {
106+
match runtime {
107+
Runtime::Maintenance => &mut self.maintenance,
108+
Runtime::Interactive => &mut self.interactive,
109+
}
110+
}
111+
112+
/// Decides whether a response received from `source` is forwarded to the controller.
113+
///
114+
/// Only `Frontiers` reports are filtered; every other response forwards verbatim.
115+
///
116+
/// Each runtime reports frontiers only for collections it exclusively hosts, so the two streams
117+
/// never overlap on a collection id:
118+
///
119+
/// * The maintenance runtime hosts every durable, maintained collection, plus the internally
120+
/// created logging/introspection indexes, and owns their frontiers. Its transient collections
121+
/// are subscribes and copy-tos, which do not emit `Frontiers` (they report through
122+
/// `SubscribeResponse`/`CopyToResponse`). So maintenance reports frontiers only for
123+
/// non-transient ids.
124+
/// * The interactive runtime hosts only wholly-transient query dataflows. It installs empty
125+
/// copies of maintenance's introspection indexes but does not report their frontiers (see
126+
/// `report_frontiers`, which reports only transient collections on the interactive runtime). So
127+
/// interactive reports frontiers only for transient ids.
128+
///
129+
/// Filtering on `id.is_transient()` for the interactive source captures that split exactly. It
130+
/// deliberately does not consult `transient_owner`: that map is evicted when a collection's
131+
/// `AllowCompaction{empty}` drop is forwarded, which races ahead of the collection's final
132+
/// (empty) frontier reports. Gating on ownership would drop those trailing reports, so the
133+
/// controller would never observe the collection's frontiers reach the empty antichain, would
134+
/// never run `cleanup_collections` for it, and would strand its read holds on its inputs (a stale
135+
/// `since` on any upstream index/MV the transient read). Forwarding on `is_transient()` delivers
136+
/// every frontier report for the collections interactive owns, terminal or not.
137+
fn filter_response(
138+
&self,
139+
source: Runtime,
140+
response: ComputeResponse,
141+
) -> Option<ComputeResponse> {
142+
match response {
143+
ComputeResponse::Frontiers(id, frontiers) => {
144+
let forward = match source {
145+
Runtime::Maintenance => true,
146+
Runtime::Interactive => id.is_transient(),
147+
};
148+
forward.then_some(ComputeResponse::Frontiers(id, frontiers))
149+
}
150+
other => Some(other),
151+
}
152+
}
153+
}
154+
155+
#[async_trait]
156+
impl GenericClient<ComputeCommand, ComputeResponse> for Multiplexer {
157+
async fn send(&mut self, command: ComputeCommand) -> Result<(), anyhow::Error> {
158+
use ComputeCommand::*;
159+
160+
match command {
161+
// Lifecycle commands drive both runtimes. Send to maintenance first, then interactive.
162+
// A failure on either surfaces via `?` rather than being swallowed.
163+
cmd @ Hello { .. } => {
164+
self.reset();
165+
self.maintenance.send(cmd.clone()).await?;
166+
self.interactive.send(cmd).await?;
167+
}
168+
cmd @ (CreateInstance(_) | InitializationComplete | UpdateConfiguration(_)) => {
169+
self.maintenance.send(cmd.clone()).await?;
170+
self.interactive.send(cmd).await?;
171+
}
172+
CreateDataflow(desc) => {
173+
// Interactive serves the dataflows that exist only to answer one peek. Each clause
174+
// of `is_peek_dataflow` earns its place here: transience because `filter_response`
175+
// forwards interactive's frontier reports only for transient ids, so a durable
176+
// collection rendered here would have its reports dropped; single-time because the
177+
// shared-index import is a snapshot bounded one step past `as_of` and cannot feed a
178+
// dataflow that runs further; no subscribe because a subscribe never stops; no
179+
// copy-to because reconciliation refuses its S3 sink.
180+
if desc.is_peek_dataflow() {
181+
// A peek dataflow reads published maintenance indexes, never another temporary
182+
// collection, so routing needs to consider only this description. Nothing
183+
// enforces that: were a peek dataflow ever to import a transient id, its
184+
// producer might sit on the other runtime, where this import cannot reach it.
185+
// Fail loudly rather than render something that silently finds no input.
186+
//
187+
// TODO(CPU-216): the durable fix is for the control plane to name the runtime
188+
// in the dataflow description, since placement is its concern. That surfaces
189+
// placement in the protocol, so it is deferred rather than folded in here.
190+
mz_ore::soft_assert_or_log!(
191+
desc.import_ids().all(|id| !id.is_transient()),
192+
"peek dataflow imports a transient collection: exports={} imports={}",
193+
desc.display_export_ids(),
194+
desc.display_import_ids(),
195+
);
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 non-owner frontier report was dropped. Poll again for the next response.
289+
}
290+
}
291+
}
292+
}
293+
}
294+
295+
#[cfg(test)]
296+
mod tests;

0 commit comments

Comments
 (0)