Skip to main content

mz_clusterd_test_driver/
driver.rs

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//! The headless `Driver`: the mechanism's top-level API. Use cases call it.
11
12use std::time::Duration;
13
14use mz_compute_client::protocol::command::{
15    ComputeCommand, ComputeParameters, InstanceConfig, Peek, PeekTarget,
16};
17use mz_compute_client::protocol::response::{FrontiersResponse, PeekResponse};
18use mz_compute_types::dataflows::DataflowDescription;
19use mz_compute_types::dyncfgs::ENABLE_PEEK_RESPONSE_STASH;
20use mz_compute_types::plan::render_plan::RenderPlan;
21use mz_dyncfg::ConfigUpdates;
22use mz_expr::{MapFilterProject, RowSetFinishing};
23use mz_ore::tracing::OpenTelemetryContext;
24use mz_repr::{GlobalId, IntoRowIterator, RelationDesc, Row, RowIterator, Timestamp};
25use mz_storage_types::controller::CollectionMetadata;
26use timely::progress::Antichain;
27
28use crate::ctp::connect_and_hello;
29use crate::persist_host::PersistHost;
30use crate::responses::{ComputeSender, Responses};
31
32/// Headless frontend to a clusterd replica.
33pub struct Driver {
34    pub host: PersistHost,
35    compute_addr: String,
36    sender: ComputeSender,
37    responses: Responses,
38}
39
40impl Driver {
41    /// Connects to `compute_addr`, sends `Hello`, and starts the response pump.
42    /// `host` provides persist + pubsub. The controller handshake proper
43    /// (`create_instance`, `update_configuration`, `InitializationComplete`) is
44    /// driven by the caller — see the script commands of the same name.
45    pub async fn connect(host: PersistHost, compute_addr: &str) -> anyhow::Result<Self> {
46        let client = connect_and_hello(compute_addr).await?;
47        let (responses, sender) = Responses::spawn(client);
48        Ok(Driver {
49            host,
50            compute_addr: compute_addr.to_string(),
51            sender,
52            responses,
53        })
54    }
55
56    /// Drops the current connection and opens a new one, sending only `Hello`, so
57    /// the caller can re-`create_instance`, replay the dataflows it expects the
58    /// replica to be running, and then send `InitializationComplete` to close the
59    /// reconciliation window.
60    ///
61    /// Replacing `sender` drops the previous [`ComputeSender`]; with no other
62    /// clones, the old pump task's command channel closes and the pump exits,
63    /// dropping the old CTP client and closing the old connection.
64    pub async fn reconnect(&mut self) -> anyhow::Result<()> {
65        let client = connect_and_hello(&self.compute_addr).await?;
66        let (responses, sender) = Responses::spawn(client);
67        self.responses = responses;
68        self.sender = sender;
69        Ok(())
70    }
71
72    /// Sends `CreateInstance`, opening the compute instance (and the reconciliation
73    /// window).
74    ///
75    /// `expiration_offset`, `arrangement_dictionary_compression`, and `initial_config` are the
76    /// caller-settable [`InstanceConfig`] knobs; `logging` is left at its default
77    /// (introspection logging off — enabling it safely needs `index_logs` wiring)
78    /// and `peek_stash_persist_location` is necessarily the host's, since the driver
79    /// hosts persist.
80    ///
81    /// `initial_config` is the create-time configuration snapshot the controller would build from
82    /// its synced dyncfg. The replica applies it before create-time setup, so a script can assert
83    /// that create-time work observes synced values rather than defaults. The peek-response stash
84    /// is always force-disabled on top of it: the driver reads peek results inline, so a stashed
85    /// peek would break [`Self::peek`]/`count`. It is patched here rather than exposed as a knob,
86    /// so neither `initial_config` nor a later `update-configuration` can turn it back on.
87    pub fn create_instance(
88        &self,
89        expiration_offset: Option<Duration>,
90        arrangement_dictionary_compression: bool,
91        mut initial_config: ConfigUpdates,
92    ) -> anyhow::Result<()> {
93        initial_config.add(&ENABLE_PEEK_RESPONSE_STASH, false);
94        self.send(ComputeCommand::CreateInstance(Box::new(InstanceConfig {
95            logging: Default::default(),
96            expiration_offset,
97            peek_stash_persist_location: self.host.location().clone(),
98            arrangement_dictionary_compression,
99            initial_config,
100        })))?;
101        let mut dyncfg_updates = ConfigUpdates::default();
102        dyncfg_updates.add(&ENABLE_PEEK_RESPONSE_STASH, false);
103        self.send(ComputeCommand::UpdateConfiguration(Box::new(
104            ComputeParameters {
105                dyncfg_updates,
106                ..Default::default()
107            },
108        )))
109    }
110
111    /// Sends `UpdateConfiguration` with a set of dyncfg updates assembled by the
112    /// caller. Generic over any configuration; the peek-response stash is not among
113    /// them — it is force-disabled in [`Self::create_instance`].
114    pub fn update_configuration(&self, dyncfg_updates: ConfigUpdates) -> anyhow::Result<()> {
115        self.send(ComputeCommand::UpdateConfiguration(Box::new(
116            ComputeParameters {
117                dyncfg_updates,
118                ..Default::default()
119            },
120        )))
121    }
122
123    /// Sends a raw `ComputeCommand`. The primitive behind every interaction;
124    /// use cases drive side effects (`AllowCompaction`, `CancelPeek`, ...) through
125    /// this without the mechanism interpreting them.
126    pub fn send(&self, cmd: ComputeCommand) -> anyhow::Result<()> {
127        self.sender.send(cmd)
128    }
129
130    /// Submits a dataflow. Does NOT schedule it — the caller decides when to
131    /// `schedule`, so side-effect timing stays under test control.
132    pub fn submit_dataflow(
133        &self,
134        df: DataflowDescription<RenderPlan, CollectionMetadata>,
135    ) -> anyhow::Result<()> {
136        self.send(ComputeCommand::CreateDataflow(Box::new(df)))
137    }
138
139    /// Schedules a previously-submitted collection, allowing it to make progress.
140    pub fn schedule(&self, id: GlobalId) -> anyhow::Result<()> {
141        self.send(ComputeCommand::Schedule(id))
142    }
143
144    /// A receiver for an id's full merged frontiers, for use cases that need
145    /// write/input frontiers rather than just output.
146    pub fn frontiers(&self, id: GlobalId) -> tokio::sync::watch::Receiver<FrontiersResponse> {
147        self.responses.frontier(id)
148    }
149
150    /// Waits until `id`'s output frontier reaches at least `target`, or fails.
151    pub async fn expect_frontier(
152        &self,
153        id: GlobalId,
154        target: Timestamp,
155        timeout: Duration,
156    ) -> anyhow::Result<()> {
157        let mut rx = self.responses.frontier(id);
158        let want = Antichain::from_elem(target);
159        tokio::time::timeout(timeout, async {
160            loop {
161                let reached = rx
162                    .borrow_and_update()
163                    .output_frontier
164                    .as_ref()
165                    .is_some_and(|of| timely::PartialOrder::less_equal(&want, of));
166                if reached {
167                    return;
168                }
169                if rx.changed().await.is_err() {
170                    // The watch sender is gone (pump exited, e.g. clusterd
171                    // disconnected). The frontier can no longer advance, so
172                    // park and let the outer timeout fire with its message.
173                    futures::future::pending::<()>().await;
174                }
175            }
176        })
177        .await
178        .map_err(|_| anyhow::anyhow!("frontier for {id} did not reach {target:?} in time"))
179    }
180
181    /// Peeks `target` at `ts`, returning the decoded rows. The target is an index
182    /// (served from the replica's arrangement) or a persist collection — notably a
183    /// materialized-view sink's output shard, which is how `SELECT * FROM mv` reads.
184    /// A persist peek blocks (async-friendly) until the shard seals through `ts`, so
185    /// it doubles as a wait for the writing sink to catch up.
186    pub async fn peek(
187        &self,
188        target: PeekTarget,
189        result_desc: RelationDesc,
190        ts: Timestamp,
191    ) -> anyhow::Result<Vec<Row>> {
192        let uuid = uuid::Uuid::new_v4();
193        let rx = self.responses.register_peek(uuid);
194        let arity = result_desc.arity();
195        // Build an identity MFP: no maps, no filters, project all columns.
196        let map_filter_project = MapFilterProject::new(arity)
197            .into_plan()
198            .map_err(|e| anyhow::anyhow!("failed to plan MFP: {e}"))?
199            .into_nontemporal()
200            .map_err(|_| anyhow::anyhow!("unexpected temporal MFP for identity plan"))?;
201        let peek = Peek {
202            target,
203            result_desc: result_desc.clone(),
204            literal_constraints: None,
205            uuid,
206            timestamp: ts,
207            finishing: RowSetFinishing::trivial(arity),
208            map_filter_project,
209            otel_ctx: OpenTelemetryContext::empty(),
210        };
211        self.send(ComputeCommand::Peek(Box::new(peek)))?;
212        match rx.await? {
213            PeekResponse::Rows(collections) => {
214                let mut rows = Vec::new();
215                for collection in collections {
216                    let mut iter = collection.into_row_iter();
217                    while let Some(row_ref) = iter.next() {
218                        rows.push(row_ref.to_owned());
219                    }
220                }
221                Ok(rows)
222            }
223            PeekResponse::Error(e) => anyhow::bail!("peek error: {e}"),
224            PeekResponse::Canceled => anyhow::bail!("peek canceled"),
225            PeekResponse::Stashed(_) => anyhow::bail!("unexpected stashed peek result"),
226        }
227    }
228
229    /// Convenience: total row count from a peek.
230    pub async fn peek_count(
231        &self,
232        target: PeekTarget,
233        result_desc: RelationDesc,
234        ts: Timestamp,
235    ) -> anyhow::Result<usize> {
236        Ok(self.peek(target, result_desc, ts).await?.len())
237    }
238
239    /// Registers a subscribe-sink buffer for `id`, so the response pump accumulates
240    /// its batches. Call this before scheduling the sink.
241    pub fn register_subscribe(&self, id: GlobalId) {
242        let _ = self.responses.ensure_subscribe(id);
243    }
244
245    /// Waits until subscribe `id`'s upper frontier reaches at least `up_to`, then
246    /// drains and returns its buffered `(row, time, diff)` updates. Fails on timeout
247    /// or if the replica reported a subscribe error.
248    pub async fn await_subscribe(
249        &self,
250        id: GlobalId,
251        up_to: Timestamp,
252        timeout: Duration,
253    ) -> anyhow::Result<Vec<(Row, Timestamp, i64)>> {
254        let mut rx = self.responses.ensure_subscribe(id);
255        let want = Antichain::from_elem(up_to);
256        tokio::time::timeout(timeout, async {
257            loop {
258                // An empty upper (the subscribe was dropped / completed) is past any
259                // finite target, so `less_equal` against it also unblocks.
260                let reached = timely::PartialOrder::less_equal(&want, &*rx.borrow_and_update());
261                if reached {
262                    return;
263                }
264                if rx.changed().await.is_err() {
265                    // The pump exited (clusterd disconnected); the upper can no longer
266                    // advance, so park and let the outer timeout fire.
267                    futures::future::pending::<()>().await;
268                }
269            }
270        })
271        .await
272        .map_err(|_| anyhow::anyhow!("subscribe {id} did not reach {up_to:?} in time"))?;
273        self.responses.drain_subscribe(id)
274    }
275}