Skip to main content

mz_storage_client/
client.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#![allow(missing_docs)]
11
12//! The public API of the storage layer.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt::Debug;
16use std::iter;
17
18use differential_dataflow::difference::Monoid;
19use differential_dataflow::lattice::Lattice;
20use mz_cluster_client::ReplicaId;
21use mz_cluster_client::client::TryIntoProtocolNonce;
22use mz_ore::assert_none;
23use mz_persist_client::batch::{BatchBuilder, ProtoBatch};
24use mz_persist_client::write::WriteHandle;
25use mz_persist_types::{Codec, Codec64, StepForward};
26use mz_repr::{Diff, GlobalId, Row, Timestamp};
27use mz_service::client::{Partitionable, PartitionedState};
28use mz_storage_types::controller::CollectionMetadata;
29use mz_storage_types::oneshot_sources::OneshotIngestionRequest;
30use mz_storage_types::parameters::StorageParameters;
31use mz_storage_types::sinks::StorageSinkDesc;
32use mz_storage_types::sources::IngestionDescription;
33use serde::{Deserialize, Serialize};
34use smallvec::SmallVec;
35use timely::PartialOrder;
36use timely::progress::frontier::{Antichain, MutableAntichain};
37use uuid::Uuid;
38
39use crate::statistics::{SinkStatisticsUpdate, SourceStatisticsUpdate};
40
41/// Commands related to the ingress and egress of collections.
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
43pub enum StorageCommand {
44    /// Transmits connection meta information, before other commands are sent.
45    Hello {
46        nonce: Uuid,
47    },
48    /// Indicates that the controller has sent all commands reflecting its
49    /// initial state.
50    InitializationComplete,
51    /// `AllowWrites` informs the replica that it can transition out of the
52    /// read-only stage and into the read-write computation stage.
53    /// It is now allowed to affect changes to external systems (writes).
54    ///
55    /// See `ComputeCommand::AllowWrites` for details. This command works
56    /// analogously to the compute version.
57    AllowWrites,
58    /// Update storage instance configuration.
59    UpdateConfiguration(Box<StorageParameters>),
60    /// Run the specified ingestion dataflow.
61    RunIngestion(Box<RunIngestionCommand>),
62    /// Enable compaction in storage-managed collections.
63    ///
64    /// A collection id and a frontier after which accumulations must be correct.
65    AllowCompaction(GlobalId, Antichain<Timestamp>),
66    RunSink(Box<RunSinkCommand>),
67    /// Run a dataflow which will ingest data from an external source and only __stage__ it in
68    /// Persist.
69    ///
70    /// Unlike regular ingestions/sources, some other component (e.g. `environmentd`) is
71    /// responsible for linking the staged data into a shard.
72    RunOneshotIngestion(Box<RunOneshotIngestion>),
73    /// `CancelOneshotIngestion` instructs the replica to cancel the identified oneshot ingestion.
74    ///
75    /// It is invalid to send a [`CancelOneshotIngestion`] command that references a oneshot
76    /// ingestion that was not created by a corresponding [`RunOneshotIngestion`] command before.
77    /// Doing so may cause the replica to exhibit undefined behavior.
78    ///
79    /// [`CancelOneshotIngestion`]: crate::client::StorageCommand::CancelOneshotIngestion
80    /// [`RunOneshotIngestion`]: crate::client::StorageCommand::RunOneshotIngestion
81    CancelOneshotIngestion(Uuid),
82}
83
84impl StorageCommand {
85    /// Returns whether this command instructs the installation of storage objects.
86    pub fn installs_objects(&self) -> bool {
87        use StorageCommand::*;
88        match self {
89            Hello { .. }
90            | InitializationComplete
91            | AllowWrites
92            | UpdateConfiguration(_)
93            | AllowCompaction(_, _)
94            | CancelOneshotIngestion { .. } => false,
95            // TODO(cf2): multi-replica oneshot ingestions. At the moment returning
96            // true here means we can't run `COPY FROM` on multi-replica clusters, this
97            // should be easy enough to support though.
98            RunIngestion(_) | RunSink(_) | RunOneshotIngestion(_) => true,
99        }
100    }
101}
102
103/// A command that starts ingesting the given ingestion description
104#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
105pub struct RunIngestionCommand {
106    /// The id of the storage collection being ingested.
107    pub id: GlobalId,
108    /// The description of what source type should be ingested and what post-processing steps must
109    /// be applied to the data before writing them down into the storage collection
110    pub description: IngestionDescription<CollectionMetadata>,
111}
112
113/// A command that starts ingesting the given ingestion description
114#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
115pub struct RunOneshotIngestion {
116    /// The ID of the ingestion dataflow.
117    pub ingestion_id: uuid::Uuid,
118    /// The ID of collection we'll stage batches for.
119    pub collection_id: GlobalId,
120    /// Metadata for the collection we'll stage batches for.
121    pub collection_meta: CollectionMetadata,
122    /// Details for the oneshot ingestion.
123    pub request: OneshotIngestionRequest,
124}
125
126/// A command that starts exporting the given sink description
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
128pub struct RunSinkCommand {
129    pub id: GlobalId,
130    pub description: StorageSinkDesc<CollectionMetadata>,
131}
132
133/// A "kind" enum for statuses tracked by the health operator
134#[derive(
135    Copy,
136    Clone,
137    Debug,
138    Serialize,
139    Deserialize,
140    PartialEq,
141    Eq,
142    PartialOrd,
143    Ord
144)]
145pub enum Status {
146    Starting,
147    Running,
148    Paused,
149    Stalled,
150    /// This status is currently unused.
151    // re-design the ceased status
152    Ceased,
153    Dropped,
154}
155
156impl std::str::FromStr for Status {
157    type Err = anyhow::Error;
158    /// Keep in sync with [`Status::to_str`].
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        Ok(match s {
161            "starting" => Status::Starting,
162            "running" => Status::Running,
163            "paused" => Status::Paused,
164            "stalled" => Status::Stalled,
165            "ceased" => Status::Ceased,
166            "dropped" => Status::Dropped,
167            s => return Err(anyhow::anyhow!("{} is not a valid status", s)),
168        })
169    }
170}
171
172impl Status {
173    /// Keep in sync with `Status::from_str`.
174    pub fn to_str(&self) -> &'static str {
175        match self {
176            Status::Starting => "starting",
177            Status::Running => "running",
178            Status::Paused => "paused",
179            Status::Stalled => "stalled",
180            Status::Ceased => "ceased",
181            Status::Dropped => "dropped",
182        }
183    }
184
185    /// Determines if a new status should be produced in context of a previous
186    /// status.
187    pub fn superseded_by(self, new: Status) -> bool {
188        match (self, new) {
189            (_, Status::Dropped) => true,
190            (Status::Dropped, _) => false,
191            // Don't re-mark that object as paused.
192            (Status::Paused, Status::Paused) => false,
193            // De-duplication of other statuses is currently managed by the
194            // `health_operator`.
195            _ => true,
196        }
197    }
198}
199
200/// A source or sink status update.
201///
202/// Represents a status update for a given object type. The inner value for each
203/// variant should be able to be packed into a status row that conforms to the schema
204/// for the object's status history relation.
205#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
206pub struct StatusUpdate {
207    pub id: GlobalId,
208    pub status: Status,
209    pub timestamp: chrono::DateTime<chrono::Utc>,
210    pub error: Option<String>,
211    pub hints: BTreeSet<String>,
212    pub namespaced_errors: BTreeMap<String, String>,
213    pub replica_id: Option<ReplicaId>,
214}
215
216impl StatusUpdate {
217    pub fn new(
218        id: GlobalId,
219        timestamp: chrono::DateTime<chrono::Utc>,
220        status: Status,
221    ) -> StatusUpdate {
222        StatusUpdate {
223            id,
224            timestamp,
225            status,
226            error: None,
227            hints: Default::default(),
228            namespaced_errors: Default::default(),
229            replica_id: None,
230        }
231    }
232}
233
234impl From<StatusUpdate> for Row {
235    fn from(update: StatusUpdate) -> Self {
236        use mz_repr::Datum;
237
238        let timestamp = Datum::TimestampTz(update.timestamp.try_into().expect("must fit"));
239        let id = update.id.to_string();
240        let id = Datum::String(&id);
241        let status = Datum::String(update.status.to_str());
242        let error = update.error.as_deref().into();
243
244        let mut row = Row::default();
245        let mut packer = row.packer();
246        packer.extend([timestamp, id, status, error]);
247
248        if !update.hints.is_empty() || !update.namespaced_errors.is_empty() {
249            packer.push_dict_with(|dict_packer| {
250                // `hint` and `namespaced` are ordered,
251                // as well as the BTree's they each contain.
252                if !update.hints.is_empty() {
253                    dict_packer.push(Datum::String("hints"));
254                    dict_packer.push_list(update.hints.iter().map(|s| Datum::String(s)));
255                }
256                if !update.namespaced_errors.is_empty() {
257                    dict_packer.push(Datum::String("namespaced"));
258                    dict_packer.push_dict(
259                        update
260                            .namespaced_errors
261                            .iter()
262                            .map(|(k, v)| (k.as_str(), Datum::String(v))),
263                    );
264                }
265            });
266        } else {
267            packer.push(Datum::Null);
268        }
269
270        match update.replica_id {
271            Some(id) => packer.push(Datum::String(&id.to_string())),
272            None => packer.push(Datum::Null),
273        }
274
275        row
276    }
277}
278
279/// An update to an append only collection.
280pub enum AppendOnlyUpdate {
281    Row((Row, Diff)),
282    Status(StatusUpdate),
283}
284
285impl AppendOnlyUpdate {
286    pub fn into_row(self) -> (Row, Diff) {
287        match self {
288            AppendOnlyUpdate::Row((row, diff)) => (row, diff),
289            AppendOnlyUpdate::Status(status) => (Row::from(status), Diff::ONE),
290        }
291    }
292}
293
294impl From<(Row, Diff)> for AppendOnlyUpdate {
295    fn from((row, diff): (Row, Diff)) -> Self {
296        Self::Row((row, diff))
297    }
298}
299
300impl From<StatusUpdate> for AppendOnlyUpdate {
301    fn from(update: StatusUpdate) -> Self {
302        Self::Status(update)
303    }
304}
305
306/// Responses that the storage nature of a worker/dataflow can provide back to the coordinator.
307#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
308pub enum StorageResponse {
309    /// A new upper frontier for the specified identifier.
310    FrontierUpper(GlobalId, Antichain<Timestamp>),
311    /// Punctuation indicates that no more responses will be transmitted for the specified id
312    DroppedId(GlobalId),
313    /// Batches that have been staged in Persist and maybe will be linked into a shard.
314    StagedBatches(BTreeMap<uuid::Uuid, Vec<Result<ProtoBatch, String>>>),
315    /// A list of statistics updates, currently only for sources.
316    StatisticsUpdates(Vec<SourceStatisticsUpdate>, Vec<SinkStatisticsUpdate>),
317    /// A status update for a source or a sink. Periodically sent from
318    /// storage workers to convey the latest status information about an object.
319    StatusUpdate(StatusUpdate),
320}
321
322/// Maintained state for partitioned storage clients.
323///
324/// This helper type unifies the responses of multiple partitioned
325/// workers in order to present as a single worker.
326#[derive(Debug)]
327pub struct PartitionedStorageState {
328    /// Number of partitions the state machine represents.
329    parts: usize,
330    /// Upper frontiers for sources and sinks, both unioned across all partitions and from each
331    /// individual partition.
332    uppers: BTreeMap<
333        GlobalId,
334        (
335            MutableAntichain<Timestamp>,
336            Vec<Option<Antichain<Timestamp>>>,
337        ),
338    >,
339    /// Staged batches from oneshot sources that will get appended by `environmentd`.
340    oneshot_source_responses:
341        BTreeMap<uuid::Uuid, BTreeMap<usize, Vec<Result<ProtoBatch, String>>>>,
342}
343
344impl Partitionable<StorageCommand, StorageResponse> for (StorageCommand, StorageResponse) {
345    type PartitionedState = PartitionedStorageState;
346
347    fn new(parts: usize) -> PartitionedStorageState {
348        PartitionedStorageState {
349            parts,
350            uppers: BTreeMap::new(),
351            oneshot_source_responses: BTreeMap::new(),
352        }
353    }
354}
355
356impl PartitionedStorageState {
357    fn observe_command(&mut self, command: &StorageCommand) {
358        // Note that `observe_command` is quite different in `mz_compute_client`.
359        // Compute (currently) only sends the command to 1 process,
360        // but storage fans out to all workers, allowing the storage processes
361        // to self-coordinate how commands and internal commands are ordered.
362        //
363        // TODO(guswynn): cluster-unification: consolidate this with compute.
364        match command {
365            StorageCommand::Hello { .. } => {}
366            StorageCommand::RunIngestion(ingestion) => {
367                self.insert_new_uppers(ingestion.description.collection_ids());
368            }
369            StorageCommand::RunSink(export) => {
370                self.insert_new_uppers([export.id]);
371            }
372            StorageCommand::InitializationComplete
373            | StorageCommand::AllowWrites
374            | StorageCommand::UpdateConfiguration(_)
375            | StorageCommand::AllowCompaction(_, _)
376            | StorageCommand::RunOneshotIngestion(_)
377            | StorageCommand::CancelOneshotIngestion { .. } => {}
378        }
379    }
380
381    /// Shared implementation for commands that install uppers with controllable behavior with
382    /// encountering existing uppers.
383    ///
384    /// If any ID was previously tracked in `self` and `skip_existing` is `false`, we return the ID
385    /// as an error.
386    fn insert_new_uppers<I: IntoIterator<Item = GlobalId>>(&mut self, ids: I) {
387        for id in ids {
388            self.uppers.entry(id).or_insert_with(|| {
389                let mut frontier = MutableAntichain::new();
390                // TODO(guswynn): cluster-unification: fix this dangerous use of `as`, by
391                // merging the types that compute and storage use.
392                #[allow(clippy::as_conversions)]
393                frontier.update_iter(iter::once((Timestamp::MIN, self.parts as i64)));
394                let part_frontiers = vec![Some(Antichain::from_elem(Timestamp::MIN)); self.parts];
395
396                (frontier, part_frontiers)
397            });
398        }
399    }
400}
401
402impl PartitionedState<StorageCommand, StorageResponse> for PartitionedStorageState {
403    fn split_command(&mut self, command: StorageCommand) -> Vec<Option<StorageCommand>> {
404        self.observe_command(&command);
405
406        // Fan out to all processes (which will fan out to all workers).
407        // StorageState manages ordering of commands internally.
408        vec![Some(command); self.parts]
409    }
410
411    fn absorb_response(
412        &mut self,
413        shard_id: usize,
414        response: StorageResponse,
415    ) -> Option<Result<StorageResponse, anyhow::Error>> {
416        match response {
417            // Avoid multiple retractions of minimum time, to present as updates from one worker.
418            StorageResponse::FrontierUpper(id, new_shard_upper) => {
419                let (frontier, shard_frontiers) = match self.uppers.get_mut(&id) {
420                    Some(value) => value,
421                    None => panic!("Reference to absent collection: {id}"),
422                };
423                let old_upper = frontier.frontier().to_owned();
424                let shard_upper = match &mut shard_frontiers[shard_id] {
425                    Some(shard_upper) => shard_upper,
426                    None => panic!("Reference to absent shard {shard_id} for collection {id}"),
427                };
428                frontier.update_iter(shard_upper.iter().map(|t| (*t, -1)));
429                frontier.update_iter(new_shard_upper.iter().map(|t| (*t, 1)));
430                shard_upper.join_assign(&new_shard_upper);
431
432                let new_upper = frontier.frontier();
433                if PartialOrder::less_than(&old_upper.borrow(), &new_upper) {
434                    Some(Ok(StorageResponse::FrontierUpper(id, new_upper.to_owned())))
435                } else {
436                    None
437                }
438            }
439            StorageResponse::DroppedId(id) => {
440                let (_, shard_frontiers) = match self.uppers.get_mut(&id) {
441                    Some(value) => value,
442                    None => panic!("Reference to absent collection: {id}"),
443                };
444                let prev = shard_frontiers[shard_id].take();
445                assert!(
446                    prev.is_some(),
447                    "got double drop for {id} from shard {shard_id}"
448                );
449
450                if shard_frontiers.iter().all(Option::is_none) {
451                    self.uppers.remove(&id);
452                    Some(Ok(StorageResponse::DroppedId(id)))
453                } else {
454                    None
455                }
456            }
457            StorageResponse::StatisticsUpdates(source_stats, sink_stats) => {
458                // Just forward it along; the `worker_id` should have been set in `storage_state`.
459                // We _could_ consolidate across worker_id's, here, but each worker only produces
460                // responses periodically, so we avoid that complexity.
461                Some(Ok(StorageResponse::StatisticsUpdates(
462                    source_stats,
463                    sink_stats,
464                )))
465            }
466            StorageResponse::StatusUpdate(updates) => {
467                Some(Ok(StorageResponse::StatusUpdate(updates)))
468            }
469            StorageResponse::StagedBatches(batches) => {
470                let mut finished_batches = BTreeMap::new();
471
472                for (collection_id, batches) in batches {
473                    tracing::info!(%shard_id, %collection_id, "got batch");
474
475                    let entry = self
476                        .oneshot_source_responses
477                        .entry(collection_id)
478                        .or_default();
479                    let novel = entry.insert(shard_id, batches);
480                    assert_none!(novel, "Duplicate oneshot source response");
481
482                    // Check if we've received responses from all shards.
483                    if entry.len() == self.parts {
484                        let entry = self
485                            .oneshot_source_responses
486                            .remove(&collection_id)
487                            .expect("checked above");
488                        let all_batches: Vec<_> = entry.into_values().flatten().collect();
489
490                        finished_batches.insert(collection_id, all_batches);
491                    }
492                }
493
494                if !finished_batches.is_empty() {
495                    Some(Ok(StorageResponse::StagedBatches(finished_batches)))
496                } else {
497                    None
498                }
499            }
500        }
501    }
502}
503
504#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
505/// A batch of updates to be fed to a local input
506pub struct Update {
507    pub row: Row,
508    pub timestamp: Timestamp,
509    pub diff: Diff,
510}
511
512#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
513/// A batch of updates to be fed to a local input; however, the input must
514/// determine the most appropriate timestamps to use.
515///
516/// TODO(cf2): Can we remove this and use only on [`TableData`].
517pub struct TimestamplessUpdate {
518    pub row: Row,
519    pub diff: Diff,
520}
521
522#[derive(Debug, Clone, PartialEq)]
523pub enum TableData {
524    /// Rows that still need to be persisted and appended.
525    ///
526    /// The contained [`Row`]s are _not_ consolidated.
527    Rows(Vec<(Row, Diff)>),
528    /// Batches already staged in Persist ready to be appended.
529    Batches(SmallVec<[ProtoBatch; 1]>),
530}
531
532impl TableData {
533    pub fn is_empty(&self) -> bool {
534        match self {
535            TableData::Rows(rows) => rows.is_empty(),
536            TableData::Batches(batches) => batches.is_empty(),
537        }
538    }
539}
540
541/// A collection of timestamp-less updates. As updates are added to the builder
542/// they are automatically spilled to blob storage.
543pub struct TimestamplessUpdateBuilder<K, V, D>
544where
545    K: Codec,
546    V: Codec,
547    D: Codec64,
548{
549    builder: BatchBuilder<K, V, Timestamp, D>,
550    initial_ts: Timestamp,
551}
552
553impl<K, V, D> TimestamplessUpdateBuilder<K, V, D>
554where
555    K: Debug + Codec,
556    V: Debug + Codec,
557    D: Monoid + Ord + Codec64 + Send + Sync,
558{
559    /// Create a new [`TimestamplessUpdateBuilder`] for the shard associated
560    /// with the provided [`WriteHandle`].
561    pub fn new(handle: &WriteHandle<K, V, Timestamp, D>) -> Self {
562        let initial_ts = Timestamp::MIN;
563        let builder = handle.builder(Antichain::from_elem(initial_ts));
564        TimestamplessUpdateBuilder {
565            builder,
566            initial_ts,
567        }
568    }
569
570    /// Add a `(K, V, D)` to the staged batch.
571    pub async fn add(&mut self, k: &K, v: &V, d: &D) {
572        self.builder
573            .add(k, v, &self.initial_ts, d)
574            .await
575            .expect("invalid Persist usage");
576    }
577
578    /// Finish the builder and return a [`ProtoBatch`] which can later be linked into a shard.
579    ///
580    /// The returned batch has nonsensical lower and upper bounds and must be re-written before
581    /// appending into the destination shard.
582    pub async fn finish(self) -> ProtoBatch {
583        let finish_ts = StepForward::step_forward(&self.initial_ts);
584        let batch = self
585            .builder
586            .finish(Antichain::from_elem(finish_ts))
587            .await
588            .expect("invalid Persist usage");
589
590        batch.into_transmittable_batch()
591    }
592}
593
594impl TryIntoProtocolNonce for StorageCommand {
595    fn try_into_protocol_nonce(self) -> Result<Uuid, Self> {
596        match self {
597            StorageCommand::Hello { nonce } => Ok(nonce),
598            cmd => Err(cmd),
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    /// Test to ensure the size of the `StorageCommand` enum doesn't regress.
608    #[mz_ore::test]
609    fn test_storage_command_size() {
610        assert_eq!(std::mem::size_of::<StorageCommand>(), 40);
611    }
612
613    /// Test to ensure the size of the `StorageResponse` enum doesn't regress.
614    #[mz_ore::test]
615    fn test_storage_response_size() {
616        assert_eq!(std::mem::size_of::<StorageResponse>(), 120);
617    }
618}