Skip to main content

xmtp_db/encrypted_store/
incoming_envelope.rs

1//! Durable receipt and ordered processing positions for network envelopes.
2
3use diesel::{
4    prelude::*,
5    sql_types::{BigInt, Binary, Integer},
6};
7use xmtp_proto::types::{Cursor, GroupId};
8
9use super::{
10    refresh_state::EntityKind,
11    schema::{
12        group_welcome_discovery as discovery, incoming_envelopes as incoming,
13        refresh_state as progress,
14    },
15    stream_storage::{BudgetScope, StreamStorageError, stream_transaction},
16};
17use crate::{ConnectionExt, StorageError};
18
19/// Separate network queues with independent pending-work budgets.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NetworkEntityKind {
22    /// Independent Welcome work for an installation.
23    Welcome,
24    /// One ordered queue for both group commits and application messages.
25    Group,
26    /// Ordered identity updates needed by group and Welcome processing.
27    Identity,
28}
29
30impl NetworkEntityKind {
31    pub fn entity_kind(self) -> EntityKind {
32        match self {
33            Self::Welcome => EntityKind::Welcome,
34            Self::Group => EntityKind::ApplicationMessage,
35            Self::Identity => EntityKind::Identity,
36        }
37    }
38}
39
40/// The database key for one network log and its received/processed positions.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct StreamTopic {
43    /// Group ID, installation ID, or inbox ID bytes, without the wire topic prefix.
44    pub entity_id: Vec<u8>,
45    /// Prevents progress or budgets from being shared across different log kinds.
46    pub kind: NetworkEntityKind,
47}
48
49impl StreamTopic {
50    pub fn group(group_id: GroupId) -> Self {
51        Self {
52            entity_id: group_id.as_ref().to_vec(),
53            kind: NetworkEntityKind::Group,
54        }
55    }
56}
57
58/// The adapter validates wire metadata and the supplied hash shape before admission.
59/// The backend owns the envelope hash; the client does not recompute it.
60#[derive(Debug, Clone)]
61pub struct NewIncomingEnvelope {
62    /// Backend sequence ID. IDs must increase, but need not be consecutive integers.
63    pub sequence_id: Cursor,
64    /// Exact serialized envelope retained for later processing and crash recovery.
65    pub envelope: Vec<u8>,
66}
67
68/// Admitted work that has not yet been applied or terminally rejected.
69#[derive(Debug, Clone, Queryable, Selectable)]
70#[diesel(table_name = incoming)]
71pub struct StoredIncomingEnvelope {
72    pub entity_id: Vec<u8>,
73    pub entity_kind: EntityKind,
74    pub sequence_id: i64,
75    /// Durable input bytes. Receipt does not imply successful MLS processing.
76    pub envelope: Vec<u8>,
77    /// Earliest retry time in Unix nanoseconds.
78    pub retry_at_ns: i64,
79    /// A new coordinator generation must recheck this work; a timer must not retry it.
80    pub blocked: bool,
81    /// Stable diagnostic code only. Do not store input data or formatted errors here.
82    pub error_code: Option<String>,
83    /// First retry deadline; later attempts must not extend it.
84    pub retry_expires_at_ns: Option<i64>,
85}
86
87/// Pending-work metadata for a fixed processing target. This does not contain ciphertext.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PendingEnvelopeState {
90    pub sequence_id: Cursor,
91    pub blocked: bool,
92    pub error_code: Option<String>,
93    pub retry_at_ns: i64,
94}
95
96/// Both limits must hold before a pending batch can commit.
97#[derive(Debug, Clone, Copy)]
98pub struct PendingBudget {
99    /// Maximum retained envelope count.
100    pub rows: u64,
101    /// Maximum sum of serialized envelope sizes.
102    pub bytes: u64,
103}
104
105/// Admission limits checked under the same writer as the envelope insertions.
106#[derive(Debug, Clone, Copy)]
107pub struct IncomingLimits {
108    /// Bound one admission call, including supplied overlap rows.
109    pub batch: PendingBudget,
110    /// Bound retained work for this topic.
111    pub topic: PendingBudget,
112    /// Each kind has its own budget. Group work cannot use dependency capacity.
113    pub kind: PendingBudget,
114}
115
116/// Durable network progress. Application acknowledgement is a separate position.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub struct TopicProgress {
119    /// P: the prefix applied or terminally rejected in committed state transactions.
120    pub processed: Cursor,
121    /// F: the prefix whose envelope bytes were admitted atomically. P never exceeds F.
122    pub received: Cursor,
123}
124
125/// The group-state proof required to install a validated Welcome's anchor.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum JoinAnchorMode {
128    /// The anchor must advance an existing processed position.
129    Advance,
130    /// The caller proved that an inactive group has a newer re-add Welcome.
131    /// Its anchor must equal the processed removal position in the same transaction.
132    InactiveReadd,
133}
134
135/// Receipt progress visible only after the full admission transaction commits.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct AdmissionResult {
138    /// The committed F position, including any previously admitted overlap.
139    pub received: Cursor,
140    /// Newly stored rows. Duplicate overlap does not increase this count.
141    pub inserted: usize,
142}
143
144/// One bounded rejection diagnostic per topic, not a payload history.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct TerminalRejection {
147    pub sequence_id: Cursor,
148    pub code: String,
149}
150
151/// Queue size metadata used to pause full topics without reading ciphertext.
152#[derive(Debug, Clone, QueryableByName)]
153pub struct PendingTopicUsage {
154    #[diesel(sql_type = Binary)]
155    pub entity_id: Vec<u8>,
156    #[diesel(sql_type = BigInt)]
157    pub rows: i64,
158    #[diesel(sql_type = BigInt)]
159    pub bytes: i64,
160}
161
162/// Retry state attached to the same still-pending envelope.
163#[derive(Debug, Clone)]
164pub struct IncomingRetry {
165    /// Earliest retry time in Unix nanoseconds.
166    pub retry_at_ns: i64,
167    /// Requires a new coordinator generation instead of a timer retry.
168    pub blocked: bool,
169    /// Stable error code with no input payload or formatted error text.
170    pub error_code: Option<String>,
171    /// The first deadline wins. A retry must not extend pointer retention.
172    pub retry_expires_at_ns: Option<i64>,
173}
174
175/// Atomic receipt, ordered completion, and bounded retry state for network logs.
176pub trait QueryIncomingEnvelope: ConnectionExt + Sized {
177    /// Record the first successful Welcome installation inside its state transaction.
178    /// Rejoin does not change discovery. Local creation and history import do not call this.
179    fn record_welcome_discovery(
180        &self,
181        group_id: GroupId,
182        welcome_cursor: Cursor,
183    ) -> Result<(), StorageError> {
184        let sequence = i64::try_from(welcome_cursor.0)
185            .ok()
186            .filter(|sequence| *sequence > 0)
187            .ok_or(StreamStorageError::InvalidBatch)?;
188        stream_transaction(self, |conn| {
189            diesel::insert_into(discovery::table)
190                .values((
191                    discovery::group_id.eq(group_id),
192                    discovery::first_welcome_sequence_id.eq(sequence),
193                ))
194                .on_conflict(discovery::group_id)
195                .do_nothing()
196                .execute(conn)?;
197            Ok(())
198        })
199    }
200
201    /// Groups first discovered through the fixed own-installation Welcome target.
202    /// Later joins and local/imported groups cannot expand this captured discovery set.
203    fn group_ids_discovered_through(&self, target: Cursor) -> Result<Vec<GroupId>, StorageError> {
204        let target = i64::try_from(target.0).map_err(|_| StreamStorageError::InvalidBatch)?;
205        self.raw_query(|conn| {
206            discovery::table
207                .filter(discovery::first_welcome_sequence_id.le(target))
208                .order((
209                    discovery::first_welcome_sequence_id.asc(),
210                    discovery::group_id.asc(),
211                ))
212                .select(discovery::group_id)
213                .load(conn)
214        })
215        .map_err(Into::into)
216    }
217
218    /// Keep one rejection diagnostic per topic, replacing it with a later rejection.
219    /// Store a stable error code, never an input payload or a formatted error message.
220    /// The caller records this inside the same state transaction that deletes the pending row.
221    fn record_terminal_rejection(
222        &self,
223        topic: &StreamTopic,
224        sequence: Cursor,
225        code: &'static str,
226    ) -> Result<(), StorageError> {
227        let sequence_id = i64::try_from(sequence.0)
228            .ok()
229            .filter(|id| *id > 0)
230            .ok_or(StreamStorageError::InvalidBatch)?;
231        if code.is_empty() {
232            return Err(StreamStorageError::InvalidBatch.into());
233        }
234        stream_transaction(self, |conn| {
235            if sequence > load_progress(conn, topic)?.received {
236                return Err(StreamStorageError::InvalidBatch.into());
237            }
238            let pending = incoming::table
239                .find((&topic.entity_id, topic.kind.entity_kind(), sequence_id))
240                .select(incoming::sequence_id)
241                .first::<i64>(conn)
242                .optional()?
243                .is_some();
244            if !pending {
245                return Err(StreamStorageError::HeadChanged.into());
246            }
247            if topic.kind != NetworkEntityKind::Welcome
248                && first_pending(conn, topic)?.is_none_or(|head| head.sequence_id != sequence_id)
249            {
250                return Err(StreamStorageError::HeadChanged.into());
251            }
252            diesel::update(progress::table.find((&topic.entity_id, topic.kind.entity_kind())))
253                .set((
254                    progress::last_rejection_sequence_id.eq(sequence_id),
255                    progress::last_rejection_code.eq(code),
256                ))
257                .execute(conn)?;
258            Ok(())
259        })
260    }
261
262    /// Read the topic's last committed rejection code without loading envelope data.
263    fn read_last_rejection(
264        &self,
265        topic: &StreamTopic,
266    ) -> Result<Option<TerminalRejection>, StorageError> {
267        let row = self.raw_query(|conn| {
268            progress::table
269                .find((&topic.entity_id, topic.kind.entity_kind()))
270                .select((
271                    progress::last_rejection_sequence_id,
272                    progress::last_rejection_code,
273                ))
274                .first::<(Option<i64>, Option<String>)>(conn)
275                .optional()
276        })?;
277        match row {
278            None | Some((None, None)) => Ok(None),
279            Some((Some(sequence), Some(code))) => Ok(Some(TerminalRejection {
280                sequence_id: Cursor(sequence as u64),
281                code,
282            })),
283            _ => Err(StorageError::DbDeserialize),
284        }
285    }
286
287    /// Commit the complete ordered batch and its received position together.
288    /// An overlap is safe; a gap before the source cursor is rejected.
289    /// Validation, capacity, or storage failure leaves both F and pending rows unchanged.
290    fn admit_ordered_batch(
291        &self,
292        topic: &StreamTopic,
293        after: Cursor,
294        envelopes: &[NewIncomingEnvelope],
295        limits: IncomingLimits,
296    ) -> Result<AdmissionResult, StorageError> {
297        let mut previous = after.0;
298        let mut bytes = 0u64;
299        for envelope in envelopes {
300            if envelope.sequence_id.0 <= previous
301                || envelope.envelope.is_empty()
302                || envelope.sequence_id.0 > i64::MAX as u64
303            {
304                return Err(StreamStorageError::InvalidBatch.into());
305            }
306            previous = envelope.sequence_id.0;
307            bytes = bytes
308                .checked_add(envelope.envelope.len() as u64)
309                .ok_or(StreamStorageError::InvalidBatch)?;
310        }
311        check_budget(
312            envelopes.len() as u64,
313            bytes,
314            limits.batch,
315            BudgetScope::Batch,
316        )?;
317        stream_transaction(self, |conn| {
318            initialize_progress(conn, topic)?;
319            let state = load_progress(conn, topic)?;
320            if after.0 > state.received.0 {
321                return Err(StreamStorageError::MissingPrefix {
322                    after: after.0,
323                    received: state.received.0,
324                }
325                .into());
326            }
327            let new: Vec<_> = envelopes
328                .iter()
329                .filter(|envelope| envelope.sequence_id.0 > state.received.0)
330                .collect();
331            if new.is_empty() {
332                return Ok(AdmissionResult {
333                    received: state.received,
334                    inserted: 0,
335                });
336            }
337            let new_bytes = new
338                .iter()
339                .map(|envelope| envelope.envelope.len() as u64)
340                .sum::<u64>();
341            let usages = pending_usage(conn, topic.kind)?;
342            let own = usages
343                .iter()
344                .find(|usage| usage.entity_id == topic.entity_id);
345            let topic_rows = own.map_or(0, |usage| usage.rows as u64);
346            let topic_bytes = own.map_or(0, |usage| usage.bytes as u64);
347            check_budget(
348                topic_rows + new.len() as u64,
349                topic_bytes + new_bytes,
350                limits.topic,
351                BudgetScope::Topic,
352            )?;
353            let kind_rows: u64 = usages.iter().map(|usage| usage.rows as u64).sum();
354            let kind_bytes: u64 = usages.iter().map(|usage| usage.bytes as u64).sum();
355            check_budget(
356                kind_rows + new.len() as u64,
357                kind_bytes + new_bytes,
358                limits.kind,
359                BudgetScope::Kind,
360            )?;
361            for envelope in &new {
362                diesel::insert_into(incoming::table)
363                    .values((
364                        incoming::entity_id.eq(&topic.entity_id),
365                        incoming::entity_kind.eq(topic.kind.entity_kind()),
366                        incoming::sequence_id.eq(envelope.sequence_id.0 as i64),
367                        incoming::envelope.eq(&envelope.envelope),
368                    ))
369                    .execute(conn)?;
370            }
371            let received = new
372                .last()
373                .ok_or(StreamStorageError::InvalidBatch)?
374                .sequence_id;
375            diesel::update(progress::table.find((&topic.entity_id, topic.kind.entity_kind())))
376                .set(progress::received_sequence_id.eq(received.0 as i64))
377                .execute(conn)?;
378            Ok(AdmissionResult {
379                received,
380                inserted: new.len(),
381            })
382        })
383    }
384
385    /// Read P and F from the same row; an unseen topic starts at zero.
386    fn topic_progress(&self, topic: &StreamTopic) -> Result<TopicProgress, StorageError> {
387        self.raw_query(|conn| Ok(load_progress(conn, topic)))?
388    }
389
390    /// Read the next actual ID. Missing integer IDs are not queue entries.
391    fn first_pending_envelope(
392        &self,
393        topic: &StreamTopic,
394    ) -> Result<Option<StoredIncomingEnvelope>, StorageError> {
395        self.raw_query(|conn| first_pending(conn, topic))
396            .map_err(Into::into)
397    }
398
399    /// Recheck one independent welcome under the caller's state transaction.
400    fn pending_envelope(
401        &self,
402        topic: &StreamTopic,
403        sequence: Cursor,
404    ) -> Result<Option<StoredIncomingEnvelope>, StorageError> {
405        let sequence = i64::try_from(sequence.0).map_err(|_| StreamStorageError::InvalidBatch)?;
406        self.raw_query(|conn| {
407            incoming::table
408                .find((&topic.entity_id, topic.kind.entity_kind(), sequence))
409                .first::<StoredIncomingEnvelope>(conn)
410                .optional()
411        })
412        .map_err(Into::into)
413    }
414
415    /// Read actual pending IDs through the fixed target without loading ciphertext.
416    /// Topic and kind admission limits bound the number of stored rows returned here.
417    fn pending_states_through(
418        &self,
419        topic: &StreamTopic,
420        target: Cursor,
421    ) -> Result<Vec<PendingEnvelopeState>, StorageError> {
422        let target = i64::try_from(target.0).map_err(|_| StreamStorageError::InvalidBatch)?;
423        let rows = self.raw_query(|conn| {
424            incoming::table
425                .filter(incoming::entity_id.eq(&topic.entity_id))
426                .filter(incoming::entity_kind.eq(topic.kind.entity_kind()))
427                .filter(incoming::sequence_id.le(target))
428                .order(incoming::sequence_id.asc())
429                .select((
430                    incoming::sequence_id,
431                    incoming::blocked,
432                    incoming::error_code,
433                    incoming::retry_at_ns,
434                ))
435                .load::<(i64, bool, Option<String>, i64)>(conn)
436        })?;
437        rows.into_iter()
438            .map(|(sequence, blocked, error_code, retry_at_ns)| {
439                Ok(PendingEnvelopeState {
440                    sequence_id: Cursor(
441                        u64::try_from(sequence).map_err(|_| StorageError::DbDeserialize)?,
442                    ),
443                    blocked,
444                    error_code,
445                    retry_at_ns,
446                })
447            })
448            .collect()
449    }
450
451    /// Complete only the current group or identity head. Welcomes are independent.
452    /// Call this inside the state transaction that applies or rejects the envelope.
453    /// Deletion and P advance commit together; an unresolved Welcome keeps its prefix open.
454    fn complete_pending_envelope(
455        &self,
456        topic: &StreamTopic,
457        sequence: Cursor,
458    ) -> Result<bool, StorageError> {
459        let sequence = i64::try_from(sequence.0).map_err(|_| StreamStorageError::InvalidBatch)?;
460        stream_transaction(self, |conn| {
461            let Some(head) = first_pending(conn, topic)? else {
462                return Ok(false);
463            };
464            if topic.kind != NetworkEntityKind::Welcome && head.sequence_id != sequence {
465                return Err(StreamStorageError::HeadChanged.into());
466            }
467            let deleted = diesel::delete(incoming::table.find((
468                &topic.entity_id,
469                topic.kind.entity_kind(),
470                sequence,
471            )))
472            .execute(conn)?;
473            if deleted == 0 {
474                return Ok(false);
475            }
476            let handled = if topic.kind == NetworkEntityKind::Welcome {
477                match first_pending(conn, topic)? {
478                    Some(pending) => pending.sequence_id - 1,
479                    None => load_progress(conn, topic)?.received.0 as i64,
480                }
481            } else {
482                sequence
483            };
484            diesel::update(progress::table.find((&topic.entity_id, topic.kind.entity_kind())))
485                .set(progress::sequence_id.eq(handled))
486                .execute(conn)?;
487            Ok(true)
488        })
489    }
490
491    /// Retry metadata is written only while this work is still current.
492    fn set_incoming_retry(
493        &self,
494        topic: &StreamTopic,
495        sequence: Cursor,
496        retry: &IncomingRetry,
497    ) -> Result<bool, StorageError> {
498        let sequence = i64::try_from(sequence.0).map_err(|_| StreamStorageError::InvalidBatch)?;
499        stream_transaction(self, |conn| {
500            if topic.kind != NetworkEntityKind::Welcome
501                && first_pending(conn, topic)?.is_none_or(|head| head.sequence_id != sequence)
502            {
503                return Ok(false);
504            }
505            let target =
506                incoming::table.find((&topic.entity_id, topic.kind.entity_kind(), sequence));
507            let deadline = target
508                .select(incoming::retry_expires_at_ns)
509                .first::<Option<i64>>(conn)
510                .optional()?;
511            let Some(deadline) = deadline else {
512                return Ok(false);
513            };
514            Ok(diesel::update(target)
515                .set((
516                    incoming::retry_at_ns.eq(retry.retry_at_ns),
517                    incoming::blocked.eq(retry.blocked),
518                    incoming::error_code.eq(&retry.error_code),
519                    incoming::retry_expires_at_ns.eq(deadline.or(retry.retry_expires_at_ns)),
520                ))
521                .execute(conn)?
522                > 0)
523        })
524    }
525
526    /// Read due, unblocked Welcome rows. Production callers must also set a byte bound.
527    fn ready_welcomes(
528        &self,
529        now_ns: i64,
530        limit: u32,
531    ) -> Result<Vec<StoredIncomingEnvelope>, StorageError> {
532        self.ready_welcomes_bounded(now_ns, limit, u64::MAX)
533    }
534
535    /// Read a due prefix without loading envelope bytes above the batch budget.
536    /// Permanently blocked rows require a new coordinator generation, not a timer retry.
537    fn ready_welcomes_bounded(
538        &self,
539        now_ns: i64,
540        limit: u32,
541        max_bytes: u64,
542    ) -> Result<Vec<StoredIncomingEnvelope>, StorageError> {
543        self.raw_query(|conn| {
544            Ok(conn.transaction::<_, StorageError, _>(|conn| {
545                let due = || {
546                    incoming::table
547                        .filter(incoming::entity_kind.eq(EntityKind::Welcome))
548                        .filter(incoming::blocked.eq(false))
549                        .filter(incoming::retry_at_ns.le(now_ns))
550                        .order((incoming::sequence_id.asc(), incoming::entity_id.asc()))
551                };
552                let sizes = due()
553                    .select(diesel::dsl::sql::<BigInt>("length(envelope)"))
554                    .limit(i64::from(limit))
555                    .load::<i64>(conn)?;
556                let mut selected_rows = 0_i64;
557                let mut selected_bytes = 0_u64;
558                for bytes in sizes {
559                    let bytes = u64::try_from(bytes).map_err(|_| StorageError::DbDeserialize)?;
560                    if bytes > max_bytes.saturating_sub(selected_bytes) {
561                        if selected_rows == 0 {
562                            return Err(StreamStorageError::Capacity {
563                                scope: BudgetScope::Batch,
564                            }
565                            .into());
566                        }
567                        break;
568                    }
569                    selected_rows += 1;
570                    selected_bytes += bytes;
571                }
572                Ok(due()
573                    .limit(selected_rows)
574                    .select(StoredIncomingEnvelope::as_select())
575                    .load(conn)?)
576            }))
577        })?
578    }
579
580    /// Read one bounded page for a new coordinator generation to recheck unsupported work.
581    fn blocked_welcomes_bounded(
582        &self,
583        topic: &StreamTopic,
584        after: Cursor,
585        limit: u32,
586        max_bytes: u64,
587    ) -> Result<Vec<StoredIncomingEnvelope>, StorageError> {
588        if topic.kind != NetworkEntityKind::Welcome {
589            return Err(StreamStorageError::InvalidBatch.into());
590        }
591        let after = i64::try_from(after.0).map_err(|_| StreamStorageError::InvalidBatch)?;
592        self.raw_query(|conn| {
593            Ok(conn.transaction::<_, StorageError, _>(|conn| {
594                let pending = || {
595                    incoming::table
596                        .filter(incoming::entity_id.eq(&topic.entity_id))
597                        .filter(incoming::entity_kind.eq(EntityKind::Welcome))
598                        .filter(incoming::blocked.eq(true))
599                        .filter(incoming::sequence_id.gt(after))
600                        .order(incoming::sequence_id.asc())
601                };
602                let sizes = pending()
603                    .select(diesel::dsl::sql::<BigInt>("length(envelope)"))
604                    .limit(i64::from(limit))
605                    .load::<i64>(conn)?;
606                let mut rows = 0_i64;
607                let mut bytes_used = 0_u64;
608                for bytes in sizes {
609                    let bytes = u64::try_from(bytes).map_err(|_| StorageError::DbDeserialize)?;
610                    if bytes > max_bytes.saturating_sub(bytes_used) {
611                        if rows == 0 {
612                            return Err(StreamStorageError::Capacity {
613                                scope: BudgetScope::Batch,
614                            }
615                            .into());
616                        }
617                        break;
618                    }
619                    rows += 1;
620                    bytes_used += bytes;
621                }
622                Ok(pending().limit(rows).load(conn)?)
623            }))
624        })?
625    }
626
627    /// Later welcome success cannot hide an earlier unresolved welcome.
628    fn welcome_barrier_complete(
629        &self,
630        topic: &StreamTopic,
631        target: Cursor,
632    ) -> Result<bool, StorageError> {
633        if topic.kind != NetworkEntityKind::Welcome {
634            return Err(StreamStorageError::InvalidBatch.into());
635        }
636        let target_id = i64::try_from(target.0).map_err(|_| StreamStorageError::InvalidBatch)?;
637        self.raw_query(|conn| {
638            Ok(conn.transaction::<_, StorageError, _>(|conn| {
639                if load_progress(conn, topic)?.received < target {
640                    return Ok(false);
641                }
642                let count = incoming::table
643                    .filter(incoming::entity_id.eq(&topic.entity_id))
644                    .filter(incoming::entity_kind.eq(EntityKind::Welcome))
645                    .filter(incoming::sequence_id.le(target_id))
646                    .select(diesel::dsl::count_star())
647                    .first::<i64>(conn)?;
648                Ok(count == 0)
649            }))
650        })?
651    }
652
653    /// Keep welcome private keys while any unresolved welcome can still need them.
654    fn has_pending_welcomes(&self) -> Result<bool, StorageError> {
655        self.raw_query(|conn| {
656            incoming::table
657                .filter(incoming::entity_kind.eq(EntityKind::Welcome))
658                .select(diesel::dsl::count_star())
659                .first::<i64>(conn)
660                .map(|count| count > 0)
661        })
662        .map_err(Into::into)
663    }
664
665    /// Install a validated join anchor without rewinding either durable position.
666    /// The caller must check the group state and install MLS state in the same transaction.
667    fn install_group_anchor(
668        &self,
669        group_id: GroupId,
670        anchor: Cursor,
671        mode: JoinAnchorMode,
672    ) -> Result<(), StorageError> {
673        let anchor = i64::try_from(anchor.0).map_err(|_| StreamStorageError::InvalidBatch)?;
674        let topic = StreamTopic::group(group_id);
675        stream_transaction(self, |conn| {
676            let state = progress::table
677                .find((&topic.entity_id, EntityKind::ApplicationMessage))
678                .select((progress::sequence_id, progress::received_sequence_id))
679                .first::<(i64, Option<i64>)>(conn)
680                .optional()?;
681            match state {
682                None => {
683                    if mode == JoinAnchorMode::InactiveReadd {
684                        return Err(StreamStorageError::StaleJoinAnchor.into());
685                    }
686                    diesel::insert_into(progress::table)
687                        .values((
688                            progress::entity_id.eq(&topic.entity_id),
689                            progress::entity_kind.eq(EntityKind::ApplicationMessage),
690                            progress::sequence_id.eq(anchor),
691                            progress::received_sequence_id.eq(anchor),
692                        ))
693                        .execute(conn)?;
694                }
695                Some((processed, received)) => {
696                    let valid = match mode {
697                        JoinAnchorMode::Advance => anchor > processed,
698                        JoinAnchorMode::InactiveReadd => anchor == processed,
699                    };
700                    if !valid {
701                        return Err(StreamStorageError::StaleJoinAnchor.into());
702                    }
703                    let changed = diesel::update(
704                        progress::table
705                            .find((&topic.entity_id, EntityKind::ApplicationMessage))
706                            .filter(progress::sequence_id.eq(processed)),
707                    )
708                    .set((
709                        progress::sequence_id.eq(anchor),
710                        progress::received_sequence_id.eq(received.unwrap_or(0).max(anchor)),
711                    ))
712                    .execute(conn)?;
713                    if changed == 0 {
714                        return Err(StreamStorageError::StaleJoinAnchor.into());
715                    }
716                }
717            }
718            diesel::delete(
719                incoming::table
720                    .filter(incoming::entity_id.eq(&topic.entity_id))
721                    .filter(incoming::entity_kind.eq(EntityKind::ApplicationMessage))
722                    .filter(incoming::sequence_id.le(anchor)),
723            )
724            .execute(conn)?;
725            Ok(())
726        })
727    }
728
729    /// Largest queues come first; empty topics are absent and must not be paused.
730    fn pending_topic_usage(
731        &self,
732        kind: NetworkEntityKind,
733    ) -> Result<Vec<PendingTopicUsage>, StorageError> {
734        self.raw_query(|conn| pending_usage(conn, kind))
735            .map_err(Into::into)
736    }
737}
738
739impl<C: ConnectionExt> QueryIncomingEnvelope for C {}
740
741fn check_budget(
742    rows: u64,
743    bytes: u64,
744    limit: PendingBudget,
745    scope: BudgetScope,
746) -> Result<(), StorageError> {
747    if rows > limit.rows || bytes > limit.bytes {
748        return Err(StreamStorageError::Capacity { scope }.into());
749    }
750    Ok(())
751}
752
753fn initialize_progress(
754    conn: &mut diesel::SqliteConnection,
755    topic: &StreamTopic,
756) -> Result<(), StorageError> {
757    diesel::insert_or_ignore_into(progress::table)
758        .values((
759            progress::entity_id.eq(&topic.entity_id),
760            progress::entity_kind.eq(topic.kind.entity_kind()),
761            progress::sequence_id.eq(0i64),
762            progress::received_sequence_id.eq(0i64),
763        ))
764        .execute(conn)?;
765    // A zero row from a legacy reader has no history to lose.
766    diesel::update(
767        progress::table
768            .find((&topic.entity_id, topic.kind.entity_kind()))
769            .filter(progress::sequence_id.eq(0))
770            .filter(progress::received_sequence_id.is_null()),
771    )
772    .set(progress::received_sequence_id.eq(0i64))
773    .execute(conn)?;
774    Ok(())
775}
776
777fn load_progress(
778    conn: &mut diesel::SqliteConnection,
779    topic: &StreamTopic,
780) -> Result<TopicProgress, StorageError> {
781    let row = progress::table
782        .find((&topic.entity_id, topic.kind.entity_kind()))
783        .select((progress::sequence_id, progress::received_sequence_id))
784        .first::<(i64, Option<i64>)>(conn)
785        .optional()?;
786    match row {
787        None | Some((0, None)) => Ok(TopicProgress::default()),
788        Some((processed, Some(received))) => Ok(TopicProgress {
789            processed: Cursor(processed as u64),
790            received: Cursor(received as u64),
791        }),
792        Some(_) => Err(StreamStorageError::UninitializedNetworkProgress.into()),
793    }
794}
795
796fn first_pending(
797    conn: &mut diesel::SqliteConnection,
798    topic: &StreamTopic,
799) -> QueryResult<Option<StoredIncomingEnvelope>> {
800    incoming::table
801        .filter(incoming::entity_id.eq(&topic.entity_id))
802        .filter(incoming::entity_kind.eq(topic.kind.entity_kind()))
803        .order(incoming::sequence_id.asc())
804        .first(conn)
805        .optional()
806}
807
808fn pending_usage(
809    conn: &mut diesel::SqliteConnection,
810    kind: NetworkEntityKind,
811) -> QueryResult<Vec<PendingTopicUsage>> {
812    diesel::sql_query("SELECT entity_id, COUNT(*) AS rows, SUM(length(envelope)) AS bytes FROM incoming_envelopes WHERE entity_kind = ? GROUP BY entity_id ORDER BY rows DESC, entity_id ASC")
813        .bind::<Integer, _>(kind.entity_kind() as i32).load(conn)
814}
815
816#[cfg(test)]
817mod tests;