Skip to main content

mz_compute_client/
service.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//! Compute layer client and server.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::mem;
14
15use bytesize::ByteSize;
16use differential_dataflow::lattice::Lattice;
17use mz_expr::row::RowCollection;
18use mz_ore::cast::CastInto;
19use mz_ore::soft_panic_or_log;
20use mz_ore::tracing::OpenTelemetryContext;
21use mz_repr::{GlobalId, Timestamp, UpdateCollection};
22use mz_service::client::{Partitionable, PartitionedState};
23use timely::PartialOrder;
24use timely::progress::frontier::{Antichain, MutableAntichain};
25use uuid::Uuid;
26
27use crate::protocol::command::ComputeCommand;
28use crate::protocol::response::{
29    ComputeResponse, CopyToResponse, FrontiersResponse, PeekError, PeekResponse,
30    StashedPeekResponse, SubscribeBatch, SubscribeResponse,
31};
32
33/// Maintained state for partitioned compute clients.
34///
35/// This helper type unifies the responses of multiple partitioned workers in order to present as a
36/// single worker:
37///
38///   * It emits `Frontiers` responses reporting the minimum/meet of frontiers reported by the
39///     individual workers.
40///   * It emits `PeekResponse`s and `SubscribeResponse`s reporting the union of the responses
41///     received from the workers.
42///
43/// In the compute communication stack, this client is instantiated several times:
44///
45///   * One instance on the controller side, dispatching between cluster processes.
46///   * One instance in each cluster process, dispatching between timely worker threads.
47///
48/// Note that because compute commands, except `Hello` and `UpdateConfiguration`, are only
49/// sent to the first process, the cluster-side instances of `PartitionedComputeState` are not
50/// guaranteed to see all compute commands. Or more specifically: The instance running inside
51/// process 0 sees all commands, whereas the instances running inside the other processes only see
52/// `Hello` and `UpdateConfiguration`. The `PartitionedComputeState` implementation must be
53/// able to cope with this limited visibility. It does so by performing most of its state management
54/// based on observed compute responses rather than commands.
55#[derive(Debug)]
56pub struct PartitionedComputeState {
57    /// Number of partitions the state machine represents.
58    parts: usize,
59    /// The maximum result size this state machine can return.
60    ///
61    /// This is updated upon receiving [`ComputeCommand::UpdateConfiguration`]s.
62    max_result_size: u64,
63    /// Tracked frontiers for indexes and sinks.
64    ///
65    /// Frontier tracking for a collection is initialized when the first `Frontiers` response
66    /// for that collection is received. Frontier tracking is ceased when all shards have reported
67    /// advancement to the empty frontier for all frontier kinds.
68    ///
69    /// The compute protocol requires that shards always emit `Frontiers` responses reporting empty
70    /// frontiers for all frontier kinds when a collection is dropped. It further requires that no
71    /// further `Frontier` responses are emitted for a collection after the empty frontiers were
72    /// reported. These properties ensure that a) we always cease frontier tracking for collections
73    /// that have been dropped and b) frontier tracking for a collection is not re-initialized
74    /// after it was ceased.
75    frontiers: BTreeMap<GlobalId, TrackedFrontiers>,
76    /// For each in-progress peek the response data received so far, and the set of shards that
77    /// provided responses already.
78    ///
79    /// Tracking of responses for a peek is initialized when the first `PeekResponse` for that peek
80    /// is received. Once all shards have provided a `PeekResponse`, a unified peek response is
81    /// emitted and the peek tracking state is dropped again.
82    ///
83    /// The compute protocol requires that exactly one response is emitted for each peek. This
84    /// property ensures that a) we can eventually drop the tracking state maintained for a peek
85    /// and b) we won't re-initialize tracking for a peek we have already served.
86    peek_responses: BTreeMap<Uuid, PendingPeek>,
87    /// For each in-progress copy-to the response data received so far, and the set of shards that
88    /// provided responses already.
89    ///
90    /// Tracking of responses for a COPY TO is initialized when the first `CopyResponse` for that command
91    /// is received. Once all shards have provided a `CopyResponse`, a unified copy response is
92    /// emitted and the copy_to tracking state is dropped again.
93    ///
94    /// The compute protocol requires that exactly one response is emitted for each COPY TO command. This
95    /// property ensures that a) we can eventually drop the tracking state maintained for a copy
96    /// and b) we won't re-initialize tracking for a copy we have already served.
97    copy_to_responses: BTreeMap<GlobalId, (CopyToResponse, BTreeSet<usize>)>,
98    /// Tracks in-progress `SUBSCRIBE`s, and the stashed rows we are holding back until their
99    /// timestamps are complete.
100    ///
101    /// The updates may be `Err` if any of the batches have reported an error, in which case the
102    /// subscribe is permanently borked.
103    ///
104    /// Tracking of a subscribe is initialized when the first `SubscribeResponse` for that
105    /// subscribe is received. Once all shards have emitted an "end-of-subscribe" response the
106    /// subscribe tracking state is dropped again.
107    ///
108    /// The compute protocol requires that for a subscribe that shuts down an end-of-subscribe
109    /// response is emitted:
110    ///
111    ///   * Either a `Batch` response reporting advancement to the empty frontier...
112    ///   * ... or a `DroppedAt` response reporting that the subscribe was dropped before
113    ///     completing.
114    ///
115    /// The compute protocol further requires that no further `SubscribeResponse`s are emitted for
116    /// a subscribe after an end-of-subscribe was reported.
117    ///
118    /// These two properties ensure that a) once a subscribe has shut down, we can eventually drop
119    /// the tracking state maintained for it and b) we won't re-initialize tracking for a subscribe
120    /// we have already dropped.
121    pending_subscribes: BTreeMap<GlobalId, PendingSubscribe>,
122}
123
124impl Partitionable<ComputeCommand, ComputeResponse> for (ComputeCommand, ComputeResponse) {
125    type PartitionedState = PartitionedComputeState;
126
127    fn new(parts: usize) -> PartitionedComputeState {
128        PartitionedComputeState {
129            parts,
130            max_result_size: u64::MAX,
131            frontiers: BTreeMap::new(),
132            peek_responses: BTreeMap::new(),
133            pending_subscribes: BTreeMap::new(),
134            copy_to_responses: BTreeMap::new(),
135        }
136    }
137}
138
139impl PartitionedComputeState {
140    /// Observes commands that move past.
141    pub fn observe_command(&mut self, command: &ComputeCommand) {
142        match command {
143            ComputeCommand::UpdateConfiguration(config) => {
144                if let Some(max_result_size) = config.max_result_size {
145                    self.max_result_size = max_result_size;
146                }
147            }
148            _ => {
149                // We are not guaranteed to observe other compute commands. We
150                // must therefore not add any logic here that relies on doing so.
151            }
152        }
153    }
154
155    /// Absorb a [`ComputeResponse::Frontiers`].
156    fn absorb_frontiers(
157        &mut self,
158        shard_id: usize,
159        collection_id: GlobalId,
160        frontiers: FrontiersResponse,
161    ) -> Option<ComputeResponse> {
162        let tracked = self
163            .frontiers
164            .entry(collection_id)
165            .or_insert_with(|| TrackedFrontiers::new(self.parts));
166
167        let write_frontier = frontiers
168            .write_frontier
169            .and_then(|f| tracked.update_write_frontier(shard_id, &f));
170        let input_frontier = frontiers
171            .input_frontier
172            .and_then(|f| tracked.update_input_frontier(shard_id, &f));
173        let output_frontier = frontiers
174            .output_frontier
175            .and_then(|f| tracked.update_output_frontier(shard_id, &f));
176
177        let frontiers = FrontiersResponse {
178            write_frontier,
179            input_frontier,
180            output_frontier,
181        };
182        let result = frontiers
183            .has_updates()
184            .then_some(ComputeResponse::Frontiers(collection_id, frontiers));
185
186        if tracked.all_empty() {
187            // All shards have reported advancement to the empty frontier, so we do not
188            // expect further updates for this collection.
189            self.frontiers.remove(&collection_id);
190        }
191
192        result
193    }
194
195    /// Absorb a [`ComputeResponse::PeekResponse`].
196    fn absorb_peek_response(
197        &mut self,
198        shard_id: usize,
199        uuid: Uuid,
200        response: PeekResponse,
201        otel_ctx: OpenTelemetryContext,
202    ) -> Option<ComputeResponse> {
203        let pending = self
204            .peek_responses
205            .entry(uuid)
206            .or_insert_with(PendingPeek::new);
207        pending.absorb(shard_id, response, self.max_result_size);
208
209        if pending.ready_shards.len() == self.parts {
210            let response = self.peek_responses.remove(&uuid).unwrap().response;
211            Some(ComputeResponse::PeekResponse(uuid, response, otel_ctx))
212        } else {
213            None
214        }
215    }
216
217    /// Absorb a [`ComputeResponse::CopyToResponse`].
218    fn absorb_copy_to_response(
219        &mut self,
220        shard_id: usize,
221        copyto_id: GlobalId,
222        response: CopyToResponse,
223    ) -> Option<ComputeResponse> {
224        use CopyToResponse::*;
225
226        let (merged, ready_shards) = self
227            .copy_to_responses
228            .entry(copyto_id)
229            .or_insert((CopyToResponse::RowCount(0), BTreeSet::new()));
230
231        let first = ready_shards.insert(shard_id);
232        assert!(first, "duplicate copy-to response");
233
234        let resp1 = mem::replace(merged, Dropped);
235        *merged = match (resp1, response) {
236            (Dropped, _) | (_, Dropped) => Dropped,
237            (Error(e), _) | (_, Error(e)) => Error(e),
238            (RowCount(r1), RowCount(r2)) => RowCount(r1 + r2),
239        };
240
241        if ready_shards.len() == self.parts {
242            let (response, _) = self.copy_to_responses.remove(&copyto_id).unwrap();
243            Some(ComputeResponse::CopyToResponse(copyto_id, response))
244        } else {
245            None
246        }
247    }
248
249    /// Absorb a [`ComputeResponse::SubscribeResponse`].
250    fn absorb_subscribe_response(
251        &mut self,
252        subscribe_id: GlobalId,
253        response: SubscribeResponse,
254    ) -> Option<ComputeResponse> {
255        let tracked = self
256            .pending_subscribes
257            .entry(subscribe_id)
258            .or_insert_with(|| PendingSubscribe::new(self.parts));
259
260        let emit_response = match response {
261            SubscribeResponse::Batch(batch) => {
262                let frontiers = &mut tracked.frontiers;
263                let old_frontier = frontiers.frontier().to_owned();
264                frontiers.update_iter(batch.lower.into_iter().map(|t| (t, -1)));
265                frontiers.update_iter(batch.upper.into_iter().map(|t| (t, 1)));
266                let new_frontier = frontiers.frontier().to_owned();
267
268                tracked.stash(batch.updates, self.max_result_size);
269
270                // If the frontier has advanced, it is time to announce subscribe progress. Unless
271                // we have already announced that the subscribe has been dropped, in which case we
272                // must keep quiet.
273                if old_frontier != new_frontier && !tracked.dropped {
274                    let updates = match &mut tracked.stashed_updates {
275                        Ok(stashed_updates) => {
276                            // Split each collection along the frontier, passing the prefix along.
277                            let mut ship = vec![];
278                            let mut keep = vec![];
279                            for collection in stashed_updates.drain(..) {
280                                let partition_point = collection
281                                    .times()
282                                    .partition_point(|t| !new_frontier.less_equal(t));
283                                let (ship_coll, keep_coll) = collection.split_at(partition_point);
284                                if ship_coll.len() > 0 {
285                                    ship.push(ship_coll);
286                                }
287                                if keep_coll.len() > 0 {
288                                    keep.push(keep_coll);
289                                }
290                            }
291                            tracked.stashed_result_size = keep.iter().map(|c| c.byte_len()).sum();
292                            tracked.stashed_updates = Ok(keep);
293                            Ok(ship)
294                        }
295                        Err(text) => Err(text.clone()),
296                    };
297                    Some(ComputeResponse::SubscribeResponse(
298                        subscribe_id,
299                        SubscribeResponse::Batch(SubscribeBatch {
300                            lower: old_frontier,
301                            upper: new_frontier,
302                            updates,
303                        }),
304                    ))
305                } else {
306                    None
307                }
308            }
309            SubscribeResponse::DroppedAt(frontier) => {
310                tracked
311                    .frontiers
312                    .update_iter(frontier.iter().map(|t| (t.clone(), -1)));
313
314                if tracked.dropped {
315                    None
316                } else {
317                    tracked.dropped = true;
318                    Some(ComputeResponse::SubscribeResponse(
319                        subscribe_id,
320                        SubscribeResponse::DroppedAt(frontier),
321                    ))
322                }
323            }
324        };
325
326        if tracked.frontiers.frontier().is_empty() {
327            // All shards have reported advancement to the empty frontier or dropping, so
328            // we do not expect further updates for this subscribe.
329            self.pending_subscribes.remove(&subscribe_id);
330        }
331
332        emit_response
333    }
334}
335
336impl PartitionedState<ComputeCommand, ComputeResponse> for PartitionedComputeState {
337    fn split_command(&mut self, command: ComputeCommand) -> Vec<Option<ComputeCommand>> {
338        self.observe_command(&command);
339
340        // As specified by the compute protocol:
341        //  * Forward `Hello` and `UpdateConfiguration` commands to all shards.
342        //  * Forward all other commands to the first shard only.
343        match command {
344            command @ ComputeCommand::Hello { .. }
345            | command @ ComputeCommand::UpdateConfiguration(_) => {
346                vec![Some(command); self.parts]
347            }
348            command => {
349                let mut r = vec![None; self.parts];
350                r[0] = Some(command);
351                r
352            }
353        }
354    }
355
356    fn absorb_response(
357        &mut self,
358        shard_id: usize,
359        message: ComputeResponse,
360    ) -> Option<Result<ComputeResponse, anyhow::Error>> {
361        let response = match message {
362            ComputeResponse::Frontiers(id, frontiers) => {
363                self.absorb_frontiers(shard_id, id, frontiers)
364            }
365            ComputeResponse::PeekResponse(uuid, response, otel_ctx) => {
366                self.absorb_peek_response(shard_id, uuid, response, otel_ctx)
367            }
368            ComputeResponse::SubscribeResponse(id, response) => {
369                self.absorb_subscribe_response(id, response)
370            }
371            ComputeResponse::CopyToResponse(id, response) => {
372                self.absorb_copy_to_response(shard_id, id, response)
373            }
374            response @ ComputeResponse::Status(_) => {
375                // Pass through status responses.
376                Some(response)
377            }
378        };
379
380        response.map(Ok)
381    }
382}
383
384/// Tracked frontiers for an index or a sink collection.
385///
386/// Each frontier is maintained both as a `MutableAntichain` across all partitions and individually
387/// for each partition.
388#[derive(Debug)]
389struct TrackedFrontiers {
390    /// The tracked write frontier.
391    write_frontier: (MutableAntichain<Timestamp>, Vec<Antichain<Timestamp>>),
392    /// The tracked input frontier.
393    input_frontier: (MutableAntichain<Timestamp>, Vec<Antichain<Timestamp>>),
394    /// The tracked output frontier.
395    output_frontier: (MutableAntichain<Timestamp>, Vec<Antichain<Timestamp>>),
396}
397
398impl TrackedFrontiers {
399    /// Initializes frontier tracking state for a new collection.
400    fn new(parts: usize) -> Self {
401        // TODO(benesch): fix this dangerous use of `as`.
402        #[allow(clippy::as_conversions)]
403        let parts_diff = parts as i64;
404
405        let mut frontier = MutableAntichain::new();
406        frontier.update_iter([(Timestamp::MIN, parts_diff)]);
407        let part_frontiers = vec![Antichain::from_elem(Timestamp::MIN); parts];
408        let frontier_entry = (frontier, part_frontiers);
409
410        Self {
411            write_frontier: frontier_entry.clone(),
412            input_frontier: frontier_entry.clone(),
413            output_frontier: frontier_entry,
414        }
415    }
416
417    /// Returns whether all tracked frontiers have advanced to the empty frontier.
418    fn all_empty(&self) -> bool {
419        self.write_frontier.0.frontier().is_empty()
420            && self.input_frontier.0.frontier().is_empty()
421            && self.output_frontier.0.frontier().is_empty()
422    }
423
424    /// Updates write frontier tracking with a new shard frontier.
425    ///
426    /// If this causes the global write frontier to advance, the advanced frontier is returned.
427    fn update_write_frontier(
428        &mut self,
429        shard_id: usize,
430        new_shard_frontier: &Antichain<Timestamp>,
431    ) -> Option<Antichain<Timestamp>> {
432        Self::update_frontier(&mut self.write_frontier, shard_id, new_shard_frontier)
433    }
434
435    /// Updates input frontier tracking with a new shard frontier.
436    ///
437    /// If this causes the global input frontier to advance, the advanced frontier is returned.
438    fn update_input_frontier(
439        &mut self,
440        shard_id: usize,
441        new_shard_frontier: &Antichain<Timestamp>,
442    ) -> Option<Antichain<Timestamp>> {
443        Self::update_frontier(&mut self.input_frontier, shard_id, new_shard_frontier)
444    }
445
446    /// Updates output frontier tracking with a new shard frontier.
447    ///
448    /// If this causes the global output frontier to advance, the advanced frontier is returned.
449    fn update_output_frontier(
450        &mut self,
451        shard_id: usize,
452        new_shard_frontier: &Antichain<Timestamp>,
453    ) -> Option<Antichain<Timestamp>> {
454        Self::update_frontier(&mut self.output_frontier, shard_id, new_shard_frontier)
455    }
456
457    /// Updates the provided frontier entry with a new shard frontier.
458    fn update_frontier(
459        entry: &mut (MutableAntichain<Timestamp>, Vec<Antichain<Timestamp>>),
460        shard_id: usize,
461        new_shard_frontier: &Antichain<Timestamp>,
462    ) -> Option<Antichain<Timestamp>> {
463        let (frontier, shard_frontiers) = entry;
464
465        let old_frontier = frontier.frontier().to_owned();
466        let shard_frontier = &mut shard_frontiers[shard_id];
467        frontier.update_iter(shard_frontier.iter().map(|t| (t.clone(), -1)));
468        shard_frontier.join_assign(new_shard_frontier);
469        frontier.update_iter(shard_frontier.iter().map(|t| (t.clone(), 1)));
470
471        let new_frontier = frontier.frontier();
472
473        if PartialOrder::less_than(&old_frontier.borrow(), &new_frontier) {
474            Some(new_frontier.to_owned())
475        } else {
476            None
477        }
478    }
479}
480
481#[derive(Debug)]
482struct PendingSubscribe {
483    /// The subscribe frontiers of the partitioned shards.
484    frontiers: MutableAntichain<Timestamp>,
485    /// The updates we are holding back until their timestamps are complete.
486    stashed_updates: Result<Vec<UpdateCollection>, String>,
487    /// The row size of stashed updates, for `max_result_size` checking.
488    stashed_result_size: usize,
489    /// Whether we have already emitted a `DroppedAt` response for this subscribe.
490    ///
491    /// This field is used to ensure we emit such a response only once.
492    dropped: bool,
493}
494
495impl PendingSubscribe {
496    fn new(parts: usize) -> Self {
497        let mut frontiers = MutableAntichain::new();
498        // TODO(benesch): fix this dangerous use of `as`.
499        #[allow(clippy::as_conversions)]
500        frontiers.update_iter([(Timestamp::MIN, parts as i64)]);
501
502        Self {
503            frontiers,
504            stashed_updates: Ok(Vec::new()),
505            stashed_result_size: 0,
506            dropped: false,
507        }
508    }
509
510    /// Stash a new batch of updates.
511    ///
512    /// This also implements the short-circuit behavior of error responses, and performs
513    /// `max_result_size` checking.
514    fn stash(&mut self, new_updates: Result<Vec<UpdateCollection>, String>, max_result_size: u64) {
515        match (&mut self.stashed_updates, new_updates) {
516            (Err(_), _) => {
517                // Subscribe is borked; nothing to do.
518                // TODO: Consider refreshing error?
519            }
520            (_, Err(text)) => {
521                self.stashed_updates = Err(text);
522            }
523            (Ok(stashed), Ok(new)) => {
524                let new_size: usize = new.iter().map(|coll| coll.byte_len()).sum();
525                self.stashed_result_size += new_size;
526
527                if self.stashed_result_size > max_result_size.cast_into() {
528                    self.stashed_updates = Err(format!(
529                        "total result exceeds max size of {}",
530                        ByteSize::b(max_result_size)
531                    ));
532                } else {
533                    stashed.extend(new);
534                }
535            }
536        }
537    }
538}
539
540/// Accumulates the per-worker responses to one peek into the single response the controller
541/// hands upwards.
542#[derive(Debug)]
543struct PendingPeek {
544    /// The responses merged so far.
545    response: PeekResponse,
546    /// Inline result bytes seen so far, across all shards.
547    ///
548    /// Tracked separately from `response` because a worker's rows are dropped as soon as any
549    /// worker reports an error. Without this the aggregate size check would depend on the order
550    /// the responses happen to arrive in.
551    inline_byte_len: usize,
552    /// The shards that have provided responses.
553    ready_shards: BTreeSet<usize>,
554}
555
556impl PendingPeek {
557    fn new() -> Self {
558        Self {
559            response: PeekResponse::Rows(vec![RowCollection::default()]),
560            inline_byte_len: 0,
561            ready_shards: BTreeSet::new(),
562        }
563    }
564
565    fn absorb(&mut self, shard_id: usize, response: PeekResponse, max_result_size: u64) {
566        let first = self.ready_shards.insert(shard_id);
567        assert!(first, "duplicate peek response");
568
569        self.inline_byte_len = self
570            .inline_byte_len
571            .saturating_add(response.inline_byte_len());
572        let current = mem::replace(&mut self.response, PeekResponse::Canceled);
573        self.response = merge_peek_responses(current, response);
574
575        // Merging eagerly is what keeps the controller's memory bounded, so the size check has to
576        // happen on every response rather than once at the end.
577        if self.inline_byte_len > max_result_size.cast_into() {
578            let error = PeekError::ResultExceedsMaxSize {
579                max_result_size: max_result_size.cast_into(),
580            };
581            let current = mem::replace(&mut self.response, PeekResponse::Canceled);
582            self.response = merge_peek_responses(current, PeekResponse::Error(error));
583        }
584    }
585}
586
587/// Merge two [`PeekResponse`]s.
588fn merge_peek_responses(resp1: PeekResponse, resp2: PeekResponse) -> PeekResponse {
589    use PeekResponse::*;
590
591    // Cancelations and errors short-circuit. Cancelations take precedence over errors.
592    let (resp1, resp2) = match (resp1, resp2) {
593        (Canceled, _) | (_, Canceled) => return Canceled,
594        (Error(e1), Error(e2)) => return Error(merge_peek_errors(e1, e2)),
595        (Error(e), _) | (_, Error(e)) => return Error(e),
596        resps => resps,
597    };
598
599    match (resp1, resp2) {
600        (Rows(mut rows1), Rows(rows2)) => {
601            rows1.extend(rows2);
602            Rows(rows1)
603        }
604        (Rows(rows), Stashed(mut stashed)) | (Stashed(mut stashed), Rows(rows)) => {
605            stashed.inline_rows.extend(rows);
606            Stashed(stashed)
607        }
608        (Stashed(stashed1), Stashed(stashed2)) => {
609            // Deconstruct so we don't miss adding new fields. We need to be careful about
610            // merging everything!
611            let StashedPeekResponse {
612                num_rows_batches: num_rows_batches1,
613                encoded_size_bytes: encoded_size_bytes1,
614                relation_desc: relation_desc1,
615                shard_id: shard_id1,
616                batches: mut batches1,
617                inline_rows: mut inline_rows1,
618            } = *stashed1;
619            let StashedPeekResponse {
620                num_rows_batches: num_rows_batches2,
621                encoded_size_bytes: encoded_size_bytes2,
622                relation_desc: relation_desc2,
623                shard_id: shard_id2,
624                batches: mut batches2,
625                inline_rows: inline_rows2,
626            } = *stashed2;
627
628            if shard_id1 != shard_id2 {
629                soft_panic_or_log!(
630                    "shard IDs of stashed responses do not match: \
631                             {shard_id1} != {shard_id2}"
632                );
633                return Error(PeekError::unstructured("internal error"));
634            }
635            if relation_desc1 != relation_desc2 {
636                soft_panic_or_log!(
637                    "relation descs of stashed responses do not match: \
638                             {relation_desc1:?} != {relation_desc2:?}"
639                );
640                return Error(PeekError::unstructured("internal error"));
641            }
642
643            batches1.append(&mut batches2);
644            inline_rows1.extend(inline_rows2);
645
646            Stashed(Box::new(StashedPeekResponse {
647                num_rows_batches: num_rows_batches1 + num_rows_batches2,
648                encoded_size_bytes: encoded_size_bytes1 + encoded_size_bytes2,
649                relation_desc: relation_desc1,
650                shard_id: shard_id1,
651                batches: batches1,
652                inline_rows: inline_rows1,
653            }))
654        }
655        _ => unreachable!("handled above"),
656    }
657}
658
659/// Merge two [`PeekError`]s into the one we report.
660///
661/// A row-iteration-limit error only wins against another one, and then the smaller limit wins so
662/// the choice does not depend on which worker answered first. Any other error outranks it: a
663/// query that both overran the limit and failed for a real reason should report the real reason.
664fn merge_peek_errors(error1: PeekError, error2: PeekError) -> PeekError {
665    match (error1, error2) {
666        (
667            PeekError::RowIterationLimitExceeded { limit: limit1 },
668            PeekError::RowIterationLimitExceeded { limit: limit2 },
669        ) => PeekError::RowIterationLimitExceeded {
670            limit: limit1.min(limit2),
671        },
672        (PeekError::RowIterationLimitExceeded { .. }, error)
673        | (error, PeekError::RowIterationLimitExceeded { .. }) => error,
674        (error, _) => error,
675    }
676}
677
678#[cfg(test)]
679mod tests;