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}