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}