1#[cfg(feature = "e2e-encryption")]
37mod decrypt;
38mod extract;
39mod map;
40mod task;
41
42#[cfg(feature = "e2e-encryption")]
43use std::collections::BTreeSet;
44use std::sync::{Arc, Mutex, OnceLock, Weak};
45
46use matrix_sdk_common::{
47 deserialized_responses::{EncryptionInfo, TimelineEventKind},
48 executor::AbortOnDrop,
49};
50use ruma::{
51 MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId,
52 events::{AnySyncTimelineEvent, TimelineEventType},
53 serde::Raw,
54};
55use tokio::sync::{Notify, broadcast};
56use tracing::{debug, warn};
57
58#[cfg(feature = "e2e-encryption")]
59pub(crate) use self::decrypt::{Decryption, decrypt, spawn_redecryptor};
60use self::map::EphemeralMap;
61pub(crate) use self::{
62 extract::{Payload, StickyMeta, classify, resolve},
63 map::Candidate,
64};
65
66const MAX_PENDING: usize = 5000;
70
71const UPDATES_CHANNEL_CAPACITY: usize = 32;
73
74#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79pub struct StickyKey {
80 pub sender: OwnedUserId,
82 pub event_type: TimelineEventType,
84 pub sticky_key: String,
86}
87
88#[derive(Clone, Debug)]
90pub struct StickyEvent {
91 pub key: StickyKey,
93 pub event_id: OwnedEventId,
95 pub kind: TimelineEventKind,
98 pub expires_at: MilliSecondsSinceUnixEpoch,
100}
101
102impl StickyEvent {
103 pub fn raw(&self) -> &Raw<AnySyncTimelineEvent> {
105 self.kind.raw()
106 }
107
108 pub fn encryption_info(&self) -> Option<&Arc<EncryptionInfo>> {
110 self.kind.encryption_info()
111 }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
117pub enum RemovalReason {
118 Expired,
120 Replaced,
123 RoomLeft,
125}
126
127#[derive(Clone, Debug, Default)]
132pub struct StickyEventsUpdate {
133 pub added: Vec<StickyEvent>,
135 pub updated: Vec<StickyEvent>,
137 pub removed: Vec<(StickyKey, RemovalReason)>,
139}
140
141impl StickyEventsUpdate {
142 pub fn is_empty(&self) -> bool {
144 self.added.is_empty() && self.updated.is_empty() && self.removed.is_empty()
145 }
146}
147
148#[derive(Debug)]
153pub(crate) struct PendingEvent {
154 pub meta: StickyMeta,
156 #[cfg_attr(not(feature = "e2e-encryption"), allow(dead_code))]
158 pub event: Raw<AnySyncTimelineEvent>,
159}
160
161impl PendingEvent {
162 fn session_id(&self) -> Option<&str> {
164 match &self.meta.payload {
165 Payload::Encrypted { session_id } => session_id.as_deref(),
166 Payload::Plain { .. } => None,
167 }
168 }
169}
170
171#[derive(Clone, Debug)]
176pub struct StickyEvents {
177 inner: Arc<StickyEventsInner>,
178}
179
180#[derive(Debug)]
181struct StickyEventsInner {
182 room_id: OwnedRoomId,
184 state: Mutex<State>,
186 updates: broadcast::Sender<StickyEventsUpdate>,
188 changed: Arc<Notify>,
191 task: OnceLock<AbortOnDrop<()>>,
195}
196
197#[derive(Debug, Default)]
199struct State {
200 map: EphemeralMap,
202 pending: Vec<PendingEvent>,
204}
205
206impl StickyEvents {
207 pub(crate) fn new(room_id: OwnedRoomId) -> Self {
208 let (updates, _) = broadcast::channel(UPDATES_CHANNEL_CAPACITY);
209
210 Self {
211 inner: Arc::new(StickyEventsInner {
212 room_id,
213 state: Default::default(),
214 updates,
215 changed: Default::default(),
216 task: OnceLock::new(),
217 }),
218 }
219 }
220
221 pub fn room_id(&self) -> &RoomId {
223 &self.inner.room_id
224 }
225
226 pub fn live(&self) -> Vec<StickyEvent> {
228 self.inner.state.lock().unwrap().map.live(now_ms()).collect()
229 }
230
231 pub fn subscribe(&self) -> broadcast::Receiver<StickyEventsUpdate> {
237 self.inner.updates.subscribe()
238 }
239
240 pub(crate) fn ingest(&self, now: u64, candidates: Vec<Candidate>) {
242 if candidates.is_empty() {
243 return;
244 }
245
246 self.ensure_task();
247
248 let update = self.inner.state.lock().unwrap().map.apply(now, candidates);
249 self.inner.changed.notify_one();
250 self.publish(update);
251 }
252
253 pub(crate) fn park(&self, pending: Vec<PendingEvent>) {
255 if pending.is_empty() {
256 return;
257 }
258
259 self.ensure_task();
260
261 {
262 let mut state = self.inner.state.lock().unwrap();
263
264 for event in pending {
265 if event.session_id().is_none() {
266 debug!(
267 room_id = %self.inner.room_id,
268 event_id = %event.meta.event_id,
269 "Dropping an encrypted sticky event without a session ID"
270 );
271 continue;
272 }
273
274 state.pending.push(event);
275 }
276
277 if state.pending.len() > MAX_PENDING {
278 warn!(
279 room_id = %self.inner.room_id,
280 "Too many encrypted sticky events await their room key, dropping the oldest"
281 );
282 let excess = state.pending.len() - MAX_PENDING;
283 state.pending.drain(..excess);
284 }
285 }
286
287 self.inner.changed.notify_one();
288 }
289
290 #[cfg(feature = "e2e-encryption")]
293 pub(crate) fn take_pending(&self, session_ids: Option<&BTreeSet<String>>) -> Vec<PendingEvent> {
294 let mut state = self.inner.state.lock().unwrap();
295
296 match session_ids {
297 None => std::mem::take(&mut state.pending),
298 Some(session_ids) => {
299 let (taken, kept) =
300 std::mem::take(&mut state.pending).into_iter().partition(|event| {
301 event.session_id().is_some_and(|id| session_ids.contains(id))
302 });
303 state.pending = kept;
304 taken
305 }
306 }
307 }
308
309 #[cfg(feature = "e2e-encryption")]
311 pub(crate) fn has_pending(&self) -> bool {
312 !self.inner.state.lock().unwrap().pending.is_empty()
313 }
314
315 pub(crate) fn clear(&self) {
317 let removed = {
318 let mut state = self.inner.state.lock().unwrap();
319 state.pending.clear();
320 state.map.clear(now_ms())
321 };
322
323 self.publish(StickyEventsUpdate {
324 removed: removed.into_iter().map(|key| (key, RemovalReason::RoomLeft)).collect(),
325 ..Default::default()
326 });
327 }
328
329 fn publish(&self, update: StickyEventsUpdate) {
330 if !update.is_empty() {
331 let _ = self.inner.updates.send(update);
333 }
334 }
335
336 fn ensure_task(&self) {
337 self.inner
338 .task
339 .get_or_init(|| task::spawn(Arc::downgrade(&self.inner), self.inner.changed.clone()));
340 }
341}
342
343impl StickyEventsInner {
344 fn expire(&self, now: u64) -> Option<u64> {
347 let (removed, next) = {
348 let mut state = self.state.lock().unwrap();
349
350 let removed = state.map.evict_expired(now);
351 state.pending.retain(|event| event.meta.expires_at > now);
352
353 let next_pending = state.pending.iter().map(|event| event.meta.expires_at).min();
354 let next = match (state.map.next_expiry(), next_pending) {
355 (Some(a), Some(b)) => Some(a.min(b)),
356 (a, b) => a.or(b),
357 };
358
359 (removed, next)
360 };
361
362 if !removed.is_empty() {
363 let _ = self.updates.send(StickyEventsUpdate {
364 removed: removed.into_iter().map(|key| (key, RemovalReason::Expired)).collect(),
365 ..Default::default()
366 });
367 }
368
369 next
370 }
371}
372
373pub(crate) fn now_ms() -> u64 {
375 MilliSecondsSinceUnixEpoch::now().get().into()
376}
377
378type WeakStickyEvents = Weak<StickyEventsInner>;
381
382#[cfg(test)]
383mod tests {
384 use std::time::Duration;
385
386 use matrix_sdk_common::deserialized_responses::TimelineEventKind;
387 use matrix_sdk_test::async_test;
388 use ruma::{
389 events::{AnySyncTimelineEvent, TimelineEventType},
390 owned_event_id, owned_user_id, room_id,
391 serde::Raw,
392 };
393 use serde_json::json;
394 use strass::assert_let;
395 use tokio::sync::broadcast::error::TryRecvError;
396
397 use super::{
398 Candidate, PendingEvent, RemovalReason, StickyEvents, StickyKey, StickyMeta, now_ms,
399 };
400 use crate::sticky::Payload;
401
402 fn raw_event() -> Raw<AnySyncTimelineEvent> {
403 serde_json::from_value(json!({
404 "type": "m.rtc.member",
405 "sender": "@alice:localhost",
406 "event_id": "$a:localhost",
407 "origin_server_ts": 1,
408 "content": { "msc4354_sticky_key": "slot" },
409 }))
410 .unwrap()
411 }
412
413 fn candidate(sticky_key: &str, expires_at: u64) -> Candidate {
414 Candidate {
415 key: StickyKey {
416 sender: owned_user_id!("@alice:localhost"),
417 event_type: TimelineEventType::from("m.rtc.member"),
418 sticky_key: sticky_key.to_owned(),
419 },
420 event_id: owned_event_id!("$a:localhost"),
421 order_ts: expires_at,
422 expires_at,
423 is_tombstone: false,
424 kind: TimelineEventKind::PlainText { event: raw_event() },
425 }
426 }
427
428 fn pending(session_id: Option<&str>, expires_at: u64) -> PendingEvent {
429 PendingEvent {
430 meta: StickyMeta {
431 sender: owned_user_id!("@alice:localhost"),
432 event_id: owned_event_id!("$a:localhost"),
433 order_ts: expires_at,
434 expires_at,
435 payload: Payload::Encrypted { session_id: session_id.map(ToOwned::to_owned) },
436 },
437 event: raw_event(),
438 }
439 }
440
441 #[async_test]
442 async fn test_ingest_broadcasts_and_exposes_live_events() {
443 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
444 let mut subscriber = sticky.subscribe();
445
446 let now = now_ms();
447 sticky.ingest(now, vec![candidate("slot", now + 60_000)]);
448
449 let live = sticky.live();
450 assert_eq!(live.len(), 1);
451 assert_eq!(live[0].key.sticky_key, "slot");
452 assert!(live[0].encryption_info().is_none());
453
454 let update = subscriber.try_recv().unwrap();
455 assert_eq!(update.added.len(), 1);
456 assert_eq!(update.added[0].key.sticky_key, "slot");
457 }
458
459 #[async_test]
460 async fn test_expired_events_are_evicted_in_the_background() {
461 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
462 let mut subscriber = sticky.subscribe();
463
464 let now = now_ms();
465 sticky.ingest(now, vec![candidate("slot", now + 50)]);
466 assert_let!(Ok(update) = subscriber.try_recv());
467 assert_eq!(update.added.len(), 1);
468
469 let update = tokio::time::timeout(Duration::from_secs(5), subscriber.recv())
472 .await
473 .expect("the expiry should be broadcast in time")
474 .unwrap();
475 assert_let!([(key, RemovalReason::Expired)] = update.removed.as_slice());
476 assert_eq!(key.sticky_key, "slot");
477
478 assert!(sticky.live().is_empty());
479 }
480
481 #[async_test]
482 async fn test_clear_broadcasts_removals() {
483 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
484 let mut subscriber = sticky.subscribe();
485
486 let now = now_ms();
487 sticky.ingest(now, vec![candidate("slot", now + 60_000)]);
488 sticky.park(vec![pending(Some("session"), now + 60_000)]);
489 let _ = subscriber.try_recv().unwrap();
490
491 sticky.clear();
492
493 let update = subscriber.try_recv().unwrap();
494 assert_let!([(key, RemovalReason::RoomLeft)] = update.removed.as_slice());
495 assert_eq!(key.sticky_key, "slot");
496 assert!(sticky.live().is_empty());
497 assert_let!(Err(TryRecvError::Empty) = subscriber.try_recv());
498 }
499
500 #[cfg(feature = "e2e-encryption")]
501 #[async_test]
502 async fn test_pending_events_are_taken_by_session_id() {
503 use std::collections::BTreeSet;
504
505 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
506
507 let now = now_ms();
508 sticky.park(vec![
509 pending(Some("session1"), now + 60_000),
510 pending(Some("session2"), now + 60_000),
511 pending(None, now + 60_000),
513 ]);
514
515 let taken = sticky.take_pending(Some(&BTreeSet::from(["session2".to_owned()])));
516 assert_eq!(taken.len(), 1);
517 assert_let!(Payload::Encrypted { session_id } = &taken[0].meta.payload);
518 assert_eq!(session_id.as_deref(), Some("session2"));
519
520 assert!(sticky.has_pending());
521 let taken = sticky.take_pending(None);
522 assert_eq!(taken.len(), 1);
523 assert!(!sticky.has_pending());
524 }
525
526 #[cfg(feature = "e2e-encryption")]
527 #[async_test]
528 async fn test_pending_events_are_capped() {
529 use std::collections::BTreeSet;
530
531 use super::MAX_PENDING;
532
533 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
534
535 let now = now_ms();
536 let mut events = vec![pending(Some("oldest"), now + 60_000)];
538 events.extend((0..MAX_PENDING).map(|_| pending(Some("newer"), now + 60_000)));
539 sticky.park(events);
540
541 assert!(sticky.take_pending(Some(&BTreeSet::from(["oldest".to_owned()]))).is_empty());
543 assert_eq!(sticky.take_pending(None).len(), MAX_PENDING);
544 }
545
546 #[cfg(feature = "e2e-encryption")]
547 #[async_test]
548 async fn test_expired_pending_events_are_pruned_in_the_background() {
549 let sticky = StickyEvents::new(room_id!("!room:localhost").to_owned());
550
551 sticky.park(vec![pending(Some("session"), now_ms() + 50)]);
552 assert!(sticky.has_pending());
553
554 tokio::time::timeout(Duration::from_secs(5), async {
555 while sticky.has_pending() {
556 tokio::time::sleep(Duration::from_millis(20)).await;
557 }
558 })
559 .await
560 .expect("the pending event should be pruned in time");
561 }
562}