Skip to main content

mail4agent_messenger/
outgoing_queue.rs

1//! [`OutgoingQueue`] — the outgoing HTTP request queue: per-lane ordering,
2//! idempotent retries with capped exponential backoff, and
3//! [`crate::store::StateStore`]-backed crash safety (plan §5, M12).
4//!
5//! # Contract
6//!
7//! This module owns none of the flush-epoch machinery itself (that's
8//! [`crate::persist::FlushEpoch`], M1/this piece's own extension of it) and
9//! performs no I/O: [`OutgoingQueue::releasable`] hands back plain
10//! [`crate::wire::OutgoingRequest`] values for a shell to execute;
11//! [`OutgoingQueue::on_response`]/[`OutgoingQueue::on_transport_error`] take
12//! the shell's result back in. `now_ms` is always an explicit input — this
13//! module never calls a clock itself (same rule as [`crate::ids`]'s
14//! monotonic-sequence inputs).
15//!
16//! # Lanes
17//!
18//! Every pending request belongs to exactly one [`Lane`], which decides how
19//! many of that lane's requests may be in flight at once:
20//!
21//! | Lane | In flight at once | Why |
22//! |---|---|---|
23//! | [`Lane::Sync`] | 1 (global) | Only one `/sync` long-poll makes sense at a time. |
24//! | [`Lane::ToDevice`] | 1 (global) | Per-device Olm ratchets are consumed in a strict sequence; two to-device sends racing (including a retry racing the next fresh send) would desynchronize a peer's ratchet. |
25//! | [`Lane::Room`]`(room_id)` | 1 per room | Preserves the room's own send order (and, for encrypted rooms, its Megolm ratchet order) without serializing unrelated rooms against each other. |
26//! | [`Lane::AccountData`] | 1 (global), strict FIFO | Every account-data write replaces the WHOLE value under its `(room, type)` key, so two racing PUTs (or a retry overtaken by a later PUT) would silently lose an update: a later request in this lane never overtakes an earlier one, not even while the earlier one is backing off before a retry. |
27//! | [`Lane::Other`] | up to [`OTHER_LANE_CONCURRENCY`] | Everything with no ordering requirement of its own (profile lookups, user-directory search, `/keys/query`, ...) — bounded so this queue cannot flood a homeserver with unlimited concurrent calls. |
28//!
29//! [`OutgoingQueue::releasable`] always returns requests in the order they
30//! were enqueued (see "Restart" below for how that order survives a
31//! reload), skipping any whose lane is currently occupied, whose flush
32//! epoch is not yet satisfied ([`crate::persist::FlushEpoch::is_released`]),
33//! or whose retry backoff has not yet elapsed.
34//!
35//! # Retry policy
36//!
37//! [`OutgoingQueue::on_response`] classifies a shell-supplied
38//! [`crate::wire::HttpResponseDescriptor`]:
39//!
40//! - **2xx** → [`ResponseOutcome::Done`] — the pending record is deleted.
41//! - **429** → [`ResponseOutcome::Retry`], using the response body's own
42//!   `retry_after_ms` (Matrix's `M_LIMIT_EXCEEDED` shape) if present,
43//!   falling back to this queue's own capped exponential backoff otherwise.
44//! - **5xx**, or [`OutgoingQueue::on_transport_error`] (no response at
45//!   all) → [`ResponseOutcome::Retry`] with this queue's own capped
46//!   exponential backoff: `min(60_000, 1000 * 2^attempt)` milliseconds,
47//!   jittered to `[50%, 100%]` of that value by an injected
48//!   [`Jitter`] source (never a global RNG — this crate keeps no hidden
49//!   shared mutable state, same rule as [`crate::ids`]'s `TxnId`/
50//!   `RequestId`). `attempt` counts this request's own retry attempts and
51//!   is capped at `6` internally (`1000 * 2^6 = 64_000` already exceeds the
52//!   60s ceiling), so backoff saturates rather than growing unbounded.
53//! - Anything else (4xx other than 429, and any other unexpected status) →
54//!   [`ResponseOutcome::Failed`] — the pending record is deleted; the
55//!   caller (a later piece, `sync_engine`/`MessengerCore`) maps this to
56//!   e.g. `SendState::Failed`.
57//!
58//! A retry always resends the **identical** [`crate::wire::OutgoingRequest`]
59//! this queue already has pending — same `txnId` baked into the path, same
60//! body — never re-encrypted, never re-minted. [`OutgoingQueue`] does not
61//! (and cannot, from this layer) tell a caller to re-encrypt; it simply
62//! never discards or mutates a pending request's own bytes between
63//! [`OutgoingQueue::releasable`] calls.
64//!
65//! # Restart
66//!
67//! [`OutgoingQueue::load`] rebuilds a queue from
68//! [`crate::store::StateStore::pending_requests`], with **none** marked in
69//! flight (a crash mid-flight means the shell doesn't know whether the
70//! previous attempt reached the server) and **no** retry backoff carried
71//! over (backoff state is deliberately ephemeral — see [`OutgoingQueue`]'s
72//! own fields). Resending after a restart is safe because every mutating
73//! Matrix endpoint this crate calls is either server-side idempotent on its
74//! own `txnId` (room sends, redactions, to-device sends) or safely
75//! re-uploadable as identical bytes (`/keys/upload`, per M5's design) —
76//! **except** `/keys/claim`, whose resend may consume a *different*
77//! one-time key than the original attempt did; this is an accepted,
78//! documented cost (a claimed-but-unused OTK is simply wasted, not a
79//! correctness bug) rather than a reason to special-case restart recovery.
80//! `/sync` itself is never persisted as a pending request in the first
81//! place — a fresh `/sync` (using the durable `sync_token`, a separate
82//! [`crate::store::StateStore`] record) is always reissued instead of
83//! resumed.
84//!
85//! [`OutgoingQueue::load`] recovers **enqueue order** from
86//! [`crate::store::StateStore::pending_requests`] by sorting on
87//! [`crate::ids::RequestId`]'s own `Ord` rather than persisting a
88//! redundant sequence field: `RequestId` is documented (`ids` module doc)
89//! to be minted from one caller-owned, strictly increasing counter per
90//! process, and its fixed-width-hex encoding keeps that counter's order
91//! exactly lexicographic (`ids` module's own
92//! `txn_id_and_request_id_are_monotonic_and_distinct_namespaces` test).
93//! This holds as long as every `RequestId` a `MessengerCore` mints for its
94//! whole lifetime comes from that one counter, never reused or reset — a
95//! precondition `MessengerCore` (a later piece, M13) is responsible for.
96//!
97//! # Flush epoch integration
98//!
99//! [`OutgoingQueue::enqueue`] is the one place this queue touches
100//! [`crate::persist::FlushEpoch`]: it calls
101//! [`crate::persist::FlushEpoch::seal_for_request`] **before** persisting
102//! the new pending record, so the returned [`crate::persist::RequiredSeq`]
103//! only ever covers mutations that predate this request (never the
104//! request's own about-to-be-created pending record — that record's
105//! durability is a separate, ordinary flush the next
106//! [`crate::persist::FlushEpoch::seal_for_request`]/
107//! [`crate::persist::FlushGate::take_batch`] call will pick up). See
108//! `persist`'s module doc for the full per-request epoch rule and a worked
109//! timeline.
110//!
111//! One consequence worth calling out: [`crate::store::Store`]'s
112//! [`FlushEpoch`] does not distinguish "a real state mutation" from
113//! "another request's own not-yet-batched pending-request record" — both
114//! are just dirty [`crate::persist::RecordKey`] entries to
115//! [`FlushEpoch::seal_for_request`]. So enqueuing two requests back to back
116//! with no flush/ack in between makes the *second* one's `required_seq`
117//! cover the *first* one's own bookkeeping write (sealed as a side effect
118//! of the second's own `enqueue` call). This is benign, not a bug: it
119//! self-resolves the moment a shell performs its normal flush/ack tick
120//! (which every driving loop — a later piece, `sync_engine` — does
121//! regardless), and it never affects [`RequiredSeq::NONE`] requests (a
122//! request enqueued with nothing pending at all, e.g. the very first
123//! request of a session).
124
125use crate::ids::{RequestId, RoomId};
126use crate::persist::{FlushBatch, FlushEpoch, RequiredSeq};
127use crate::store::{StateStore, StoreError};
128use crate::wire::{HttpResponseDescriptor, OutgoingRequest, OutgoingRequestKind};
129use serde::{Deserialize, Serialize};
130use std::collections::{BTreeMap, BTreeSet};
131
132/// How many [`Lane::Other`] requests may be in flight at once.
133const OTHER_LANE_CONCURRENCY: usize = 4;
134
135/// Retry backoff floor: the smallest possible backoff (before jitter can
136/// shrink it toward half of this), used at the first retry attempt.
137const BACKOFF_BASE_MS: u64 = 1_000;
138
139/// Retry backoff ceiling: no computed backoff, regardless of attempt count
140/// or jitter, ever exceeds this.
141const BACKOFF_CAP_MS: u64 = 60_000;
142
143/// Which ordering class a pending request belongs to — see the module
144/// doc's "Lanes" table for the exact in-flight limit and rationale of each
145/// variant.
146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147pub enum Lane {
148    /// `GET /sync` — at most one in flight.
149    Sync,
150    /// `PUT /sendToDevice/...` — strictly ordered, at most one in flight
151    /// globally (preserves per-device Olm ratchet order across retries).
152    ToDevice,
153    /// A room-scoped call — ordered per room, at most one in flight per
154    /// room id.
155    Room(RoomId),
156    /// Every account-data write (`PUT .../account_data/...`, global and
157    /// room-scoped) -- strictly FIFO, at most one in flight. A record
158    /// persisted before this lane existed carries [`Lane::Other`] instead;
159    /// [`OutgoingQueue::load`] moves those into this lane.
160    AccountData,
161    /// Everything else — unordered, bounded concurrency
162    /// ([`OTHER_LANE_CONCURRENCY`]).
163    Other,
164}
165
166/// One request this queue is tracking: its wire shape, which [`Lane`] it
167/// belongs to, and the [`RequiredSeq`] it must wait on before it may ever
168/// be released. Persisted via [`crate::store::StateStore::save_pending_request`]
169/// so it survives a crash — see the module doc's "Restart" section for
170/// exactly what survives and what doesn't.
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct PendingRequest {
173    /// The request itself — resent byte-for-byte on every retry.
174    pub request: OutgoingRequest,
175    /// Which lane governs this request's ordering/concurrency.
176    pub lane: Lane,
177    /// The flush epoch this request must wait on before release — see the
178    /// module doc's "Flush epoch integration" section.
179    pub required_seq: RequiredSeq,
180}
181
182/// What happened to a request after a shell fed its result back in via
183/// [`OutgoingQueue::on_response`] or [`OutgoingQueue::on_transport_error`].
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub enum ResponseOutcome {
186    /// A 2xx response. The pending record has already been deleted.
187    Done(HttpResponseDescriptor),
188    /// The request should be retried (unchanged) after `after_ms`
189    /// milliseconds. The pending record is still tracked; this queue
190    /// itself will not offer it again via [`OutgoingQueue::releasable`]
191    /// until `after_ms` has elapsed (per the `now_ms` the caller supplies
192    /// to that call).
193    Retry {
194        /// How long to wait before this request may be retried.
195        after_ms: u64,
196    },
197    /// A non-retryable error response. The pending record has already
198    /// been deleted; the caller maps `errcode`/`error` to its own
199    /// user-facing failure state.
200    Failed {
201        /// The Matrix `errcode` (e.g. `"M_FORBIDDEN"`), or `"M_UNKNOWN"`
202        /// if the body did not carry one.
203        errcode: String,
204        /// The Matrix `error` human-readable string, empty if absent.
205        error: String,
206    },
207}
208
209/// A source of jitter for retry backoff, injected explicitly rather than
210/// reaching for a global RNG — this crate keeps no hidden shared mutable
211/// state (same rule as [`crate::ids`]'s `TxnId`/`RequestId`). Implementors
212/// return a value in `[0.0, 1.0]`; a shell wires in a real RNG, tests wire
213/// in a deterministic fake.
214pub trait Jitter {
215    /// The next jitter sample, in `[0.0, 1.0]`. Values outside that range
216    /// are clamped by the caller.
217    fn next_unit(&mut self) -> f64;
218}
219
220/// Computes `min(60_000, 1000 * 2^attempt)` milliseconds, then jitters the
221/// result to `[50%, 100%]` of that value using `jitter_unit` (clamped to
222/// `[0.0, 1.0]`), then re-caps (jitter can only shrink the value here, but
223/// the re-cap keeps this function correct even if that ever changes).
224/// `attempt` is capped at `6` internally: `1000 * 2^6 = 64_000` already
225/// exceeds [`BACKOFF_CAP_MS`], so every attempt from `6` onward computes
226/// the same, fully-capped value — this is "capped exponential", not
227/// "exponential forever".
228fn capped_exponential_backoff_ms(attempt: u32, jitter_unit: f64) -> u64 {
229    let shift = attempt.min(6);
230    let base = BACKOFF_BASE_MS.saturating_mul(1u64 << shift).min(BACKOFF_CAP_MS);
231    let unit = jitter_unit.clamp(0.0, 1.0);
232    let jittered = (base as f64 * (0.5 + 0.5 * unit)).round() as u64;
233    jittered.min(BACKOFF_CAP_MS)
234}
235
236/// Reads a `retry_after_ms` field out of a Matrix-shaped error body (the
237/// `M_LIMIT_EXCEEDED` response's own extension field), if present.
238fn retry_after_ms_from_body(response: &HttpResponseDescriptor) -> Option<u64> {
239    let value: serde_json::Value = serde_json::from_slice(&response.body).ok()?;
240    value.get("retry_after_ms")?.as_u64()
241}
242
243/// The outgoing HTTP request queue — see the module doc.
244#[derive(Debug, Default)]
245pub struct OutgoingQueue {
246    /// Enqueue order, across every lane. Entries for completed requests
247    /// are removed as soon as they complete (see `complete`); a stale
248    /// entry (one whose id is no longer in `pending`) is otherwise
249    /// impossible.
250    order: Vec<RequestId>,
251    /// Every currently-pending request, keyed by its own id.
252    pending: BTreeMap<RequestId, PendingRequest>,
253    /// Every id currently released (handed out by `releasable` and not
254    /// yet completed or retried) — prevents re-releasing the same request
255    /// concurrently.
256    in_flight: BTreeSet<RequestId>,
257    sync_in_flight: bool,
258    to_device_in_flight: bool,
259    room_in_flight: BTreeSet<RoomId>,
260    other_in_flight: BTreeSet<RequestId>,
261    /// How many times each pending request has been retried so far — feeds
262    /// [`capped_exponential_backoff_ms`]. Deliberately NOT persisted: see
263    /// the module doc's "Restart" section.
264    attempts: BTreeMap<RequestId, u32>,
265    /// The earliest `now_ms` at which a retried request may be released
266    /// again. Deliberately NOT persisted, for the same reason as
267    /// `attempts`.
268    retry_not_before_ms: BTreeMap<RequestId, i64>,
269}
270
271impl OutgoingQueue {
272    /// Builds an empty queue — the brand-new-device case. A shell with an
273    /// existing store calls [`OutgoingQueue::load`] instead.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Rebuilds a queue from `store`'s durably-persisted pending requests.
279    /// See the module doc's "Restart" section for exactly what survives
280    /// (enqueue order, lanes, flush epochs) and what doesn't (in-flight
281    /// status, retry backoff).
282    pub fn load<S: StateStore>(store: &S) -> Result<Self, StoreError> {
283        let mut records = store.pending_requests()?;
284        records.sort_by(|a, b| a.request.id.cmp(&b.request.id));
285        let mut queue = Self::new();
286        for mut record in records {
287            // Account-data writes persisted before `Lane::AccountData`
288            // existed were enqueued on `Lane::Other`; they must keep their
289            // relative order after an upgrade too.
290            if record.lane == Lane::Other
291                && matches!(record.request.kind, OutgoingRequestKind::AccountData | OutgoingRequestKind::RoomAccountData)
292            {
293                record.lane = Lane::AccountData;
294            }
295            let id = record.request.id.clone();
296            queue.order.push(id.clone());
297            queue.pending.insert(id, record);
298        }
299        Ok(queue)
300    }
301
302    /// Drops every pending request of `kind` (from the queue and from
303    /// `store`). For a kind whose request carries no side effect a restart
304    /// could lose -- `/sync` -- so a core can mint a fresh one from current
305    /// state instead of replaying a stale one.
306    pub fn discard_kind<S: StateStore>(&mut self, store: &mut S, kind: OutgoingRequestKind) -> Result<(), StoreError> {
307        let ids: Vec<RequestId> = self.pending.iter().filter(|(_, record)| record.request.kind == kind).map(|(id, _)| id.clone()).collect();
308        for id in ids {
309            self.complete(store, &id)?;
310        }
311        Ok(())
312    }
313
314    /// Enqueues `request` on `lane`, persisting it via
315    /// [`crate::store::StateStore::save_pending_request`] so it survives a
316    /// crash before ever being acknowledged. Idempotent on `request.id`:
317    /// enqueuing the same id twice (a duplicate dispatch) is a safe no-op
318    /// the second time — `Ok(None)` either because nothing was pending to
319    /// seal at the time, or because the id was already tracked; both cases
320    /// leave this queue and `store` unchanged from the first call's
321    /// result, so callers never need to distinguish them.
322    ///
323    /// Returns whatever [`FlushBatch`] `store`'s flush epoch forced open
324    /// (see [`FlushEpoch::seal_for_request`]) for the caller to hand a
325    /// shell — `None` if nothing was pending to seal.
326    pub fn enqueue<S: StateStore + FlushEpoch>(
327        &mut self,
328        store: &mut S,
329        request: OutgoingRequest,
330        lane: Lane,
331    ) -> Result<Option<FlushBatch>, StoreError> {
332        if self.pending.contains_key(&request.id) {
333            return Ok(None);
334        }
335        let (required_seq, batch) = store.seal_for_request();
336        let id = request.id.clone();
337        let record = PendingRequest { request, lane, required_seq };
338        store.save_pending_request(record.clone())?;
339        self.pending.insert(id.clone(), record);
340        self.order.push(id);
341        Ok(batch)
342    }
343
344    /// Every request currently safe to send, in enqueue order: releasable
345    /// means not already in flight, its lane currently has room (see the
346    /// module doc's "Lanes" table), its flush epoch is satisfied
347    /// ([`FlushEpoch::is_released`]), and — for a request that has been
348    /// retried before — its backoff has elapsed as of `now_ms`. Every
349    /// returned request is marked in flight; a caller that fails to
350    /// execute one must eventually call [`OutgoingQueue::on_transport_error`]
351    /// (or [`OutgoingQueue::on_response`]) to release its lane again.
352    pub fn releasable<S: FlushEpoch>(&mut self, store: &S, now_ms: i64) -> Vec<OutgoingRequest> {
353        let mut out = Vec::new();
354        let ids = self.order.clone();
355        let mut account_data_blocked = false;
356        for id in ids {
357            let Some(pending) = self.pending.get(&id) else { continue };
358            if pending.lane == Lane::AccountData {
359                // Strict FIFO: only the head of this lane is ever a
360                // candidate, whatever state it is in (in flight, backing
361                // off, waiting on its flush) -- everything behind it waits.
362                if account_data_blocked {
363                    continue;
364                }
365                account_data_blocked = true;
366            }
367            if self.in_flight.contains(&id) {
368                continue;
369            }
370            if let Some(&not_before) = self.retry_not_before_ms.get(&id) {
371                if now_ms < not_before {
372                    continue;
373                }
374            }
375            if !store.is_released(pending.required_seq) {
376                continue;
377            }
378            if !self.lane_available(&pending.lane) {
379                continue;
380            }
381            let request = pending.request.clone();
382            let lane = pending.lane.clone();
383            self.mark_in_flight(&id, &lane);
384            out.push(request);
385        }
386        out
387    }
388
389    /// Feeds one HTTP response back in for `request_id`. Returns `Ok(None)`
390    /// if `request_id` is not (or no longer) pending — a duplicate or
391    /// stale response is safely ignored. See the module doc's "Retry
392    /// policy" section for the exact status-code classification.
393    pub fn on_response<S: StateStore>(
394        &mut self,
395        store: &mut S,
396        request_id: &RequestId,
397        response: HttpResponseDescriptor,
398        now_ms: i64,
399        jitter: &mut dyn Jitter,
400    ) -> Result<Option<ResponseOutcome>, StoreError> {
401        let Some(pending) = self.pending.get(request_id).cloned() else { return Ok(None) };
402
403        if (200..300).contains(&response.status) {
404            self.complete(store, request_id)?;
405            return Ok(Some(ResponseOutcome::Done(response)));
406        }
407
408        if response.status == 429 {
409            self.clear_in_flight(request_id, &pending.lane);
410            let after_ms =
411                retry_after_ms_from_body(&response).unwrap_or_else(|| self.next_backoff_ms(request_id, jitter));
412            self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
413            return Ok(Some(ResponseOutcome::Retry { after_ms }));
414        }
415
416        if (500..600).contains(&response.status) {
417            self.clear_in_flight(request_id, &pending.lane);
418            let after_ms = self.next_backoff_ms(request_id, jitter);
419            self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
420            return Ok(Some(ResponseOutcome::Retry { after_ms }));
421        }
422
423        let (errcode, error) =
424            response.matrix_error().unwrap_or_else(|| ("M_UNKNOWN".to_string(), String::new()));
425        self.complete(store, request_id)?;
426        Ok(Some(ResponseOutcome::Failed { errcode, error }))
427    }
428
429    /// Feeds a transport-level failure back in (no response at all — a
430    /// timeout, a connection reset, ...). Always a
431    /// [`ResponseOutcome::Retry`] with this queue's own capped exponential
432    /// backoff. Returns `None` if `request_id` is not (or no longer)
433    /// pending.
434    pub fn on_transport_error(
435        &mut self,
436        request_id: &RequestId,
437        now_ms: i64,
438        jitter: &mut dyn Jitter,
439    ) -> Option<ResponseOutcome> {
440        let pending = self.pending.get(request_id)?.clone();
441        self.clear_in_flight(request_id, &pending.lane);
442        let after_ms = self.next_backoff_ms(request_id, jitter);
443        self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
444        Some(ResponseOutcome::Retry { after_ms })
445    }
446
447    fn next_backoff_ms(&mut self, request_id: &RequestId, jitter: &mut dyn Jitter) -> u64 {
448        let attempt = self.attempts.entry(request_id.clone()).or_insert(0);
449        let ms = capped_exponential_backoff_ms(*attempt, jitter.next_unit());
450        *attempt = attempt.saturating_add(1);
451        ms
452    }
453
454    fn lane_available(&self, lane: &Lane) -> bool {
455        match lane {
456            Lane::Sync => !self.sync_in_flight,
457            Lane::ToDevice => !self.to_device_in_flight,
458            Lane::Room(room_id) => !self.room_in_flight.contains(room_id),
459            // Head-of-line ordering is enforced in `releasable` itself.
460            Lane::AccountData => true,
461            Lane::Other => self.other_in_flight.len() < OTHER_LANE_CONCURRENCY,
462        }
463    }
464
465    fn mark_in_flight(&mut self, id: &RequestId, lane: &Lane) {
466        match lane {
467            Lane::Sync => self.sync_in_flight = true,
468            Lane::ToDevice => self.to_device_in_flight = true,
469            Lane::Room(room_id) => {
470                self.room_in_flight.insert(room_id.clone());
471            }
472            Lane::AccountData => {}
473            Lane::Other => {
474                self.other_in_flight.insert(id.clone());
475            }
476        }
477        self.in_flight.insert(id.clone());
478    }
479
480    fn clear_in_flight(&mut self, id: &RequestId, lane: &Lane) {
481        match lane {
482            Lane::Sync => self.sync_in_flight = false,
483            Lane::ToDevice => self.to_device_in_flight = false,
484            Lane::Room(room_id) => {
485                self.room_in_flight.remove(room_id);
486            }
487            Lane::AccountData => {}
488            Lane::Other => {
489                self.other_in_flight.remove(id);
490            }
491        }
492        self.in_flight.remove(id);
493    }
494
495    fn complete<S: StateStore>(&mut self, store: &mut S, id: &RequestId) -> Result<(), StoreError> {
496        if let Some(pending) = self.pending.remove(id) {
497            self.clear_in_flight(id, &pending.lane);
498            self.attempts.remove(id);
499            self.retry_not_before_ms.remove(id);
500            self.order.retain(|existing| existing != id);
501            store.delete_pending_request(id)?;
502        }
503        Ok(())
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::ids::{DeviceId, RequestId, RoomId, TxnId};
511    use crate::store::{CryptoStore, InsecurePlainCodecForTests, Store};
512    use crate::wire::OutgoingRequest;
513
514    struct FixedJitter(f64);
515
516    impl Jitter for FixedJitter {
517        fn next_unit(&mut self) -> f64 {
518            self.0
519        }
520    }
521
522    fn device_id() -> DeviceId {
523        DeviceId::parse("DEV1").expect("valid device id")
524    }
525
526    fn room_id() -> RoomId {
527        RoomId::parse("!room:example.org").expect("valid room id")
528    }
529
530    #[test]
531    fn pending_request_survives_a_fresh_core_construction_before_ack() {
532        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
533        let mut queue = OutgoingQueue::new();
534
535        let request = OutgoingRequest::sync(RequestId::next(0), None, None);
536        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue succeeds");
537
538        // The shell reads back whatever bytes it durably wrote -- WITHOUT
539        // ever acking the old, about-to-be-discarded store's FlushGate.
540        let batch = store.take_flush_batch().expect("save_pending_request dirtied a record");
541        let reloaded =
542            Store::load(batch.records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
543
544        let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
545        assert_eq!(restored.releasable(&reloaded, 0), vec![request]);
546    }
547
548    #[test]
549    fn discard_kind_drops_the_restored_sync_but_keeps_other_requests() {
550        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
551        let mut queue = OutgoingQueue::new();
552        let sync = OutgoingRequest::sync(RequestId::next(0), Some("s1"), Some(30_000));
553        let join = OutgoingRequest::join_room(RequestId::next(1), room_id().as_str());
554        queue.enqueue(&mut store, sync, Lane::Sync).expect("enqueue sync");
555        queue.enqueue(&mut store, join.clone(), Lane::Other).expect("enqueue join");
556        let batch = store.take_flush_batch().expect("pending requests dirtied records");
557        let mut reloaded = Store::load(batch.records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
558
559        let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
560        restored.discard_kind(&mut reloaded, OutgoingRequestKind::Sync).expect("discard succeeds");
561
562        assert_eq!(restored.releasable(&reloaded, 0), vec![join], "only the non-sync request is left");
563        let stored: Vec<OutgoingRequestKind> = reloaded.pending_requests().expect("no error").iter().map(|record| record.request.kind).collect();
564        assert_eq!(stored, vec![OutgoingRequestKind::JoinRoom], "the sync record is gone from the store too");
565    }
566
567    #[test]
568    fn duplicate_dispatch_does_not_double_enqueue() {
569        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
570        let mut queue = OutgoingQueue::new();
571        let request = OutgoingRequest::sync(RequestId::next(0), None, None);
572
573        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("first enqueue");
574        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("duplicate enqueue is a no-op");
575
576        assert_eq!(store.pending_requests().expect("no error").len(), 1, "only one record persisted");
577        assert_eq!(queue.releasable(&store, 0).len(), 1, "only one request queued for release");
578    }
579
580    #[test]
581    fn sync_is_not_blocked_by_mutations_made_after_it_was_enqueued() {
582        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
583        let mut queue = OutgoingQueue::new();
584
585        let sync_req = OutgoingRequest::sync(RequestId::next(0), None, None);
586        queue.enqueue(&mut store, sync_req.clone(), Lane::Sync).expect("enqueue sync");
587        if let Some(batch) = store.take_flush_batch() {
588            store.ack_flush(batch.id);
589        }
590
591        // A later, unrelated mutation happens AFTER the sync was already
592        // enqueued -- and is never even flushed, let alone acked.
593        store.save_account(b"later-mutation".to_vec()).expect("save succeeds");
594
595        assert_eq!(
596            queue.releasable(&store, 0),
597            vec![sync_req],
598            "the sync never depended on a mutation made after it was enqueued"
599        );
600    }
601
602    #[test]
603    fn send_is_blocked_until_the_flush_containing_its_ratchet_advance_is_acked() {
604        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
605        let mut queue = OutgoingQueue::new();
606        let room = room_id();
607
608        // A Megolm ratchet advance happens just before the send is minted.
609        store.save_outbound_group_session(&room, b"ratchet-v1".to_vec()).expect("save succeeds");
610
611        let send_req = OutgoingRequest::room_send(
612            RequestId::next(1),
613            &room,
614            "m.room.encrypted",
615            &TxnId::new(0),
616            serde_json::json!({ "ciphertext": "..." }),
617        );
618        let batch = queue
619            .enqueue(&mut store, send_req.clone(), Lane::Room(room.clone()))
620            .expect("enqueue succeeds")
621            .expect("the pending ratchet advance was sealed into a batch");
622
623        assert!(queue.releasable(&store, 0).is_empty(), "blocked: the sealed batch is not yet acked");
624
625        store.ack_flush(batch.id);
626        assert_eq!(queue.releasable(&store, 0), vec![send_req]);
627    }
628
629    #[test]
630    fn to_device_lane_is_strictly_ordered_across_retries() {
631        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
632        let mut queue = OutgoingQueue::new();
633
634        let first = OutgoingRequest::send_to_device(
635            RequestId::next(0),
636            "m.room.encrypted",
637            &TxnId::new(0),
638            serde_json::json!({}),
639        );
640        let second = OutgoingRequest::send_to_device(
641            RequestId::next(1),
642            "m.room.encrypted",
643            &TxnId::new(1),
644            serde_json::json!({}),
645        );
646        queue.enqueue(&mut store, first.clone(), Lane::ToDevice).expect("enqueue first");
647        queue.enqueue(&mut store, second.clone(), Lane::ToDevice).expect("enqueue second");
648
649        assert_eq!(queue.releasable(&store, 0), vec![first.clone()], "only the oldest is released");
650        assert!(queue.releasable(&store, 0).is_empty(), "the lane is occupied while the first is in flight");
651
652        let mut jitter = FixedJitter(0.0);
653        let outcome = queue.on_transport_error(&first.id, 0, &mut jitter).expect("pending");
654        let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
655
656        assert!(queue.releasable(&store, 0).is_empty(), "back off before retrying");
657        assert_eq!(
658            queue.releasable(&store, after_ms as i64),
659            vec![first],
660            "the SAME request is retried before the second ever runs"
661        );
662    }
663
664    #[test]
665    fn room_lane_preserves_send_order() {
666        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
667        let mut queue = OutgoingQueue::new();
668        let room = room_id();
669
670        let first = OutgoingRequest::room_send(
671            RequestId::next(0),
672            &room,
673            "m.room.message",
674            &TxnId::new(0),
675            serde_json::json!({ "body": "a" }),
676        );
677        let second = OutgoingRequest::room_send(
678            RequestId::next(1),
679            &room,
680            "m.room.message",
681            &TxnId::new(1),
682            serde_json::json!({ "body": "b" }),
683        );
684        queue.enqueue(&mut store, first.clone(), Lane::Room(room.clone())).expect("enqueue first");
685        // Enqueuing `second` right behind `first`, with nothing acked yet
686        // in between, forces `first`'s own not-yet-batched pending-request
687        // record into a batch that becomes `second`'s own required epoch
688        // (see the module doc's "Flush epoch integration" section) -- ack
689        // it, exactly as a shell's normal flush tick would.
690        let batch = queue.enqueue(&mut store, second.clone(), Lane::Room(room.clone())).expect("enqueue second");
691        if let Some(batch) = batch {
692            store.ack_flush(batch.id);
693        }
694
695        assert_eq!(queue.releasable(&store, 0), vec![first.clone()], "only the oldest send is released");
696
697        let resp = HttpResponseDescriptor { status: 200, body: b"{}".to_vec() };
698        let outcome = queue
699            .on_response(&mut store, &first.id, resp, 0, &mut FixedJitter(0.0))
700            .expect("no store error")
701            .expect("pending");
702        assert!(matches!(outcome, ResponseOutcome::Done(_)));
703
704        assert_eq!(queue.releasable(&store, 0), vec![second], "second is released only after the first completes");
705    }
706
707    #[test]
708    fn retry_resends_identical_bytes() {
709        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
710        let mut queue = OutgoingQueue::new();
711
712        let request = OutgoingRequest::room_send(
713            RequestId::next(0),
714            &room_id(),
715            "m.room.encrypted",
716            &TxnId::new(0),
717            serde_json::json!({ "ciphertext": "same-bytes" }),
718        );
719        queue.enqueue(&mut store, request.clone(), Lane::Room(room_id())).expect("enqueue");
720
721        assert_eq!(queue.releasable(&store, 0), vec![request.clone()]);
722
723        let resp = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
724        let outcome = queue
725            .on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.0))
726            .expect("no error")
727            .expect("pending");
728        let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
729
730        assert_eq!(
731            queue.releasable(&store, after_ms as i64),
732            vec![request],
733            "the retry carries the exact same body/txn id -- never re-minted"
734        );
735    }
736
737    #[test]
738    fn rate_limited_response_uses_retry_after_ms() {
739        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
740        let mut queue = OutgoingQueue::new();
741        let request = OutgoingRequest::sync(RequestId::next(0), None, None);
742        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
743        queue.releasable(&store, 0);
744
745        let resp = HttpResponseDescriptor {
746            status: 429,
747            body: br#"{"errcode":"M_LIMIT_EXCEEDED","retry_after_ms":7500}"#.to_vec(),
748        };
749        let outcome = queue
750            .on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.5))
751            .expect("no error")
752            .expect("pending");
753        assert_eq!(outcome, ResponseOutcome::Retry { after_ms: 7500 });
754    }
755
756    #[test]
757    fn four_hundred_class_error_fails_and_deletes_pending() {
758        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
759        let mut queue = OutgoingQueue::new();
760        let request = OutgoingRequest::sync(RequestId::next(0), None, None);
761        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
762        queue.releasable(&store, 0);
763
764        let resp = HttpResponseDescriptor {
765            status: 403,
766            body: br#"{"errcode":"M_FORBIDDEN","error":"nope"}"#.to_vec(),
767        };
768        let outcome = queue
769            .on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.0))
770            .expect("no error")
771            .expect("pending");
772        assert_eq!(outcome, ResponseOutcome::Failed { errcode: "M_FORBIDDEN".to_string(), error: "nope".to_string() });
773        assert!(store.pending_requests().expect("no error").is_empty(), "the pending record was deleted");
774        assert!(queue.releasable(&store, 0).is_empty(), "nothing left to release");
775    }
776
777    #[test]
778    fn backoff_is_capped() {
779        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
780        let mut queue = OutgoingQueue::new();
781        let request = OutgoingRequest::sync(RequestId::next(0), None, None);
782        queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
783
784        let mut jitter = FixedJitter(1.0); // maximum jitter -- exercises the upper edge
785        let mut now = 0i64;
786        let mut last_after_ms = 0u64;
787        for _ in 0..10 {
788            queue.releasable(&store, now);
789            let resp = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
790            let outcome = queue
791                .on_response(&mut store, &request.id, resp, now, &mut jitter)
792                .expect("no error")
793                .expect("pending");
794            let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
795            assert!(after_ms <= BACKOFF_CAP_MS, "backoff never exceeds the cap: got {after_ms}");
796            last_after_ms = after_ms;
797            now += after_ms as i64;
798        }
799        assert_eq!(last_after_ms, BACKOFF_CAP_MS, "after enough retries, backoff saturates at the cap");
800    }
801
802    fn account_data_request(seed: u64) -> OutgoingRequest {
803        let user = crate::ids::UserId::parse("@alice:example.org").expect("valid user id");
804        OutgoingRequest::account_data(RequestId::next(seed), &user, "org.example.t", serde_json::json!({ "n": seed }))
805    }
806
807    #[test]
808    fn account_data_lane_holds_followers_behind_a_head_that_is_backing_off() {
809        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
810        let mut queue = OutgoingQueue::new();
811        let requests: Vec<OutgoingRequest> = (0..3).map(account_data_request).collect();
812        for request in &requests {
813            if let Some(batch) = queue.enqueue(&mut store, request.clone(), Lane::AccountData).expect("enqueue") {
814                store.ack_flush(batch.id);
815            }
816        }
817
818        assert_eq!(queue.releasable(&store, 0), vec![requests[0].clone()], "only the head is released");
819        assert!(queue.releasable(&store, 0).is_empty(), "nothing behind an in-flight head");
820
821        let mut jitter = FixedJitter(0.0);
822        let unavailable = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
823        let outcome = queue.on_response(&mut store, &requests[0].id, unavailable, 0, &mut jitter).expect("no error");
824        assert!(matches!(outcome, Some(ResponseOutcome::Retry { .. })));
825        assert!(
826            queue.releasable(&store, 0).is_empty(),
827            "followers never overtake a head that is only backing off before its retry"
828        );
829
830        assert_eq!(queue.releasable(&store, 10_000), vec![requests[0].clone()], "the head retries once its backoff elapsed");
831        let ok = HttpResponseDescriptor { status: 200, body: b"{}".to_vec() };
832        queue.on_response(&mut store, &requests[0].id, ok, 10_000, &mut jitter).expect("no error");
833        assert_eq!(queue.releasable(&store, 10_000), vec![requests[1].clone()], "FIFO order continues");
834    }
835
836    #[test]
837    fn account_data_requests_persisted_on_the_other_lane_load_into_the_account_data_lane() {
838        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
839        let mut queue = OutgoingQueue::new();
840        let mut records = Vec::new();
841        for seed in 0..2 {
842            if let Some(batch) = queue.enqueue(&mut store, account_data_request(seed), Lane::Other).expect("enqueue") {
843                records.extend(batch.records);
844            }
845        }
846        records.extend(store.take_flush_batch().expect("the last record is still dirty").records);
847
848        let reloaded = Store::load(records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
849        let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
850        assert_eq!(
851            restored.releasable(&reloaded, 0),
852            vec![account_data_request(0)],
853            "a pre-AccountData-lane record keeps its relative order after an upgrade"
854        );
855    }
856}