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::{
11    ExponentialBackoff, Retry, RetryableError, Strategy, retry_async,
12    time::{Duration, Instant},
13};
14use xmtp_configuration::*;
15use xmtp_proto::{
16    api::grpc_status,
17    api_client::XmtpBackendClient,
18    backend_v1 as wire,
19    types::{
20        CanonicalEnvelope, Cursor, IncomingBatchLimits, OrderedEnvelopeBatch, Topic, TopicCursor,
21    },
22};
23
24pub const MAX_PUBLISH_CHUNKS_IN_FLIGHT: usize = 4;
25pub const MAX_READ_CHUNKS_IN_FLIGHT: usize = 4;
26
27/// One bounded ordered query result. Receipt is not committed by the query.
28#[derive(Debug)]
29pub struct OrderedQueryPage {
30    /// Per-topic batches with their original read positions.
31    pub batches: Vec<OrderedEnvelopeBatch>,
32    /// More data or an unread topic chunk remains. Resume from committed receipt.
33    pub has_more: bool,
34}
35
36#[derive(Clone, Debug)]
37struct PublishEnvelope {
38    envelope: wire::ClientEnvelope,
39    canonical: CanonicalEnvelope,
40    topic: Topic,
41}
42
43impl PublishEnvelope {
44    fn parse(envelope: wire::ClientEnvelope) -> Result<Self> {
45        let parsed = xmtp_mls_validation::parse_envelope(envelope)?;
46        Ok(Self {
47            envelope: parsed.envelope,
48            canonical: parsed.canonical,
49            topic: parsed.topic,
50        })
51    }
52}
53
54/// One envelope, or a commit and its proposals. A unit is never split.
55#[derive(Clone, Debug)]
56pub struct PublishUnit {
57    envelopes: Vec<PublishEnvelope>,
58}
59impl PublishUnit {
60    /// Build a unit against the shapes one deployment published.
61    /// A unit that cannot fit in a single request is rejected here,
62    /// before any network call.
63    // implements: CONF-073
64    pub fn new_within(
65        envelopes: Vec<wire::ClientEnvelope>,
66        limits: &LimitsConfiguration,
67    ) -> Result<Self> {
68        if envelopes.is_empty() {
69            return Err(ApiError::InvalidRequest("empty publish unit"));
70        }
71        let envelopes = envelopes
72            .into_iter()
73            .map(|envelope| {
74                let envelope = PublishEnvelope::parse(envelope)?;
75                if envelope.canonical.bytes.len() > limits.max_envelope_bytes {
76                    return Err(ApiError::EnvelopeTooLarge);
77                }
78                Ok(envelope)
79            })
80            .collect::<Result<Vec<_>>>()?;
81        let unit = Self { envelopes };
82        let mut measure = PublishMeasure::new(limits);
83        measure.add(&unit);
84        if !measure.fits() {
85            return Err(ApiError::UnitTooLarge);
86        }
87        Ok(unit)
88    }
89
90    pub fn single_within(
91        envelope: wire::ClientEnvelope,
92        limits: &LimitsConfiguration,
93    ) -> Result<Self> {
94        Self::new_within(vec![envelope], limits)
95    }
96
97    /// Build a unit against the compiled defaults, for a caller that holds no
98    /// snapshot. Every client path goes through `new_within`.
99    pub fn new(envelopes: Vec<wire::ClientEnvelope>) -> Result<Self> {
100        Self::new_within(envelopes, &LimitsConfiguration::default())
101    }
102
103    pub fn single(envelope: wire::ClientEnvelope) -> Result<Self> {
104        Self::new(vec![envelope])
105    }
106
107    /// Build a unit of saved envelopes only to settle them with
108    /// [`ApiClientWrapper::settle_units`]. It is not measured against any
109    /// bound, so it may not fit in one request: never publish it.
110    pub fn for_settlement(envelopes: Vec<wire::ClientEnvelope>) -> Result<Self> {
111        if envelopes.is_empty() {
112            return Err(ApiError::InvalidRequest("empty publish unit"));
113        }
114        let envelopes = envelopes
115            .into_iter()
116            .map(PublishEnvelope::parse)
117            .collect::<Result<Vec<_>>>()?;
118        Ok(Self { envelopes })
119    }
120}
121
122/// The backend fails a whole read that names a topic it does not route, such
123/// as one built from a 16-byte group id. Refuse such a read before sending.
124fn routable<'a>(topics: impl IntoIterator<Item = &'a Topic>) -> Result<()> {
125    for topic in topics {
126        Topic::parse(topic)?;
127    }
128    Ok(())
129}
130
131pub(crate) fn request(units: &[PublishUnit]) -> wire::PublishRequest {
132    wire::PublishRequest {
133        envelopes: units
134            .iter()
135            .flat_map(|unit| unit.envelopes.iter().map(|e| e.envelope.clone()))
136            .collect(),
137    }
138}
139/// Measure repeated envelope fields without cloning or encoding the request.
140struct PublishMeasure<'a> {
141    bytes: usize,
142    envelopes: usize,
143    topics: HashSet<&'a Topic>,
144    limits: &'a LimitsConfiguration,
145}
146impl<'a> PublishMeasure<'a> {
147    fn new(limits: &'a LimitsConfiguration) -> Self {
148        Self {
149            bytes: 0,
150            envelopes: 0,
151            topics: HashSet::new(),
152            limits,
153        }
154    }
155
156    fn add(&mut self, unit: &'a PublishUnit) {
157        for envelope in &unit.envelopes {
158            let len = envelope.canonical.bytes.len();
159            self.bytes += 1 + prost::length_delimiter_len(len) + len;
160            self.envelopes += 1;
161            self.topics.insert(&envelope.topic);
162        }
163    }
164
165    fn fits(&self) -> bool {
166        self.bytes <= self.limits.max_request_bytes
167            && self.envelopes <= MAX_PUBLISH_ENVELOPES
168            && self.topics.len() <= self.limits.max_publish_topics
169    }
170}
171
172/// Split units into requests the deployment accepts, each with at most
173/// `MAX_PUBLISH_ENVELOPES` envelopes. A commit and its proposals are one unit
174/// and never straddle a chunk; a unit that fits in no request is refused.
175// implements: CONF-073
176pub fn chunk_publish_within<'a>(
177    units: &'a [PublishUnit],
178    limits: &LimitsConfiguration,
179) -> Result<Vec<&'a [PublishUnit]>> {
180    let mut chunks = Vec::new();
181    let mut start = 0;
182    let mut measure = PublishMeasure::new(limits);
183    for (end, unit) in units.iter().enumerate() {
184        measure.add(unit);
185        if !measure.fits() {
186            if start == end {
187                return Err(ApiError::UnitTooLarge);
188            }
189            chunks.push(&units[start..end]);
190            start = end;
191            measure = PublishMeasure::new(limits);
192            measure.add(unit);
193            if !measure.fits() {
194                return Err(ApiError::UnitTooLarge);
195            }
196        }
197    }
198    if start < units.len() {
199        chunks.push(&units[start..]);
200    }
201    Ok(chunks)
202}
203
204/// Chunk against the compiled defaults, for a caller that holds no snapshot.
205pub fn chunk_publish(units: &[PublishUnit]) -> Result<Vec<&[PublishUnit]>> {
206    chunk_publish_within(units, &LimitsConfiguration::default())
207}
208
209/// The type URL of the structured detail on a refused publish.
210const PUBLISH_ERROR_TYPE_URL: &str = "type.googleapis.com/xmtp.backend.v1.PublishError";
211
212/// The reason in the structured `PublishError` detail of an
213/// `INVALID_ARGUMENT` publish refusal, or `None` when the error carries no
214/// such detail. The status message is never read.
215pub(crate) fn publish_error_reason(
216    error: &(dyn std::error::Error + 'static),
217) -> Option<wire::publish_error::Reason> {
218    if crate::preflight::failure(error).is_some() {
219        return None;
220    }
221    let status = grpc_status(error).filter(|status| status.code() == Code::InvalidArgument)?;
222    let details = tonic_types::pb::Status::decode(status.details()).ok()?;
223    details
224        .details
225        .iter()
226        .filter(|detail| detail.type_url == PUBLISH_ERROR_TYPE_URL)
227        .find_map(|detail| wire::PublishError::decode(detail.value.as_slice()).ok())
228        .and_then(|error| wire::publish_error::Reason::try_from(error.reason).ok())
229}
230
231/// A refusal that a smaller request may avoid. A rate-limit rejection is
232/// never one: splitting it multiplies the load the limit refused.
233// implements: CONF-073, RATE-041
234pub(crate) fn size_error(error: &(dyn std::error::Error + 'static)) -> bool {
235    if crate::preflight::failure(error).is_some()
236        || xmtp_api_grpc::error::rate_limit_delay(error).is_some()
237    {
238        return false;
239    }
240    let Some(status) = grpc_status(error) else {
241        return false;
242    };
243    match status.code() {
244        Code::OutOfRange | Code::ResourceExhausted => true,
245        Code::InvalidArgument => {
246            publish_error_reason(error) == Some(wire::publish_error::Reason::TooLarge)
247        }
248        _ => false,
249    }
250}
251
252/// A size refusal that proves nothing was stored, so smaller requests may
253/// follow. `OUT_OF_RANGE` proves nothing: the request can commit before its
254/// response outgrows the transport, so its caller settles it with a read.
255// implements: API-284
256fn publish_size_error(error: &(dyn std::error::Error + 'static)) -> bool {
257    size_error(error) && grpc_status(error).is_none_or(|status| status.code() != Code::OutOfRange)
258}
259
260/// Size failures bypass backoff while the caller can reduce the request.
261#[derive(Debug, thiserror::Error)]
262enum CallError<E> {
263    #[error(transparent)]
264    Resize(E),
265    #[error(transparent)]
266    Retry(E),
267}
268impl<E: RetryableError + 'static> RetryableError for CallError<E> {
269    fn is_retryable(&self) -> bool {
270        match self {
271            Self::Resize(_) => false,
272            Self::Retry(error) => {
273                crate::preflight::failure(error).is_none() && error.is_retryable()
274            }
275        }
276    }
277
278    fn rate_limit_delay(&self) -> Option<Duration> {
279        match self {
280            Self::Resize(_) => None,
281            Self::Retry(error) => xmtp_api_grpc::error::rate_limit_delay(error),
282        }
283    }
284}
285
286/// The client's backoff, which also gives up on a rate-limit wait that would
287/// end after the caller's deadline, so the caller still sees the delay.
288struct Until<'a> {
289    strategy: &'a ExponentialBackoff,
290    deadline: Option<Instant>,
291}
292
293impl Strategy for Until<'_> {
294    fn backoff(&self, attempts: usize, time_spent: Instant) -> Option<Duration> {
295        self.strategy.backoff(attempts, time_spent)
296    }
297
298    fn backoff_at_least(
299        &self,
300        attempts: usize,
301        time_spent: Instant,
302        floor: Duration,
303    ) -> Option<Duration> {
304        let wait = self
305            .strategy
306            .backoff_at_least(attempts, time_spent, floor)?;
307        self.deadline
308            .is_none_or(|deadline| deadline.saturating_duration_since(Instant::now()) > wait)
309            .then_some(wait)
310    }
311}
312
313impl<C> ApiClientWrapper<C> {
314    pub(crate) async fn retry_call<T, E, F, Fut>(
315        &self,
316        call: F,
317        resize: bool,
318    ) -> std::result::Result<T, E>
319    where
320        E: RetryableError + 'static,
321        F: FnMut() -> Fut,
322        Fut: Future<Output = std::result::Result<T, E>>,
323    {
324        self.retry_call_until(call, resize, None).await
325    }
326
327    /// Retry `call` with backoff. A rate-limit rejection waits at least its
328    /// delay before the next attempt; a wait that does not fit the retry
329    /// budget, or that would end after `deadline`, returns the error at once.
330    // implements: RATE-041
331    pub(crate) async fn retry_call_until<T, E, F, Fut>(
332        &self,
333        mut call: F,
334        resize: bool,
335        deadline: Option<Instant>,
336    ) -> std::result::Result<T, E>
337    where
338        E: RetryableError + 'static,
339        F: FnMut() -> Fut,
340        Fut: Future<Output = std::result::Result<T, E>>,
341    {
342        let retry = Retry::builder()
343            .retries(self.retry_strategy.retries())
344            .with_strategy(Until {
345                strategy: self.retry_strategy.strategy(),
346                deadline,
347            })
348            .build();
349        retry_async!(
350            retry,
351            (async {
352                call().await.map_err(|error| {
353                    if resize && size_error(&error) {
354                        CallError::Resize(error)
355                    } else {
356                        CallError::Retry(error)
357                    }
358                })
359            })
360        )
361        .map_err(|error| match error {
362            CallError::Resize(error) | CallError::Retry(error) => error,
363        })
364    }
365}
366
367impl<C: XmtpBackendClient> ApiClientWrapper<C> {
368    /// Publish each atomic unit and return metadata in envelope order.
369    #[xmtp_common::rpc_span]
370    pub async fn publish_units(&self, units: Vec<PublishUnit>) -> Result<Vec<wire::EnvelopeMeta>> {
371        let chunks = chunk_publish_within(&units, self.limits())?
372            .into_iter()
373            .map(<[_]>::to_vec)
374            .collect::<Vec<_>>();
375        let mut responses: Vec<_> = stream::iter(chunks.into_iter().enumerate().map(
376            |(index, chunk)| async move {
377                let mut metas = Vec::new();
378                self.publish_chunk(&chunk, &mut metas).await?;
379                Ok::<_, ApiError>((index, metas))
380            },
381        ))
382        .buffer_unordered(MAX_PUBLISH_CHUNKS_IN_FLIGHT)
383        .try_collect()
384        .await?;
385        responses.sort_by_key(|(index, _)| *index);
386        Ok(responses.into_iter().flat_map(|(_, metas)| metas).collect())
387    }
388
389    /// Publish units with one request in flight at a time, in unit order, and
390    /// stop at the first failed request. Each stored envelope's metadata is
391    /// appended to `stored` as its request returns, so on an error `stored`
392    /// names the stored prefix; the envelopes of the failed request may or
393    /// may not be stored. A commit-log reader, which keeps only a continuous
394    /// chain, never sees a later request stored ahead of an earlier one.
395    pub async fn publish_units_in_order(
396        &self,
397        units: Vec<PublishUnit>,
398        stored: &mut Vec<wire::EnvelopeMeta>,
399    ) -> Result<()> {
400        for chunk in chunk_publish_within(&units, self.limits())? {
401            self.publish_chunk(chunk, stored).await?;
402        }
403        Ok(())
404    }
405
406    /// Publish one chunk, appending each stored envelope's metadata to
407    /// `metas` in envelope order. A size refusal splits the chunk, and the
408    /// halves go out one after the other.
409    async fn publish_chunk(
410        &self,
411        units: &[PublishUnit],
412        metas: &mut Vec<wire::EnvelopeMeta>,
413    ) -> Result<()> {
414        let mut pending = VecDeque::from([units]);
415        while let Some(units) = pending.pop_front() {
416            let request = request(units);
417            match self
418                .retry_call(|| self.api_client.publish(request.clone()), units.len() > 1)
419                .await
420            {
421                Ok(response) => {
422                    let expected: Vec<_> = units.iter().flat_map(|unit| &unit.envelopes).collect();
423                    if response.envelope_metas.len() != expected.len() {
424                        return Err(ApiError::InvalidResponse("publish metadata count"));
425                    }
426                    for (meta, envelope) in response.envelope_metas.iter().zip(expected) {
427                        let (topic, _, _) =
428                            xmtp_api_backend::envelope::metadata(meta, envelope.topic.kind())?;
429                        if topic != envelope.topic {
430                            return Err(ApiError::InvalidResponse("publish topic"));
431                        }
432                    }
433                    metas.extend(response.envelope_metas);
434                }
435                Err(error) if publish_size_error(&error) && units.len() > 1 => {
436                    let (left, right) = units.split_at(units.len() / 2);
437                    pending.push_front(right);
438                    pending.push_front(left);
439                }
440                Err(error) => return Err(dyn_err(error)),
441            }
442        }
443        Ok(())
444    }
445
446    /// Read one bounded ordered page without decoding MLS payloads.
447    /// Commit each batch before requesting more from the new received prefix `F`.
448    // implements: PROC-020
449    pub async fn query_ordered_page(
450        &self,
451        cursors: TopicCursor,
452        limit: u32,
453        limits: IncomingBatchLimits,
454    ) -> Result<OrderedQueryPage> {
455        if limit == 0
456            || limit as usize > self.limits().max_query_limit
457            || limits.max_rows == 0
458            || limits.max_bytes == 0
459        {
460            return Err(ApiError::InvalidRequest("query limit"));
461        }
462        routable(cursors.keys())?;
463        let topics: Vec<_> = cursors.into_iter().collect();
464        let mut pending: VecDeque<_> = topics
465            .chunks(self.limits().max_query_topics)
466            .map(|topics| (topics.to_vec(), limit))
467            .collect();
468        let mut page = OrderedQueryPage {
469            batches: Vec::new(),
470            has_more: false,
471        };
472        let mut remaining = limits;
473        while let Some((topics, requested_limit)) = pending.pop_front() {
474            if remaining.max_rows == 0 || remaining.max_bytes == 0 {
475                page.has_more = true;
476                break;
477            }
478            let requested_limit =
479                requested_limit.min(remaining.max_rows.min(u32::MAX as usize) as u32);
480            let request = wire::QueryRequest {
481                queries: topics
482                    .iter()
483                    .map(|(topic, cursor)| wire::TopicQuery {
484                        topic: Some(wire::Topic {
485                            topic: topic.cloned_vec(),
486                        }),
487                        cursor: Some((*cursor).into()),
488                    })
489                    .collect(),
490                limit: requested_limit,
491            };
492            let response = match self
493                .retry_call(
494                    || self.api_client.query_raw_envelopes(request.clone()),
495                    requested_limit > 1 || topics.len() > 1,
496                )
497                .await
498            {
499                Ok(response) => response,
500                Err(error) if size_error(&error) && requested_limit > 1 => {
501                    pending.push_front((topics, (requested_limit / 2).max(1)));
502                    continue;
503                }
504                Err(error) if size_error(&error) && topics.len() > 1 => {
505                    let (left, right) = topics.split_at(topics.len() / 2);
506                    pending.push_front((right.to_vec(), requested_limit));
507                    pending.push_front((left.to_vec(), requested_limit));
508                    continue;
509                }
510                Err(error) => return Err(dyn_err(error)),
511            };
512            let has_more = response
513                .continuation
514                .ok_or(ApiError::InvalidResponse("query continuation"))?
515                .has_more;
516            if has_more && response.envelopes.is_empty() {
517                return Err(ApiError::InvalidResponse("query has more without progress"));
518            }
519            if response.envelopes.len() > requested_limit as usize {
520                return Err(ApiError::InvalidResponse("query response limit"));
521            }
522            let mut positions = topics.iter().cloned().collect();
523            let envelopes = xmtp_proto::types::received_envelopes(response.envelopes)
524                .map_err(|error| dyn_err(xmtp_proto::api::ApiClientError::from(error)))?;
525            let batches = match xmtp_api_backend::envelope::ordered_batches(
526                &mut positions,
527                envelopes,
528                remaining,
529            ) {
530                Ok(batches) => batches,
531                Err(xmtp_api_backend::envelope::EnvelopeError::Capacity) if requested_limit > 1 => {
532                    pending.push_front((topics, (requested_limit / 2).max(1)));
533                    continue;
534                }
535                Err(xmtp_api_backend::envelope::EnvelopeError::Capacity)
536                    if !page.batches.is_empty() =>
537                {
538                    page.has_more = true;
539                    break;
540                }
541                Err(error) => return Err(error.into()),
542            };
543            for batch in &batches {
544                remaining.max_rows -= batch.envelopes.len();
545                remaining.max_bytes -= batch
546                    .envelopes
547                    .iter()
548                    .map(|envelope| envelope.raw().len())
549                    .sum::<usize>();
550            }
551            page.has_more |= has_more;
552            page.batches.extend(batches);
553        }
554        Ok(page)
555    }
556
557    /// Fold one bounded ordered page at a time without retaining earlier pages.
558    /// Cursors advance before the fold runs. Remove a topic from the fold's
559    /// cursors to stop reading it. An API or fold error stops the read immediately;
560    /// the caller owns persistence of each page and its durable cursors.
561    pub async fn fold_ordered_pages<E: From<ApiError>>(
562        &self,
563        mut cursors: TopicCursor,
564        limits: IncomingBatchLimits,
565        mut fold: impl FnMut(Vec<OrderedEnvelopeBatch>, &mut TopicCursor) -> std::result::Result<(), E>,
566    ) -> std::result::Result<(), E> {
567        let limit = limits
568            .max_rows
569            .min(self.limits().max_query_limit)
570            .min(u32::MAX as usize) as u32;
571        while !cursors.is_empty() {
572            let page = self
573                .query_ordered_page(cursors.clone(), limit, limits)
574                .await?;
575            for batch in &page.batches {
576                let cursor = batch
577                    .envelopes
578                    .last()
579                    .and_then(|envelope| envelope.meta())
580                    .and_then(|meta| meta.cursor.as_ref())
581                    .ok_or(ApiError::InvalidResponse("ordered page cursor"))?;
582                cursors.insert(batch.topic.clone(), Cursor(cursor.sequence_id));
583            }
584            fold(page.batches, &mut cursors)?;
585            if !page.has_more {
586                break;
587            }
588        }
589        Ok(())
590    }
591
592    /// Capture fixed newest targets. Absent topics have target zero.
593    /// Keep these targets unchanged while waiting for processing to reach them.
594    // implements: PROC-015
595    pub async fn newest_topic_cursors(&self, topics: Vec<Topic>) -> Result<TopicCursor> {
596        let mut cursors: TopicCursor = topics
597            .iter()
598            .cloned()
599            .map(|topic| (topic, Cursor(0)))
600            .collect();
601        for result in self.newest(topics, false).await? {
602            let topic = Topic::parse(
603                &result
604                    .topic
605                    .ok_or(ApiError::InvalidResponse("newest topic"))?
606                    .topic,
607            )?;
608            let meta = result
609                .meta
610                .ok_or(ApiError::InvalidResponse("newest metadata"))?;
611            let (_, cursor, _) = xmtp_api_backend::envelope::metadata(&meta, topic.kind())?;
612            cursors.insert(topic, cursor);
613        }
614        Ok(cursors)
615    }
616
617    /// Read every page. Each topic advances only to its own returned cursor.
618    /// Use [`Self::fold_ordered_pages`] to process a backlog without retaining it all.
619    pub async fn query_all(
620        &self,
621        cursors: TopicCursor,
622        limit: u32,
623    ) -> Result<Vec<wire::ServerEnvelope>> {
624        if limit == 0 || limit as usize > self.limits().max_query_limit {
625            return Err(ApiError::InvalidRequest("query limit"));
626        }
627        routable(cursors.keys())?;
628        let topics: Vec<_> = cursors.into_iter().collect();
629        let results: Vec<Vec<_>> = stream::iter(
630            topics
631                .chunks(self.limits().max_query_topics)
632                .map(<[_]>::to_vec)
633                .collect::<Vec<_>>()
634                .into_iter()
635                .map(|chunk| self.query_chunk(chunk, limit)),
636        )
637        .buffer_unordered(MAX_READ_CHUNKS_IN_FLIGHT)
638        .try_collect()
639        .await?;
640        Ok(results.into_iter().flatten().collect())
641    }
642
643    async fn query_chunk(
644        &self,
645        topics: Vec<(Topic, Cursor)>,
646        limit: u32,
647    ) -> Result<Vec<wire::ServerEnvelope>> {
648        let mut output = Vec::new();
649        self.scan_chunk(topics, limit, |page| {
650            output.extend(page);
651            false
652        })
653        .await?;
654        Ok(output)
655    }
656
657    /// Settle a publish whose outcome is unknown without resending it: a
658    /// `Query` of every topic in `units`, from the start, returns the stored
659    /// metadata of each envelope in envelope order, matched by its canonical
660    /// hash. `None` marks an envelope the backend did not store. Pages that
661    /// match nothing are dropped, and the read stops once every envelope matched.
662    /// A record without valid metadata fails the read: it could be any envelope,
663    /// so the read cannot prove one absent.
664    #[xmtp_common::rpc_span]
665    pub async fn settle_units(
666        &self,
667        units: &[PublishUnit],
668    ) -> Result<Vec<Option<wire::EnvelopeMeta>>> {
669        let envelopes: Vec<_> = units.iter().flat_map(|unit| &unit.envelopes).collect();
670        let mut pending: HashMap<_, Vec<_>> = HashMap::new();
671        for (index, envelope) in envelopes.iter().enumerate() {
672            pending
673                .entry(envelope.canonical.hash.to_vec())
674                .or_default()
675                .push(index);
676        }
677        let mut settled = vec![None; envelopes.len()];
678        let topics: HashSet<_> = envelopes.iter().map(|envelope| &envelope.topic).collect();
679        let topics: Vec<_> = topics
680            .into_iter()
681            .map(|topic| (topic.clone(), Cursor(0)))
682            .collect();
683        let limit = self.limits().max_query_limit as u32;
684        for chunk in topics.chunks(self.limits().max_query_topics) {
685            let mut invalid = false;
686            self.scan_chunk(chunk.to_vec(), limit, |page| {
687                invalid = page.into_iter().any(|stored| {
688                    let Some(meta) = stored.meta else { return true };
689                    let Ok(hash) = xmtp_api_backend::envelope::message_hash(&meta) else {
690                        return true;
691                    };
692                    let Some(indices) = pending.remove(&hash) else {
693                        return false;
694                    };
695                    // Identical envelopes share one hash and one stored copy.
696                    let topic = &envelopes[indices[0]].topic;
697                    let valid = xmtp_api_backend::envelope::metadata(&meta, topic.kind())
698                        .is_ok_and(|(stored_topic, _, _)| stored_topic == *topic);
699                    for index in indices {
700                        settled[index] = Some(meta.clone());
701                    }
702                    !valid
703                });
704                invalid || pending.is_empty()
705            })
706            .await?;
707            if invalid {
708                return Err(ApiError::InvalidResponse("settled metadata"));
709            }
710            if pending.is_empty() {
711                break;
712            }
713        }
714        Ok(settled)
715    }
716
717    /// Read pages of `topics` in cursor order until `visit` returns true or
718    /// every topic is exhausted.
719    async fn scan_chunk(
720        &self,
721        topics: Vec<(Topic, Cursor)>,
722        limit: u32,
723        mut visit: impl FnMut(Vec<wire::ServerEnvelope>) -> bool,
724    ) -> Result<()> {
725        let mut pending = VecDeque::from([(topics, limit)]);
726        while let Some((mut topics, mut limit)) = pending.pop_front() {
727            loop {
728                let request = wire::QueryRequest {
729                    queries: topics
730                        .iter()
731                        .map(|(topic, cursor)| wire::TopicQuery {
732                            topic: Some(wire::Topic {
733                                topic: topic.cloned_vec(),
734                            }),
735                            cursor: Some((*cursor).into()),
736                        })
737                        .collect(),
738                    limit,
739                };
740                match self
741                    .retry_call(
742                        || self.api_client.query(request.clone()),
743                        limit > 1 || topics.len() > 1,
744                    )
745                    .await
746                {
747                    Ok(response) => {
748                        let has_more = response
749                            .continuation
750                            .ok_or(ApiError::InvalidResponse("query continuation"))?
751                            .has_more;
752                        let mut cursors: HashMap<_, _> = topics
753                            .iter_mut()
754                            .map(|(topic, cursor)| (topic.clone(), cursor))
755                            .collect();
756                        let mut advanced = false;
757                        for envelope in &response.envelopes {
758                            let meta = envelope
759                                .meta
760                                .as_ref()
761                                .ok_or(ApiError::InvalidResponse("query metadata"))?;
762                            let topic = Topic::parse(
763                                &meta
764                                    .topic
765                                    .as_ref()
766                                    .ok_or(ApiError::InvalidResponse("query topic"))?
767                                    .topic,
768                            )?;
769                            let cursor = cursors
770                                .get_mut(&topic)
771                                .ok_or(ApiError::InvalidResponse("unrequested query topic"))?;
772                            let sequence = meta
773                                .cursor
774                                .as_ref()
775                                .ok_or(ApiError::InvalidResponse("query cursor"))?
776                                .sequence_id;
777                            if sequence <= cursor.0 || sequence > i64::MAX as u64 {
778                                return Err(ApiError::InvalidResponse(
779                                    "query cursor did not advance",
780                                ));
781                            }
782                            **cursor = Cursor(sequence);
783                            advanced = true;
784                        }
785                        if visit(response.envelopes) {
786                            return Ok(());
787                        }
788                        if !has_more {
789                            break;
790                        }
791                        if !advanced {
792                            return Err(ApiError::InvalidResponse(
793                                "query has more without progress",
794                            ));
795                        }
796                    }
797                    Err(error) if size_error(&error) && limit > 1 => {
798                        limit = (limit / 2).max(1);
799                    }
800                    Err(error) if size_error(&error) && topics.len() > 1 => {
801                        let right = topics.split_off(topics.len() / 2);
802                        pending.push_front((right, limit));
803                    }
804                    Err(error) => return Err(dyn_err(error)),
805                }
806            }
807        }
808        Ok(())
809    }
810
811    pub(crate) async fn newest(
812        &self,
813        topics: Vec<Topic>,
814        full: bool,
815    ) -> Result<Vec<wire::query_newest_response::Result>> {
816        routable(&topics)?;
817        let topics: Vec<_> = topics
818            .into_iter()
819            .collect::<HashSet<_>>()
820            .into_iter()
821            .collect();
822        let cap = if full {
823            self.limits().max_newest_full_topics
824        } else {
825            self.limits().max_newest_metadata_topics
826        };
827        let results: Vec<Vec<_>> = stream::iter(
828            topics
829                .chunks(cap)
830                .map(<[_]>::to_vec)
831                .collect::<Vec<_>>()
832                .into_iter()
833                .map(|chunk| async move { self.newest_chunk(&chunk, full).await }),
834        )
835        .buffer_unordered(MAX_READ_CHUNKS_IN_FLIGHT)
836        .try_collect()
837        .await?;
838        Ok(results.into_iter().flatten().collect())
839    }
840
841    async fn newest_chunk(
842        &self,
843        topics: &[Topic],
844        full: bool,
845    ) -> Result<Vec<wire::query_newest_response::Result>> {
846        let mut pending = VecDeque::from([topics]);
847        let mut output = Vec::new();
848        while let Some(topics) = pending.pop_front() {
849            let request = wire::QueryNewestRequest {
850                topics: topics
851                    .iter()
852                    .map(|topic| wire::Topic {
853                        topic: topic.cloned_vec(),
854                    })
855                    .collect(),
856                include_full_envelope: full,
857            };
858            match self
859                .retry_call(
860                    || self.api_client.query_newest(request.clone()),
861                    topics.len() > 1,
862                )
863                .await
864            {
865                Ok(response) => {
866                    let mut seen = HashSet::new();
867                    for result in &response.results {
868                        let topic = Topic::parse(
869                            &result
870                                .topic
871                                .as_ref()
872                                .ok_or(ApiError::InvalidResponse("newest topic"))?
873                                .topic,
874                        )?;
875                        if !topics.contains(&topic) || !seen.insert(topic.clone()) {
876                            return Err(ApiError::InvalidResponse(
877                                "unrequested or duplicate newest topic",
878                            ));
879                        }
880                        let meta = result
881                            .meta
882                            .as_ref()
883                            .ok_or(ApiError::InvalidResponse("newest metadata"))?;
884                        let (meta_topic, _, _) =
885                            xmtp_api_backend::envelope::metadata(meta, topic.kind())?;
886                        if meta_topic != topic || (full && result.envelope.is_none()) {
887                            return Err(ApiError::InvalidResponse("newest envelope"));
888                        }
889                    }
890                    output.extend(response.results);
891                }
892                Err(error) if size_error(&error) && topics.len() > 1 => {
893                    let (left, right) = topics.split_at(topics.len() / 2);
894                    pending.push_front(right);
895                    pending.push_front(left);
896                }
897                Err(error) => return Err(dyn_err(error)),
898            }
899        }
900        Ok(output)
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use xmtp_mls_validation::test_utils::{
908        GroupMessageKind, group_keys, inline_welcome_envelope, keyed_group_message_envelope,
909    };
910
911    #[xmtp_common::test(unwrap_try = true)]
912    fn running_publish_measure_matches_encoded_mixed_request() {
913        let keys = group_keys(2);
914        let units = [
915            PublishUnit::single(inline_welcome_envelope([1; 32]))?,
916            PublishUnit::new(vec![
917                keyed_group_message_envelope(&keys, GroupMessageKind::Proposal, [0; 127]),
918                keyed_group_message_envelope(&keys, GroupMessageKind::Commit, [0; 16384]),
919            ])?,
920            PublishUnit::single(inline_welcome_envelope([1; 32]))?,
921            PublishUnit::single(inline_welcome_envelope([3; 32]))?,
922        ];
923        let limits = LimitsConfiguration::default();
924        let mut measure = PublishMeasure::new(&limits);
925        for (index, unit) in units.iter().enumerate() {
926            measure.add(unit);
927            assert_eq!(measure.bytes, request(&units[..=index]).encoded_len());
928        }
929        assert_eq!(measure.topics.len(), 3);
930    }
931}