mz_compute_client/controller/sequential_hydration.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//! Sequential dataflow hydration support for replicas.
11//!
12//! Sequential hydration enforces a configurable "hydration concurrency" that limits how many
13//! dataflows may be hydrating at the same time. Limiting hydrating concurrency can be beneficial
14//! in reducing peak memory usage, cross-dataflow thrashing, and hydration time.
15//!
16//! The configured hydration concurrency is enforced by delaying the delivery of `Schedule` compute
17//! commands to the replica. Those commands are emitted by the controller for collections that
18//! become ready to hydrate (based on availability of input data) and are directly applied by
19//! replicas by unsuspending the corresponding dataflows. Delaying `Schedule` commands allows us to
20//! ensure only a limited number of dataflows can hydrate at the same time.
21//!
22//! Note that a dataflow may export multiple collections. `Schedule` commands are produced per
23//! collection but hydration is a dataflow-level mechanism. In practice Materialize today only
24//! produces dataflow with a single export and we rely on this assumption here to simplify the
25//! implementation. If the assumption ever ceases to hold, we will need to adjust the code in this
26//! module.
27//!
28//! Sequential hydration is enforced by a `SequentialHydration` interceptor that sits between the
29//! controller and the `PartitionedState` client that splits commands across replica processes.
30//! This location is important:
31//!
32//! * It needs to be behind the controller since hydration is a per-replica mechanism. Different
33//! replicas can progress through hydration at different paces.
34//! * It needs to be before the `PartitionedState` client because all replica workers must see
35//! `Schedule` commands in the same order. Otherwise we risk getting stuck when different
36//! workers hydrate different dataflows and wait on each other for progress in these dataflows.
37//! * It also needs to be before the `PartitionedState` client because it needs to be able to
38//! observe all compute commands. Clients behind `PartitionedState` are not guaranteed to do so,
39//! since commands are only forwarded to the first process.
40//!
41//! `SequentialHydration` is a synchronous interceptor: the replica task feeds it every command it
42//! is about to send and every response it receives, and the interceptor returns the commands that
43//! should actually be sent to the replica. The task is responsible for sending those commands, so
44//! the interceptor holds no client and spawns no task of its own.
45
46use std::collections::{BTreeMap, VecDeque};
47use std::sync::Arc;
48
49use mz_compute_types::dyncfgs::HYDRATION_CONCURRENCY;
50use mz_dyncfg::ConfigSet;
51use mz_ore::cast::CastFrom;
52use mz_ore::collections::CollectionExt;
53use mz_ore::soft_assert_eq_or_log;
54use mz_repr::{GlobalId, Timestamp};
55use timely::PartialOrder;
56use timely::progress::Antichain;
57use tracing::debug;
58
59use crate::metrics::ReplicaMetrics;
60use crate::protocol::command::ComputeCommand;
61use crate::protocol::response::{ComputeResponse, FrontiersResponse};
62
63/// A shareable token.
64type Token = Arc<()>;
65
66/// An interceptor enforcing sequential dataflow hydration.
67///
68/// The replica task drives this interceptor by feeding it the commands it intends to send (via
69/// [`SequentialHydration::absorb_command`]) and the responses it receives (via
70/// [`SequentialHydration::observe_response`]). Both methods return the commands the task should
71/// send to the replica, with `Schedule` commands held back or released according to the configured
72/// hydration concurrency.
73///
74/// Both methods take the replica's effective configuration, which the task owns and keeps current.
75/// Reading [`HYDRATION_CONCURRENCY`] from there rather than from the controller's environment-wide
76/// set is what makes its `Replica` scope effective, given that the config is enforced here and
77/// never read on the replica itself.
78#[derive(Debug)]
79pub(super) struct SequentialHydration {
80 /// Tracked metrics.
81 metrics: ReplicaMetrics,
82 /// Tracked collections.
83 ///
84 /// Entries are inserted in response to observed `CreateDataflow` commands.
85 /// Entries are removed in response to `Frontiers` commands that report collection
86 /// hydration, or in response to `AllowCompaction` commands that specify the empty frontier.
87 collections: BTreeMap<GlobalId, Collection>,
88 /// A queue of scheduled collections that are awaiting hydration.
89 hydration_queue: VecDeque<GlobalId>,
90 /// A token held by hydrating collections.
91 ///
92 /// Useful to efficiently determine how many collections are currently in the process of
93 /// hydration, and thus how much capacity is available.
94 hydration_token: Token,
95}
96
97impl SequentialHydration {
98 /// Create a new `SequentialHydration` interceptor.
99 pub(super) fn new(metrics: ReplicaMetrics) -> Self {
100 Self {
101 metrics,
102 collections: Default::default(),
103 hydration_queue: Default::default(),
104 hydration_token: Default::default(),
105 }
106 }
107
108 /// Return the number of hydrating collections.
109 fn hydration_count(&self) -> usize {
110 Arc::strong_count(&self.hydration_token) - 1
111 }
112
113 /// Absorb a command the task intends to send, returning the commands it should actually send.
114 ///
115 /// `dyncfg` is the replica's effective configuration, as maintained by the task.
116 pub(super) fn absorb_command(
117 &mut self,
118 cmd: ComputeCommand,
119 dyncfg: &ConfigSet,
120 ) -> Vec<ComputeCommand> {
121 // Whether to forward this command to the replica.
122 let mut forward = true;
123
124 match &cmd {
125 // We enforce sequential hydration only for non-transient dataflows, assuming that
126 // transient dataflows are created for interactive user queries and should always be
127 // scheduled as soon as possible.
128 ComputeCommand::CreateDataflow(dataflow) if !dataflow.is_transient() => {
129 let export_ids: Vec<_> = dataflow.export_ids().collect();
130 let id = export_ids.expect_element(|| "multi-export dataflows are not supported");
131 let as_of = dataflow.as_of.clone().unwrap();
132
133 debug!(%id, ?as_of, "tracking collection");
134 self.collections.insert(id, Collection::new(as_of));
135 }
136 ComputeCommand::Schedule(id) => {
137 if let Some(collection) = self.collections.get_mut(id) {
138 debug!(%id, "enqueuing collection for hydration");
139 self.hydration_queue.push_back(*id);
140 collection.set_scheduled();
141 forward = false;
142 }
143 }
144 ComputeCommand::AllowCompaction { id, frontier } if frontier.is_empty() => {
145 // The collection was dropped by the controller. Remove it from the tracking state
146 // to ensure we don't produce any more commands for it.
147 if self.collections.remove(id).is_some() {
148 debug!(%id, "collection dropped");
149 }
150 }
151 _ => (),
152 }
153
154 let mut commands = Vec::new();
155 if forward {
156 commands.push(cmd);
157 }
158
159 // Schedule collections that are ready now.
160 commands.extend(self.hydrate_collections(dyncfg));
161 commands
162 }
163
164 /// Observe a response the task received, returning the commands it should send in reaction.
165 ///
166 /// `dyncfg` is the replica's effective configuration, as maintained by the task.
167 pub(super) fn observe_response(
168 &mut self,
169 resp: &ComputeResponse,
170 dyncfg: &ConfigSet,
171 ) -> Vec<ComputeCommand> {
172 let mut commands = Vec::new();
173
174 if let ComputeResponse::Frontiers(
175 id,
176 FrontiersResponse {
177 output_frontier: Some(frontier),
178 ..
179 },
180 ) = resp
181 {
182 if let Some(collection) = self.collections.remove(id) {
183 let hydrated = PartialOrder::less_than(&collection.as_of, frontier);
184 if hydrated || frontier.is_empty() {
185 debug!(%id, "collection hydrated");
186
187 // Note that it is possible to observe hydration even for collections for which
188 // we never sent a `Schedule` command, if the replica decided to not suspend
189 // the dataflow after creation. The compute protocol does not require replicas
190 // to create dataflows in suspended state. It seems like a good idea to still
191 // send a `Schedule` command in this case, rather than swallowing it, to make
192 // the protocol communication more predicatable.
193
194 match collection.state {
195 State::Created => {
196 // We haven't seen a `Schedule` command yet, so no obligations to send
197 // one either.
198 }
199 State::QueuedForHydration => {
200 // We are holding back the `Schedule` command for this collection. Send
201 // it now.
202 commands.push(ComputeCommand::Schedule(*id));
203 }
204 State::Hydrating(token) => {
205 // We freed some hydration capacity and may be able to start hydrating
206 // new collections.
207 drop(token);
208 commands.extend(self.hydrate_collections(dyncfg));
209 }
210 }
211 } else {
212 self.collections.insert(*id, collection);
213 }
214 }
215 }
216
217 commands
218 }
219
220 /// Allow hydration based on the available capacity, returning the `Schedule` commands to send.
221 fn hydrate_collections(&mut self, dyncfg: &ConfigSet) -> Vec<ComputeCommand> {
222 let mut commands = Vec::new();
223
224 let capacity = HYDRATION_CONCURRENCY.get(dyncfg);
225 while self.hydration_count() < capacity {
226 let Some(id) = self.hydration_queue.pop_front() else {
227 // Hydration queue is empty.
228 break;
229 };
230 let Some(collection) = self.collections.get_mut(&id) else {
231 // Collection has already been dropped.
232 continue;
233 };
234
235 debug!(%id, "starting collection hydration");
236 commands.push(ComputeCommand::Schedule(id));
237
238 let token = Arc::clone(&self.hydration_token);
239 collection.set_hydrating(token);
240 }
241
242 let queue_size = u64::cast_from(self.hydration_queue.len());
243 self.metrics.inner.hydration_queue_size.set(queue_size);
244
245 commands
246 }
247}
248
249/// Information about a tracked collection.
250#[derive(Debug)]
251struct Collection {
252 /// The as-of frontier at collection creation.
253 as_of: Antichain<Timestamp>,
254 /// The current state of the collection.
255 state: State,
256}
257
258impl Collection {
259 /// Create a new `Collection`.
260 fn new(as_of: Antichain<Timestamp>) -> Self {
261 Self {
262 as_of,
263 state: State::Created,
264 }
265 }
266
267 /// Advance this collection's state to `Scheduled`.
268 fn set_scheduled(&mut self) {
269 soft_assert_eq_or_log!(self.state, State::Created);
270 self.state = State::QueuedForHydration;
271 }
272
273 fn set_hydrating(&mut self, token: Token) {
274 soft_assert_eq_or_log!(self.state, State::QueuedForHydration);
275 self.state = State::Hydrating(token);
276 }
277}
278
279/// The state of a tracked collection.
280#[derive(Debug, PartialEq, Eq)]
281enum State {
282 /// Collection has been created and is waiting for a `Schedule` command.
283 Created,
284 /// The collection has received a `Schedule` command and has been added to the hydration queue,
285 /// waiting for hydration capacity.
286 QueuedForHydration,
287 /// Collection is hydrating and waiting for hydration to complete.
288 Hydrating(Token),
289}
290
291#[cfg(test)]
292mod tests {
293 use mz_cluster_client::metrics::ControllerMetrics;
294 use mz_compute_types::ComputeInstanceId;
295 use mz_compute_types::dataflows::{DataflowDescription, IndexDesc};
296 use mz_dyncfg::ConfigUpdates;
297 use mz_ore::metrics::MetricsRegistry;
298 use mz_repr::ReprRelationType;
299
300 use crate::metrics::ComputeControllerMetrics;
301 use crate::protocol::command::ComputeParameters;
302
303 use super::*;
304
305 fn metrics() -> ReplicaMetrics {
306 let registry = MetricsRegistry::new();
307 let shared = ControllerMetrics::new(®istry);
308 ComputeControllerMetrics::new(®istry, shared)
309 .for_instance(ComputeInstanceId::User(1))
310 .for_replica(mz_cluster_client::ReplicaId::User(1))
311 }
312
313 /// A `CreateDataflow` command for a non-transient dataflow exporting `id`.
314 fn create_dataflow(id: GlobalId) -> ComputeCommand {
315 let mut desc = DataflowDescription::new("test".into());
316 desc.as_of = Some(Antichain::from_elem(Timestamp::MIN));
317 desc.index_exports.insert(
318 id,
319 (
320 IndexDesc {
321 on_id: id,
322 key: Vec::new(),
323 },
324 ReprRelationType::empty(),
325 ),
326 );
327 ComputeCommand::CreateDataflow(Box::new(desc))
328 }
329
330 /// The interceptor enforces the hydration concurrency of the configuration it is handed, which
331 /// is the replica's own, specialized by the replica task. This is the regression guard for it
332 /// reading the environment-wide value instead, which would make the config's `Replica` scope
333 /// inert, given that it is enforced here and never read on the replica.
334 #[mz_ore::test]
335 fn hydration_concurrency_follows_supplied_config() {
336 let dyncfg = mz_dyncfgs::all_dyncfgs();
337 let mut updates = ConfigUpdates::default();
338 updates.add(&HYDRATION_CONCURRENCY, 1);
339 updates.apply(&dyncfg);
340
341 let mut hydration = SequentialHydration::new(metrics());
342
343 let id1 = GlobalId::User(1);
344 let id2 = GlobalId::User(2);
345 for id in [id1, id2] {
346 let commands = hydration.absorb_command(create_dataflow(id), &dyncfg);
347 assert_eq!(commands, vec![create_dataflow(id)]);
348 }
349
350 // At a concurrency of one, only the first `Schedule` is released.
351 let commands = hydration.absorb_command(ComputeCommand::Schedule(id1), &dyncfg);
352 assert_eq!(commands, vec![ComputeCommand::Schedule(id1)]);
353 let commands = hydration.absorb_command(ComputeCommand::Schedule(id2), &dyncfg);
354 assert_eq!(commands, vec![]);
355
356 // Raising the concurrency in the supplied configuration releases the held-back command.
357 let mut updates = ConfigUpdates::default();
358 updates.add(&HYDRATION_CONCURRENCY, 2);
359 updates.apply(&dyncfg);
360
361 let update = ComputeCommand::UpdateConfiguration(Box::new(ComputeParameters::default()));
362 let commands = hydration.absorb_command(update.clone(), &dyncfg);
363 assert_eq!(commands, vec![update, ComputeCommand::Schedule(id2)]);
364 }
365}