Skip to main content

mz_clusterd/
lib.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
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::sync::LazyLock;
13use std::time::Duration;
14
15use anyhow::Context;
16use axum::http::StatusCode;
17use axum::routing;
18use fail::FailScenario;
19use futures::future;
20use hyper_util::rt::TokioIo;
21use mz_build_info::{BuildInfo, build_info};
22use mz_cloud_resources::AwsExternalIdPrefix;
23use mz_cluster_client::client::TimelyConfig;
24use mz_compute::server::{ComputeInstanceContext, ComputeRuntimeRole};
25use mz_http_util::DynamicFilterTarget;
26use mz_orchestrator_tracing::{StaticTracingConfig, TracingCliArgs};
27use mz_ore::cli::{self, CliConfig};
28use mz_ore::error::ErrorExt;
29use mz_ore::metrics::{MetricsRegistry, register_runtime_metrics};
30use mz_ore::netio::{Listener, SocketAddr};
31use mz_ore::now::SYSTEM_TIME;
32use mz_persist_client::cache::PersistClientCache;
33use mz_persist_client::cfg::PersistConfig;
34use mz_persist_client::rpc::{GrpcPubSubClient, PersistPubSubClient, PersistPubSubClientConfig};
35use mz_secrets_cli::SecretsReaderCliArgs;
36use mz_service::emit_boot_diagnostics;
37use mz_service::transport;
38use mz_service::transport::ClusterServerMetrics;
39use mz_storage::storage_state::StorageInstanceContext;
40use mz_storage_types::connections::ConnectionContext;
41use mz_txn_wal::operator::TxnsContext;
42use tokio::runtime::Handle;
43use tower::Service;
44use tracing::{Instrument, debug, error, info, info_span};
45
46mod usage_metrics;
47
48const BUILD_INFO: BuildInfo = build_info!();
49
50pub static VERSION: LazyLock<String> = LazyLock::new(|| BUILD_INFO.human_version(None));
51
52/// Independent cluster server for Materialize.
53#[derive(clap::Parser)]
54#[clap(name = "clusterd", version = VERSION.as_str())]
55struct Args {
56    // === Connection options. ===
57    /// The address on which to listen for a connection from the storage
58    /// controller.
59    #[clap(
60        long,
61        env = "STORAGE_CONTROLLER_LISTEN_ADDR",
62        value_name = "HOST:PORT",
63        default_value = "127.0.0.1:2100"
64    )]
65    storage_controller_listen_addr: SocketAddr,
66    /// The address on which to listen for a connection from the compute
67    /// controller.
68    #[clap(
69        long,
70        env = "COMPUTE_CONTROLLER_LISTEN_ADDR",
71        value_name = "HOST:PORT",
72        default_value = "127.0.0.1:2101"
73    )]
74    compute_controller_listen_addr: SocketAddr,
75    /// The address of the internal HTTP server.
76    #[clap(
77        long,
78        env = "INTERNAL_HTTP_LISTEN_ADDR",
79        value_name = "HOST:PORT",
80        default_value = "127.0.0.1:6878"
81    )]
82    internal_http_listen_addr: SocketAddr,
83    /// The FQDN of this process, for GRPC request validation.
84    ///
85    /// Not providing this value or setting it to the empty string disables host validation for
86    /// GRPC requests.
87    #[clap(long, env = "GRPC_HOST", value_name = "NAME")]
88    grpc_host: Option<String>,
89
90    // === Timely cluster options. ===
91    /// Configuration for the storage Timely cluster.
92    #[clap(long, env = "STORAGE_TIMELY_CONFIG")]
93    storage_timely_config: TimelyConfig,
94    /// Configuration for the compute Timely cluster.
95    #[clap(long, env = "COMPUTE_TIMELY_CONFIG")]
96    compute_timely_config: TimelyConfig,
97    /// The index of the process in both Timely clusters.
98    #[clap(long, env = "PROCESS")]
99    process: usize,
100
101    // === Storage options. ===
102    /// The URL for the Persist PubSub service.
103    #[clap(
104        long,
105        env = "PERSIST_PUBSUB_URL",
106        value_name = "http://HOST:PORT",
107        default_value = "http://localhost:6879"
108    )]
109    persist_pubsub_url: String,
110
111    // === Cloud options. ===
112    /// An external ID to be supplied to all AWS AssumeRole operations.
113    ///
114    /// Details: <https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html>
115    #[clap(long, env = "AWS_EXTERNAL_ID", value_name = "ID", value_parser = AwsExternalIdPrefix::new_from_cli_argument_or_environment_variable)]
116    aws_external_id_prefix: Option<AwsExternalIdPrefix>,
117
118    /// The ARN for a Materialize-controlled role to assume before assuming
119    /// a customer's requested role for an AWS connection.
120    #[clap(long, env = "AWS_CONNECTION_ROLE_ARN")]
121    aws_connection_role_arn: Option<String>,
122
123    // === Secrets reader options. ===
124    #[clap(flatten)]
125    secrets: SecretsReaderCliArgs,
126
127    // === Tracing options. ===
128    #[clap(flatten)]
129    tracing: TracingCliArgs,
130
131    // === Other options. ===
132    /// An opaque identifier for the environment in which this process is
133    /// running.
134    #[clap(long, env = "ENVIRONMENT_ID")]
135    environment_id: String,
136
137    /// A scratch directory that can be used for ephemeral storage.
138    #[clap(long, env = "SCRATCH_DIRECTORY", value_name = "PATH")]
139    scratch_directory: Option<PathBuf>,
140
141    /// The replica runs without swap.
142    #[clap(long)]
143    no_swap: bool,
144
145    /// Memory limit (bytes) of the cluster replica, if known.
146    ///
147    /// The limit is expected to be enforced by the orchestrator. The clusterd process only uses it
148    /// to inform configuration of backpressure mechanism.
149    #[clap(long)]
150    announce_memory_limit: Option<usize>,
151
152    /// Heap limit (bytes) of the cluster replica.
153    ///
154    /// A process heap usage is calculated as the sum of its memory and swap usage.
155    ///
156    /// In contrast to `announce_memory_limit`, this limit is enforced by the clusterd process. If
157    /// the limit is exceeded, the process terminates itself with a 167 exit code.
158    #[clap(long)]
159    heap_limit: Option<usize>,
160
161    /// Whether this size represents a modern "cc" size rather than a legacy
162    /// T-shirt size.
163    #[clap(long)]
164    is_cc: bool,
165
166    /// Set core affinity for Timely workers.
167    ///
168    /// This flag should only be set if the process is provided with exclusive access to its
169    /// supplied CPU cores. If other processes are competing over the same cores, setting core
170    /// affinity might degrade dataflow performance rather than improving it.
171    #[clap(long)]
172    worker_core_affinity: bool,
173
174    /// Host storage objects on the compute Timely cluster instead of building a separate
175    /// storage Timely cluster. The storage and compute controller protocols are served
176    /// unchanged, from the same cluster.
177    #[clap(long, env = "UNIFIED_CLUSTER")]
178    unified_cluster: bool,
179}
180
181/// The process ordinal for a StatefulSet pod, taken from the trailing
182/// `-`-delimited segment of its hostname (e.g.
183/// "mz5ncn-cluster-s1-replica-s1-gen-1-0" → "0"). This mirrors how
184/// orchestrator-kubernetes recovers the process id from pod names.
185///
186/// Returns `None` when the trailing segment is not a non-negative integer, so
187/// an unexpected hostname leaves `CLUSTERD_PROCESS` unset rather than set to a
188/// value that fails to parse as the process index.
189fn process_ordinal_from_hostname(hostname: &str) -> Option<&str> {
190    let ordinal = hostname.rsplit('-').next()?;
191    ordinal.parse::<usize>().ok().map(|_| ordinal)
192}
193
194pub fn main() {
195    mz_ore::panic::install_enhanced_handler();
196
197    // Pin the rustls crypto provider to aws-lc-rs. The LaunchDarkly SDK uses
198    // hyper-rustls, so building its client resolves the process-default rustls
199    // provider. The workspace also links rustls' `ring` feature (pulled by
200    // other hyper-rustls chains), and with both provider features enabled
201    // rustls cannot choose a default on its own and panics. The call is
202    // idempotent, so ignore the result.
203    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
204
205    // Derive `CLUSTERD_PROCESS` (the process ordinal) from the pod hostname
206    // when running under Kubernetes and it was not set explicitly. The
207    // distroless image has no shell entrypoint to do this, so clusterd does it
208    // itself.
209    if std::env::var("KUBERNETES_SERVICE_HOST").is_ok()
210        && std::env::var("CLUSTERD_PROCESS").is_err()
211    {
212        if let Ok(hostname) = std::env::var("HOSTNAME") {
213            if let Some(ordinal) = process_ordinal_from_hostname(&hostname) {
214                // SAFETY: `set_var` is called before any threads are spawned.
215                // `install_enhanced_handler` above only registers a panic hook.
216                // That hook spawns a thread only on panic, which cannot happen
217                // before this call.
218                unsafe { std::env::set_var("CLUSTERD_PROCESS", ordinal) };
219            }
220        }
221    }
222
223    let args = cli::parse_args(CliConfig {
224        env_prefix: Some("CLUSTERD_"),
225        enable_version_flag: true,
226    });
227
228    let ncpus_useful = usize::max(1, std::cmp::min(num_cpus::get(), num_cpus::get_physical()));
229    let runtime = tokio::runtime::Builder::new_multi_thread()
230        .worker_threads(ncpus_useful)
231        .thread_stack_size(3 * 1024 * 1024) // 3 MiB
232        // The default thread name exceeds the Linux limit on thread name
233        // length, so pick something shorter. The maximum length is 16 including
234        // a \0 terminator. This gives us four decimals, which should be enough
235        // for most existing computers.
236        .thread_name_fn(|| {
237            use std::sync::atomic::{AtomicUsize, Ordering};
238            static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
239            let id = ATOMIC_ID.fetch_add(1, Ordering::Relaxed);
240            format!("tokio:work-{}", id)
241        })
242        .enable_all()
243        .build()
244        .unwrap();
245    if let Err(err) = runtime.block_on(run(args)) {
246        panic!("clusterd: fatal: {}", err.display_with_causes());
247    }
248}
249
250async fn run(args: Args) -> Result<(), anyhow::Error> {
251    let metrics_registry = MetricsRegistry::new();
252    let tracing_handle = args
253        .tracing
254        .configure_tracing(
255            StaticTracingConfig {
256                service_name: "clusterd",
257                build_info: BUILD_INFO,
258            },
259            metrics_registry.clone(),
260        )
261        .await?;
262
263    let tracing_handle = Arc::new(tracing_handle);
264    register_runtime_metrics("main", Handle::current().metrics(), &metrics_registry);
265
266    // Keep this _after_ the mz_ore::tracing::configure call so that its panic
267    // hook runs _before_ the one that sends things to sentry.
268    mz_timely_util::panic::halt_on_timely_communication_panic();
269
270    let _failpoint_scenario = FailScenario::setup();
271
272    emit_boot_diagnostics!(&BUILD_INFO);
273
274    mz_alloc::register_metrics_into(&metrics_registry).await;
275    mz_metrics::register_metrics_into(
276        &metrics_registry,
277        mz_dyncfgs::all_dyncfgs(),
278        args.scratch_directory.clone(),
279    )
280    .await;
281
282    if let Some(heap_limit) = args.heap_limit {
283        mz_compute::memory_limiter::start_limiter(heap_limit, &metrics_registry);
284    } else {
285        info!("no heap limit announced; disabling memory limiter");
286    }
287
288    let secrets_reader = mz_secrets_loader::load(args.secrets)
289        .await
290        .context("loading secrets reader")?;
291
292    let usage_collector = Arc::new(usage_metrics::Collector {
293        disk_root: args.scratch_directory.clone(),
294    });
295
296    mz_ore::task::spawn(|| "clusterd_internal_http_server", {
297        let metrics_registry = metrics_registry.clone();
298        tracing::info!(
299            "serving internal HTTP server on {}",
300            args.internal_http_listen_addr
301        );
302        let listener = Listener::bind(args.internal_http_listen_addr).await?;
303        let mut make_service = mz_prof_http::router(&BUILD_INFO)
304            .route(
305                "/api/livez",
306                routing::get(mz_http_util::handle_liveness_check),
307            )
308            .route(
309                "/metrics",
310                routing::get(move |headers: axum::http::HeaderMap| async move {
311                    mz_http_util::handle_prometheus(&metrics_registry, headers).await
312                }),
313            )
314            .route("/api/tracing", routing::get(mz_http_util::handle_tracing))
315            .route(
316                "/api/opentelemetry/config",
317                routing::put({
318                    move |_: axum::Json<DynamicFilterTarget>| async {
319                        (
320                            StatusCode::BAD_REQUEST,
321                            "This endpoint has been replaced. \
322                                Use the `opentelemetry_filter` system variable."
323                                .to_string(),
324                        )
325                    }
326                }),
327            )
328            .route(
329                "/api/stderr/config",
330                routing::put({
331                    move |_: axum::Json<DynamicFilterTarget>| async {
332                        (
333                            StatusCode::BAD_REQUEST,
334                            "This endpoint has been replaced. \
335                                Use the `log_filter` system variable."
336                                .to_string(),
337                        )
338                    }
339                }),
340            )
341            .route(
342                "/api/usage-metrics",
343                routing::get(async move || axum::Json(usage_collector.collect())),
344            )
345            .into_make_service();
346
347        // Once https://github.com/tokio-rs/axum/pull/2479 lands, this can become just a call to
348        // `axum::serve`.
349        async move {
350            loop {
351                let (conn, remote_addr) = match listener.accept().await {
352                    Ok(peer) => peer,
353                    Err(error) => {
354                        // Match hyper's AddrIncoming error handling:
355                        // connection errors are per-connection and can be
356                        // skipped immediately; all other errors (e.g., EMFILE)
357                        // sleep to avoid a tight loop on resource exhaustion.
358                        if is_connection_error(&error) {
359                            debug!("accepted connection already errored: {error:#}");
360                        } else {
361                            error!("internal_http accept error: {error:#}");
362                            tokio::time::sleep(Duration::from_secs(1)).await;
363                        }
364                        continue;
365                    }
366                };
367
368                let tower_service = make_service.call(&conn).await.expect("infallible");
369                let hyper_service =
370                    hyper::service::service_fn(move |req| tower_service.clone().call(req));
371
372                mz_ore::task::spawn(
373                    || format!("clusterd_internal_http_server:{remote_addr}"),
374                    async move {
375                        if let Err(error) = hyper::server::conn::http1::Builder::new()
376                            .serve_connection(TokioIo::new(conn), hyper_service)
377                            .await
378                        {
379                            // This can happen when the client performs an unclean shutdown, so a
380                            // high severity isn't warranted. Might even downgrade this to DEBUG if
381                            // it turns out too noisy.
382                            info!("error serving internal_http connection: {error:#}");
383                        }
384                    },
385                );
386            }
387        }
388    });
389
390    let pubsub_caller_id = std::env::var("HOSTNAME")
391        .ok()
392        .or_else(|| args.tracing.log_prefix.clone())
393        .unwrap_or_default();
394    let mut persist_cfg =
395        PersistConfig::new(&BUILD_INFO, SYSTEM_TIME.clone(), mz_dyncfgs::all_dyncfgs());
396    persist_cfg.is_cc_active = args.is_cc;
397    persist_cfg.announce_memory_limit = args.announce_memory_limit;
398    // Start with compaction disabled, will get enabled once a cluster receives AllowWrites.
399    persist_cfg.disable_compaction();
400
401    let persist_clients = Arc::new(PersistClientCache::new(
402        persist_cfg,
403        &metrics_registry,
404        |persist_cfg, metrics| {
405            let cfg = PersistPubSubClientConfig {
406                url: args.persist_pubsub_url,
407                caller_id: pubsub_caller_id,
408                persist_cfg: persist_cfg.clone(),
409            };
410            GrpcPubSubClient::connect(cfg, metrics)
411        },
412    ));
413    let txns_ctx = TxnsContext::default();
414
415    let connection_context = ConnectionContext::from_cli_args(
416        args.environment_id,
417        &args.tracing.startup_log_filter,
418        args.aws_external_id_prefix,
419        args.aws_connection_role_arn,
420        secrets_reader,
421        None,
422    );
423
424    let grpc_host = args.grpc_host.and_then(|h| (!h.is_empty()).then_some(h));
425    let cluster_server_metrics = ClusterServerMetrics::register_with(&metrics_registry);
426
427    let mut storage_timely_config = args.storage_timely_config;
428    storage_timely_config.process = args.process;
429    let mut compute_timely_config = args.compute_timely_config;
430    compute_timely_config.process = args.process;
431
432    // We assume each storage worker has a corresponding compute worker that can process its logs.
433    assert_eq!(
434        storage_timely_config.workers, compute_timely_config.workers,
435        "storage and compute must have equal workers-per-process",
436    );
437
438    if args.unified_cluster {
439        info!("running with a unified timely cluster");
440
441        let (compute_client_builder, storage_client_builder) = mz_compute::server::serve_unified(
442            compute_timely_config,
443            ComputeRuntimeRole::Solo,
444            &metrics_registry,
445            persist_clients,
446            txns_ctx,
447            tracing_handle,
448            ComputeInstanceContext {
449                scratch_directory: args.scratch_directory.clone(),
450                worker_core_affinity: args.worker_core_affinity,
451                swap_available: !args.no_swap,
452                connection_context: connection_context.clone(),
453            },
454            SYSTEM_TIME.clone(),
455            connection_context,
456            StorageInstanceContext::new(
457                args.scratch_directory,
458                args.announce_memory_limit,
459                !args.no_swap,
460            ),
461        )
462        .await?;
463
464        info!(
465            "listening for storage controller connections on {}",
466            args.storage_controller_listen_addr
467        );
468        mz_ore::task::spawn(
469            || "storage_server",
470            transport::serve(
471                args.storage_controller_listen_addr,
472                BUILD_INFO.semver_version(),
473                grpc_host.clone(),
474                Duration::MAX,
475                storage_client_builder,
476                cluster_server_metrics.for_server("storage"),
477            )
478            .instrument(info_span!("ctp", name = "storage")),
479        );
480
481        info!(
482            "listening for compute controller connections on {}",
483            args.compute_controller_listen_addr
484        );
485        mz_ore::task::spawn(
486            || "compute_server",
487            transport::serve(
488                args.compute_controller_listen_addr,
489                BUILD_INFO.semver_version(),
490                grpc_host,
491                Duration::MAX,
492                compute_client_builder,
493                cluster_server_metrics.for_server("compute"),
494            )
495            .instrument(info_span!("ctp", name = "compute")),
496        );
497
498        // Block forever.
499        return future::pending().await;
500    }
501
502    // Start storage server.
503    let storage_client_builder = mz_storage::serve(
504        storage_timely_config,
505        &metrics_registry,
506        Arc::clone(&persist_clients),
507        txns_ctx.clone(),
508        Arc::clone(&tracing_handle),
509        SYSTEM_TIME.clone(),
510        connection_context.clone(),
511        StorageInstanceContext::new(
512            args.scratch_directory.clone(),
513            args.announce_memory_limit,
514            !args.no_swap,
515        ),
516    )
517    .await?;
518    info!(
519        "listening for storage controller connections on {}",
520        args.storage_controller_listen_addr
521    );
522    mz_ore::task::spawn(
523        || "storage_server",
524        transport::serve(
525            args.storage_controller_listen_addr,
526            BUILD_INFO.semver_version(),
527            grpc_host.clone(),
528            Duration::MAX,
529            storage_client_builder,
530            cluster_server_metrics.for_server("storage"),
531        )
532        .instrument(info_span!("ctp", name = "storage")),
533    );
534
535    // Start compute server.
536    let compute_client_builder = mz_compute::server::serve(
537        compute_timely_config,
538        ComputeRuntimeRole::Solo,
539        &metrics_registry,
540        persist_clients,
541        txns_ctx,
542        tracing_handle,
543        ComputeInstanceContext {
544            scratch_directory: args.scratch_directory,
545            worker_core_affinity: args.worker_core_affinity,
546            swap_available: !args.no_swap,
547            connection_context,
548        },
549    )
550    .await?;
551    info!(
552        "listening for compute controller connections on {}",
553        args.compute_controller_listen_addr
554    );
555    mz_ore::task::spawn(
556        || "compute_server",
557        transport::serve(
558            args.compute_controller_listen_addr,
559            BUILD_INFO.semver_version(),
560            grpc_host.clone(),
561            Duration::MAX,
562            compute_client_builder,
563            cluster_server_metrics.for_server("compute"),
564        )
565        .instrument(info_span!("ctp", name = "compute")),
566    );
567
568    // TODO: retire this two-cluster topology once the unified cluster has production mileage.
569
570    // Block forever.
571    future::pending().await
572}
573
574/// Per-connection errors from `accept()` that can be skipped immediately.
575/// All other errors (e.g., EMFILE/ENFILE resource exhaustion) warrant a sleep
576/// before retrying. Mirrors hyper's `AddrIncoming` classification.
577fn is_connection_error(e: &std::io::Error) -> bool {
578    matches!(
579        e.kind(),
580        std::io::ErrorKind::ConnectionRefused
581            | std::io::ErrorKind::ConnectionAborted
582            | std::io::ErrorKind::ConnectionReset
583    )
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[mz_ore::test]
591    fn test_process_ordinal_from_hostname() {
592        // A StatefulSet pod name ends in the process ordinal.
593        assert_eq!(
594            process_ordinal_from_hostname("mz5ncn-cluster-s1-replica-s1-gen-1-0"),
595            Some("0")
596        );
597        assert_eq!(
598            process_ordinal_from_hostname("mz5ncn-cluster-s1-replica-s1-gen-1-11"),
599            Some("11")
600        );
601        // A bare numeric hostname is its own ordinal.
602        assert_eq!(process_ordinal_from_hostname("7"), Some("7"));
603
604        // A trailing segment that is not a non-negative integer yields `None`,
605        // so `CLUSTERD_PROCESS` stays unset rather than being set to a value
606        // that fails to parse as the process index.
607        assert_eq!(process_ordinal_from_hostname("clusterd"), None);
608        assert_eq!(process_ordinal_from_hostname("replica-abc"), None);
609        assert_eq!(process_ordinal_from_hostname("replica-"), None);
610        assert_eq!(process_ordinal_from_hostname(""), None);
611    }
612}