use crate::ids::{RequestId, RoomId};
use crate::persist::{FlushBatch, FlushEpoch, RequiredSeq};
use crate::store::{StateStore, StoreError};
use crate::wire::{HttpResponseDescriptor, OutgoingRequest, OutgoingRequestKind};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
const OTHER_LANE_CONCURRENCY: usize = 4;
const BACKOFF_BASE_MS: u64 = 1_000;
const BACKOFF_CAP_MS: u64 = 60_000;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Lane {
Sync,
ToDevice,
Room(RoomId),
AccountData,
Other,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PendingRequest {
pub request: OutgoingRequest,
pub lane: Lane,
pub required_seq: RequiredSeq,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ResponseOutcome {
Done(HttpResponseDescriptor),
Retry {
after_ms: u64,
},
Failed {
errcode: String,
error: String,
},
}
pub trait Jitter {
fn next_unit(&mut self) -> f64;
}
fn capped_exponential_backoff_ms(attempt: u32, jitter_unit: f64) -> u64 {
let shift = attempt.min(6);
let base = BACKOFF_BASE_MS.saturating_mul(1u64 << shift).min(BACKOFF_CAP_MS);
let unit = jitter_unit.clamp(0.0, 1.0);
let jittered = (base as f64 * (0.5 + 0.5 * unit)).round() as u64;
jittered.min(BACKOFF_CAP_MS)
}
fn retry_after_ms_from_body(response: &HttpResponseDescriptor) -> Option<u64> {
let value: serde_json::Value = serde_json::from_slice(&response.body).ok()?;
value.get("retry_after_ms")?.as_u64()
}
#[derive(Debug, Default)]
pub struct OutgoingQueue {
order: Vec<RequestId>,
pending: BTreeMap<RequestId, PendingRequest>,
in_flight: BTreeSet<RequestId>,
sync_in_flight: bool,
to_device_in_flight: bool,
room_in_flight: BTreeSet<RoomId>,
other_in_flight: BTreeSet<RequestId>,
attempts: BTreeMap<RequestId, u32>,
retry_not_before_ms: BTreeMap<RequestId, i64>,
}
impl OutgoingQueue {
pub fn new() -> Self {
Self::default()
}
pub fn load<S: StateStore>(store: &S) -> Result<Self, StoreError> {
let mut records = store.pending_requests()?;
records.sort_by(|a, b| a.request.id.cmp(&b.request.id));
let mut queue = Self::new();
for mut record in records {
if record.lane == Lane::Other
&& matches!(record.request.kind, OutgoingRequestKind::AccountData | OutgoingRequestKind::RoomAccountData)
{
record.lane = Lane::AccountData;
}
let id = record.request.id.clone();
queue.order.push(id.clone());
queue.pending.insert(id, record);
}
Ok(queue)
}
pub fn discard_kind<S: StateStore>(&mut self, store: &mut S, kind: OutgoingRequestKind) -> Result<(), StoreError> {
let ids: Vec<RequestId> = self.pending.iter().filter(|(_, record)| record.request.kind == kind).map(|(id, _)| id.clone()).collect();
for id in ids {
self.complete(store, &id)?;
}
Ok(())
}
pub fn enqueue<S: StateStore + FlushEpoch>(
&mut self,
store: &mut S,
request: OutgoingRequest,
lane: Lane,
) -> Result<Option<FlushBatch>, StoreError> {
if self.pending.contains_key(&request.id) {
return Ok(None);
}
let (required_seq, batch) = store.seal_for_request();
let id = request.id.clone();
let record = PendingRequest { request, lane, required_seq };
store.save_pending_request(record.clone())?;
self.pending.insert(id.clone(), record);
self.order.push(id);
Ok(batch)
}
pub fn releasable<S: FlushEpoch>(&mut self, store: &S, now_ms: i64) -> Vec<OutgoingRequest> {
let mut out = Vec::new();
let ids = self.order.clone();
let mut account_data_blocked = false;
for id in ids {
let Some(pending) = self.pending.get(&id) else { continue };
if pending.lane == Lane::AccountData {
if account_data_blocked {
continue;
}
account_data_blocked = true;
}
if self.in_flight.contains(&id) {
continue;
}
if let Some(¬_before) = self.retry_not_before_ms.get(&id) {
if now_ms < not_before {
continue;
}
}
if !store.is_released(pending.required_seq) {
continue;
}
if !self.lane_available(&pending.lane) {
continue;
}
let request = pending.request.clone();
let lane = pending.lane.clone();
self.mark_in_flight(&id, &lane);
out.push(request);
}
out
}
pub fn on_response<S: StateStore>(
&mut self,
store: &mut S,
request_id: &RequestId,
response: HttpResponseDescriptor,
now_ms: i64,
jitter: &mut dyn Jitter,
) -> Result<Option<ResponseOutcome>, StoreError> {
let Some(pending) = self.pending.get(request_id).cloned() else { return Ok(None) };
if (200..300).contains(&response.status) {
self.complete(store, request_id)?;
return Ok(Some(ResponseOutcome::Done(response)));
}
if response.status == 429 {
self.clear_in_flight(request_id, &pending.lane);
let after_ms =
retry_after_ms_from_body(&response).unwrap_or_else(|| self.next_backoff_ms(request_id, jitter));
self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
return Ok(Some(ResponseOutcome::Retry { after_ms }));
}
if (500..600).contains(&response.status) {
self.clear_in_flight(request_id, &pending.lane);
let after_ms = self.next_backoff_ms(request_id, jitter);
self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
return Ok(Some(ResponseOutcome::Retry { after_ms }));
}
let (errcode, error) =
response.matrix_error().unwrap_or_else(|| ("M_UNKNOWN".to_string(), String::new()));
self.complete(store, request_id)?;
Ok(Some(ResponseOutcome::Failed { errcode, error }))
}
pub fn on_transport_error(
&mut self,
request_id: &RequestId,
now_ms: i64,
jitter: &mut dyn Jitter,
) -> Option<ResponseOutcome> {
let pending = self.pending.get(request_id)?.clone();
self.clear_in_flight(request_id, &pending.lane);
let after_ms = self.next_backoff_ms(request_id, jitter);
self.retry_not_before_ms.insert(request_id.clone(), now_ms.saturating_add(after_ms as i64));
Some(ResponseOutcome::Retry { after_ms })
}
fn next_backoff_ms(&mut self, request_id: &RequestId, jitter: &mut dyn Jitter) -> u64 {
let attempt = self.attempts.entry(request_id.clone()).or_insert(0);
let ms = capped_exponential_backoff_ms(*attempt, jitter.next_unit());
*attempt = attempt.saturating_add(1);
ms
}
fn lane_available(&self, lane: &Lane) -> bool {
match lane {
Lane::Sync => !self.sync_in_flight,
Lane::ToDevice => !self.to_device_in_flight,
Lane::Room(room_id) => !self.room_in_flight.contains(room_id),
Lane::AccountData => true,
Lane::Other => self.other_in_flight.len() < OTHER_LANE_CONCURRENCY,
}
}
fn mark_in_flight(&mut self, id: &RequestId, lane: &Lane) {
match lane {
Lane::Sync => self.sync_in_flight = true,
Lane::ToDevice => self.to_device_in_flight = true,
Lane::Room(room_id) => {
self.room_in_flight.insert(room_id.clone());
}
Lane::AccountData => {}
Lane::Other => {
self.other_in_flight.insert(id.clone());
}
}
self.in_flight.insert(id.clone());
}
fn clear_in_flight(&mut self, id: &RequestId, lane: &Lane) {
match lane {
Lane::Sync => self.sync_in_flight = false,
Lane::ToDevice => self.to_device_in_flight = false,
Lane::Room(room_id) => {
self.room_in_flight.remove(room_id);
}
Lane::AccountData => {}
Lane::Other => {
self.other_in_flight.remove(id);
}
}
self.in_flight.remove(id);
}
fn complete<S: StateStore>(&mut self, store: &mut S, id: &RequestId) -> Result<(), StoreError> {
if let Some(pending) = self.pending.remove(id) {
self.clear_in_flight(id, &pending.lane);
self.attempts.remove(id);
self.retry_not_before_ms.remove(id);
self.order.retain(|existing| existing != id);
store.delete_pending_request(id)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{DeviceId, RequestId, RoomId, TxnId};
use crate::store::{CryptoStore, InsecurePlainCodecForTests, Store};
use crate::wire::OutgoingRequest;
struct FixedJitter(f64);
impl Jitter for FixedJitter {
fn next_unit(&mut self) -> f64 {
self.0
}
}
fn device_id() -> DeviceId {
DeviceId::parse("DEV1").expect("valid device id")
}
fn room_id() -> RoomId {
RoomId::parse("!room:example.org").expect("valid room id")
}
#[test]
fn pending_request_survives_a_fresh_core_construction_before_ack() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue succeeds");
let batch = store.take_flush_batch().expect("save_pending_request dirtied a record");
let reloaded =
Store::load(batch.records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
assert_eq!(restored.releasable(&reloaded, 0), vec![request]);
}
#[test]
fn discard_kind_drops_the_restored_sync_but_keeps_other_requests() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let sync = OutgoingRequest::sync(RequestId::next(0), Some("s1"), Some(30_000));
let join = OutgoingRequest::join_room(RequestId::next(1), room_id().as_str());
queue.enqueue(&mut store, sync, Lane::Sync).expect("enqueue sync");
queue.enqueue(&mut store, join.clone(), Lane::Other).expect("enqueue join");
let batch = store.take_flush_batch().expect("pending requests dirtied records");
let mut reloaded = Store::load(batch.records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
restored.discard_kind(&mut reloaded, OutgoingRequestKind::Sync).expect("discard succeeds");
assert_eq!(restored.releasable(&reloaded, 0), vec![join], "only the non-sync request is left");
let stored: Vec<OutgoingRequestKind> = reloaded.pending_requests().expect("no error").iter().map(|record| record.request.kind).collect();
assert_eq!(stored, vec![OutgoingRequestKind::JoinRoom], "the sync record is gone from the store too");
}
#[test]
fn duplicate_dispatch_does_not_double_enqueue() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("first enqueue");
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("duplicate enqueue is a no-op");
assert_eq!(store.pending_requests().expect("no error").len(), 1, "only one record persisted");
assert_eq!(queue.releasable(&store, 0).len(), 1, "only one request queued for release");
}
#[test]
fn sync_is_not_blocked_by_mutations_made_after_it_was_enqueued() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let sync_req = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, sync_req.clone(), Lane::Sync).expect("enqueue sync");
if let Some(batch) = store.take_flush_batch() {
store.ack_flush(batch.id);
}
store.save_account(b"later-mutation".to_vec()).expect("save succeeds");
assert_eq!(
queue.releasable(&store, 0),
vec![sync_req],
"the sync never depended on a mutation made after it was enqueued"
);
}
#[test]
fn send_is_blocked_until_the_flush_containing_its_ratchet_advance_is_acked() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let room = room_id();
store.save_outbound_group_session(&room, b"ratchet-v1".to_vec()).expect("save succeeds");
let send_req = OutgoingRequest::room_send(
RequestId::next(1),
&room,
"m.room.encrypted",
&TxnId::new(0),
serde_json::json!({ "ciphertext": "..." }),
);
let batch = queue
.enqueue(&mut store, send_req.clone(), Lane::Room(room.clone()))
.expect("enqueue succeeds")
.expect("the pending ratchet advance was sealed into a batch");
assert!(queue.releasable(&store, 0).is_empty(), "blocked: the sealed batch is not yet acked");
store.ack_flush(batch.id);
assert_eq!(queue.releasable(&store, 0), vec![send_req]);
}
#[test]
fn to_device_lane_is_strictly_ordered_across_retries() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let first = OutgoingRequest::send_to_device(
RequestId::next(0),
"m.room.encrypted",
&TxnId::new(0),
serde_json::json!({}),
);
let second = OutgoingRequest::send_to_device(
RequestId::next(1),
"m.room.encrypted",
&TxnId::new(1),
serde_json::json!({}),
);
queue.enqueue(&mut store, first.clone(), Lane::ToDevice).expect("enqueue first");
queue.enqueue(&mut store, second.clone(), Lane::ToDevice).expect("enqueue second");
assert_eq!(queue.releasable(&store, 0), vec![first.clone()], "only the oldest is released");
assert!(queue.releasable(&store, 0).is_empty(), "the lane is occupied while the first is in flight");
let mut jitter = FixedJitter(0.0);
let outcome = queue.on_transport_error(&first.id, 0, &mut jitter).expect("pending");
let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
assert!(queue.releasable(&store, 0).is_empty(), "back off before retrying");
assert_eq!(
queue.releasable(&store, after_ms as i64),
vec![first],
"the SAME request is retried before the second ever runs"
);
}
#[test]
fn room_lane_preserves_send_order() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let room = room_id();
let first = OutgoingRequest::room_send(
RequestId::next(0),
&room,
"m.room.message",
&TxnId::new(0),
serde_json::json!({ "body": "a" }),
);
let second = OutgoingRequest::room_send(
RequestId::next(1),
&room,
"m.room.message",
&TxnId::new(1),
serde_json::json!({ "body": "b" }),
);
queue.enqueue(&mut store, first.clone(), Lane::Room(room.clone())).expect("enqueue first");
let batch = queue.enqueue(&mut store, second.clone(), Lane::Room(room.clone())).expect("enqueue second");
if let Some(batch) = batch {
store.ack_flush(batch.id);
}
assert_eq!(queue.releasable(&store, 0), vec![first.clone()], "only the oldest send is released");
let resp = HttpResponseDescriptor { status: 200, body: b"{}".to_vec() };
let outcome = queue
.on_response(&mut store, &first.id, resp, 0, &mut FixedJitter(0.0))
.expect("no store error")
.expect("pending");
assert!(matches!(outcome, ResponseOutcome::Done(_)));
assert_eq!(queue.releasable(&store, 0), vec![second], "second is released only after the first completes");
}
#[test]
fn retry_resends_identical_bytes() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::room_send(
RequestId::next(0),
&room_id(),
"m.room.encrypted",
&TxnId::new(0),
serde_json::json!({ "ciphertext": "same-bytes" }),
);
queue.enqueue(&mut store, request.clone(), Lane::Room(room_id())).expect("enqueue");
assert_eq!(queue.releasable(&store, 0), vec![request.clone()]);
let resp = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
let outcome = queue
.on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.0))
.expect("no error")
.expect("pending");
let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
assert_eq!(
queue.releasable(&store, after_ms as i64),
vec![request],
"the retry carries the exact same body/txn id -- never re-minted"
);
}
#[test]
fn rate_limited_response_uses_retry_after_ms() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
queue.releasable(&store, 0);
let resp = HttpResponseDescriptor {
status: 429,
body: br#"{"errcode":"M_LIMIT_EXCEEDED","retry_after_ms":7500}"#.to_vec(),
};
let outcome = queue
.on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.5))
.expect("no error")
.expect("pending");
assert_eq!(outcome, ResponseOutcome::Retry { after_ms: 7500 });
}
#[test]
fn four_hundred_class_error_fails_and_deletes_pending() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
queue.releasable(&store, 0);
let resp = HttpResponseDescriptor {
status: 403,
body: br#"{"errcode":"M_FORBIDDEN","error":"nope"}"#.to_vec(),
};
let outcome = queue
.on_response(&mut store, &request.id, resp, 0, &mut FixedJitter(0.0))
.expect("no error")
.expect("pending");
assert_eq!(outcome, ResponseOutcome::Failed { errcode: "M_FORBIDDEN".to_string(), error: "nope".to_string() });
assert!(store.pending_requests().expect("no error").is_empty(), "the pending record was deleted");
assert!(queue.releasable(&store, 0).is_empty(), "nothing left to release");
}
#[test]
fn backoff_is_capped() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let request = OutgoingRequest::sync(RequestId::next(0), None, None);
queue.enqueue(&mut store, request.clone(), Lane::Sync).expect("enqueue");
let mut jitter = FixedJitter(1.0); let mut now = 0i64;
let mut last_after_ms = 0u64;
for _ in 0..10 {
queue.releasable(&store, now);
let resp = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
let outcome = queue
.on_response(&mut store, &request.id, resp, now, &mut jitter)
.expect("no error")
.expect("pending");
let ResponseOutcome::Retry { after_ms } = outcome else { panic!("expected a retry") };
assert!(after_ms <= BACKOFF_CAP_MS, "backoff never exceeds the cap: got {after_ms}");
last_after_ms = after_ms;
now += after_ms as i64;
}
assert_eq!(last_after_ms, BACKOFF_CAP_MS, "after enough retries, backoff saturates at the cap");
}
fn account_data_request(seed: u64) -> OutgoingRequest {
let user = crate::ids::UserId::parse("@alice:example.org").expect("valid user id");
OutgoingRequest::account_data(RequestId::next(seed), &user, "org.example.t", serde_json::json!({ "n": seed }))
}
#[test]
fn account_data_lane_holds_followers_behind_a_head_that_is_backing_off() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let requests: Vec<OutgoingRequest> = (0..3).map(account_data_request).collect();
for request in &requests {
if let Some(batch) = queue.enqueue(&mut store, request.clone(), Lane::AccountData).expect("enqueue") {
store.ack_flush(batch.id);
}
}
assert_eq!(queue.releasable(&store, 0), vec![requests[0].clone()], "only the head is released");
assert!(queue.releasable(&store, 0).is_empty(), "nothing behind an in-flight head");
let mut jitter = FixedJitter(0.0);
let unavailable = HttpResponseDescriptor { status: 503, body: b"{}".to_vec() };
let outcome = queue.on_response(&mut store, &requests[0].id, unavailable, 0, &mut jitter).expect("no error");
assert!(matches!(outcome, Some(ResponseOutcome::Retry { .. })));
assert!(
queue.releasable(&store, 0).is_empty(),
"followers never overtake a head that is only backing off before its retry"
);
assert_eq!(queue.releasable(&store, 10_000), vec![requests[0].clone()], "the head retries once its backoff elapsed");
let ok = HttpResponseDescriptor { status: 200, body: b"{}".to_vec() };
queue.on_response(&mut store, &requests[0].id, ok, 10_000, &mut jitter).expect("no error");
assert_eq!(queue.releasable(&store, 10_000), vec![requests[1].clone()], "FIFO order continues");
}
#[test]
fn account_data_requests_persisted_on_the_other_lane_load_into_the_account_data_lane() {
let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
let mut queue = OutgoingQueue::new();
let mut records = Vec::new();
for seed in 0..2 {
if let Some(batch) = queue.enqueue(&mut store, account_data_request(seed), Lane::Other).expect("enqueue") {
records.extend(batch.records);
}
}
records.extend(store.take_flush_batch().expect("the last record is still dirty").records);
let reloaded = Store::load(records, InsecurePlainCodecForTests, device_id()).expect("load succeeds");
let mut restored = OutgoingQueue::load(&reloaded).expect("queue reloads");
assert_eq!(
restored.releasable(&reloaded, 0),
vec![account_data_request(0)],
"a pre-AccountData-lane record keeps its relative order after an upgrade"
);
}
}