Skip to main content

xmtp_api/
chunk.rs

1//! Request limits, canonical publish units, and complete topic reads.
2use crate::{ApiClientWrapper, ApiError, Result, dyn_err};
3use futures::{StreamExt, TryStreamExt, stream};
4use prost::Message;
5use std::{
6    collections::{HashMap, HashSet, VecDeque},
7    future::Future,
8};
9use tonic::Code;
10use xmtp_common::{RetryableError, retry_async};
11use xmtp_configuration::*;
12use xmtp_proto::{
13    api::grpc_status,
14    api_client::XmtpBackendClient,
15    backend_v1 as wire,
16    types::{
17        CanonicalEnvelope, Cursor, IncomingBatchLimits, OrderedEnvelopeBatch, Topic, TopicCursor,
18    },
19};
20
21pub const MAX_PUBLISH_CHUNKS_IN_FLIGHT: usize = 4;
22pub const MAX_READ_CHUNKS_IN_FLIGHT: usize = 4;
23
24/// One bounded ordered query result. Receipt is not committed by the query.
25#[derive(Debug)]
26pub struct OrderedQueryPage {
27    /// Per-topic batches with their original read positions.
28    pub batches: Vec<OrderedEnvelopeBatch>,
29    /// More data or an unread topic chunk remains. Resume from committed receipt.
30    pub has_more: bool,
31}
32
33#[derive(Clone, Debug)]
34struct PublishEnvelope {
35    envelope: wire::ClientEnvelope,
36    canonical: CanonicalEnvelope,
37    topic: Topic,
38}
39
40/// One envelope, or a commit and its proposals. A unit is never split.
41#[derive(Clone, Debug)]
42pub struct PublishUnit {
43    envelopes: Vec<PublishEnvelope>,
44}
45impl PublishUnit {
46    /// Build a unit against the shapes one deployment published (CFG-064,
47    /// CFG-065). A unit that cannot fit in a single request is rejected here,
48    /// before any network call.
49    pub fn new_within(
50        envelopes: Vec<wire::ClientEnvelope>,
51        limits: &LimitsConfiguration,
52    ) -> Result<Self> {
53        if envelopes.is_empty() {
54            return Err(ApiError::InvalidRequest("empty publish unit"));
55        }
56        let envelopes = envelopes
57            .into_iter()
58            .map(|envelope| {
59                let parsed = xmtp_mls_validation::parse_envelope(envelope)?;
60                if parsed.canonical.bytes.len() > limits.max_envelope_bytes {
61                    return Err(ApiError::EnvelopeTooLarge);
62                }
63                Ok(PublishEnvelope {
64                    envelope: parsed.envelope,
65                    canonical: parsed.canonical,
66                    topic: parsed.topic,
67                })
68            })
69            .collect::<Result<Vec<_>>>()?;
70        let unit = Self { envelopes };
71        let mut measure = PublishMeasure::new(limits);
72        measure.add(&unit);
73        if !measure.fits() {
74            return Err(ApiError::UnitTooLarge);
75        }
76        Ok(unit)
77    }
78
79    pub fn single_within(
80        envelope: wire::ClientEnvelope,
81        limits: &LimitsConfiguration,
82    ) -> Result<Self> {
83        Self::new_within(vec![envelope], limits)
84    }
85
86    /// Build a unit against the compiled defaults, for a caller that holds no
87    /// snapshot. Every client path goes through `new_within`.
88    pub fn new(envelopes: Vec<wire::ClientEnvelope>) -> Result<Self> {
89        Self::new_within(envelopes, &LimitsConfiguration::default())
90    }
91
92    pub fn single(envelope: wire::ClientEnvelope) -> Result<Self> {
93        Self::new(vec![envelope])
94    }
95}
96
97pub(crate) fn request(units: &[PublishUnit]) -> wire::PublishRequest {
98    wire::PublishRequest {
99        envelopes: units
100            .iter()
101            .flat_map(|unit| unit.envelopes.iter().map(|e| e.envelope.clone()))
102            .collect(),
103    }
104}
105/// Measure repeated envelope fields without cloning or encoding the request.
106struct PublishMeasure<'a> {
107    bytes: usize,
108    topics: HashSet<&'a Topic>,
109    limits: &'a LimitsConfiguration,
110}
111impl<'a> PublishMeasure<'a> {
112    fn new(limits: &'a LimitsConfiguration) -> Self {
113        Self {
114            bytes: 0,
115            topics: HashSet::new(),
116            limits,
117        }
118    }
119
120    fn add(&mut self, unit: &'a PublishUnit) {
121        for envelope in &unit.envelopes {
122            let len = envelope.canonical.bytes.len();
123            self.bytes += 1 + prost::length_delimiter_len(len) + len;
124            self.topics.insert(&envelope.topic);
125        }
126    }
127
128    fn fits(&self) -> bool {
129        self.bytes <= self.limits.max_request_bytes
130            && self.topics.len() <= self.limits.max_publish_topics
131    }
132}
133
134/// Split units into requests the deployment accepts (CFG-064). A commit and
135/// its proposals are one unit and never straddle a chunk.
136pub fn chunk_publish_within<'a>(
137    units: &'a [PublishUnit],
138    limits: &LimitsConfiguration,
139) -> Result<Vec<&'a [PublishUnit]>> {
140    let mut chunks = Vec::new();
141    let mut start = 0;
142    let mut measure = PublishMeasure::new(limits);
143    for (end, unit) in units.iter().enumerate() {
144        measure.add(unit);
145        if !measure.fits() {
146            if start == end {
147                return Err(ApiError::UnitTooLarge);
148            }
149            chunks.push(&units[start..end]);
150            start = end;
151            measure = PublishMeasure::new(limits);
152            measure.add(unit);
153            if !measure.fits() {
154                return Err(ApiError::UnitTooLarge);
155            }
156        }
157    }
158    if start < units.len() {
159        chunks.push(&units[start..]);
160    }
161    Ok(chunks)
162}
163
164/// Chunk against the compiled defaults, for a caller that holds no snapshot.
165pub fn chunk_publish(units: &[PublishUnit]) -> Result<Vec<&[PublishUnit]>> {
166    chunk_publish_within(units, &LimitsConfiguration::default())
167}
168
169pub(crate) fn size_error(error: &(dyn std::error::Error + 'static)) -> bool {
170    let Some(status) = grpc_status(error) else {
171        return false;
172    };
173    match status.code() {
174        Code::OutOfRange | Code::ResourceExhausted => true,
175        Code::InvalidArgument => {
176            tonic_types::pb::Status::decode(status.details()).is_ok_and(|details| {
177                details.details.iter().any(|detail| {
178                    detail.type_url == "type.googleapis.com/xmtp.backend.v1.PublishError"
179                        && wire::PublishError::decode(detail.value.as_slice()).is_ok_and(|error| {
180                            error.reason == wire::publish_error::Reason::TooLarge as i32
181                        })
182                })
183            })
184        }
185        _ => false,
186    }
187}
188
189/// Size failures bypass backoff while the caller can reduce the request.
190#[derive(Debug, thiserror::Error)]
191enum CallError<E> {
192    #[error(transparent)]
193    Resize(E),
194    #[error(transparent)]
195    Retry(E),
196}
197impl<E: RetryableError> RetryableError for CallError<E> {
198    fn is_retryable(&self) -> bool {
199        match self {
200            Self::Resize(_) => false,
201            Self::Retry(error) => error.is_retryable(),
202        }
203    }
204}
205
206impl<C> ApiClientWrapper<C> {
207    pub(crate) async fn retry_call<T, E, F, Fut>(
208        &self,
209        mut call: F,
210        resize: bool,
211    ) -> std::result::Result<T, E>
212    where
213        E: RetryableError + 'static,
214        F: FnMut() -> Fut,
215        Fut: Future<Output = std::result::Result<T, E>>,
216    {
217        retry_async!(
218            self.retry_strategy,
219            (async {
220                call().await.map_err(|error| {
221                    if resize && size_error(&error) {
222                        CallError::Resize(error)
223                    } else {
224                        CallError::Retry(error)
225                    }
226                })
227            })
228        )
229        .map_err(|error| match error {
230            CallError::Resize(error) | CallError::Retry(error) => error,
231        })
232    }
233}
234
235impl<C: XmtpBackendClient> ApiClientWrapper<C> {
236    /// Publish each atomic unit and return metadata in envelope order.
237    #[xmtp_common::rpc_span]
238    pub async fn publish_units(&self, units: Vec<PublishUnit>) -> Result<Vec<wire::EnvelopeMeta>> {
239        let chunks = chunk_publish_within(&units, self.limits())?
240            .into_iter()
241            .map(<[_]>::to_vec)
242            .collect::<Vec<_>>();
243        let mut responses: Vec<_> = stream::iter(chunks.into_iter().enumerate().map(
244            |(index, chunk)| async move {
245                Ok::<_, ApiError>((index, self.publish_chunk(&chunk).await?))
246            },
247        ))
248        .buffer_unordered(MAX_PUBLISH_CHUNKS_IN_FLIGHT)
249        .try_collect()
250        .await?;
251        responses.sort_by_key(|(index, _)| *index);
252        Ok(responses.into_iter().flat_map(|(_, metas)| metas).collect())
253    }
254
255    async fn publish_chunk(&self, units: &[PublishUnit]) -> Result<Vec<wire::EnvelopeMeta>> {
256        let mut pending = VecDeque::from([units]);
257        let mut metas = Vec::new();
258        while let Some(units) = pending.pop_front() {
259            let request = request(units);
260            match self
261                .retry_call(|| self.api_client.publish(request.clone()), units.len() > 1)
262                .await
263            {
264                Ok(response) => {
265                    let expected: Vec<_> = units.iter().flat_map(|unit| &unit.envelopes).collect();
266                    if response.envelope_metas.len() != expected.len() {
267                        return Err(ApiError::InvalidResponse("publish metadata count"));
268                    }
269                    for (meta, envelope) in response.envelope_metas.iter().zip(expected) {
270                        let (topic, _, _) =
271                            xmtp_api_backend::envelope::metadata(meta, envelope.topic.kind())?;
272                        if topic != envelope.topic {
273                            return Err(ApiError::InvalidResponse("publish topic"));
274                        }
275                    }
276                    metas.extend(response.envelope_metas);
277                }
278                Err(error) if size_error(&error) && units.len() > 1 => {
279                    let (left, right) = units.split_at(units.len() / 2);
280                    pending.push_front(right);
281                    pending.push_front(left);
282                }
283                Err(error) => return Err(dyn_err(error)),
284            }
285        }
286        Ok(metas)
287    }
288
289    /// Read one bounded ordered page without decoding MLS payloads.
290    /// Commit each batch before requesting more from the new received prefix `F`.
291    pub async fn query_ordered_page(
292        &self,
293        cursors: TopicCursor,
294        limit: u32,
295        limits: IncomingBatchLimits,
296    ) -> Result<OrderedQueryPage> {
297        if limit == 0
298            || limit as usize > self.limits().max_query_limit
299            || limits.max_rows == 0
300            || limits.max_bytes == 0
301        {
302            return Err(ApiError::InvalidRequest("query limit"));
303        }
304        let topics: Vec<_> = cursors.into_iter().collect();
305        let mut pending: VecDeque<_> = topics
306            .chunks(self.limits().max_query_topics)
307            .map(|topics| (topics.to_vec(), limit))
308            .collect();
309        let mut page = OrderedQueryPage {
310            batches: Vec::new(),
311            has_more: false,
312        };
313        let mut remaining = limits;
314        while let Some((topics, requested_limit)) = pending.pop_front() {
315            if remaining.max_rows == 0 || remaining.max_bytes == 0 {
316                page.has_more = true;
317                break;
318            }
319            let requested_limit =
320                requested_limit.min(remaining.max_rows.min(u32::MAX as usize) as u32);
321            let request = wire::QueryRequest {
322                queries: topics
323                    .iter()
324                    .map(|(topic, cursor)| wire::TopicQuery {
325                        topic: Some(wire::Topic {
326                            topic: topic.cloned_vec(),
327                        }),
328                        cursor: Some((*cursor).into()),
329                    })
330                    .collect(),
331                limit: requested_limit,
332            };
333            let response = match self
334                .retry_call(
335                    || self.api_client.query(request.clone()),
336                    requested_limit > 1 || topics.len() > 1,
337                )
338                .await
339            {
340                Ok(response) => response,
341                Err(error) if size_error(&error) && requested_limit > 1 => {
342                    pending.push_front((topics, (requested_limit / 2).max(1)));
343                    continue;
344                }
345                Err(error) if size_error(&error) && topics.len() > 1 => {
346                    let (left, right) = topics.split_at(topics.len() / 2);
347                    pending.push_front((right.to_vec(), requested_limit));
348                    pending.push_front((left.to_vec(), requested_limit));
349                    continue;
350                }
351                Err(error) => return Err(dyn_err(error)),
352            };
353            let has_more = response
354                .continuation
355                .ok_or(ApiError::InvalidResponse("query continuation"))?
356                .has_more;
357            if has_more && response.envelopes.is_empty() {
358                return Err(ApiError::InvalidResponse("query has more without progress"));
359            }
360            if response.envelopes.len() > requested_limit as usize {
361                return Err(ApiError::InvalidResponse("query response limit"));
362            }
363            let mut positions = topics.iter().cloned().collect();
364            let batches = match xmtp_api_backend::envelope::ordered_batches(
365                &mut positions,
366                response.envelopes,
367                remaining,
368            ) {
369                Ok(batches) => batches,
370                Err(xmtp_api_backend::envelope::EnvelopeError::Capacity) if requested_limit > 1 => {
371                    pending.push_front((topics, (requested_limit / 2).max(1)));
372                    continue;
373                }
374                Err(xmtp_api_backend::envelope::EnvelopeError::Capacity)
375                    if !page.batches.is_empty() =>
376                {
377                    page.has_more = true;
378                    break;
379                }
380                Err(error) => return Err(error.into()),
381            };
382            for batch in &batches {
383                remaining.max_rows -= batch.envelopes.len();
384                remaining.max_bytes -= batch
385                    .envelopes
386                    .iter()
387                    .map(Message::encoded_len)
388                    .sum::<usize>();
389            }
390            page.has_more |= has_more;
391            page.batches.extend(batches);
392        }
393        Ok(page)
394    }
395
396    /// Capture fixed newest targets. Absent topics have target zero.
397    /// Keep these targets unchanged while waiting for processing to reach them.
398    pub async fn newest_topic_cursors(&self, topics: Vec<Topic>) -> Result<TopicCursor> {
399        let mut cursors: TopicCursor = topics
400            .iter()
401            .cloned()
402            .map(|topic| (topic, Cursor(0)))
403            .collect();
404        for result in self.newest(topics, false).await? {
405            let topic = Topic::parse(
406                &result
407                    .topic
408                    .ok_or(ApiError::InvalidResponse("newest topic"))?
409                    .topic,
410            )?;
411            let meta = result
412                .meta
413                .ok_or(ApiError::InvalidResponse("newest metadata"))?;
414            let (_, cursor, _) = xmtp_api_backend::envelope::metadata(&meta, topic.kind())?;
415            cursors.insert(topic, cursor);
416        }
417        Ok(cursors)
418    }
419
420    /// Read every page. Each topic advances only to its own returned cursor.
421    pub async fn query_all(
422        &self,
423        cursors: TopicCursor,
424        limit: u32,
425    ) -> Result<Vec<wire::ServerEnvelope>> {
426        if limit == 0 || limit as usize > self.limits().max_query_limit {
427            return Err(ApiError::InvalidRequest("query limit"));
428        }
429        let topics: Vec<_> = cursors.into_iter().collect();
430        let results: Vec<Vec<_>> = stream::iter(
431            topics
432                .chunks(self.limits().max_query_topics)
433                .map(<[_]>::to_vec)
434                .collect::<Vec<_>>()
435                .into_iter()
436                .map(|chunk| self.query_chunk(chunk, limit)),
437        )
438        .buffer_unordered(MAX_READ_CHUNKS_IN_FLIGHT)
439        .try_collect()
440        .await?;
441        Ok(results.into_iter().flatten().collect())
442    }
443
444    async fn query_chunk(
445        &self,
446        topics: Vec<(Topic, Cursor)>,
447        limit: u32,
448    ) -> Result<Vec<wire::ServerEnvelope>> {
449        let mut pending = VecDeque::from([(topics, limit)]);
450        let mut output = Vec::new();
451        while let Some((mut topics, mut limit)) = pending.pop_front() {
452            loop {
453                let request = wire::QueryRequest {
454                    queries: topics
455                        .iter()
456                        .map(|(topic, cursor)| wire::TopicQuery {
457                            topic: Some(wire::Topic {
458                                topic: topic.cloned_vec(),
459                            }),
460                            cursor: Some((*cursor).into()),
461                        })
462                        .collect(),
463                    limit,
464                };
465                match self
466                    .retry_call(
467                        || self.api_client.query(request.clone()),
468                        limit > 1 || topics.len() > 1,
469                    )
470                    .await
471                {
472                    Ok(response) => {
473                        let has_more = response
474                            .continuation
475                            .ok_or(ApiError::InvalidResponse("query continuation"))?
476                            .has_more;
477                        let mut cursors: HashMap<_, _> = topics
478                            .iter_mut()
479                            .map(|(topic, cursor)| (topic.clone(), cursor))
480                            .collect();
481                        let mut advanced = false;
482                        for envelope in &response.envelopes {
483                            let meta = envelope
484                                .meta
485                                .as_ref()
486                                .ok_or(ApiError::InvalidResponse("query metadata"))?;
487                            let topic = Topic::parse(
488                                &meta
489                                    .topic
490                                    .as_ref()
491                                    .ok_or(ApiError::InvalidResponse("query topic"))?
492                                    .topic,
493                            )?;
494                            let cursor = cursors
495                                .get_mut(&topic)
496                                .ok_or(ApiError::InvalidResponse("unrequested query topic"))?;
497                            let sequence = meta
498                                .cursor
499                                .as_ref()
500                                .ok_or(ApiError::InvalidResponse("query cursor"))?
501                                .sequence_id;
502                            if sequence <= cursor.0 || sequence > i64::MAX as u64 {
503                                return Err(ApiError::InvalidResponse(
504                                    "query cursor did not advance",
505                                ));
506                            }
507                            **cursor = Cursor(sequence);
508                            advanced = true;
509                        }
510                        output.extend(response.envelopes);
511                        if !has_more {
512                            break;
513                        }
514                        if !advanced {
515                            return Err(ApiError::InvalidResponse(
516                                "query has more without progress",
517                            ));
518                        }
519                    }
520                    Err(error) if size_error(&error) && limit > 1 => {
521                        limit = (limit / 2).max(1);
522                    }
523                    Err(error) if size_error(&error) && topics.len() > 1 => {
524                        let right = topics.split_off(topics.len() / 2);
525                        pending.push_front((right, limit));
526                    }
527                    Err(error) => return Err(dyn_err(error)),
528                }
529            }
530        }
531        Ok(output)
532    }
533
534    pub(crate) async fn newest(
535        &self,
536        topics: Vec<Topic>,
537        full: bool,
538    ) -> Result<Vec<wire::query_newest_response::Result>> {
539        let topics: Vec<_> = topics
540            .into_iter()
541            .collect::<HashSet<_>>()
542            .into_iter()
543            .collect();
544        let cap = if full {
545            self.limits().max_newest_full_topics
546        } else {
547            self.limits().max_newest_metadata_topics
548        };
549        let results: Vec<Vec<_>> = stream::iter(
550            topics
551                .chunks(cap)
552                .map(<[_]>::to_vec)
553                .collect::<Vec<_>>()
554                .into_iter()
555                .map(|chunk| async move { self.newest_chunk(&chunk, full).await }),
556        )
557        .buffer_unordered(MAX_READ_CHUNKS_IN_FLIGHT)
558        .try_collect()
559        .await?;
560        Ok(results.into_iter().flatten().collect())
561    }
562
563    async fn newest_chunk(
564        &self,
565        topics: &[Topic],
566        full: bool,
567    ) -> Result<Vec<wire::query_newest_response::Result>> {
568        let mut pending = VecDeque::from([topics]);
569        let mut output = Vec::new();
570        while let Some(topics) = pending.pop_front() {
571            let request = wire::QueryNewestRequest {
572                topics: topics
573                    .iter()
574                    .map(|topic| wire::Topic {
575                        topic: topic.cloned_vec(),
576                    })
577                    .collect(),
578                include_full_envelope: full,
579            };
580            match self
581                .retry_call(
582                    || self.api_client.query_newest(request.clone()),
583                    topics.len() > 1,
584                )
585                .await
586            {
587                Ok(response) => {
588                    let mut seen = HashSet::new();
589                    for result in &response.results {
590                        let topic = Topic::parse(
591                            &result
592                                .topic
593                                .as_ref()
594                                .ok_or(ApiError::InvalidResponse("newest topic"))?
595                                .topic,
596                        )?;
597                        if !topics.contains(&topic) || !seen.insert(topic.clone()) {
598                            return Err(ApiError::InvalidResponse(
599                                "unrequested or duplicate newest topic",
600                            ));
601                        }
602                        let meta = result
603                            .meta
604                            .as_ref()
605                            .ok_or(ApiError::InvalidResponse("newest metadata"))?;
606                        let (meta_topic, _, _) =
607                            xmtp_api_backend::envelope::metadata(meta, topic.kind())?;
608                        if meta_topic != topic || (full && result.envelope.is_none()) {
609                            return Err(ApiError::InvalidResponse("newest envelope"));
610                        }
611                    }
612                    output.extend(response.results);
613                }
614                Err(error) if size_error(&error) && topics.len() > 1 => {
615                    let (left, right) = topics.split_at(topics.len() / 2);
616                    pending.push_front(right);
617                    pending.push_front(left);
618                }
619                Err(error) => return Err(dyn_err(error)),
620            }
621        }
622        Ok(output)
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use xmtp_mls_validation::test_utils::{
630        GroupMessageKind, group_message_envelope, inline_welcome_envelope,
631    };
632
633    #[xmtp_common::test(unwrap_try = true)]
634    fn running_publish_measure_matches_encoded_mixed_request() {
635        let units = [
636            PublishUnit::single(inline_welcome_envelope([1; 32]))?,
637            PublishUnit::new(vec![
638                group_message_envelope([2; 16], GroupMessageKind::Proposal, [0; 127]),
639                group_message_envelope([2; 16], GroupMessageKind::Commit, [0; 16384]),
640            ])?,
641            PublishUnit::single(inline_welcome_envelope([1; 32]))?,
642            PublishUnit::single(inline_welcome_envelope([3; 32]))?,
643        ];
644        let limits = LimitsConfiguration::default();
645        let mut measure = PublishMeasure::new(&limits);
646        for (index, unit) in units.iter().enumerate() {
647            measure.add(unit);
648            assert_eq!(measure.bytes, request(&units[..=index]).encoded_len());
649        }
650        assert_eq!(measure.topics.len(), 3);
651    }
652}