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