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