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(×tamp) {
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}