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}