1use std::{
16 collections::BTreeMap,
17 sync::{
18 Arc, OnceLock,
19 atomic::{self, AtomicBool},
20 },
21};
22
23use matrix_sdk_base::{
24 StateStoreDataKey, StateStoreDataValue, ThreadSubscriptionCatchupToken,
25 store::{StoredThreadSubscription, ThreadSubscriptionStatus},
26 task_monitor::BackgroundTaskHandle,
27};
28use ruma::{
29 EventId, OwnedEventId, OwnedRoomId, RoomId,
30 api::client::threads::get_thread_subscriptions_changes::unstable::{
31 ThreadSubscription, ThreadUnsubscription,
32 },
33 assign,
34};
35use tokio::sync::{Mutex, Notify, OwnedMutexGuard};
36use tracing::{debug, instrument, trace, warn};
37
38use crate::{Client, Result, client::WeakClient};
39
40struct GuardedStoreAccess {
41 _mutex: OwnedMutexGuard<()>,
42 client: Client,
43 is_outdated: Arc<AtomicBool>,
44}
45
46impl GuardedStoreAccess {
47 async fn load_catchup_tokens(&self) -> Result<Option<Vec<ThreadSubscriptionCatchupToken>>> {
51 let loaded = self
52 .client
53 .state_store()
54 .get_kv_data(StateStoreDataKey::ThreadSubscriptionsCatchupTokens)
55 .await?;
56
57 match loaded {
58 Some(data) => {
59 if let Some(tokens) = data.into_thread_subscriptions_catchup_tokens() {
60 if tokens.is_empty() {
62 self.save_catchup_tokens(tokens).await?;
63 Ok(None)
64 } else {
65 Ok(Some(tokens))
66 }
67 } else {
68 warn!(
69 "invalid data in thread subscriptions catchup tokens state store k/v entry"
70 );
71 Ok(None)
72 }
73 }
74
75 None => Ok(None),
76 }
77 }
78
79 #[instrument(skip_all, fields(num_tokens = tokens.len()))]
81 async fn save_catchup_tokens(&self, tokens: Vec<ThreadSubscriptionCatchupToken>) -> Result<()> {
82 let store = self.client.state_store();
83 if tokens.is_empty() {
84 store.remove_kv_data(StateStoreDataKey::ThreadSubscriptionsCatchupTokens).await?;
85
86 trace!("Marking thread subscriptions as not outdated \\o/");
87 self.is_outdated.store(false, atomic::Ordering::SeqCst);
88 } else {
89 store
90 .set_kv_data(
91 StateStoreDataKey::ThreadSubscriptionsCatchupTokens,
92 StateStoreDataValue::ThreadSubscriptionsCatchupTokens(tokens),
93 )
94 .await?;
95
96 trace!("Marking thread subscriptions as outdated.");
97 self.is_outdated.store(true, atomic::Ordering::SeqCst);
98 }
99
100 Ok(())
101 }
102}
103
104pub struct ThreadSubscriptionCatchup {
105 _task: OnceLock<BackgroundTaskHandle>,
107
108 is_outdated: Arc<AtomicBool>,
111
112 client: WeakClient,
114
115 ping: Arc<Notify>,
118
119 uniq_mutex: Arc<Mutex<()>>,
122}
123
124impl ThreadSubscriptionCatchup {
125 pub async fn new(client: Client) -> Arc<Self> {
126 let is_outdated = Arc::new(AtomicBool::new(true));
127 let weak_client = WeakClient::from_client(&client);
128 let ping = Arc::new(Notify::new());
129 let task_ping = Arc::clone(&ping);
130 let uniq_mutex = Arc::new(Mutex::new(()));
131
132 let this = Arc::new(Self {
133 _task: OnceLock::new(),
134 is_outdated,
135 client: weak_client.clone(),
136 ping,
137 uniq_mutex,
138 });
139
140 match client.enabled_thread_subscriptions().await {
143 Ok(true) => {
144 let _ = this._task.get_or_init(|| {
145 let that = this.clone();
146
147 client
148 .task_monitor()
149 .spawn_infinite_task("client::thread_subscriptions_catchup", async move {
150 Self::thread_subscriptions_catchup_task(that, task_ping).await;
151 })
152 .abort_on_drop()
153 });
154 }
155
156 Ok(false) => {
157 debug!("Thread subscriptions catchup not enabled, not starting the catchup task");
158 }
159
160 Err(err) => {
161 warn!("Failed to check if thread subscriptions catchup is enabled: {err}");
162 }
163 }
164
165 this
166 }
167
168 pub(crate) fn is_outdated(&self) -> bool {
171 self.is_outdated.load(atomic::Ordering::SeqCst)
172 }
173
174 #[instrument(skip_all)]
177 pub(crate) async fn sync_subscriptions(
178 &self,
179 subscribed: BTreeMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadSubscription>>,
180 unsubscribed: BTreeMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadUnsubscription>>,
181 token: Option<ThreadSubscriptionCatchupToken>,
182 ) -> Result<()> {
183 let updates = build_subscription_updates(&subscribed, &unsubscribed);
185 let Some(guard) = self.lock().await else {
186 return Ok(());
188 };
189 self.save_catchup_token(&guard, token).await?;
190 if !updates.is_empty() {
191 trace!(
192 "saving {} new subscriptions and {} unsubscriptions",
193 subscribed.values().map(|by_room| by_room.len()).sum::<usize>(),
194 unsubscribed.values().map(|by_room| by_room.len()).sum::<usize>(),
195 );
196 guard.client.state_store().upsert_thread_subscriptions(updates).await?;
197 }
198 Ok(())
199 }
200
201 async fn lock(&self) -> Option<GuardedStoreAccess> {
204 let client = self.client.get()?;
205 let mutex_guard = self.uniq_mutex.clone().lock_owned().await;
206 Some(GuardedStoreAccess {
207 _mutex: mutex_guard,
208 client,
209 is_outdated: self.is_outdated.clone(),
210 })
211 }
212
213 async fn save_catchup_token(
215 &self,
216 guard: &GuardedStoreAccess,
217 token: Option<ThreadSubscriptionCatchupToken>,
218 ) -> Result<()> {
219 let mut tokens = guard.load_catchup_tokens().await?.unwrap_or_default();
222
223 if let Some(token) = token {
224 if tokens.contains(&token) {
227 trace!(?token, "Skipping duplicate catchup token");
228 } else {
229 trace!(?token, "Saving catchup token");
230 tokens.push(token);
231
232 guard.save_catchup_tokens(tokens).await?;
233
234 self.ping.notify_one();
236 }
237 } else {
238 trace!("No catchup token to save");
239 }
240
241 Ok(())
242 }
243
244 #[instrument(skip_all)]
260 async fn thread_subscriptions_catchup_task(this: Arc<Self>, ping: Arc<Notify>) {
261 loop {
262 let Some(guard) = this.lock().await else {
264 return;
266 };
267
268 let store_tokens = match guard.load_catchup_tokens().await {
269 Ok(tokens) => tokens,
270 Err(err) => {
271 warn!("Failed to load thread subscriptions catchup tokens: {err}");
272 continue;
273 }
274 };
275
276 let Some(mut tokens) = store_tokens else {
277 drop(guard);
279
280 trace!("Waiting for an explicit wake up to process future thread subscriptions");
282 ping.notified().await;
283 trace!("Woke up!");
284 continue;
285 };
286
287 let last = tokens.pop().expect("must be set per `load_catchup_tokens` contract");
289
290 let client = guard.client.clone();
292 drop(guard);
293
294 let req = assign!(ruma::api::client::threads::get_thread_subscriptions_changes::unstable::Request::new(), {
296 from: Some(last.from.clone()),
297 to: last.to.clone(),
298 });
299
300 match client.send(req).await {
301 Ok(resp) => {
302 let updates = build_subscription_updates(&resp.subscribed, &resp.unsubscribed);
304
305 let guard = this
306 .lock()
307 .await
308 .expect("a client instance is alive, so the locking should not fail");
309
310 if !updates.is_empty() {
311 trace!(
312 "saving {} new subscriptions and {} unsubscriptions",
313 resp.subscribed.values().map(|by_room| by_room.len()).sum::<usize>(),
314 resp.unsubscribed.values().map(|by_room| by_room.len()).sum::<usize>(),
315 );
316
317 if let Err(err) =
318 guard.client.state_store().upsert_thread_subscriptions(updates).await
319 {
320 warn!("Failed to store caught up thread subscriptions: {err}");
321 continue;
322 }
323 }
324
325 let mut tokens = match guard.load_catchup_tokens().await {
328 Ok(tokens) => tokens.unwrap_or_default(),
329 Err(err) => {
330 warn!("Failed to load thread subscriptions catchup tokens: {err}");
331 continue;
332 }
333 };
334
335 let Some(index) = tokens.iter().position(|t| *t == last) else {
336 warn!("Thread subscriptions catchup token disappeared while processing it");
337 continue;
338 };
339
340 if let Some(next_batch) = resp.end {
341 tokens[index] =
344 ThreadSubscriptionCatchupToken { from: next_batch, to: last.to };
345 } else {
346 tokens.remove(index);
348 }
349
350 if let Err(err) = guard.save_catchup_tokens(tokens).await {
351 warn!("Failed to save updated thread subscriptions catchup tokens: {err}");
352 }
353 }
354
355 Err(err) => {
356 warn!("Failed to catch up thread subscriptions: {err}");
357 }
358 }
359 }
360 }
361}
362
363fn build_subscription_updates<'a>(
365 subscribed: &'a BTreeMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadSubscription>>,
366 unsubscribed: &'a BTreeMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadUnsubscription>>,
367) -> Vec<(&'a RoomId, &'a EventId, StoredThreadSubscription)> {
368 let mut updates: Vec<(&RoomId, &EventId, StoredThreadSubscription)> =
369 Vec::with_capacity(unsubscribed.len() + subscribed.len());
370
371 for (room_id, room_map) in unsubscribed {
373 for (event_id, thread_sub) in room_map {
374 updates.push((
375 room_id,
376 event_id,
377 StoredThreadSubscription {
378 status: ThreadSubscriptionStatus::Unsubscribed,
379 bump_stamp: Some(thread_sub.bump_stamp.into()),
380 },
381 ));
382 }
383 }
384
385 for (room_id, room_map) in subscribed {
387 for (event_id, thread_sub) in room_map {
388 updates.push((
389 room_id,
390 event_id,
391 StoredThreadSubscription {
392 status: ThreadSubscriptionStatus::Subscribed {
393 automatic: thread_sub.automatic,
394 },
395 bump_stamp: Some(thread_sub.bump_stamp.into()),
396 },
397 ));
398 }
399 }
400
401 updates
402}
403
404#[cfg(test)]
405mod tests {
406 use std::ops::Not as _;
407
408 use matrix_sdk_base::ThreadSubscriptionCatchupToken;
409 use matrix_sdk_test::async_test;
410
411 use crate::test_utils::client::MockClientBuilder;
412
413 #[async_test]
414 async fn test_load_save_catchup_tokens() {
415 let client = MockClientBuilder::new(None).build().await;
416
417 let tsc = client.thread_subscription_catchup();
418
419 let guard = tsc.lock().await.unwrap();
421 assert!(guard.load_catchup_tokens().await.unwrap().is_none());
422 assert!(tsc.is_outdated());
423
424 let token =
426 ThreadSubscriptionCatchupToken { from: "from".to_owned(), to: Some("to".to_owned()) };
427 guard.save_catchup_tokens(vec![token.clone()]).await.unwrap();
428
429 let tokens = guard.load_catchup_tokens().await.unwrap();
431 assert_eq!(tokens, Some(vec![token]));
432
433 assert!(tsc.is_outdated());
435
436 guard.save_catchup_tokens(vec![]).await.unwrap();
438
439 assert!(guard.load_catchup_tokens().await.unwrap().is_none());
441
442 assert!(tsc.is_outdated().not());
444 }
445
446 #[async_test]
447 async fn test_save_catchup_token_deduplicates() {
448 let client = MockClientBuilder::new(None).build().await;
449
450 let tsc = client.thread_subscription_catchup();
451 let guard = tsc.lock().await.unwrap();
452
453 let token =
454 ThreadSubscriptionCatchupToken { from: "from".to_owned(), to: Some("to".to_owned()) };
455
456 tsc.save_catchup_token(&guard, Some(token.clone())).await.unwrap();
458 tsc.save_catchup_token(&guard, Some(token.clone())).await.unwrap();
459
460 let tokens = guard.load_catchup_tokens().await.unwrap();
462 assert_eq!(tokens, Some(vec![token]));
463 }
464}
465
466#[cfg(all(test, not(target_family = "wasm")))]
467mod timed_tests {
468 use std::time::Duration;
469
470 use matrix_sdk_base::{ThreadingSupport, sleep::sleep};
471 use matrix_sdk_test::async_test;
472 use tokio::task::yield_now;
473
474 use crate::{client::WeakClient, test_utils::mocks::MatrixMockServer};
475
476 #[async_test]
477 async fn test_issue_6573_client_can_drop_thread_subscriptions_task() {
478 let server = MatrixMockServer::new().await;
479 server.mock_versions().with_thread_subscriptions().ok().mount().await;
480
481 let client = server
482 .client_builder()
483 .no_server_versions()
484 .on_builder(|builder| {
485 builder
486 .with_threading_support(ThreadingSupport::Enabled { with_subscriptions: true })
487 })
488 .build()
489 .await;
490
491 let tsc = client.thread_subscription_catchup();
492
493 yield_now().await;
495
496 assert!(tsc._task.get().is_some());
498
499 let weak_client = WeakClient::from_client(&client);
501
502 drop(client);
504
505 sleep(Duration::from_secs(2)).await;
507
508 assert_eq!(weak_client.strong_count(), 0);
510 }
511}