mz_adapter/active_compute_sink.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 bookkeeping for active compute sinks.
11
12use std::cmp::Ordering;
13use std::collections::{BTreeSet, VecDeque};
14use std::num::NonZeroUsize;
15use std::sync::{Arc, Mutex};
16
17use mz_adapter_types::connection::ConnectionId;
18use mz_compute_client::protocol::response::SubscribeBatch;
19use mz_controller_types::ClusterId;
20use mz_expr::row::RowCollection;
21use mz_expr::{RowComparator, compare_columns};
22use mz_ore::cast::CastFrom;
23use mz_ore::now::EpochMillis;
24use mz_repr::adt::numeric;
25use mz_repr::{CatalogItemId, Datum, Diff, GlobalId, IntoRowIterator, Row, RowRef, Timestamp};
26use mz_sql::plan::SubscribeOutput;
27use mz_storage_types::instances::StorageInstanceId;
28use timely::progress::Antichain;
29use tokio::sync::{mpsc, oneshot};
30use uuid::Uuid;
31
32use crate::coord::peek::{DroppedDependency, PeekResponseUnary};
33use crate::{AdapterError, ExecuteContext, ExecuteResponse};
34
35#[derive(Debug)]
36/// A description of an active compute sink from the coordinator's perspective.
37pub enum ActiveComputeSink {
38 /// An active subscribe sink.
39 Subscribe(ActiveSubscribe),
40 /// An active copy to sink.
41 CopyTo(ActiveCopyTo),
42}
43
44impl ActiveComputeSink {
45 /// Reports the ID of the cluster on which the sink is running.
46 pub fn cluster_id(&self) -> ClusterId {
47 match &self {
48 ActiveComputeSink::Subscribe(subscribe) => subscribe.cluster_id,
49 ActiveComputeSink::CopyTo(copy_to) => copy_to.cluster_id,
50 }
51 }
52
53 /// Reports the ID of the connection which created the sink.
54 pub fn connection_id(&self) -> &ConnectionId {
55 match &self {
56 ActiveComputeSink::Subscribe(subscribe) => &subscribe.conn_id,
57 ActiveComputeSink::CopyTo(copy_to) => ©_to.conn_id,
58 }
59 }
60
61 /// Reports the IDs of the objects on which the sink depends.
62 pub fn depends_on(&self) -> &BTreeSet<GlobalId> {
63 match &self {
64 ActiveComputeSink::Subscribe(subscribe) => &subscribe.depends_on,
65 ActiveComputeSink::CopyTo(copy_to) => ©_to.depends_on,
66 }
67 }
68
69 /// Retires the sink with the specified reason.
70 ///
71 /// This method must be called on every sink before it is dropped. It
72 /// informs the end client that the sink is finished for the specified
73 /// reason.
74 pub fn retire(self, reason: ActiveComputeSinkRetireReason) {
75 match self {
76 ActiveComputeSink::Subscribe(subscribe) => subscribe.retire(reason),
77 ActiveComputeSink::CopyTo(copy_to) => copy_to.retire(reason),
78 }
79 }
80}
81
82/// The reason for removing an [`ActiveComputeSink`].
83#[derive(Debug, Clone)]
84pub enum ActiveComputeSinkRetireReason {
85 /// The compute sink completed successfully.
86 Finished,
87 /// The compute sink was canceled due to a user request.
88 Canceled,
89 /// The compute sink was forcibly terminated because an object it depended on
90 /// was dropped.
91 DependencyDropped(DroppedDependency),
92 /// The compute sink was retired because its coordinator-side buffer exceeded
93 /// its budget while the client was not reading fast enough. Carries the
94 /// buffered and budget byte counts for the terminal error.
95 BufferExceeded {
96 buffered_bytes: usize,
97 max_buffered_bytes: usize,
98 },
99}
100
101/// Overhead charged to every queued message on top of its payload.
102///
103/// A frontier-only advance carries no rows, so its payload is zero bytes. It
104/// still costs real memory: a node in the unbounded channel and an entry in
105/// `footprints`. Charging payload alone would leave the backlog flat while
106/// those two grow without bound, so the retire check would never trip on a
107/// stalled client that only receives progress messages. The fixed charge makes
108/// the budget bound message *count* as well as payload bytes.
109const SUBSCRIBE_MESSAGE_OVERHEAD_BYTES: usize = 1024;
110
111/// Footprints of the subscribe messages queued to the client but not yet
112/// drained, oldest first. The producer pushes one per message sent, the
113/// client-writer task pops as it drains. FIFO delivery keeps the queue aligned
114/// with the channel.
115///
116/// The message at the front is the one the client is currently draining. It is
117/// always tolerated, however large, so a client working through one big batch is
118/// not retired. Only what is queued behind it counts as backlog.
119#[derive(Debug, Default)]
120pub struct SubscribeBacklogAccounting {
121 /// Per-message footprints, in send order.
122 footprints: VecDeque<usize>,
123 /// Sum of `footprints`.
124 total: usize,
125}
126
127impl SubscribeBacklogAccounting {
128 /// Records a queued message of the given footprint.
129 pub fn push(&mut self, footprint: usize) {
130 self.footprints.push_back(footprint);
131 self.total = self.total.saturating_add(footprint);
132 }
133
134 /// Records the oldest queued message being drained by the client writer.
135 pub fn pop(&mut self) {
136 if let Some(footprint) = self.footprints.pop_front() {
137 self.total = self.total.saturating_sub(footprint);
138 }
139 }
140
141 /// Bytes queued behind the message the client is currently draining. That
142 /// message is tolerated whatever its size, so only this counts against the
143 /// budget.
144 pub fn backlog_size(&self) -> usize {
145 self.total
146 .saturating_sub(self.footprints.front().copied().unwrap_or(0))
147 }
148}
149
150/// A description of an active subscribe from coord's perspective
151#[derive(Debug)]
152pub struct ActiveSubscribe {
153 /// The ID of the connection which created the subscribe.
154 pub conn_id: ConnectionId,
155 /// The UUID of the session which created the subscribe.
156 pub session_uuid: Uuid,
157 /// The ID of the cluster on which the subscribe is running.
158 pub cluster_id: ClusterId,
159 /// The IDs of the objects on which the subscribe depends.
160 pub depends_on: BTreeSet<GlobalId>,
161 /// Channel on which to send responses to the client.
162 // The responses have the form `PeekResponseUnary` but should perhaps
163 // become `SubscribeResponse`.
164 pub channel: mpsc::UnboundedSender<PeekResponseUnary>,
165 /// Footprints of the messages queued in `channel` but not yet drained by the
166 /// client writer. Shared with the receiver side, which pops as it drains.
167 ///
168 /// The producer runs on the non-blockable coordinator loop and cannot block
169 /// on a slow client, so instead of applying backpressure the coordinator
170 /// watches `backlog_size` against `max_buffered_bytes` and retires the
171 /// subscribe once the backlog exceeds it.
172 pub backlog_accounting: Arc<Mutex<SubscribeBacklogAccounting>>,
173 /// Budget for the buffered backlog. A snapshot of `subscribe_max_buffered_bytes`
174 /// taken when the subscribe was created.
175 pub max_buffered_bytes: usize,
176 /// Whether progress information should be emitted.
177 pub emit_progress: bool,
178 /// The logical timestamp at which the subscribe began execution.
179 pub as_of: Timestamp,
180 /// The number of columns in the relation that was subscribed to.
181 pub arity: usize,
182 /// The time when the subscribe started.
183 pub start_time: EpochMillis,
184 /// How to present the subscribe's output.
185 pub output: SubscribeOutput,
186 /// If true, this is an internal subscribe that should not appear in
187 /// introspection tables like mz_subscriptions.
188 pub internal: bool,
189}
190
191impl ActiveSubscribe {
192 /// Initializes the subscription.
193 ///
194 /// This method must be called exactly once, after constructing an
195 /// `ActiveSubscribe` and before calling `process_response`.
196 pub fn initialize(&self) {
197 // Always emit progress message indicating snapshot timestamp.
198 self.send_progress_message(&Antichain::from_elem(self.as_of));
199 }
200
201 fn send_progress_message(&self, upper: &Antichain<Timestamp>) {
202 if !self.emit_progress {
203 return;
204 }
205 if let Some(upper) = upper.as_option() {
206 let mut row_buf = Row::default();
207 let mut packer = row_buf.packer();
208 packer.push(Datum::from(numeric::Numeric::from(*upper)));
209 packer.push(Datum::True);
210
211 // Fill in the mz_diff or mz_state column
212 packer.push(Datum::Null);
213
214 // Fill all table columns with NULL.
215 for _ in 0..self.arity {
216 packer.push(Datum::Null);
217 }
218
219 if let SubscribeOutput::EnvelopeDebezium { order_by_keys } = &self.output {
220 for _ in 0..(self.arity - order_by_keys.len()) {
221 packer.push(Datum::Null);
222 }
223 }
224
225 let bytes = row_buf.byte_len();
226 let row_iter = Box::new(row_buf.into_row_iter());
227 self.send(PeekResponseUnary::Rows(row_iter), bytes);
228 }
229 }
230
231 /// Processes a subscribe response from the controller.
232 ///
233 /// Returns `true` if the subscribe is finished.
234 pub fn process_response(&self, batch: SubscribeBatch) -> bool {
235 let comparator = RowComparator::new(self.output.row_order());
236 let rows = match batch.updates {
237 Ok(ref rows) => {
238 let iters = rows.iter().map(|r| r.iter());
239 let merged = mz_ore::iter::merge_iters_by(
240 iters,
241 |(left_row, left_time, _), (right_row, right_time, _)| {
242 left_time.cmp(right_time).then_with(|| {
243 comparator.compare_rows(left_row, right_row, || left_row.cmp(right_row))
244 })
245 },
246 );
247 mz_ore::iter::consolidate_update_iter(merged)
248 }
249 Err(s) => {
250 self.send(PeekResponseUnary::Error(s), 0);
251 return true;
252 }
253 };
254
255 // Sort results by time. We use stable sort here because it will produce
256 // deterministic results since the cursor will always produce rows in
257 // the same order. Compute doesn't guarantee that the results are sorted
258 // (materialize#18936)
259 let mut output_buf = Row::default();
260 let mut output_builder = RowCollection::builder(0, 0);
261 let mut left_datum_vec = mz_repr::DatumVec::new();
262 let mut right_datum_vec = mz_repr::DatumVec::new();
263 let mut push_row = |row: &RowRef, time: Timestamp, diff: Diff| {
264 assert!(self.as_of <= time);
265 let mut packer = output_buf.packer();
266 // TODO: Change to MzTimestamp.
267 packer.push(Datum::from(numeric::Numeric::from(time)));
268 if self.emit_progress {
269 // When sinking with PROGRESS, the output includes an
270 // additional column that indicates whether a timestamp is
271 // complete. For regular "data" updates this is always
272 // `false`.
273 packer.push(Datum::False);
274 }
275
276 match &self.output {
277 SubscribeOutput::EnvelopeUpsert { .. }
278 | SubscribeOutput::EnvelopeDebezium { .. } => {}
279 SubscribeOutput::Diffs | SubscribeOutput::WithinTimestampOrderBy { .. } => {
280 packer.push(Datum::Int64(diff.into_inner()));
281 }
282 }
283
284 packer.extend_by_row_ref(row);
285
286 output_builder.push(output_buf.as_row_ref(), NonZeroUsize::MIN);
287 };
288
289 match &self.output {
290 SubscribeOutput::WithinTimestampOrderBy { order_by } => {
291 let mut rows: Vec<_> = rows.collect();
292 // Since the diff is inserted as the first column, we can't take advantage of the
293 // known ordering. (Aside from timestamp, I suppose.)
294 rows.sort_by(
295 |(left_row, left_time, left_diff), (right_row, right_time, right_diff)| {
296 left_time.cmp(right_time).then_with(|| {
297 let mut left_datums = left_datum_vec.borrow();
298 left_datums.extend(&[Datum::Int64(left_diff.into_inner())]);
299 left_datums.extend(left_row.iter());
300 let mut right_datums = right_datum_vec.borrow();
301 right_datums.extend(&[Datum::Int64(right_diff.into_inner())]);
302 right_datums.extend(right_row.iter());
303 compare_columns(order_by, &left_datums, &right_datums, || {
304 left_row.cmp(right_row).then(left_diff.cmp(right_diff))
305 })
306 })
307 },
308 );
309 for (row, time, diff) in rows {
310 push_row(row, *time, diff);
311 }
312 }
313 SubscribeOutput::EnvelopeUpsert { order_by_keys }
314 | SubscribeOutput::EnvelopeDebezium { order_by_keys } => {
315 let debezium = matches!(self.output, SubscribeOutput::EnvelopeDebezium { .. });
316 let mut it = rows.peekable();
317 let mut datum_vec = mz_repr::DatumVec::new();
318 let mut old_datum_vec = mz_repr::DatumVec::new();
319 let comparator = RowComparator::new(order_by_keys.as_slice());
320 let mut group = Vec::with_capacity(2);
321 let mut row_buf = Row::default();
322 // The iterator is sorted by time and key, so elements in the same group should be
323 // adjacent already.
324 while let Some(start) = it.next() {
325 group.clear();
326 group.push(start);
327 while let Some(row) = it.peek()
328 && start.1 == row.1
329 && {
330 comparator
331 .compare_rows(start.0, row.0, || Ordering::Equal)
332 .is_eq()
333 }
334 {
335 group.extend(it.next());
336 }
337 group.sort_by_key(|(_, _, d)| *d);
338
339 // Four cases:
340 // [(key, value, +1)] => ("insert", key, NULL, value)
341 // [(key, v1, -1), (key, v2, +1)] => ("upsert", key, v1, v2)
342 // [(key, value, -1)] => ("delete", key, value, NULL)
343 // everything else => ("key_violation", key, NULL, NULL)
344 // Defense in depth: the planner ensures that KEY columns are
345 // distinct columns of the underlying relation, so this
346 // subtraction must never underflow. If it does, we'd OOM
347 // the coordinator with a giant loop, so check it here.
348 mz_ore::soft_assert_or_log!(
349 order_by_keys.len() <= self.arity,
350 "SUBSCRIBE ENVELOPE has more KEY columns ({}) than \
351 relation arity ({}); planner should have rejected this",
352 order_by_keys.len(),
353 self.arity,
354 );
355 let value_columns = self.arity.saturating_sub(order_by_keys.len());
356 let mut packer = row_buf.packer();
357 match &group[..] {
358 [(row, _, Diff::ONE)] => {
359 packer.push(if debezium {
360 Datum::String("insert")
361 } else {
362 Datum::String("upsert")
363 });
364 let datums = datum_vec.borrow_with(row);
365 for column_order in order_by_keys {
366 packer.push(datums[column_order.column]);
367 }
368 if debezium {
369 for _ in 0..value_columns {
370 packer.push(Datum::Null);
371 }
372 }
373 for idx in 0..self.arity {
374 if !order_by_keys.iter().any(|co| co.column == idx) {
375 packer.push(datums[idx]);
376 }
377 }
378 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
379 }
380 [(_, _, Diff::MINUS_ONE)] => {
381 packer.push(Datum::String("delete"));
382 let datums = datum_vec.borrow_with(start.0);
383 for column_order in order_by_keys {
384 packer.push(datums[column_order.column]);
385 }
386 if debezium {
387 for idx in 0..self.arity {
388 if !order_by_keys.iter().any(|co| co.column == idx) {
389 packer.push(datums[idx]);
390 }
391 }
392 }
393 for _ in 0..value_columns {
394 packer.push(Datum::Null);
395 }
396 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
397 }
398 [(old_row, _, Diff::MINUS_ONE), (row, _, Diff::ONE)] => {
399 packer.push(Datum::String("upsert"));
400 let datums = datum_vec.borrow_with(row);
401 let old_datums = old_datum_vec.borrow_with(old_row);
402
403 for column_order in order_by_keys {
404 packer.push(datums[column_order.column]);
405 }
406 if debezium {
407 for idx in 0..self.arity {
408 if !order_by_keys.iter().any(|co| co.column == idx) {
409 packer.push(old_datums[idx]);
410 }
411 }
412 }
413 for idx in 0..self.arity {
414 if !order_by_keys.iter().any(|co| co.column == idx) {
415 packer.push(datums[idx]);
416 }
417 }
418 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
419 }
420 _ => {
421 packer.push(Datum::String("key_violation"));
422 let datums = datum_vec.borrow_with(start.0);
423 for column_order in order_by_keys {
424 packer.push(datums[column_order.column]);
425 }
426 if debezium {
427 for _ in 0..value_columns {
428 packer.push(Datum::Null);
429 }
430 }
431 for _ in 0..value_columns {
432 packer.push(Datum::Null);
433 }
434 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
435 }
436 };
437 }
438 }
439 SubscribeOutput::Diffs => {
440 // Diffs output is sorted by time and row, so it can be pushed directly.
441 for (row, time, diff) in rows {
442 push_row(row, *time, diff)
443 }
444 }
445 };
446
447 let rows = output_builder.build();
448 let bytes = rows.byte_len();
449 let rows = Box::new(rows.into_row_iter());
450 self.send(PeekResponseUnary::Rows(rows), bytes);
451
452 // Emit progress message if requested. Don't emit progress for the first
453 // batch if the upper is exactly `as_of` (we're guaranteed it is not
454 // less than `as_of`, but it might be exactly `as_of`) as we've already
455 // emitted that progress message in `initialize`.
456 if !batch.upper.less_equal(&self.as_of) {
457 self.send_progress_message(&batch.upper);
458 }
459
460 batch.upper.is_empty()
461 }
462
463 /// Retires the subscribe with the specified reason.
464 ///
465 /// This method must be called on every subscribe before it is dropped. It
466 /// informs the end client that the subscribe is finished for the specified
467 /// reason.
468 pub fn retire(self, reason: ActiveComputeSinkRetireReason) {
469 let message = match reason {
470 ActiveComputeSinkRetireReason::Finished => return,
471 ActiveComputeSinkRetireReason::Canceled => PeekResponseUnary::Canceled,
472 ActiveComputeSinkRetireReason::DependencyDropped(d) => {
473 PeekResponseUnary::DependencyDropped(d)
474 }
475 ActiveComputeSinkRetireReason::BufferExceeded {
476 buffered_bytes,
477 max_buffered_bytes,
478 } => PeekResponseUnary::Error(
479 AdapterError::SubscribeFellBehind {
480 buffered_bytes,
481 max_buffered_bytes,
482 }
483 .to_string(),
484 ),
485 };
486 self.send(message, 0);
487 }
488
489 /// Sends a message to the client if the subscribe has not already completed
490 /// and if the client has not already gone away.
491 ///
492 /// `bytes` is the message's payload size. Its footprint (payload plus a fixed
493 /// per-message overhead) is recorded in `backlog_accounting` here and
494 /// released by the receiver side when the message is drained. Overflow of
495 /// the budget is detected by the coordinator after `process_response`
496 /// returns, not here, because this method cannot retire the sink.
497 fn send(&self, response: PeekResponseUnary, bytes: usize) {
498 let footprint = bytes.saturating_add(SUBSCRIBE_MESSAGE_OVERHEAD_BYTES);
499 self.backlog_accounting
500 .lock()
501 .expect("subscribe backlog accounting poisoned")
502 .push(footprint);
503 let _ = self.channel.send(response);
504 }
505}
506
507/// A description of an active copy to sink from the coordinator's perspective.
508#[derive(Debug)]
509pub struct ActiveCopyTo {
510 /// The ID of the connection which created the subscribe.
511 pub conn_id: ConnectionId,
512 /// The result channel for the `COPY ... TO` statement that created the copy to sink.
513 pub tx: oneshot::Sender<Result<ExecuteResponse, AdapterError>>,
514 /// The ID of the cluster on which the copy to is running.
515 pub cluster_id: ClusterId,
516 /// The IDs of the objects on which the copy to depends.
517 pub depends_on: BTreeSet<GlobalId>,
518}
519
520impl ActiveCopyTo {
521 /// Retires the copy to with a response from the controller.
522 ///
523 /// Unlike subscribes, copy tos only expect a single response from the
524 /// controller, so `process_response` and `retire` are unified into a single
525 /// operation.
526 ///
527 /// Either this method or `retire` must be called on every copy to before it
528 /// is dropped.
529 pub fn retire_with_response(self, response: Result<u64, anyhow::Error>) {
530 let response = match response {
531 Ok(n) => Ok(ExecuteResponse::Copied(usize::cast_from(n))),
532 Err(error) => Err(AdapterError::Unstructured(error)),
533 };
534 let _ = self.tx.send(response);
535 }
536
537 /// Retires the copy to with the specified reason.
538 ///
539 /// Either this method or `retire_with_response` must be called on every
540 /// copy to before it is dropped.
541 pub fn retire(self, reason: ActiveComputeSinkRetireReason) {
542 let message = match reason {
543 ActiveComputeSinkRetireReason::Finished => return,
544 ActiveComputeSinkRetireReason::Canceled => Err(AdapterError::Canceled),
545 ActiveComputeSinkRetireReason::DependencyDropped(dep) => {
546 Err(dep.to_concurrent_dependency_drop())
547 }
548 ActiveComputeSinkRetireReason::BufferExceeded {
549 buffered_bytes,
550 max_buffered_bytes,
551 } => Err(AdapterError::SubscribeFellBehind {
552 buffered_bytes,
553 max_buffered_bytes,
554 }),
555 };
556 let _ = self.tx.send(message);
557 }
558}
559
560/// State we keep in the `Coordinator` to track active `COPY FROM` statements.
561#[derive(Debug)]
562pub(crate) struct ActiveCopyFrom {
563 /// ID of the ingestion running in clusterd.
564 pub ingestion_id: uuid::Uuid,
565 /// The cluster this is currently running on.
566 pub cluster_id: StorageInstanceId,
567 /// The table we're currently copying into.
568 pub table_id: CatalogItemId,
569 /// Context of the SQL session that ran the statement.
570 pub ctx: ExecuteContext,
571}
572
573#[cfg(test)]
574mod tests {
575 use crate::active_compute_sink::SubscribeBacklogAccounting;
576
577 /// The backlog excludes the message being drained, and zero-payload
578 /// messages (footprint = overhead only) still accumulate against it.
579 #[mz_ore::test]
580 fn test_subscribe_backlog_accounting() {
581 let mut acc = SubscribeBacklogAccounting::default();
582 assert_eq!(acc.backlog_size(), 0);
583
584 // A single large message is fully tolerated: nothing is queued behind it.
585 acc.push(10_000);
586 assert_eq!(acc.backlog_size(), 0);
587
588 // Near-empty messages (only per-message overhead) still build backlog, so
589 // a flood of frontier-only advances cannot grow without bound.
590 acc.push(1_024);
591 acc.push(1_024);
592 assert_eq!(acc.backlog_size(), 2_048);
593
594 // Draining the oldest message advances the tolerated front.
595 acc.pop();
596 assert_eq!(acc.backlog_size(), 1_024);
597
598 acc.pop();
599 acc.pop();
600 assert_eq!(acc.backlog_size(), 0);
601 }
602
603 /// A client that drains each message before the next is sent never
604 /// accumulates backlog, however many messages flow and however large they
605 /// are. This is the property that keeps a well-behaved subscribe from ever
606 /// being retired, so it is asserted after every step rather than at the end.
607 #[mz_ore::test]
608 fn test_subscribe_backlog_keeping_up_client() {
609 let mut acc = SubscribeBacklogAccounting::default();
610 for i in 0..1_000 {
611 acc.push(1_024 + i * 4_096);
612 assert_eq!(acc.backlog_size(), 0, "message {i} built backlog");
613 acc.pop();
614 assert_eq!(acc.backlog_size(), 0, "message {i} left backlog behind");
615 }
616 }
617}