Skip to main content

mz_compute_client/controller/
replica.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//! A client for replicas of a compute instance.
11
12use std::sync::Arc;
13use std::sync::atomic::{self, AtomicBool};
14use std::time::{Duration, Instant};
15
16use anyhow::bail;
17use mz_build_info::BuildInfo;
18use mz_cluster_client::client::ClusterReplicaLocation;
19use mz_compute_types::dyncfgs::ENABLE_COMPUTE_REPLICA_EXPIRATION;
20use mz_dyncfg::{ConfigSet, ConfigUpdates};
21use mz_ore::channel::InstrumentedUnboundedSender;
22use mz_ore::retry::{Retry, RetryState};
23use mz_ore::task::AbortOnDropHandle;
24use mz_service::client::{GenericClient, Partitioned};
25use mz_service::params::GrpcClientParameters;
26use mz_service::transport;
27use tokio::select;
28use tokio::sync::mpsc::error::SendError;
29use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
30use tracing::{debug, info, trace, warn};
31use uuid::Uuid;
32
33use crate::controller::ReplicaId;
34use crate::controller::instance::ReplicaResponse;
35use crate::controller::sequential_hydration::SequentialHydration;
36use crate::logging::LoggingConfig;
37use crate::metrics::IntCounter;
38use crate::metrics::ReplicaMetrics;
39use crate::protocol::command::ComputeCommand;
40use crate::protocol::response::ComputeResponse;
41
42type Client = Partitioned<ComputeCtpClient, ComputeCommand, ComputeResponse>;
43
44/// Replica-specific configuration.
45#[derive(Clone, Debug)]
46pub(super) struct ReplicaConfig {
47    pub location: ClusterReplicaLocation,
48    pub logging: LoggingConfig,
49    pub grpc_client: GrpcClientParameters,
50    /// The offset to use for replica expiration, if any.
51    pub expiration_offset: Option<Duration>,
52    /// Whether arrangements on this replica use dictionary compression, captured at creation.
53    pub arrangement_dictionary_compression: bool,
54}
55
56/// A client for a replica task.
57#[derive(Debug)]
58pub(super) struct ReplicaClient {
59    /// A sender for commands for the replica.
60    command_tx: UnboundedSender<ComputeCommand>,
61    /// A handle to the task that aborts it when the replica is dropped.
62    ///
63    /// If the task is finished, the replica has failed and needs rehydration.
64    task: AbortOnDropHandle<()>,
65    /// Replica metrics.
66    metrics: ReplicaMetrics,
67    /// Flag reporting whether the replica connection has been established.
68    connected: Arc<AtomicBool>,
69}
70
71impl ReplicaClient {
72    pub(super) fn spawn(
73        id: ReplicaId,
74        build_info: &'static BuildInfo,
75        config: ReplicaConfig,
76        epoch: u64,
77        metrics: ReplicaMetrics,
78        dyncfg: Arc<ConfigSet>,
79        response_tx: InstrumentedUnboundedSender<ReplicaResponse, IntCounter>,
80    ) -> Self {
81        // Launch a task to handle communication with the replica
82        // asynchronously. This isolates the main controller thread from
83        // the replica.
84        let (command_tx, command_rx) = unbounded_channel();
85        let connected = Arc::new(AtomicBool::new(false));
86
87        let task = mz_ore::task::spawn(
88            || format!("active-replication-replica-{id}"),
89            ReplicaTask {
90                replica_id: id,
91                build_info,
92                config: config.clone(),
93                command_rx,
94                response_tx,
95                epoch,
96                metrics: metrics.clone(),
97                connected: Arc::clone(&connected),
98                replica_dyncfg: seed_replica_dyncfg(&dyncfg),
99                dyncfg,
100            }
101            .run(),
102        );
103
104        Self {
105            command_tx,
106            task: task.abort_on_drop(),
107            metrics,
108            connected,
109        }
110    }
111}
112
113impl ReplicaClient {
114    /// Sends a command to this replica.
115    pub(super) fn send(&self, command: ComputeCommand) -> Result<(), SendError<ComputeCommand>> {
116        self.command_tx.send(command).map(|r| {
117            self.metrics.inner.command_queue_size.inc();
118            r
119        })
120    }
121
122    /// Determine if the replica task has failed.
123    pub(super) fn is_failed(&self) -> bool {
124        self.task.is_finished()
125    }
126
127    /// Determine if the replica connection has been established.
128    pub(super) fn is_connected(&self) -> bool {
129        self.connected.load(atomic::Ordering::Relaxed)
130    }
131}
132
133type ComputeCtpClient = transport::Client<ComputeCommand, ComputeResponse>;
134
135/// Creates a replica's effective configuration, seeded from the environment-wide one.
136///
137/// The seed covers the window before the first configuration command arrives, and is replaced
138/// wholesale by the snapshot that `CreateInstance` carries.
139fn seed_replica_dyncfg(dyncfg: &ConfigSet) -> ConfigSet {
140    let replica_dyncfg = mz_dyncfgs::all_dyncfgs();
141    ConfigUpdates::from(dyncfg).apply(&replica_dyncfg);
142    replica_dyncfg
143}
144
145/// Applies the configuration a command carries, if any, to a replica's effective configuration.
146///
147/// `CreateInstance` carries a full snapshot, `UpdateConfiguration` the subsequent deltas.
148fn apply_config_command(command: &ComputeCommand, dyncfg: &ConfigSet) {
149    match command {
150        ComputeCommand::CreateInstance(config) => config.initial_config.apply(dyncfg),
151        ComputeCommand::UpdateConfiguration(params) => params.dyncfg_updates.apply(dyncfg),
152        _ => (),
153    }
154}
155
156/// Configuration for `replica_task`.
157struct ReplicaTask {
158    /// The ID of the replica.
159    replica_id: ReplicaId,
160    /// Replica configuration.
161    config: ReplicaConfig,
162    /// The build information for this process.
163    build_info: &'static BuildInfo,
164    /// A channel upon which commands intended for the replica are delivered.
165    command_rx: UnboundedReceiver<ComputeCommand>,
166    /// A channel upon which responses from the replica are delivered.
167    response_tx: InstrumentedUnboundedSender<ReplicaResponse, IntCounter>,
168    /// A number identifying this incarnation of the replica.
169    /// The semantics of this don't matter, except that it must strictly increase.
170    epoch: u64,
171    /// Replica metrics.
172    metrics: ReplicaMetrics,
173    /// Flag to report successful replica connection.
174    connected: Arc<AtomicBool>,
175    /// The controller's environment-wide dynamic system configuration.
176    dyncfg: Arc<ConfigSet>,
177    /// This replica's effective dynamic system configuration.
178    ///
179    /// Holds what the replica itself reads, including its scoped overrides, as opposed to
180    /// [`Self::dyncfg`], which holds the environment-wide values. Seeded from the environment-wide
181    /// configuration and then kept current from the configuration commands passing through this
182    /// task, which `Instance::specialize_command_for_replica` has already specialized for this
183    /// replica. Read it for any `ParameterScope::Replica` config the controller realizes on this
184    /// replica's behalf, else the scope declaration is a silent no-op.
185    ///
186    /// A set of its own, rather than a clone of the controller's: a cloned `ConfigSet` shares its
187    /// values with the original, so applying this replica's overrides to a clone would overwrite
188    /// the environment-wide configuration for everyone.
189    replica_dyncfg: ConfigSet,
190}
191
192impl ReplicaTask {
193    /// Asynchronously forwards commands to and responses from a single replica.
194    async fn run(self) {
195        let replica_id = self.replica_id;
196        info!(replica = ?replica_id, "starting replica task");
197
198        let client = self.connect().await;
199        match self.run_message_loop(client).await {
200            Ok(()) => info!(replica = ?replica_id, "stopped replica task"),
201            Err(error) => warn!(replica = ?replica_id, "replica task failed: {error:#}"),
202        }
203    }
204
205    /// Connects to the replica.
206    ///
207    /// The connection is retried forever (with backoff) and this method returns only after
208    /// a connection was successfully established.
209    async fn connect(&self) -> Client {
210        let try_connect = async |retry: RetryState| {
211            let version = self.build_info.semver_version();
212            let client_params = &self.config.grpc_client;
213            let connect_timeout = client_params.connect_timeout.unwrap_or(Duration::MAX);
214            let keepalive_timeout = client_params
215                .http2_keep_alive_timeout
216                .unwrap_or(Duration::MAX);
217
218            let connect_start = Instant::now();
219            let connect_result = ComputeCtpClient::connect_partitioned(
220                self.config.location.ctl_addrs.clone(),
221                version,
222                connect_timeout,
223                keepalive_timeout,
224                self.metrics.clone(),
225            )
226            .await;
227
228            self.metrics.observe_connect_time(connect_start.elapsed());
229
230            connect_result.inspect_err(|error| {
231                let next_backoff = retry.next_backoff.unwrap();
232                if retry.i >= mz_service::retry::INFO_MIN_RETRIES {
233                    info!(
234                        replica_id = %self.replica_id, ?next_backoff,
235                        "error connecting to replica: {error:#}",
236                    );
237                } else {
238                    debug!(
239                        replica_id = %self.replica_id, ?next_backoff,
240                        "error connecting to replica: {error:#}",
241                    );
242                }
243            })
244        };
245
246        let client = Retry::default()
247            .clamp_backoff(Duration::from_secs(1))
248            .retry_async(try_connect)
249            .await
250            .expect("retry retries forever");
251
252        self.metrics.observe_connect();
253        self.connected.store(true, atomic::Ordering::Relaxed);
254
255        client
256    }
257
258    /// Runs the message loop.
259    ///
260    /// Returns (with an `Err`) if it encounters an error condition (e.g. the replica disconnects).
261    /// If no error condition is encountered, the task runs until the controller disconnects from
262    /// the command channel, or the task is dropped.
263    async fn run_message_loop(mut self, mut client: Client) -> Result<(), anyhow::Error> {
264        // The sequential hydration interceptor holds back `Schedule` commands and releases them as
265        // hydration capacity frees up. It is recreated per incarnation, matching the lifetime of
266        // the connection: any in-flight hydration state is reset when we reconnect.
267        let mut hydration = SequentialHydration::new(self.metrics.clone());
268
269        loop {
270            select! {
271                // Command from controller to forward to replica.
272                command = self.command_rx.recv() => {
273                    let Some(mut command) = command else {
274                        // Controller is no longer interested in this replica. Shut down.
275                        break;
276                    };
277
278                    self.specialize_command(&mut command);
279                    self.observe_command(&command);
280                    apply_config_command(&command, &self.replica_dyncfg);
281                    for command in hydration.absorb_command(command, &self.replica_dyncfg) {
282                        client.send(command).await?;
283                    }
284                },
285                // Response from replica to forward to controller.
286                response = client.recv() => {
287                    let Some(response) = response? else {
288                        bail!("replica unexpectedly gracefully terminated connection");
289                    };
290
291                    self.observe_response(&response);
292
293                    for command in hydration.observe_response(&response, &self.replica_dyncfg) {
294                        client.send(command).await?;
295                    }
296
297                    if self.response_tx.send((self.replica_id, self.epoch, response)).is_err() {
298                        // Controller is no longer interested in this replica. Shut down.
299                        break;
300                    }
301                }
302            }
303        }
304
305        Ok(())
306    }
307
308    /// Specialize a command for the given replica configuration.
309    ///
310    /// Most `ComputeCommand`s are independent of the target replica, but some
311    /// contain replica-specific fields that must be adjusted before sending.
312    fn specialize_command(&self, command: &mut ComputeCommand) {
313        match command {
314            ComputeCommand::Hello { nonce } => {
315                *nonce = Uuid::new_v4();
316            }
317            ComputeCommand::CreateInstance(config) => {
318                config.logging = self.config.logging.clone();
319                if ENABLE_COMPUTE_REPLICA_EXPIRATION.get(&self.dyncfg) {
320                    config.expiration_offset = self.config.expiration_offset;
321                }
322                config.arrangement_dictionary_compression =
323                    self.config.arrangement_dictionary_compression;
324            }
325            _ => {}
326        }
327    }
328
329    /// Update task state according to an observed command.
330    #[mz_ore::instrument(level = "debug")]
331    fn observe_command(&self, command: &ComputeCommand) {
332        if let ComputeCommand::Peek(peek) = command {
333            peek.otel_ctx.attach_as_parent();
334        }
335
336        trace!(
337            replica = ?self.replica_id,
338            command = ?command,
339            "sending command to replica",
340        );
341
342        self.metrics.inner.command_queue_size.dec();
343    }
344
345    /// Update task state according to an observed response.
346    #[mz_ore::instrument(level = "debug")]
347    fn observe_response(&self, response: &ComputeResponse) {
348        if let ComputeResponse::PeekResponse(_, _, otel_ctx) = response {
349            otel_ctx.attach_as_parent();
350        }
351
352        trace!(
353            replica = ?self.replica_id,
354            response = ?response,
355            "received response from replica",
356        );
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use mz_compute_types::dyncfgs::HYDRATION_CONCURRENCY;
363
364    use crate::protocol::command::{ComputeParameters, InstanceConfig};
365
366    use super::*;
367
368    /// A replica's effective configuration tracks the configuration commands passing through its
369    /// task, which carry the replica's scoped overrides, and leaves the environment-wide
370    /// configuration alone.
371    #[mz_ore::test]
372    fn replica_dyncfg_tracks_config_commands() {
373        let env_wide = mz_dyncfgs::all_dyncfgs();
374        let mut updates = ConfigUpdates::default();
375        updates.add(&HYDRATION_CONCURRENCY, 1);
376        updates.apply(&env_wide);
377
378        let replica_dyncfg = seed_replica_dyncfg(&env_wide);
379        assert_eq!(HYDRATION_CONCURRENCY.get(&replica_dyncfg), 1);
380
381        // A replica-scoped override arrives merged into the create-time snapshot.
382        let mut initial_config = ConfigUpdates::default();
383        initial_config.add(&HYDRATION_CONCURRENCY, 2);
384        let create = ComputeCommand::CreateInstance(Box::new(InstanceConfig {
385            logging: Default::default(),
386            expiration_offset: None,
387            peek_stash_persist_location: mz_persist_client::PersistLocation::new_in_mem(),
388            arrangement_dictionary_compression: false,
389            initial_config,
390        }));
391        apply_config_command(&create, &replica_dyncfg);
392        assert_eq!(HYDRATION_CONCURRENCY.get(&replica_dyncfg), 2);
393
394        // And into subsequent configuration updates.
395        let mut dyncfg_updates = ConfigUpdates::default();
396        dyncfg_updates.add(&HYDRATION_CONCURRENCY, 3);
397        let update = ComputeCommand::UpdateConfiguration(Box::new(ComputeParameters {
398            dyncfg_updates,
399            ..Default::default()
400        }));
401        apply_config_command(&update, &replica_dyncfg);
402        assert_eq!(HYDRATION_CONCURRENCY.get(&replica_dyncfg), 3);
403
404        // The environment-wide configuration is untouched by the replica's overrides.
405        assert_eq!(HYDRATION_CONCURRENCY.get(&env_wide), 1);
406    }
407}