Skip to main content

mz_adapter/coord/
read_then_write.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//! Coordinator-side support machinery for (frontend) read-then write.
11//!
12//! TODO(aljoscha): Write submission still goes through the coordinator. In the
13//! long run we want a group-commit task that runs independently, so that
14//! session tasks can submit write requests to it directly.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use mz_catalog::memory::objects::CatalogItem;
19use mz_repr::CatalogItemId;
20use mz_repr::{Diff, GlobalId, Row, Timestamp};
21use mz_sql::catalog::CatalogItemType;
22use mz_sql::plan::SubscribeOutput;
23use mz_storage_client::client::TableData;
24use smallvec::smallvec;
25use tokio::sync::mpsc;
26use tracing::Span;
27
28use crate::PeekResponseUnary;
29use crate::active_compute_sink::{ActiveComputeSink, ActiveSubscribe};
30use crate::catalog::Catalog;
31use crate::coord::Coordinator;
32use crate::coord::appends::{
33    InternalWriteResponder, PendingWriteTxn, TableWriteCmd, TimestampedWriteRequest,
34    UserWriteResponder, WriteResult, WriteTarget,
35};
36use crate::error::AdapterError;
37
38/// Adds `id` to the worklist the first time it is seen, enforcing the
39/// dependency bound.
40///
41/// Deduping at enqueue time keeps `seen` and `stack` proportional to the number
42/// of distinct objects, not the number of dependency edges. A diamond-shaped
43/// graph is validated once per object.
44fn enqueue(
45    seen: &mut BTreeSet<CatalogItemId>,
46    stack: &mut Vec<CatalogItemId>,
47    id: CatalogItemId,
48    max_rw_dependencies: usize,
49) -> Result<(), AdapterError> {
50    if seen.insert(id) {
51        if seen.len() > max_rw_dependencies {
52            return Err(AdapterError::ReadThenWriteDependencyLimitExceeded {
53                max_rw_dependencies,
54            });
55        }
56        stack.push(id);
57    }
58    Ok(())
59}
60
61impl Coordinator {
62    /// Creates a subscribe that writes no `mz_subscriptions` row.
63    ///
64    /// The dataflow is otherwise ordinary and shows up in replica
65    /// introspection like any other.
66    ///
67    /// Takes ownership of `read_holds` and drops them only once the dataflow is
68    /// shipped, so the `since` cannot advance past `as_of` in between.
69    ///
70    /// Answers through `response_tx`, with an error if the connection went away
71    /// or if a dependency was dropped since the plan was optimized.
72    #[allow(clippy::too_many_arguments)]
73    pub(crate) async fn handle_create_internal_subscribe(
74        &mut self,
75        df_desc: crate::optimize::LirDataflowDescription,
76        cluster_id: mz_compute_types::ComputeInstanceId,
77        replica_id: Option<mz_cluster_client::ReplicaId>,
78        depends_on: BTreeSet<GlobalId>,
79        as_of: Timestamp,
80        arity: usize,
81        sink_id: GlobalId,
82        conn_id: mz_adapter_types::connection::ConnectionId,
83        session_uuid: uuid::Uuid,
84        start_time: mz_ore::now::EpochMillis,
85        read_holds: crate::ReadHolds,
86        response_tx: tokio::sync::oneshot::Sender<
87            Result<mpsc::UnboundedReceiver<PeekResponseUnary>, AdapterError>,
88        >,
89    ) {
90        // Client disconnected while waiting for the semaphore.
91        if !self.active_conns.contains_key(&conn_id) {
92            let _ = response_tx.send(Err(AdapterError::Canceled));
93            return;
94        }
95
96        let (tx, rx) = mpsc::unbounded_channel();
97
98        let active_subscribe = ActiveSubscribe {
99            conn_id: conn_id.clone(),
100            session_uuid,
101            channel: tx,
102            backlog_accounting: std::sync::Arc::new(std::sync::Mutex::new(
103                crate::active_compute_sink::SubscribeBacklogAccounting::default(),
104            )),
105            // This internal subscribe is drained by the coordinator for OCC
106            // read-then-write, not by a slow external client, so the slow-client
107            // backlog budget must not apply. A large read-then-write read set
108            // (e.g. an UPDATE that rewrites every row of a big table)
109            // legitimately exceeds the budget, so bounding it here would
110            // spuriously retire the statement with `SubscribeFellBehind`.
111            max_buffered_bytes: usize::MAX,
112            emit_progress: true, // We need progress updates for OCC
113            as_of,
114            arity,
115            cluster_id,
116            depends_on,
117            start_time,
118            output: SubscribeOutput::Diffs,
119            internal: true, // no mz_subscriptions row and no active-subscribes metric
120        };
121        active_subscribe.initialize();
122
123        // Ship the dataflow before registering the sink, so a failure has
124        // nothing to unwind.
125        //
126        // Creation can fail here: the plan was optimized against a catalog
127        // snapshot taken off the coordinator loop, so a dependency can be
128        // dropped before this message is handled. That makes it a conflict to
129        // report rather than an invariant violation, hence `try_ship_dataflow`.
130        if let Err(err) = self
131            .try_ship_dataflow(df_desc, cluster_id, replica_id)
132            .await
133        {
134            let _ = response_tx.send(Err(
135                AdapterError::concurrent_dependency_drop_from_dataflow_creation_error(err),
136            ));
137            return;
138        }
139
140        self.add_active_compute_sink(sink_id, ActiveComputeSink::Subscribe(active_subscribe))
141            .await;
142
143        if response_tx.send(Ok(rx)).is_err() {
144            // The receiver is gone, so cancellation or a statement timeout
145            // dropped the caller's future between the command being sent and
146            // this handler running. Retire the sink here, rather than leave a
147            // dataflow running against a closed channel. The cancel path
148            // retires it too, but only because its command is queued behind
149            // ours, and that ordering is not ours to depend on.
150            self.drop_internal_subscribe(sink_id).await;
151            return;
152        }
153
154        // Drop read holds only after `ship_dataflow` returns, so the since
155        // can't advance past `as_of` before the dataflow is running.
156        drop(read_holds);
157    }
158
159    /// Enqueues a write attempt, answering through `result_tx`.
160    ///
161    /// `write_ts` picks the path. `Some` names a timestamp the diffs are only
162    /// valid at and goes straight to the committer, pinned to the `GlobalId`
163    /// validated here. `None` is a blind write that rides the next group
164    /// commit, whose staging re-checks the target generation.
165    pub(crate) fn handle_attempt_write(
166        &mut self,
167        conn_id: mz_adapter_types::connection::ConnectionId,
168        target_id: mz_repr::CatalogItemId,
169        target_global_id: GlobalId,
170        diffs: Vec<(Row, Diff)>,
171        write_ts: Option<Timestamp>,
172        result_tx: tokio::sync::oneshot::Sender<WriteResult>,
173    ) {
174        let result = InternalWriteResponder::new(result_tx);
175        if !self.active_conns.contains_key(&conn_id) {
176            result.send(WriteResult::Canceled);
177            return;
178        }
179        if self.controller.read_only() {
180            result.send(WriteResult::ReadOnly);
181            return;
182        }
183
184        let current_global_id = self
185            .catalog()
186            .try_get_entry(&target_id)
187            .map(|entry| entry.latest_global_id());
188        if current_global_id != Some(target_global_id) {
189            result.send(WriteResult::TargetChanged);
190            return;
191        }
192
193        let table_data = TableData::Rows(diffs);
194        match write_ts {
195            Some(target_timestamp) => {
196                let request = TimestampedWriteRequest {
197                    appends: vec![(target_global_id, vec![table_data])],
198                    target_timestamp,
199                    result,
200                    span: Span::current(),
201                };
202                if self
203                    .group_committer_tx
204                    .send(TableWriteCmd::TimestampedWrite(request))
205                    .is_err()
206                {
207                    tracing::warn!("group committer task gone, dropping timestamped write");
208                }
209            }
210            None => {
211                let writes = BTreeMap::from([(target_id, smallvec![table_data])]);
212                self.pending_writes.push(PendingWriteTxn::User {
213                    span: Span::current(),
214                    writes,
215                    write_locks: None,
216                    responder: UserWriteResponder::Internal {
217                        conn_id,
218                        target: WriteTarget {
219                            item_id: target_id,
220                            global_id: target_global_id,
221                        },
222                        result,
223                    },
224                });
225                self.trigger_group_commit();
226            }
227        }
228    }
229
230    /// Drop an internal subscribe.
231    pub(crate) async fn drop_internal_subscribe(&mut self, sink_id: GlobalId) {
232        // Use drop_compute_sink instead of remove_active_compute_sink to also
233        // cancel the dataflow on the compute side, not just remove bookkeeping.
234        let _ = self.drop_compute_sink(sink_id).await;
235    }
236}
237
238/// Validates that all dependencies are valid for read-then-write operations.
239///
240/// Ensures all objects the selection transitively depends on (seeded by `ids`) are valid for
241/// `ReadThenWrite` operations:
242///
243/// - They do not refer to any objects whose notion of time moves differently than that of
244///   user tables. This limitation is meant to ensure no writes occur between this read and the
245///   subsequent write.
246/// - They do not use mz_now(), whose time produced during read will differ from the write
247///   timestamp.
248///
249/// The first invalid or temporal dependency encountered short-circuits with the corresponding
250/// error. Traversal is bounded at `max_rw_dependencies` distinct objects, returning
251/// [`AdapterError::ReadThenWriteDependencyLimitExceeded`] if exceeded.
252pub(crate) fn validate_read_then_write_dependencies(
253    catalog: &Catalog,
254    ids: impl IntoIterator<Item = CatalogItemId>,
255    max_rw_dependencies: usize,
256) -> Result<(), AdapterError> {
257    use CatalogItemType::*;
258    use mz_catalog::memory::objects;
259
260    // Iterative worklist rather than recursion. Dependency chains are user
261    // controlled and can be arbitrarily deep (e.g. a long chain of stacked
262    // views), so recursing risks a stack overflow on the coordinator thread.
263    let mut seen = BTreeSet::new();
264    let mut stack = Vec::new();
265    for id in ids {
266        enqueue(&mut seen, &mut stack, id, max_rw_dependencies)?;
267    }
268    while let Some(id) = stack.pop() {
269        let mut ids_to_check = Vec::new();
270        let valid = match catalog.try_get_entry(&id) {
271            Some(entry) => {
272                if let CatalogItem::View(objects::View {
273                    locally_optimized_expr: optimized_expr,
274                    ..
275                })
276                | CatalogItem::MaterializedView(objects::MaterializedView {
277                    locally_optimized_expr: optimized_expr,
278                    ..
279                }) = entry.item()
280                {
281                    if optimized_expr.contains_temporal() {
282                        return Err(AdapterError::Unsupported(
283                            "calls to mz_now in write statements",
284                        ));
285                    }
286                }
287                match entry.item().typ() {
288                    typ @ (Func | View | MaterializedView) => {
289                        ids_to_check.extend(entry.uses());
290                        let valid_id = id.is_user() || matches!(typ, Func);
291                        valid_id
292                    }
293                    Source | Secret | Connection => false,
294                    // Cannot select from sinks or indexes.
295                    Sink | MetricSink | Index => unreachable!(),
296                    Table => {
297                        if !id.is_user() {
298                            // We can't read from non-user tables
299                            false
300                        } else {
301                            // We can't read from tables that are source-exports
302                            entry.source_export_details().is_none()
303                        }
304                    }
305                    Type => true,
306                }
307            }
308            None => false,
309        };
310        if !valid {
311            let (object_name, object_type) = match catalog.try_get_entry(&id) {
312                Some(entry) => {
313                    let object_name = catalog.resolve_full_name(entry.name(), None).to_string();
314                    let object_type = match entry.item().typ() {
315                        // We only need the disallowed types here; the allowed types are handled above.
316                        Source => "source",
317                        Secret => "secret",
318                        Connection => "connection",
319                        Table => {
320                            if !id.is_user() {
321                                "system table"
322                            } else {
323                                "source-export table"
324                            }
325                        }
326                        View => "system view",
327                        MaterializedView => "system materialized view",
328                        _ => "invalid dependency",
329                    };
330                    (object_name, object_type.to_string())
331                }
332                None => (id.to_string(), "unknown".to_string()),
333            };
334            return Err(AdapterError::InvalidTableMutationSelection {
335                object_name,
336                object_type,
337            });
338        }
339        for dep in ids_to_check {
340            enqueue(&mut seen, &mut stack, dep, max_rw_dependencies)?;
341        }
342    }
343    Ok(())
344}