Skip to main content

mz_compute_client/
controller.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 controller that provides an interface to the compute layer, and the storage layer below it.
11//!
12//! The compute controller manages the creation, maintenance, and removal of compute instances.
13//! This involves ensuring the intended service state with the orchestrator, as well as maintaining
14//! a dedicated compute instance controller for each active compute instance.
15//!
16//! For each compute instance, the compute controller curates the creation of indexes and sinks
17//! installed on the instance, the progress of readers through these collections, and their
18//! eventual dropping and resource reclamation.
19//!
20//! The state maintained for a compute instance can be viewed as a partial map from `GlobalId` to
21//! collection. It is an error to use an identifier before it has been "created" with
22//! `create_dataflow()`. Once created, the controller holds a read capability for each output
23//! collection of a dataflow, which is manipulated with `set_read_policy()`. Eventually, a
24//! collection is dropped with `drop_collections()`.
25//!
26//! A dataflow can be in read-only or read-write mode. In read-only mode, the dataflow does not
27//! modify any persistent state. Sending a `allow_write` message to the compute instance will
28//! transition the dataflow to read-write mode, allowing it to write to persistent sinks.
29//!
30//! Created dataflows will prevent the compaction of their inputs, including other compute
31//! collections but also collections managed by the storage layer. Each dataflow input is prevented
32//! from compacting beyond the allowed compaction of each of its outputs, ensuring that we can
33//! recover each dataflow to its current state in case of failure or other reconfiguration.
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::sync::{Arc, Mutex};
37use std::time::Duration;
38
39use mz_build_info::BuildInfo;
40use mz_cluster_client::client::ClusterReplicaLocation;
41use mz_cluster_client::metrics::ControllerMetrics;
42use mz_cluster_client::{ReplicaId, WallclockLagFn};
43use mz_compute_types::ComputeInstanceId;
44use mz_compute_types::config::ComputeReplicaConfig;
45use mz_compute_types::dyncfgs::{
46    COMPUTE_REPLICA_EXPIRATION_OFFSET, ENABLE_ARRANGEMENT_DICTIONARY_COMPRESSION_ALPHA,
47};
48use mz_dyncfg::{ConfigSet, ConfigUpdates};
49use mz_expr::RowSetFinishing;
50use mz_expr::row::RowCollection;
51use mz_ore::cast::CastFrom;
52use mz_ore::metrics::MetricsRegistry;
53use mz_ore::now::NowFn;
54use mz_ore::soft_assert_or_log;
55use mz_ore::soft_panic_or_log;
56use mz_ore::tracing::OpenTelemetryContext;
57use mz_persist_types::PersistLocation;
58use mz_repr::{GlobalId, RelationDesc, Row, Timestamp, frontier_within_lag};
59use mz_storage_client::controller::StorageController;
60use mz_storage_types::dyncfgs::ORE_OVERFLOWING_BEHAVIOR;
61use mz_storage_types::read_holds::ReadHold;
62use mz_storage_types::read_policy::ReadPolicy;
63use mz_storage_types::time_dependence::{TimeDependence, TimeDependenceError};
64use prometheus::proto::LabelPair;
65use serde::{Deserialize, Serialize};
66use timely::PartialOrder;
67use timely::progress::Antichain;
68use tokio::sync::{mpsc, oneshot};
69use tokio::time::{self, MissedTickBehavior};
70use uuid::Uuid;
71
72use crate::controller::error::{
73    CollectionLookupError, CollectionMissing, CollectionUpdateError, DataflowCreationError,
74    HydrationCheckBadTarget, InstanceExists, InstanceMissing, PeekError, ReadPolicyError,
75    ReplicaCreationError, ReplicaDropError,
76};
77use crate::controller::instance::{Instance, SharedCollectionState};
78use crate::controller::introspection::{IntrospectionUpdates, spawn_introspection_sink};
79use crate::controller::replica::ReplicaConfig;
80use crate::logging::{LogVariant, LoggingConfig};
81use crate::metrics::ComputeControllerMetrics;
82use crate::protocol::command::{ComputeParameters, PeekTarget};
83use crate::protocol::response::{PeekResponse, SubscribeBatch};
84
85mod instance;
86mod introspection;
87mod replica;
88mod sequential_hydration;
89
90pub mod error;
91pub mod instance_client;
92pub use instance_client::InstanceClient;
93
94pub(crate) type StorageCollections =
95    Arc<dyn mz_storage_client::storage_collections::StorageCollections + Send + Sync>;
96
97/// A collection's hydration and catch-up status.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum CollectionReadiness {
100    /// Hydrated and within the requested lag allowance.
101    Ready,
102    /// Hydrated, but behind the requested frontier.
103    Lagging {
104        /// The gap in timestamp ticks, or `None` when awaiting completion.
105        /// Ticks are milliseconds only on the epoch-milliseconds timeline.
106        lag: Option<u64>,
107    },
108    /// Not yet hydrated.
109    Unhydrated,
110}
111
112impl CollectionReadiness {
113    /// Classifies progress against an optional reference and lag allowance.
114    ///
115    /// The caller selects comparable frontiers: graceful replacement uses
116    /// per-replica output frontiers, whereas 0dt uses collection write frontiers.
117    /// No lag requirement means hydration alone. An empty reference requires
118    /// an empty frontier, since completion cannot be matched by finite progress.
119    pub fn classify(
120        hydrated: bool,
121        frontier: &Antichain<Timestamp>,
122        lag_requirement: Option<(&Antichain<Timestamp>, Timestamp)>,
123    ) -> Self {
124        if !hydrated {
125            Self::Unhydrated
126        } else if lag_requirement
127            .is_some_and(|(reference, lag)| !frontier_within_lag(frontier, reference, lag))
128        {
129            let reference = lag_requirement.expect("lag requirement was checked").0;
130            let lag =
131                frontier
132                    .as_option()
133                    .zip(reference.as_option())
134                    .map(|(frontier, reference)| {
135                        u64::from(*reference).saturating_sub(u64::from(*frontier))
136                    });
137            Self::Lagging { lag }
138        } else {
139            Self::Ready
140        }
141    }
142}
143
144/// Responses from the compute controller.
145#[derive(Debug)]
146pub enum ComputeControllerResponse {
147    /// See [`PeekNotification`].
148    PeekNotification(Uuid, PeekNotification, OpenTelemetryContext),
149    /// See [`crate::protocol::response::ComputeResponse::SubscribeResponse`].
150    SubscribeResponse(GlobalId, SubscribeBatch),
151    /// The response from a dataflow containing an `CopyToS3Oneshot` sink.
152    ///
153    /// The `GlobalId` identifies the sink. The `Result` is the response from
154    /// the sink, where an `Ok(n)` indicates that `n` rows were successfully
155    /// copied to S3 and an `Err` indicates that an error was encountered
156    /// during the copy operation.
157    ///
158    /// For a given `CopyToS3Oneshot` sink, there will be at most one `CopyToResponse`
159    /// produced. (The sink may produce no responses if its dataflow is dropped
160    /// before completion.)
161    CopyToResponse(GlobalId, Result<u64, anyhow::Error>),
162    /// A response reporting advancement of a collection's upper frontier.
163    ///
164    /// Once a collection's upper (aka "write frontier") has advanced to beyond a given time, the
165    /// contents of the collection as of that time have been sealed and cannot change anymore.
166    FrontierUpper {
167        /// The ID of a compute collection.
168        id: GlobalId,
169        /// The new upper frontier of the identified compute collection.
170        upper: Antichain<Timestamp>,
171    },
172}
173
174/// Notification and summary of a received and forwarded [`crate::protocol::response::ComputeResponse::PeekResponse`].
175#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
176pub enum PeekNotification {
177    /// Returned rows of a successful peek.
178    Success {
179        /// Number of rows in the returned peek result.
180        rows: u64,
181        /// Size of the returned peek result in bytes.
182        result_size: u64,
183    },
184    /// Error of an unsuccessful peek, including the reason for the error.
185    Error(String),
186    /// The peek was canceled.
187    Canceled,
188}
189
190impl PeekNotification {
191    /// Construct a new [`PeekNotification`] from a [`PeekResponse`]. The `offset` and `limit`
192    /// parameters are used to calculate the number of rows in the peek result.
193    fn new(peek_response: &PeekResponse, offset: usize, limit: Option<usize>) -> Self {
194        match peek_response {
195            PeekResponse::Rows(rows) => {
196                let num_rows = u64::cast_from(RowCollection::offset_limit(
197                    rows.iter().map(|r| r.count()).sum(),
198                    offset,
199                    limit,
200                ));
201                let result_size = u64::cast_from(rows.iter().map(|r| r.byte_len()).sum::<usize>());
202
203                tracing::trace!(?num_rows, ?result_size, "inline peek result");
204
205                Self::Success {
206                    rows: num_rows,
207                    result_size,
208                }
209            }
210            PeekResponse::Stashed(stashed_response) => {
211                let rows = stashed_response.num_rows(offset, limit);
212                let result_size = stashed_response.size_bytes();
213
214                tracing::trace!(?rows, ?result_size, "stashed peek result");
215
216                Self::Success {
217                    rows: u64::cast_from(rows),
218                    result_size: u64::cast_from(result_size),
219                }
220            }
221            PeekResponse::Error(err) => Self::Error(err.to_string()),
222            PeekResponse::Canceled => Self::Canceled,
223        }
224    }
225}
226
227/// A controller for the compute layer.
228pub struct ComputeController {
229    instances: BTreeMap<ComputeInstanceId, InstanceState>,
230    /// A map from an instance ID to an arbitrary string that describes the
231    /// class of the workload that compute instance is running (e.g.,
232    /// `production` or `staging`).
233    instance_workload_classes: Arc<Mutex<BTreeMap<ComputeInstanceId, Option<String>>>>,
234    build_info: &'static BuildInfo,
235    /// A handle providing access to storage collections.
236    storage_collections: StorageCollections,
237    /// Set to `true` once `initialization_complete` has been called.
238    initialized: bool,
239    /// Whether or not this controller is in read-only mode.
240    ///
241    /// When in read-only mode, neither this controller nor the instances
242    /// controlled by it are allowed to affect changes to external systems
243    /// (largely persist).
244    read_only: bool,
245    /// Compute configuration to apply to new instances.
246    config: ComputeParameters,
247    /// The persist location where we can stash large peek results.
248    peek_stash_persist_location: PersistLocation,
249    /// A controller response to be returned on the next call to [`ComputeController::process`].
250    stashed_response: Option<ComputeControllerResponse>,
251    /// The compute controller metrics.
252    metrics: ComputeControllerMetrics,
253    /// A function that produces the current wallclock time.
254    now: NowFn,
255    /// A function that computes the lag between the given time and wallclock time.
256    wallclock_lag: WallclockLagFn<Timestamp>,
257    /// Dynamic system configuration.
258    ///
259    /// Updated through `ComputeController::update_configuration` calls and shared with all
260    /// subcomponents of the compute controller.
261    dyncfg: Arc<ConfigSet>,
262    /// The replica-local scoped overrides of [`Self::dyncfg`], by replica.
263    ///
264    /// Sparse, and kept here in addition to on the `Instance`s because replica
265    /// configuration that the controller resolves once, at replica creation,
266    /// must be read through the new replica's overrides.
267    replica_dyncfg_overrides: BTreeMap<ReplicaId, ConfigUpdates>,
268
269    /// Receiver for responses produced by `Instance`s.
270    response_rx: mpsc::UnboundedReceiver<ComputeControllerResponse>,
271    /// Response sender that's passed to new `Instance`s.
272    response_tx: mpsc::UnboundedSender<ComputeControllerResponse>,
273    /// Receiver for introspection updates produced by `Instance`s.
274    ///
275    /// When [`ComputeController::start_introspection_sink`] is first called, this receiver is
276    /// passed to the introspection sink task.
277    introspection_rx: Option<mpsc::UnboundedReceiver<IntrospectionUpdates>>,
278    /// Introspection updates sender that's passed to new `Instance`s.
279    introspection_tx: mpsc::UnboundedSender<IntrospectionUpdates>,
280
281    /// Ticker for scheduling periodic maintenance work.
282    maintenance_ticker: tokio::time::Interval,
283    /// Whether maintenance work was scheduled.
284    maintenance_scheduled: bool,
285}
286
287impl ComputeController {
288    /// Construct a new [`ComputeController`].
289    pub fn new(
290        build_info: &'static BuildInfo,
291        storage_collections: StorageCollections,
292        read_only: bool,
293        metrics_registry: &MetricsRegistry,
294        peek_stash_persist_location: PersistLocation,
295        controller_metrics: ControllerMetrics,
296        now: NowFn,
297        wallclock_lag: WallclockLagFn<Timestamp>,
298    ) -> Self {
299        let (response_tx, response_rx) = mpsc::unbounded_channel();
300        let (introspection_tx, introspection_rx) = mpsc::unbounded_channel();
301
302        let mut maintenance_ticker = time::interval(Duration::from_secs(1));
303        maintenance_ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
304
305        let instance_workload_classes = Arc::new(Mutex::new(BTreeMap::<
306            ComputeInstanceId,
307            Option<String>,
308        >::new()));
309
310        // Apply a `workload_class` label to all metrics in the registry that
311        // have an `instance_id` label for an instance whose workload class is
312        // known.
313        metrics_registry.register_postprocessor({
314            let instance_workload_classes = Arc::clone(&instance_workload_classes);
315            move |metrics| {
316                let instance_workload_classes = instance_workload_classes
317                    .lock()
318                    .expect("lock poisoned")
319                    .iter()
320                    .map(|(id, workload_class)| (id.to_string(), workload_class.clone()))
321                    .collect::<BTreeMap<String, Option<String>>>();
322                for metric in metrics {
323                    'metric: for metric in metric.mut_metric() {
324                        for label in metric.get_label() {
325                            if label.name() == "instance_id" {
326                                if let Some(workload_class) = instance_workload_classes
327                                    .get(label.value())
328                                    .cloned()
329                                    .flatten()
330                                {
331                                    let mut label = LabelPair::default();
332                                    label.set_name("workload_class".into());
333                                    label.set_value(workload_class.clone());
334
335                                    let mut labels = metric.take_label();
336                                    labels.push(label);
337                                    metric.set_label(labels);
338                                }
339                                continue 'metric;
340                            }
341                        }
342                    }
343                }
344            }
345        });
346
347        let metrics = ComputeControllerMetrics::new(metrics_registry, controller_metrics);
348
349        Self {
350            instances: BTreeMap::new(),
351            instance_workload_classes,
352            build_info,
353            storage_collections,
354            initialized: false,
355            read_only,
356            config: Default::default(),
357            peek_stash_persist_location,
358            stashed_response: None,
359            metrics,
360            now,
361            wallclock_lag,
362            dyncfg: Arc::new(mz_dyncfgs::all_dyncfgs()),
363            replica_dyncfg_overrides: BTreeMap::new(),
364            response_rx,
365            response_tx,
366            introspection_rx: Some(introspection_rx),
367            introspection_tx,
368            maintenance_ticker,
369            maintenance_scheduled: false,
370        }
371    }
372
373    /// Start sinking the compute controller's introspection data into storage.
374    ///
375    /// This method should be called once the introspection collections have been registered with
376    /// the storage controller. It will panic if invoked earlier than that.
377    pub fn start_introspection_sink(&mut self, storage_controller: &dyn StorageController) {
378        if let Some(rx) = self.introspection_rx.take() {
379            spawn_introspection_sink(rx, storage_controller);
380        }
381    }
382
383    /// TODO(database-issues#7533): Add documentation.
384    pub fn instance_exists(&self, id: ComputeInstanceId) -> bool {
385        self.instances.contains_key(&id)
386    }
387
388    /// Return a reference to the indicated compute instance.
389    fn instance(&self, id: ComputeInstanceId) -> Result<&InstanceState, InstanceMissing> {
390        self.instances.get(&id).ok_or(InstanceMissing(id))
391    }
392
393    /// Return an `InstanceClient` for the indicated compute instance.
394    pub fn instance_client(
395        &self,
396        id: ComputeInstanceId,
397    ) -> Result<InstanceClient, InstanceMissing> {
398        self.instance(id).map(|instance| instance.client.clone())
399    }
400
401    /// Return a mutable reference to the indicated compute instance.
402    fn instance_mut(
403        &mut self,
404        id: ComputeInstanceId,
405    ) -> Result<&mut InstanceState, InstanceMissing> {
406        self.instances.get_mut(&id).ok_or(InstanceMissing(id))
407    }
408
409    /// List the IDs of all collections in the identified compute instance.
410    pub fn collection_ids(
411        &self,
412        instance_id: ComputeInstanceId,
413    ) -> Result<impl Iterator<Item = GlobalId> + '_, InstanceMissing> {
414        let instance = self.instance(instance_id)?;
415        let ids = instance.collections.keys().copied();
416        Ok(ids)
417    }
418
419    /// Return the frontiers of the indicated collection.
420    ///
421    /// If an `instance_id` is provided, the collection is assumed to be installed on that
422    /// instance. Otherwise all available instances are searched.
423    pub fn collection_frontiers(
424        &self,
425        collection_id: GlobalId,
426        instance_id: Option<ComputeInstanceId>,
427    ) -> Result<CollectionFrontiers, CollectionLookupError> {
428        let collection = match instance_id {
429            Some(id) => self.instance(id)?.collection(collection_id)?,
430            None => self
431                .instances
432                .values()
433                .find_map(|i| i.collections.get(&collection_id))
434                .ok_or(CollectionMissing(collection_id))?,
435        };
436
437        Ok(collection.frontiers())
438    }
439
440    /// List compute collections that depend on the given collection.
441    pub fn collection_reverse_dependencies(
442        &self,
443        instance_id: ComputeInstanceId,
444        id: GlobalId,
445    ) -> Result<impl Iterator<Item = GlobalId> + '_, InstanceMissing> {
446        let instance = self.instance(instance_id)?;
447        let collections = instance.collections.iter();
448        let ids = collections
449            .filter_map(move |(cid, c)| c.compute_dependencies.contains(&id).then_some(*cid));
450        Ok(ids)
451    }
452
453    /// Requests whether each collection is hydrated on at least one hosting replica.
454    ///
455    /// Collections with no hosting replicas count as hydrated. Missing collections
456    /// have an error entry. A dropped response means the instance shut down
457    /// before answering. This method sends one request and does not wait for it.
458    pub fn collections_hydrated(
459        &self,
460        instance_id: ComputeInstanceId,
461        collection_ids: Vec<GlobalId>,
462    ) -> Result<
463        oneshot::Receiver<BTreeMap<GlobalId, Result<bool, CollectionMissing>>>,
464        InstanceMissing,
465    > {
466        let instance = self.instance(instance_id)?;
467        let (tx, rx) = oneshot::channel();
468        instance.call(move |i| {
469            let result = collection_ids
470                .into_iter()
471                .map(|id| (id, i.collection_hydrated(id)))
472                .collect();
473            let _ = tx.send(result);
474        });
475        Ok(rx)
476    }
477
478    /// Returns `true` if all non-transient, non-excluded collections are ready on any of the
479    /// provided replicas: hydrated, and, when `allowed_lag` is `Some`, no further than that
480    /// behind the furthest output frontier any of the `reference_replicas` reports for the
481    /// collection.
482    ///
483    /// See `Instance::collections_ready_on_replicas` for why hydration alone is not a
484    /// readiness signal for a cut-over.
485    ///
486    /// For this check, zero-replica clusters are always considered ready.
487    /// Their collections would never normally be considered hydrated but it's
488    /// clearly intentional that they have no replicas.
489    pub fn collections_ready_for_replicas(
490        &self,
491        instance_id: ComputeInstanceId,
492        replicas: Vec<ReplicaId>,
493        exclude_collections: BTreeSet<GlobalId>,
494        allowed_lag: Option<Timestamp>,
495        reference_replicas: BTreeSet<ReplicaId>,
496    ) -> Result<oneshot::Receiver<bool>, anyhow::Error> {
497        let instance = self.instance(instance_id)?;
498
499        // Validation
500        if !instance.replicas.is_empty()
501            && !replicas.iter().any(|id| instance.replicas.contains(id))
502        {
503            return Err(HydrationCheckBadTarget(replicas).into());
504        }
505
506        let (tx, rx) = oneshot::channel();
507        instance.call(move |i| {
508            let result = i
509                .collections_ready_on_replicas(
510                    Some(replicas),
511                    &exclude_collections,
512                    allowed_lag,
513                    &reference_replicas,
514                )
515                .expect("validated");
516            let _ = tx.send(result);
517        });
518
519        Ok(rx)
520    }
521
522    /// Returns the state of the [`ComputeController`] formatted as JSON.
523    ///
524    /// The returned value is not guaranteed to be stable and may change at any point in time.
525    pub async fn dump(&self) -> Result<serde_json::Value, anyhow::Error> {
526        // Note: We purposefully use the `Debug` formatting for the value of all fields in the
527        // returned object as a tradeoff between usability and stability. `serde_json` will fail
528        // to serialize an object if the keys aren't strings, so `Debug` formatting the values
529        // prevents a future unrelated change from silently breaking this method.
530
531        // Destructure `self` here so we don't forget to consider dumping newly added fields.
532        let Self {
533            instances,
534            instance_workload_classes,
535            build_info: _,
536            storage_collections: _,
537            initialized,
538            read_only,
539            config: _,
540            peek_stash_persist_location: _,
541            stashed_response,
542            metrics: _,
543            now: _,
544            wallclock_lag: _,
545            dyncfg: _,
546            replica_dyncfg_overrides: _,
547            response_rx: _,
548            response_tx: _,
549            introspection_rx: _,
550            introspection_tx: _,
551            maintenance_ticker: _,
552            maintenance_scheduled,
553        } = self;
554
555        let mut instances_dump = BTreeMap::new();
556        for (id, instance) in instances {
557            let dump = instance.dump().await?;
558            instances_dump.insert(id.to_string(), dump);
559        }
560
561        let instance_workload_classes: BTreeMap<_, _> = instance_workload_classes
562            .lock()
563            .expect("lock poisoned")
564            .iter()
565            .map(|(id, wc)| (id.to_string(), format!("{wc:?}")))
566            .collect();
567
568        Ok(serde_json::json!({
569            "instances": instances_dump,
570            "instance_workload_classes": instance_workload_classes,
571            "initialized": initialized,
572            "read_only": read_only,
573            "stashed_response": format!("{stashed_response:?}"),
574            "maintenance_scheduled": maintenance_scheduled,
575        }))
576    }
577}
578
579impl ComputeController {
580    /// Create a compute instance.
581    pub fn create_instance(
582        &mut self,
583        id: ComputeInstanceId,
584        arranged_logs: BTreeMap<LogVariant, GlobalId>,
585        workload_class: Option<String>,
586    ) -> Result<(), InstanceExists> {
587        if self.instances.contains_key(&id) {
588            return Err(InstanceExists(id));
589        }
590
591        let mut collections = BTreeMap::new();
592        let mut logs = Vec::with_capacity(arranged_logs.len());
593        for (&log, &id) in &arranged_logs {
594            let collection = Collection::new_log();
595            let shared = collection.shared.clone();
596            collections.insert(id, collection);
597            logs.push((log, id, shared));
598        }
599
600        let client = InstanceClient::spawn(
601            id,
602            self.build_info,
603            Arc::clone(&self.storage_collections),
604            self.peek_stash_persist_location.clone(),
605            logs,
606            self.metrics.for_instance(id),
607            self.now.clone(),
608            self.wallclock_lag.clone(),
609            Arc::clone(&self.dyncfg),
610            self.response_tx.clone(),
611            self.introspection_tx.clone(),
612            self.read_only,
613        );
614
615        let instance = InstanceState::new(client, collections);
616        self.instances.insert(id, instance);
617
618        self.instance_workload_classes
619            .lock()
620            .expect("lock poisoned")
621            .insert(id, workload_class.clone());
622
623        let instance = self.instances.get_mut(&id).expect("instance just added");
624        if self.initialized {
625            instance.call(Instance::initialization_complete);
626        }
627
628        // The replica also receives the current dyncfg create-time, folded into `CreateInstance`
629        // so create-time setup observes synced values. This `UpdateConfiguration` is still
630        // required: it carries the rest of `ComputeParameters` (workload class, max result size,
631        // tracing) and syncs the dyncfg into the persist config and metrics, none of which ride in
632        // `CreateInstance`. The overlapping dyncfg application is idempotent.
633        let mut config_params = self.config.clone();
634        config_params.workload_class = Some(workload_class);
635        instance.call(|i| i.update_configuration(config_params));
636
637        Ok(())
638    }
639
640    /// Updates a compute instance's workload class.
641    pub fn update_instance_workload_class(
642        &mut self,
643        id: ComputeInstanceId,
644        workload_class: Option<String>,
645    ) -> Result<(), InstanceMissing> {
646        // Ensure that the instance exists first.
647        let _ = self.instance(id)?;
648
649        self.instance_workload_classes
650            .lock()
651            .expect("lock poisoned")
652            .insert(id, workload_class);
653
654        // Cause a config update to notify the instance about its new workload class.
655        self.update_configuration(Default::default());
656
657        Ok(())
658    }
659
660    /// Remove a compute instance.
661    ///
662    /// # Panics
663    ///
664    /// Panics if the identified `instance` still has active replicas.
665    pub fn drop_instance(&mut self, id: ComputeInstanceId) {
666        if let Some(instance) = self.instances.remove(&id) {
667            instance.call(|i| i.shutdown());
668        }
669
670        self.instance_workload_classes
671            .lock()
672            .expect("lock poisoned")
673            .remove(&id);
674    }
675
676    /// Returns the compute controller's config set.
677    pub fn dyncfg(&self) -> &Arc<ConfigSet> {
678        &self.dyncfg
679    }
680
681    /// Update compute configuration.
682    pub fn update_configuration(&mut self, config_params: ComputeParameters) {
683        // Apply dyncfg updates.
684        config_params.dyncfg_updates.apply(&self.dyncfg);
685
686        let instance_workload_classes = self
687            .instance_workload_classes
688            .lock()
689            .expect("lock poisoned");
690
691        // Forward updates to existing clusters.
692        // Workload classes are cluster-specific, so we need to overwrite them here.
693        for (id, instance) in self.instances.iter_mut() {
694            let mut params = config_params.clone();
695            params.workload_class = Some(instance_workload_classes[id].clone());
696            instance.call(|i| i.update_configuration(params));
697        }
698
699        let overflowing_behavior = ORE_OVERFLOWING_BEHAVIOR.get(&self.dyncfg);
700        match overflowing_behavior.parse() {
701            Ok(behavior) => mz_ore::overflowing::set_behavior(behavior),
702            Err(err) => {
703                tracing::error!(
704                    err,
705                    overflowing_behavior,
706                    "Invalid value for ore_overflowing_behavior"
707                );
708            }
709        }
710
711        // Remember updates for future clusters.
712        self.config.update(config_params);
713    }
714
715    /// Replaces the per-replica dyncfg overrides for the given instances.
716    ///
717    /// This only stores the overrides, here and on the instances; callers
718    /// should follow with a configuration push (e.g.
719    /// [`Self::update_configuration`]) so existing replicas observe the new
720    /// values. Instances absent from `overrides` have their overrides cleared,
721    /// so a replica that no longer has an override reverts to the
722    /// environment-wide configuration. Used by the scoped feature flags
723    /// (replica-local) layer.
724    pub fn update_replica_dyncfg_overrides(
725        &mut self,
726        mut overrides: BTreeMap<ComputeInstanceId, BTreeMap<ReplicaId, ConfigUpdates>>,
727    ) {
728        self.replica_dyncfg_overrides = overrides
729            .values()
730            .flat_map(|replicas| replicas.iter())
731            .map(|(replica_id, updates)| (*replica_id, updates.clone()))
732            .collect();
733        for (id, instance) in self.instances.iter_mut() {
734            let instance_overrides = overrides.remove(id).unwrap_or_default();
735            instance.call(move |i| i.update_replica_dyncfg_overrides(instance_overrides));
736        }
737    }
738
739    /// Mark the end of any initialization commands.
740    ///
741    /// The implementor may wait for this method to be called before implementing prior commands,
742    /// and so it is important for a user to invoke this method as soon as it is comfortable.
743    /// This method can be invoked immediately, at the potential expense of performance.
744    pub fn initialization_complete(&mut self) {
745        self.initialized = true;
746        for instance in self.instances.values_mut() {
747            instance.call(Instance::initialization_complete);
748        }
749    }
750
751    /// Wait until the controller is ready to do some processing.
752    ///
753    /// This method may block for an arbitrarily long time.
754    ///
755    /// When the method returns, the caller should call [`ComputeController::process`].
756    ///
757    /// This method is cancellation safe.
758    pub async fn ready(&mut self) {
759        if self.stashed_response.is_some() {
760            // We still have a response stashed, which we are immediately ready to process.
761            return;
762        }
763        if self.maintenance_scheduled {
764            // Maintenance work has been scheduled.
765            return;
766        }
767
768        tokio::select! {
769            resp = self.response_rx.recv() => {
770                let resp = resp.expect("`self.response_tx` not dropped");
771                self.stashed_response = Some(resp);
772            }
773            _ = self.maintenance_ticker.tick() => {
774                self.maintenance_scheduled = true;
775            },
776        }
777    }
778
779    /// Adds replicas of an instance.
780    pub fn add_replica_to_instance(
781        &mut self,
782        instance_id: ComputeInstanceId,
783        replica_id: ReplicaId,
784        location: ClusterReplicaLocation,
785        config: ComputeReplicaConfig,
786    ) -> Result<(), ReplicaCreationError> {
787        use ReplicaCreationError::*;
788
789        let instance = self.instance(instance_id)?;
790
791        // Validation
792        if instance.replicas.contains(&replica_id) {
793            return Err(ReplicaExists(replica_id));
794        }
795
796        let (enable_logging, interval) = match config.logging.interval {
797            Some(interval) => (true, interval),
798            None => (false, Duration::from_secs(1)),
799        };
800
801        // Both configs below are `ParameterScope::Replica` and are resolved
802        // here, once, for the replica being created. Reading them through the
803        // new replica's scoped overrides is what makes those declarations
804        // effective: the values are frozen into `ReplicaConfig` and never
805        // re-read from the environment-wide set. The overrides for a replica
806        // created by DDL are committed in the same transaction that creates it,
807        // so they are already installed by the time we get here.
808        let overrides = self.replica_dyncfg_overrides.get(&replica_id);
809
810        let expiration_offset =
811            COMPUTE_REPLICA_EXPIRATION_OFFSET.get_with_overrides(&self.dyncfg, overrides);
812
813        // Capture dictionary compression once, at replica creation, and hold it fixed for the
814        // replica's lifetime (see `InstanceConfig::arrangement_dictionary_compression`). This is
815        // why a later flip of the flag only affects replicas created afterwards. The feature flag
816        // only gates the feature: a replica honors its per-cluster/replica configured value only
817        // while the flag is enabled, so turning the flag off disables compression on new or
818        // restarted replicas regardless of their configuration.
819        let arrangement_dictionary_compression = ENABLE_ARRANGEMENT_DICTIONARY_COMPRESSION_ALPHA
820            .get_with_overrides(&self.dyncfg, overrides)
821            && config.arrangement_compression;
822
823        let replica_config = ReplicaConfig {
824            location,
825            logging: LoggingConfig {
826                interval,
827                enable_logging,
828                log_logging: config.logging.log_logging,
829                index_logs: Default::default(),
830            },
831            grpc_client: self.config.grpc_client.clone(),
832            expiration_offset: (!expiration_offset.is_zero()).then_some(expiration_offset),
833            arrangement_dictionary_compression,
834        };
835
836        let dyncfg_override = overrides.cloned().unwrap_or_default();
837
838        let instance = self.instance_mut(instance_id).expect("validated");
839        instance.replicas.insert(replica_id);
840
841        instance.call(move |i| {
842            i.add_replica(replica_id, replica_config, None, dyncfg_override)
843                .expect("validated")
844        });
845
846        Ok(())
847    }
848
849    /// Removes a replica from an instance, including its service in the orchestrator.
850    pub fn drop_replica(
851        &mut self,
852        instance_id: ComputeInstanceId,
853        replica_id: ReplicaId,
854    ) -> Result<(), ReplicaDropError> {
855        use ReplicaDropError::*;
856
857        let instance = self.instance_mut(instance_id)?;
858
859        // Validation
860        if !instance.replicas.contains(&replica_id) {
861            return Err(ReplicaMissing(replica_id));
862        }
863
864        instance.replicas.remove(&replica_id);
865
866        // The coordinator only re-pushes the override map when the scoped
867        // configuration itself changes, so a dropped replica's entry would
868        // otherwise be retained until the next such change.
869        self.replica_dyncfg_overrides.remove(&replica_id);
870
871        let instance = self.instance_mut(instance_id).expect("validated");
872        instance.call(move |i| i.remove_replica(replica_id).expect("validated"));
873
874        Ok(())
875    }
876
877    /// Creates the described dataflow and initializes state for its output.
878    ///
879    /// Only sink exports are allowed to have a `target_replica`: materialized views, subscribes,
880    /// and metric sinks. A user's `CREATE METRIC SINK` runs untargeted, so every replica renders it
881    /// into its own registry. The coordinator's curated metric sinks are installed per replica and
882    /// do target one, so each replica's series are attributable to it.
883    ///
884    /// Panics if called with a dataflow description that has index exports
885    /// when `target_replica` is set.
886    pub fn create_dataflow(
887        &mut self,
888        instance_id: ComputeInstanceId,
889        mut dataflow: mz_compute_types::dataflows::LirDataflowDescription,
890        target_replica: Option<ReplicaId>,
891    ) -> Result<(), DataflowCreationError> {
892        use DataflowCreationError::*;
893
894        let instance = self.instance(instance_id)?;
895
896        // Validation: target replica
897        if let Some(replica_id) = target_replica {
898            if !instance.replicas.contains(&replica_id) {
899                return Err(ReplicaMissing(replica_id));
900            }
901            assert!(
902                dataflow.exported_index_ids().next().is_none(),
903                "Replica-targeted indexes are not supported"
904            );
905        }
906
907        // Validation: as_of
908        let as_of = dataflow.as_of.as_ref().ok_or(MissingAsOf)?;
909        if as_of.is_empty() && dataflow.subscribe_ids().next().is_some() {
910            return Err(EmptyAsOfForSubscribe);
911        }
912        if as_of.is_empty() && dataflow.copy_to_ids().next().is_some() {
913            return Err(EmptyAsOfForCopyTo);
914        }
915
916        // Validation: the dataflow exports something
917        //
918        // An export-less description has nothing to render and no answer to "what do the exports
919        // read", which the checks below are phrased in terms of. `optimize_dataflow` leaves such a
920        // description's imports alone for that reason, so one arriving here would fail the import
921        // check for the wrong reason.
922        soft_assert_or_log!(
923            !dataflow.index_exports.is_empty() || !dataflow.sink_exports.is_empty(),
924            "dataflow {} has no exports",
925            dataflow.debug_name,
926        );
927
928        // The imports the exports actually read. `optimize_dataflow` prunes the import list to
929        // exactly this set, so the two agree unless a producer stopped pruning.
930        //
931        // Computed once and used twice: the check below reports a loose list, and
932        // `determine_time_dependence` counts through it rather than over the raw list. That
933        // consumer is the one whose wrong answer hangs an environment: an import no export reads
934        // would report wall-clock dependence for a dataflow whose exports are constant, earning it
935        // a dataflow expiration that pins the output frontier days short of the empty antichain,
936        // and nothing downstream would learn the collection is final. Deriving it from this set
937        // makes that correct by construction, leaving the prune to reclaim the read hold and the
938        // persist source.
939        let used_imports = dataflow.used_import_ids();
940
941        // Validation: every import is read
942        //
943        // The read holds and the persist sources the replicas build are still derived from the raw
944        // list below, so a loose one describes a dataflow other than the one that will run. A
945        // logging variant rather than `soft_assert_no_log!`: the walk is paid for above either way,
946        // so reporting it in production costs only the comparison.
947        soft_assert_or_log!(
948            dataflow.import_ids().all(|id| used_imports.contains(&id)),
949            "dataflow {} imports collections no export reads: imports {:?}, read {:?}",
950            dataflow.debug_name,
951            dataflow.import_ids().collect::<Vec<_>>(),
952            used_imports,
953        );
954
955        // Validation: input collections
956        let storage_ids = dataflow.imported_source_ids().collect();
957        let mut import_read_holds = self.storage_collections.acquire_read_holds(storage_ids)?;
958        for id in dataflow.imported_index_ids() {
959            let read_hold = instance.acquire_read_hold(id)?;
960            import_read_holds.push(read_hold);
961        }
962        for hold in &import_read_holds {
963            if PartialOrder::less_than(as_of, hold.since()) {
964                return Err(SinceViolation(hold.id()));
965            }
966        }
967
968        // Validation: storage sink collections
969        for id in dataflow.persist_sink_ids() {
970            if self.storage_collections.check_exists(id).is_err() {
971                return Err(CollectionMissing(id));
972            }
973        }
974        let time_dependence = self
975            .determine_time_dependence(instance_id, &dataflow, &used_imports)
976            .expect("must exist");
977
978        let instance = self.instance_mut(instance_id).expect("validated");
979
980        let mut shared_collection_state = BTreeMap::new();
981        for id in dataflow.export_ids() {
982            let shared = SharedCollectionState::new(as_of.clone());
983            let collection = Collection {
984                write_only: dataflow.sink_exports.contains_key(&id),
985                compute_dependencies: dataflow.imported_index_ids().collect(),
986                shared: shared.clone(),
987                time_dependence: time_dependence.clone(),
988            };
989            instance.collections.insert(id, collection);
990            shared_collection_state.insert(id, shared);
991        }
992
993        dataflow.time_dependence = time_dependence;
994
995        instance.call(move |i| {
996            i.create_dataflow(
997                dataflow,
998                import_read_holds,
999                shared_collection_state,
1000                target_replica,
1001            )
1002            .expect("validated")
1003        });
1004
1005        Ok(())
1006    }
1007
1008    /// Drop the read capability for the given collections and allow their resources to be
1009    /// reclaimed.
1010    pub fn drop_collections(
1011        &mut self,
1012        instance_id: ComputeInstanceId,
1013        collection_ids: Vec<GlobalId>,
1014    ) -> Result<(), CollectionUpdateError> {
1015        let instance = self.instance_mut(instance_id)?;
1016
1017        // Validation
1018        for id in &collection_ids {
1019            instance.collection(*id)?;
1020        }
1021
1022        for id in &collection_ids {
1023            instance.collections.remove(id);
1024        }
1025
1026        instance.call(|i| i.drop_collections(collection_ids).expect("validated"));
1027
1028        Ok(())
1029    }
1030
1031    /// Initiate a peek request for the contents of the given collection at `timestamp`.
1032    ///
1033    /// The caller supplies a `read_hold` for the peek target — via
1034    /// [`ComputeController::acquire_read_hold`] for `PeekTarget::Index`, or via the storage
1035    /// collections for `PeekTarget::Persist`. The hold keeps the collection's `since` at
1036    /// `<= timestamp` until the peek completes.
1037    pub fn peek(
1038        &self,
1039        instance_id: ComputeInstanceId,
1040        peek_target: PeekTarget,
1041        literal_constraints: Option<Vec<Row>>,
1042        uuid: Uuid,
1043        timestamp: Timestamp,
1044        result_desc: RelationDesc,
1045        finishing: RowSetFinishing,
1046        map_filter_project: mz_expr::SafeMfpPlan,
1047        read_hold: ReadHold,
1048        target_replica: Option<ReplicaId>,
1049        peek_response_tx: oneshot::Sender<PeekResponse>,
1050    ) -> Result<(), PeekError> {
1051        use PeekError::*;
1052
1053        let instance = self.instance(instance_id)?;
1054
1055        // Validation: target replica
1056        if let Some(replica_id) = target_replica {
1057            if !instance.replicas.contains(&replica_id) {
1058                return Err(ReplicaMissing(replica_id));
1059            }
1060        }
1061
1062        // Validation: the read hold must target this collection and must hold its `since`
1063        // at `<= timestamp`.
1064        if read_hold.id() != peek_target.id() {
1065            return Err(ReadHoldIdMismatch(read_hold.id()));
1066        }
1067        if !read_hold.since().less_equal(&timestamp) {
1068            return Err(SinceViolation(peek_target.id()));
1069        }
1070
1071        instance.call(move |i| {
1072            i.peek(
1073                peek_target,
1074                literal_constraints,
1075                uuid,
1076                timestamp,
1077                result_desc,
1078                finishing,
1079                map_filter_project,
1080                read_hold,
1081                target_replica,
1082                peek_response_tx,
1083            )
1084            .expect("validated")
1085        });
1086
1087        Ok(())
1088    }
1089
1090    /// Cancel an existing peek request.
1091    ///
1092    /// Canceling a peek is best effort. The caller may see any of the following
1093    /// after canceling a peek request:
1094    ///
1095    ///   * A `PeekResponse::Rows` indicating that the cancellation request did
1096    ///     not take effect in time and the query succeeded.
1097    ///   * A `PeekResponse::Canceled` affirming that the peek was canceled.
1098    ///   * No `PeekResponse` at all.
1099    pub fn cancel_peek(
1100        &self,
1101        instance_id: ComputeInstanceId,
1102        uuid: Uuid,
1103        reason: PeekResponse,
1104    ) -> Result<(), InstanceMissing> {
1105        self.instance(instance_id)?
1106            .call(move |i| i.cancel_peek(uuid, reason));
1107        Ok(())
1108    }
1109
1110    /// Assign a read policy to specific identifiers.
1111    ///
1112    /// The policies are assigned in the order presented, and repeated identifiers should
1113    /// conclude with the last policy. Changing a policy will immediately downgrade the read
1114    /// capability if appropriate, but it will not "recover" the read capability if the prior
1115    /// capability is already ahead of it.
1116    ///
1117    /// Identifiers not present in `policies` retain their existing read policies.
1118    ///
1119    /// It is an error to attempt to set a read policy for a collection that is not readable in the
1120    /// context of compute. At this time, only indexes are readable compute collections.
1121    pub fn set_read_policy(
1122        &self,
1123        instance_id: ComputeInstanceId,
1124        policies: Vec<(GlobalId, ReadPolicy)>,
1125    ) -> Result<(), ReadPolicyError> {
1126        use ReadPolicyError::*;
1127
1128        let instance = self.instance(instance_id)?;
1129
1130        // Validation
1131        for (id, _) in &policies {
1132            let collection = instance.collection(*id)?;
1133            if collection.write_only {
1134                return Err(WriteOnlyCollection(*id));
1135            }
1136        }
1137
1138        self.instance(instance_id)?
1139            .call(|i| i.set_read_policy(policies).expect("validated"));
1140
1141        Ok(())
1142    }
1143
1144    /// Acquires a [`ReadHold`] for the identified compute collection.
1145    pub fn acquire_read_hold(
1146        &self,
1147        instance_id: ComputeInstanceId,
1148        collection_id: GlobalId,
1149    ) -> Result<ReadHold, CollectionUpdateError> {
1150        let read_hold = self
1151            .instance(instance_id)?
1152            .acquire_read_hold(collection_id)?;
1153        Ok(read_hold)
1154    }
1155
1156    /// Determine the time dependence for a dataflow.
1157    ///
1158    /// `used_imports` are the imports the exports read, as
1159    /// [`DataflowDescription::used_import_ids`] reports them. Only those count: an import no export
1160    /// reads would report wall-clock dependence for a dataflow whose exports are constant, and that
1161    /// earns it a dataflow expiration, which pins its output frontier at the expiration time. A
1162    /// constant export's frontier is the empty antichain, so the pin would hold it days short of
1163    /// the truth and whoever reads that frontier would never learn the collection can no longer
1164    /// change.
1165    ///
1166    /// `optimize_dataflow` prunes the import list to this set, so the two agree and the filtering
1167    /// is a no-op. It is here because this is the consumer whose wrong answer hangs an environment,
1168    /// and deriving the answer from the read set makes it independent of the list staying tight.
1169    ///
1170    /// [`DataflowDescription::used_import_ids`]: mz_compute_types::dataflows::DataflowDescription::used_import_ids
1171    fn determine_time_dependence(
1172        &self,
1173        instance_id: ComputeInstanceId,
1174        dataflow: &mz_compute_types::dataflows::LirDataflowDescription,
1175        used_imports: &BTreeSet<GlobalId>,
1176    ) -> Result<Option<TimeDependence>, TimeDependenceError> {
1177        let instance = self
1178            .instance(instance_id)
1179            .map_err(|err| TimeDependenceError::InstanceMissing(err.0))?;
1180        let mut time_dependencies = Vec::new();
1181
1182        for id in dataflow
1183            .imported_index_ids()
1184            .filter(|id| used_imports.contains(id))
1185        {
1186            let dependence = instance
1187                .get_time_dependence(id)
1188                .map_err(|err| TimeDependenceError::CollectionMissing(err.0))?;
1189            time_dependencies.push(dependence);
1190        }
1191
1192        'source: for id in dataflow
1193            .imported_source_ids()
1194            .filter(|id| used_imports.contains(id))
1195        {
1196            // We first check whether the id is backed by a compute object, in which case we use
1197            // the time dependence we know. This is true for storage sinks.
1198            for instance in self.instances.values() {
1199                if let Ok(dependence) = instance.get_time_dependence(id) {
1200                    time_dependencies.push(dependence);
1201                    continue 'source;
1202                }
1203            }
1204
1205            // Not a compute object: Consult the storage collections controller.
1206            time_dependencies.push(self.storage_collections.determine_time_dependence(id)?);
1207        }
1208
1209        Ok(TimeDependence::merge(
1210            time_dependencies,
1211            dataflow.refresh_schedule.as_ref(),
1212        ))
1213    }
1214
1215    /// Processes the work queued by [`ComputeController::ready`].
1216    #[mz_ore::instrument(level = "debug")]
1217    pub fn process(&mut self) -> Option<ComputeControllerResponse> {
1218        // Perform periodic maintenance work.
1219        if self.maintenance_scheduled {
1220            self.maintain();
1221            self.maintenance_scheduled = false;
1222        }
1223
1224        // Return a ready response, if any.
1225        self.stashed_response.take()
1226    }
1227
1228    #[mz_ore::instrument(level = "debug")]
1229    fn maintain(&mut self) {
1230        // Perform instance maintenance work.
1231        for instance in self.instances.values_mut() {
1232            instance.call(Instance::maintain);
1233        }
1234    }
1235
1236    /// Allow writes for the specified collections on `instance_id`.
1237    ///
1238    /// If this controller is in read-only mode, this is a no-op.
1239    pub fn allow_writes(
1240        &mut self,
1241        instance_id: ComputeInstanceId,
1242        collection_id: GlobalId,
1243    ) -> Result<(), CollectionUpdateError> {
1244        if self.read_only {
1245            tracing::debug!("Skipping allow_writes in read-only mode");
1246            return Ok(());
1247        }
1248
1249        self.allow_writes_inner(instance_id, collection_id)
1250    }
1251
1252    /// Like [`Self::allow_writes`], but takes effect even in read-only mode.
1253    ///
1254    /// The caller must guarantee that no leader environment writes the collection's output shard.
1255    /// In a 0dt deployment that means a shard this environment created for itself, the replacement
1256    /// shard of a `Replacement`-migrated builtin collection, never one the leader is still serving
1257    /// from. `Evolution` migrates in place and reuses the leader's shard, so it must not reach this
1258    /// path. Violating the guarantee races two writers on one shard.
1259    ///
1260    /// NOTE: ownership is exclusive per (build version, deploy generation), not per process: the
1261    /// migration shard entry naming the shard is keyed by that pair and a read-only catalog open is
1262    /// a savepoint, so two read-only processes of one generation both write it. Same shape as a
1263    /// multi-replica materialized view, which the self-correcting persist sink tolerates (see the
1264    /// `mz_compute::sink::materialized_view` module docs).
1265    ///
1266    /// This is the compute-side counterpart to the storage controller's `force_writable` handling
1267    /// of migrated storage collections. Migrated builtin tables are storage collections that
1268    /// storage force-writes read-only; migrated builtin MVs are compute collections that only this
1269    /// path can force-write. Both rest on the same guarantee (this environment exclusively owns the
1270    /// replacement shard) but run on separate write paths, so each needs its own bypass.
1271    ///
1272    /// NOTE: the replica-side handler enables persist compaction process-wide on the clusterd
1273    /// (`ComputeState::handle_allow_writes`), which this path is the first to trigger inside a
1274    /// read-only deployment.
1275    pub fn allow_writes_in_read_only(
1276        &mut self,
1277        instance_id: ComputeInstanceId,
1278        collection_id: GlobalId,
1279    ) -> Result<(), CollectionUpdateError> {
1280        // Every builtin eligible for this bypass has a system id, so a non-system id means the
1281        // caller's `Replacement`-only invariant broke. No-op rather than risk writing a shard the
1282        // leader still serves. The collection then sits in the caught-up gate on an unwritten
1283        // shard and blocks promotion, which is the loud, safe direction to fail.
1284        //
1285        // Storage asserts the same invariant hard, in
1286        // `StorageController::register_introspection_collection`. The asymmetry is deliberate: a
1287        // soft panic keeps a caller bug visible in CI and Sentry without downing production.
1288        if self.read_only && !collection_id.is_system() {
1289            soft_panic_or_log!(
1290                "allow_writes_in_read_only called for non-system collection {collection_id}; \
1291                 falling back to read-only no-op"
1292            );
1293            return Ok(());
1294        }
1295
1296        self.allow_writes_inner(instance_id, collection_id)
1297    }
1298
1299    fn allow_writes_inner(
1300        &mut self,
1301        instance_id: ComputeInstanceId,
1302        collection_id: GlobalId,
1303    ) -> Result<(), CollectionUpdateError> {
1304        let instance = self.instance_mut(instance_id)?;
1305
1306        // Validation
1307        instance.collection(collection_id)?;
1308
1309        instance.call(move |i| i.allow_writes(collection_id).expect("validated"));
1310
1311        Ok(())
1312    }
1313}
1314
1315#[derive(Debug)]
1316struct InstanceState {
1317    client: InstanceClient,
1318    replicas: BTreeSet<ReplicaId>,
1319    collections: BTreeMap<GlobalId, Collection>,
1320}
1321
1322impl InstanceState {
1323    fn new(client: InstanceClient, collections: BTreeMap<GlobalId, Collection>) -> Self {
1324        Self {
1325            client,
1326            replicas: Default::default(),
1327            collections,
1328        }
1329    }
1330
1331    fn collection(&self, id: GlobalId) -> Result<&Collection, CollectionMissing> {
1332        self.collections.get(&id).ok_or(CollectionMissing(id))
1333    }
1334
1335    /// Calls the given function on the instance task. Does not await the result.
1336    ///
1337    /// # Panics
1338    ///
1339    /// Panics if the instance corresponding to `self` does not exist.
1340    fn call<F>(&self, f: F)
1341    where
1342        F: FnOnce(&mut Instance) + Send + 'static,
1343    {
1344        self.client.call(f).expect("instance not dropped")
1345    }
1346
1347    /// Calls the given function on the instance task, and awaits the result.
1348    ///
1349    /// # Panics
1350    ///
1351    /// Panics if the instance corresponding to `self` does not exist.
1352    async fn call_sync<F, R>(&self, f: F) -> R
1353    where
1354        F: FnOnce(&mut Instance) -> R + Send + 'static,
1355        R: Send + 'static,
1356    {
1357        self.client
1358            .call_sync(f)
1359            .await
1360            .expect("instance not dropped")
1361    }
1362
1363    /// Acquires a [`ReadHold`] for the identified compute collection.
1364    pub fn acquire_read_hold(&self, id: GlobalId) -> Result<ReadHold, CollectionMissing> {
1365        // We acquire read holds at the earliest possible time rather than returning a copy
1366        // of the implied read hold. This is so that in `create_dataflow` we can acquire read holds
1367        // on compute dependencies at frontiers that are held back by other read holds the caller
1368        // has previously taken.
1369        //
1370        // If/when we change the compute API to expect callers to pass in the `ReadHold`s rather
1371        // than acquiring them ourselves, we might tighten this up and instead acquire read holds
1372        // at the implied capability.
1373
1374        let collection = self.collection(id)?;
1375        let since = collection.shared.lock_read_capabilities(|caps| {
1376            let since = caps.frontier().to_owned();
1377            caps.update_iter(since.iter().map(|t| (t.clone(), 1)));
1378            since
1379        });
1380
1381        let hold = ReadHold::new(id, since, self.client.read_hold_tx());
1382        Ok(hold)
1383    }
1384
1385    /// Return the stored time dependence for a collection.
1386    fn get_time_dependence(
1387        &self,
1388        id: GlobalId,
1389    ) -> Result<Option<TimeDependence>, CollectionMissing> {
1390        Ok(self.collection(id)?.time_dependence.clone())
1391    }
1392
1393    /// Returns the [`InstanceState`] formatted as JSON.
1394    pub async fn dump(&self) -> Result<serde_json::Value, anyhow::Error> {
1395        // Destructure `self` here so we don't forget to consider dumping newly added fields.
1396        let Self {
1397            client: _,
1398            replicas,
1399            collections,
1400        } = self;
1401
1402        let instance = self.call_sync(|i| i.dump()).await?;
1403        let replicas: Vec<_> = replicas.iter().map(|id| id.to_string()).collect();
1404        let collections: BTreeMap<_, _> = collections
1405            .iter()
1406            .map(|(id, c)| (id.to_string(), format!("{c:?}")))
1407            .collect();
1408
1409        Ok(serde_json::json!({
1410            "instance": instance,
1411            "replicas": replicas,
1412            "collections": collections,
1413        }))
1414    }
1415}
1416
1417#[derive(Debug)]
1418struct Collection {
1419    /// Whether a collection is write-only, i.e., we cannot read it directly like an index.
1420    write_only: bool,
1421    compute_dependencies: BTreeSet<GlobalId>,
1422    shared: SharedCollectionState,
1423    /// The computed time dependence for this collection. None indicates no specific information,
1424    /// a value describes how the collection relates to wall-clock time.
1425    time_dependence: Option<TimeDependence>,
1426}
1427
1428impl Collection {
1429    fn new_log() -> Self {
1430        let as_of = Antichain::from_elem(Timestamp::MIN);
1431        Self {
1432            write_only: false,
1433            compute_dependencies: Default::default(),
1434            shared: SharedCollectionState::new(as_of),
1435            time_dependence: Some(TimeDependence::default()),
1436        }
1437    }
1438
1439    fn frontiers(&self) -> CollectionFrontiers {
1440        let read_frontier = self
1441            .shared
1442            .lock_read_capabilities(|c| c.frontier().to_owned());
1443        let write_frontier = self.shared.lock_write_frontier(|f| f.clone());
1444        CollectionFrontiers {
1445            read_frontier,
1446            write_frontier,
1447        }
1448    }
1449}
1450
1451/// The frontiers of a compute collection.
1452#[derive(Clone, Debug)]
1453pub struct CollectionFrontiers {
1454    /// The read frontier.
1455    pub read_frontier: Antichain<Timestamp>,
1456    /// The write frontier.
1457    pub write_frontier: Antichain<Timestamp>,
1458}
1459
1460impl Default for CollectionFrontiers {
1461    fn default() -> Self {
1462        Self {
1463            read_frontier: Antichain::from_elem(Timestamp::MIN),
1464            write_frontier: Antichain::from_elem(Timestamp::MIN),
1465        }
1466    }
1467}