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