Skip to main content

matrix_sdk/client/
thread_subscriptions.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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    /// Return the current list of catchup tokens, if any.
48    ///
49    /// It is guaranteed that if the list is set, then it's non-empty.
50    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 the tokens list is empty, automatically clean it up.
61                    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    /// Saves the tokens in the database.
80    #[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    /// The task catching up thread subscriptions in the background.
106    _task: OnceLock<BackgroundTaskHandle>,
107
108    /// Whether the known list of thread subscriptions is outdated or not, i.e.
109    /// all thread subscriptions have been caught up
110    is_outdated: Arc<AtomicBool>,
111
112    /// A weak reference to the parent [`Client`] instance.
113    client: WeakClient,
114
115    /// A signal to wake up the catchup task when new catchup tokens are
116    /// available.
117    ping: Arc<Notify>,
118
119    /// A mutex to ensure there's only one writer on the thread subscriptions
120    /// catchup tokens at a time.
121    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        // Create the task only if the client is configured to handle thread
141        // subscriptions.
142        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    /// Returns whether the known list of thread subscriptions is outdated or
169    /// more thread subscriptions need to be caught up.
170    pub(crate) fn is_outdated(&self) -> bool {
171        self.is_outdated.load(atomic::Ordering::SeqCst)
172    }
173
174    /// Store the new subscriptions changes, received via the sync response or
175    /// from the msc4308 companion endpoint.
176    #[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        // Precompute the updates so we don't hold the guard for too long.
184        let updates = build_subscription_updates(&subscribed, &unsubscribed);
185        let Some(guard) = self.lock().await else {
186            // Client is shutting down.
187            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    /// Internal helper to lock writes to the thread subscriptions catchup
202    /// tokens list.
203    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    /// Save a new catchup token (or absence thereof) in the state store.
214    async fn save_catchup_token(
215        &self,
216        guard: &GuardedStoreAccess,
217        token: Option<ThreadSubscriptionCatchupToken>,
218    ) -> Result<()> {
219        // Note: saving an empty tokens list will mark the thread subscriptions list as
220        // not outdated.
221        let mut tokens = guard.load_catchup_tokens().await?.unwrap_or_default();
222
223        if let Some(token) = token {
224            // Gappy syncs on a busy account can cause the same catchup token to be sent
225            // repeatedly. We dedupe the tokens here to prevent duplicate catchup requests.
226            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                // Wake up the catchup task, in case it's waiting.
235                self.ping.notify_one();
236            }
237        } else {
238            trace!("No catchup token to save");
239        }
240
241        Ok(())
242    }
243
244    /// The background task listening to new catchup tokens, and using them to
245    /// catch up the thread subscriptions via the [MSC4308] companion
246    /// endpoint.
247    ///
248    /// It will continue to process catchup tokens until there are none, and
249    /// then wait for a new one to be available and inserted in the
250    /// database.
251    ///
252    /// It always processes catch up tokens from the newest to the oldest, since
253    /// newest tokens are more interesting than older ones. Indeed, they're
254    /// more likely to include entries with higher bump-stamps, i.e. to include
255    /// more recent thread subscriptions statuses for each thread, so more
256    /// relevant information.
257    ///
258    /// [MSC4308]: https://github.com/matrix-org/matrix-spec-proposals/pull/4308
259    #[instrument(skip_all)]
260    async fn thread_subscriptions_catchup_task(this: Arc<Self>, ping: Arc<Notify>) {
261        loop {
262            // Load the current catchup token.
263            let Some(guard) = this.lock().await else {
264                // Client is shutting down.
265                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                // Release the mutex.
278                drop(guard);
279
280                // Wait for a wake up.
281                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            // We do have a tokens. Pop the last value, and use it to catch up!
288            let last = tokens.pop().expect("must be set per `load_catchup_tokens` contract");
289
290            // Release the mutex before running the network request.
291            let client = guard.client.clone();
292            drop(guard);
293
294            // Start the actual catchup!
295            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                    // Precompute the updates so we don't hold the guard for too long.
303                    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                    // Refresh the tokens, as the list might have changed while we sent the
326                    // request.
327                    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                        // If the response contained a next batch token, reuse the same catchup
342                        // token entry, so the `to` value remains the same.
343                        tokens[index] =
344                            ThreadSubscriptionCatchupToken { from: next_batch, to: last.to };
345                    } else {
346                        // No next batch, we can remove this token from the list.
347                        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
363/// Internal helper for building the thread subscription updates Vec.
364fn 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    // Take into account the new unsubscriptions.
372    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    // Take into account the new subscriptions.
386    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        // At first there are no catchup tokens, and we are outdated.
420        let guard = tsc.lock().await.unwrap();
421        assert!(guard.load_catchup_tokens().await.unwrap().is_none());
422        assert!(tsc.is_outdated());
423
424        // When I save a token,
425        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        // Well, it is saved,
430        let tokens = guard.load_catchup_tokens().await.unwrap();
431        assert_eq!(tokens, Some(vec![token]));
432
433        // And we are still outdated.
434        assert!(tsc.is_outdated());
435
436        // When I remove the token,
437        guard.save_catchup_tokens(vec![]).await.unwrap();
438
439        // It is gone,
440        assert!(guard.load_catchup_tokens().await.unwrap().is_none());
441
442        // And we are not outdated anymore!
443        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        // When the same catchup token is saved twice,
457        tsc.save_catchup_token(&guard, Some(token.clone())).await.unwrap();
458        tsc.save_catchup_token(&guard, Some(token.clone())).await.unwrap();
459
460        // Then it must only appear once in the stored list.
461        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        // Wait for anything to start up.
494        yield_now().await;
495
496        // Ensure the task is running.
497        assert!(tsc._task.get().is_some());
498
499        // Get a weak reference to the client.
500        let weak_client = WeakClient::from_client(&client);
501
502        // Drop the client will drop the task.
503        drop(client);
504
505        // Wait for anything to shutdown.
506        sleep(Duration::from_secs(2)).await;
507
508        // The client has been dropped correctly.
509        assert_eq!(weak_client.strong_count(), 0);
510    }
511}