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) -> Option<&ConnectionId> {
55 match &self {
56 ActiveComputeSink::Subscribe(subscribe) => subscribe.connection_id(),
57 ActiveComputeSink::CopyTo(copy_to) => Some(©_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/// Ownership and cleanup scope of an active subscribe.
151#[derive(Debug)]
152pub enum ActiveSubscribeOwner {
153 /// The subscribe belongs to a SQL session.
154 Session {
155 conn_id: ConnectionId,
156 session_uuid: Uuid,
157 },
158 /// The subscribe belongs to a coordinator background task.
159 ///
160 /// Always `internal`, since there is no session to attribute a
161 /// `mz_subscriptions` row to.
162 Background,
163}
164
165/// A description of an active subscribe from coord's perspective
166#[derive(Debug)]
167pub struct ActiveSubscribe {
168 /// The owner responsible for retiring the subscribe.
169 pub owner: ActiveSubscribeOwner,
170 /// The ID of the cluster on which the subscribe is running.
171 pub cluster_id: ClusterId,
172 /// The IDs of the objects on which the subscribe depends.
173 pub depends_on: BTreeSet<GlobalId>,
174 /// Channel on which to send responses to the client.
175 // The responses have the form `PeekResponseUnary` but should perhaps
176 // become `SubscribeResponse`.
177 pub channel: mpsc::UnboundedSender<PeekResponseUnary>,
178 /// Footprints of the messages queued in `channel` but not yet drained by the
179 /// client writer. Shared with the receiver side, which pops as it drains.
180 ///
181 /// The producer runs on the non-blockable coordinator loop and cannot block
182 /// on a slow client, so instead of applying backpressure the coordinator
183 /// watches `backlog_size` against `max_buffered_bytes` and retires the
184 /// subscribe once the backlog exceeds it.
185 pub backlog_accounting: Arc<Mutex<SubscribeBacklogAccounting>>,
186 /// Budget for the buffered backlog. A snapshot of `subscribe_max_buffered_bytes`
187 /// taken when the subscribe was created.
188 pub max_buffered_bytes: usize,
189 /// Whether progress information should be emitted.
190 pub emit_progress: bool,
191 /// The logical timestamp at which the subscribe began execution.
192 pub as_of: Timestamp,
193 /// The number of columns in the relation that was subscribed to.
194 pub arity: usize,
195 /// The time when the subscribe started.
196 pub start_time: EpochMillis,
197 /// How to present the subscribe's output.
198 pub output: SubscribeOutput,
199 /// If true, this is an internal subscribe that should not appear in
200 /// introspection tables like mz_subscriptions.
201 pub internal: bool,
202}
203
204impl ActiveSubscribe {
205 /// The session uuid for this subscribe's `mz_subscriptions` row, or `None`
206 /// if it does not appear there.
207 pub fn introspection_session_uuid(&self) -> Option<Uuid> {
208 match &self.owner {
209 ActiveSubscribeOwner::Session { session_uuid, .. } if !self.internal => {
210 Some(*session_uuid)
211 }
212 _ => None,
213 }
214 }
215
216 /// Returns the owning connection, if this is a session subscribe.
217 pub fn connection_id(&self) -> Option<&ConnectionId> {
218 match &self.owner {
219 ActiveSubscribeOwner::Session { conn_id, .. } => Some(conn_id),
220 ActiveSubscribeOwner::Background => None,
221 }
222 }
223
224 /// Initializes the subscription.
225 ///
226 /// This method must be called exactly once, after constructing an
227 /// `ActiveSubscribe` and before calling `process_response`.
228 pub fn initialize(&self) {
229 // Always emit progress message indicating snapshot timestamp.
230 self.send_progress_message(&Antichain::from_elem(self.as_of));
231 }
232
233 fn send_progress_message(&self, upper: &Antichain<Timestamp>) {
234 if !self.emit_progress {
235 return;
236 }
237 if let Some(upper) = upper.as_option() {
238 let mut row_buf = Row::default();
239 let mut packer = row_buf.packer();
240 packer.push(Datum::from(numeric::Numeric::from(*upper)));
241 packer.push(Datum::True);
242
243 // Fill in the mz_diff or mz_state column
244 packer.push(Datum::Null);
245
246 // Fill all table columns with NULL.
247 for _ in 0..self.arity {
248 packer.push(Datum::Null);
249 }
250
251 if let SubscribeOutput::EnvelopeDebezium { order_by_keys } = &self.output {
252 for _ in 0..(self.arity - order_by_keys.len()) {
253 packer.push(Datum::Null);
254 }
255 }
256
257 let bytes = row_buf.byte_len();
258 let row_iter = Box::new(row_buf.into_row_iter());
259 self.send(PeekResponseUnary::Rows(row_iter), bytes);
260 }
261 }
262
263 /// Processes a subscribe response from the controller.
264 ///
265 /// Returns `true` if the subscribe is finished.
266 pub fn process_response(&self, batch: SubscribeBatch) -> bool {
267 let comparator = RowComparator::new(self.output.row_order());
268 let rows = match batch.updates {
269 Ok(ref rows) => {
270 let iters = rows.iter().map(|r| r.iter());
271 let merged = mz_ore::iter::merge_iters_by(
272 iters,
273 |(left_row, left_time, _), (right_row, right_time, _)| {
274 left_time.cmp(right_time).then_with(|| {
275 comparator.compare_rows(left_row, right_row, || left_row.cmp(right_row))
276 })
277 },
278 );
279 mz_ore::iter::consolidate_update_iter(merged)
280 }
281 Err(s) => {
282 self.send(
283 PeekResponseUnary::Error(AdapterError::Unstructured(anyhow::Error::msg(s))),
284 0,
285 );
286 return true;
287 }
288 };
289
290 // Sort results by time. We use stable sort here because it will produce
291 // deterministic results since the cursor will always produce rows in
292 // the same order. Compute doesn't guarantee that the results are sorted
293 // (materialize#18936)
294 let mut output_buf = Row::default();
295 let mut output_builder = RowCollection::builder(0, 0);
296 let mut left_datum_vec = mz_repr::DatumVec::new();
297 let mut right_datum_vec = mz_repr::DatumVec::new();
298 let mut push_row = |row: &RowRef, time: Timestamp, diff: Diff| {
299 assert!(self.as_of <= time);
300 let mut packer = output_buf.packer();
301 // TODO: Change to MzTimestamp.
302 packer.push(Datum::from(numeric::Numeric::from(time)));
303 if self.emit_progress {
304 // When sinking with PROGRESS, the output includes an
305 // additional column that indicates whether a timestamp is
306 // complete. For regular "data" updates this is always
307 // `false`.
308 packer.push(Datum::False);
309 }
310
311 match &self.output {
312 SubscribeOutput::EnvelopeUpsert { .. }
313 | SubscribeOutput::EnvelopeDebezium { .. } => {}
314 SubscribeOutput::Diffs | SubscribeOutput::WithinTimestampOrderBy { .. } => {
315 packer.push(Datum::Int64(diff.into_inner()));
316 }
317 }
318
319 packer.extend_by_row_ref(row);
320
321 output_builder.push(output_buf.as_row_ref(), NonZeroUsize::MIN);
322 };
323
324 match &self.output {
325 SubscribeOutput::WithinTimestampOrderBy { order_by } => {
326 let mut rows: Vec<_> = rows.collect();
327 // Since the diff is inserted as the first column, we can't take advantage of the
328 // known ordering. (Aside from timestamp, I suppose.)
329 rows.sort_by(
330 |(left_row, left_time, left_diff), (right_row, right_time, right_diff)| {
331 left_time.cmp(right_time).then_with(|| {
332 let mut left_datums = left_datum_vec.borrow();
333 left_datums.extend(&[Datum::Int64(left_diff.into_inner())]);
334 left_datums.extend(left_row.iter());
335 let mut right_datums = right_datum_vec.borrow();
336 right_datums.extend(&[Datum::Int64(right_diff.into_inner())]);
337 right_datums.extend(right_row.iter());
338 compare_columns(order_by, &left_datums, &right_datums, || {
339 left_row.cmp(right_row).then(left_diff.cmp(right_diff))
340 })
341 })
342 },
343 );
344 for (row, time, diff) in rows {
345 push_row(row, *time, diff);
346 }
347 }
348 SubscribeOutput::EnvelopeUpsert { order_by_keys }
349 | SubscribeOutput::EnvelopeDebezium { order_by_keys } => {
350 let debezium = matches!(self.output, SubscribeOutput::EnvelopeDebezium { .. });
351 let mut it = rows.peekable();
352 let mut datum_vec = mz_repr::DatumVec::new();
353 let mut old_datum_vec = mz_repr::DatumVec::new();
354 let comparator = RowComparator::new(order_by_keys.as_slice());
355 let mut group = Vec::with_capacity(2);
356 let mut row_buf = Row::default();
357 // The iterator is sorted by time and key, so elements in the same group should be
358 // adjacent already.
359 while let Some(start) = it.next() {
360 group.clear();
361 group.push(start);
362 while let Some(row) = it.peek()
363 && start.1 == row.1
364 && {
365 comparator
366 .compare_rows(start.0, row.0, || Ordering::Equal)
367 .is_eq()
368 }
369 {
370 group.extend(it.next());
371 }
372 group.sort_by_key(|(_, _, d)| *d);
373
374 // Four cases:
375 // [(key, value, +1)] => ("insert", key, NULL, value)
376 // [(key, v1, -1), (key, v2, +1)] => ("upsert", key, v1, v2)
377 // [(key, value, -1)] => ("delete", key, value, NULL)
378 // everything else => ("key_violation", key, NULL, NULL)
379 // Defense in depth: the planner ensures that KEY columns are
380 // distinct columns of the underlying relation, so this
381 // subtraction must never underflow. If it does, we'd OOM
382 // the coordinator with a giant loop, so check it here.
383 mz_ore::soft_assert_or_log!(
384 order_by_keys.len() <= self.arity,
385 "SUBSCRIBE ENVELOPE has more KEY columns ({}) than \
386 relation arity ({}); planner should have rejected this",
387 order_by_keys.len(),
388 self.arity,
389 );
390 let value_columns = self.arity.saturating_sub(order_by_keys.len());
391 let mut packer = row_buf.packer();
392 match &group[..] {
393 [(row, _, Diff::ONE)] => {
394 packer.push(if debezium {
395 Datum::String("insert")
396 } else {
397 Datum::String("upsert")
398 });
399 let datums = datum_vec.borrow_with(row);
400 for column_order in order_by_keys {
401 packer.push(datums[column_order.column]);
402 }
403 if debezium {
404 for _ in 0..value_columns {
405 packer.push(Datum::Null);
406 }
407 }
408 for idx in 0..self.arity {
409 if !order_by_keys.iter().any(|co| co.column == idx) {
410 packer.push(datums[idx]);
411 }
412 }
413 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
414 }
415 [(_, _, Diff::MINUS_ONE)] => {
416 packer.push(Datum::String("delete"));
417 let datums = datum_vec.borrow_with(start.0);
418 for column_order in order_by_keys {
419 packer.push(datums[column_order.column]);
420 }
421 if debezium {
422 for idx in 0..self.arity {
423 if !order_by_keys.iter().any(|co| co.column == idx) {
424 packer.push(datums[idx]);
425 }
426 }
427 }
428 for _ in 0..value_columns {
429 packer.push(Datum::Null);
430 }
431 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
432 }
433 [(old_row, _, Diff::MINUS_ONE), (row, _, Diff::ONE)] => {
434 packer.push(Datum::String("upsert"));
435 let datums = datum_vec.borrow_with(row);
436 let old_datums = old_datum_vec.borrow_with(old_row);
437
438 for column_order in order_by_keys {
439 packer.push(datums[column_order.column]);
440 }
441 if debezium {
442 for idx in 0..self.arity {
443 if !order_by_keys.iter().any(|co| co.column == idx) {
444 packer.push(old_datums[idx]);
445 }
446 }
447 }
448 for idx in 0..self.arity {
449 if !order_by_keys.iter().any(|co| co.column == idx) {
450 packer.push(datums[idx]);
451 }
452 }
453 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
454 }
455 _ => {
456 packer.push(Datum::String("key_violation"));
457 let datums = datum_vec.borrow_with(start.0);
458 for column_order in order_by_keys {
459 packer.push(datums[column_order.column]);
460 }
461 if debezium {
462 for _ in 0..value_columns {
463 packer.push(Datum::Null);
464 }
465 }
466 for _ in 0..value_columns {
467 packer.push(Datum::Null);
468 }
469 push_row(row_buf.as_row_ref(), *start.1, Diff::ZERO)
470 }
471 };
472 }
473 }
474 SubscribeOutput::Diffs => {
475 // Diffs output is sorted by time and row, so it can be pushed directly.
476 for (row, time, diff) in rows {
477 push_row(row, *time, diff)
478 }
479 }
480 };
481
482 let rows = output_builder.build();
483 let bytes = rows.byte_len();
484 let rows = Box::new(rows.into_row_iter());
485 self.send(PeekResponseUnary::Rows(rows), bytes);
486
487 // Emit progress message if requested. Don't emit progress for the first
488 // batch if the upper is exactly `as_of` (we're guaranteed it is not
489 // less than `as_of`, but it might be exactly `as_of`) as we've already
490 // emitted that progress message in `initialize`.
491 if !batch.upper.less_equal(&self.as_of) {
492 self.send_progress_message(&batch.upper);
493 }
494
495 batch.upper.is_empty()
496 }
497
498 /// Retires the subscribe with the specified reason.
499 ///
500 /// This method must be called on every subscribe before it is dropped. It
501 /// informs the end client that the subscribe is finished for the specified
502 /// reason.
503 pub fn retire(self, reason: ActiveComputeSinkRetireReason) {
504 let message = match reason {
505 ActiveComputeSinkRetireReason::Finished => return,
506 ActiveComputeSinkRetireReason::Canceled => PeekResponseUnary::Canceled,
507 ActiveComputeSinkRetireReason::DependencyDropped(d) => {
508 PeekResponseUnary::DependencyDropped(d)
509 }
510 ActiveComputeSinkRetireReason::BufferExceeded {
511 buffered_bytes,
512 max_buffered_bytes,
513 } => PeekResponseUnary::Error(AdapterError::SubscribeFellBehind {
514 buffered_bytes,
515 max_buffered_bytes,
516 }),
517 };
518 self.send(message, 0);
519 }
520
521 /// Sends a message to the client if the subscribe has not already completed
522 /// and if the client has not already gone away.
523 ///
524 /// `bytes` is the message's payload size. Its footprint (payload plus a fixed
525 /// per-message overhead) is recorded in `backlog_accounting` here and
526 /// released by the receiver side when the message is drained. Overflow of
527 /// the budget is detected by the coordinator after `process_response`
528 /// returns, not here, because this method cannot retire the sink.
529 fn send(&self, response: PeekResponseUnary, bytes: usize) {
530 let footprint = bytes.saturating_add(SUBSCRIBE_MESSAGE_OVERHEAD_BYTES);
531 self.backlog_accounting
532 .lock()
533 .expect("subscribe backlog accounting poisoned")
534 .push(footprint);
535 let _ = self.channel.send(response);
536 }
537}
538
539/// A description of an active copy to sink from the coordinator's perspective.
540#[derive(Debug)]
541pub struct ActiveCopyTo {
542 /// The ID of the connection which created the subscribe.
543 pub conn_id: ConnectionId,
544 /// The result channel for the `COPY ... TO` statement that created the copy to sink.
545 pub tx: oneshot::Sender<Result<ExecuteResponse, AdapterError>>,
546 /// The ID of the cluster on which the copy to is running.
547 pub cluster_id: ClusterId,
548 /// The IDs of the objects on which the copy to depends.
549 pub depends_on: BTreeSet<GlobalId>,
550}
551
552impl ActiveCopyTo {
553 /// Retires the copy to with a response from the controller.
554 ///
555 /// Unlike subscribes, copy tos only expect a single response from the
556 /// controller, so `process_response` and `retire` are unified into a single
557 /// operation.
558 ///
559 /// Either this method or `retire` must be called on every copy to before it
560 /// is dropped.
561 pub fn retire_with_response(self, response: Result<u64, anyhow::Error>) {
562 let response = match response {
563 Ok(n) => Ok(ExecuteResponse::Copied(usize::cast_from(n))),
564 Err(error) => Err(AdapterError::Unstructured(error)),
565 };
566 let _ = self.tx.send(response);
567 }
568
569 /// Retires the copy to with the specified reason.
570 ///
571 /// Either this method or `retire_with_response` must be called on every
572 /// copy to before it is dropped.
573 pub fn retire(self, reason: ActiveComputeSinkRetireReason) {
574 let message = match reason {
575 ActiveComputeSinkRetireReason::Finished => return,
576 ActiveComputeSinkRetireReason::Canceled => Err(AdapterError::Canceled),
577 ActiveComputeSinkRetireReason::DependencyDropped(dep) => {
578 Err(dep.to_concurrent_dependency_drop())
579 }
580 ActiveComputeSinkRetireReason::BufferExceeded {
581 buffered_bytes,
582 max_buffered_bytes,
583 } => Err(AdapterError::SubscribeFellBehind {
584 buffered_bytes,
585 max_buffered_bytes,
586 }),
587 };
588 let _ = self.tx.send(message);
589 }
590}
591
592/// State we keep in the `Coordinator` to track active `COPY FROM` statements.
593#[derive(Debug)]
594pub(crate) struct ActiveCopyFrom {
595 /// ID of the ingestion running in clusterd.
596 pub ingestion_id: uuid::Uuid,
597 /// The cluster this is currently running on.
598 pub cluster_id: StorageInstanceId,
599 /// The table we're currently copying into.
600 pub table_id: CatalogItemId,
601 /// Context of the SQL session that ran the statement.
602 pub ctx: ExecuteContext,
603}
604
605#[cfg(test)]
606mod tests {
607 use crate::active_compute_sink::SubscribeBacklogAccounting;
608
609 /// The backlog excludes the message being drained, and zero-payload
610 /// messages (footprint = overhead only) still accumulate against it.
611 #[mz_ore::test]
612 fn test_subscribe_backlog_accounting() {
613 let mut acc = SubscribeBacklogAccounting::default();
614 assert_eq!(acc.backlog_size(), 0);
615
616 // A single large message is fully tolerated: nothing is queued behind it.
617 acc.push(10_000);
618 assert_eq!(acc.backlog_size(), 0);
619
620 // Near-empty messages (only per-message overhead) still build backlog, so
621 // a flood of frontier-only advances cannot grow without bound.
622 acc.push(1_024);
623 acc.push(1_024);
624 assert_eq!(acc.backlog_size(), 2_048);
625
626 // Draining the oldest message advances the tolerated front.
627 acc.pop();
628 assert_eq!(acc.backlog_size(), 1_024);
629
630 acc.pop();
631 acc.pop();
632 assert_eq!(acc.backlog_size(), 0);
633 }
634
635 /// A client that drains each message before the next is sent never
636 /// accumulates backlog, however many messages flow and however large they
637 /// are. This is the property that keeps a well-behaved subscribe from ever
638 /// being retired, so it is asserted after every step rather than at the end.
639 #[mz_ore::test]
640 fn test_subscribe_backlog_keeping_up_client() {
641 let mut acc = SubscribeBacklogAccounting::default();
642 for i in 0..1_000 {
643 acc.push(1_024 + i * 4_096);
644 assert_eq!(acc.backlog_size(), 0, "message {i} built backlog");
645 acc.pop();
646 assert_eq!(acc.backlog_size(), 0, "message {i} left backlog behind");
647 }
648 }
649}