xmtp_mls/groups/change_callbacks.rs
1//! Unstable: post-commit notification of group-state changes.
2//!
3//! Registered once at client construction ([`crate::builder::ClientBuilder`]),
4//! not passed per call — the changes worth reacting to arrive from the stream
5//! and sync paths, where no SDK method call is on the stack to carry a
6//! parameter.
7//!
8//! # Why a struct of callbacks
9//!
10//! Only [`UnstableChangeCallbacks::app_data`] is implemented today. The
11//! registry is a struct rather than a bare callback argument so callbacks for
12//! the other mutable fields (name, description, image url, admin lists,
13//! permissions, disappearing settings) land as *additive* fields on a type the
14//! SDKs already construct — the same reason the bindings' `UpdateAppDataOptions`
15//! and the other FFI options records are structs.
16//!
17//! # Delivery contract
18//!
19//! Callbacks fire once the storage transaction has committed, the group commit
20//! lock has been released, **and** the per-group sync mutex has been dropped —
21//! so an implementation is free to publish the result of its merge with
22//! `update_app_data` on the same group. That call re-enters `sync_with_conn`
23//! and takes the same sync mutex, which is why dispatch cannot happen inline
24//! during message processing; see
25//! [`crate::groups::MlsGroup::dispatch_app_data_changes`].
26//!
27//! Concretely, that means the sync path delivers a batch after the whole sync
28//! completes rather than between messages, and the stream path delivers each
29//! change as its message is processed. Changes are dispatched one at a time, in
30//! the order they were observed — but see [Timeouts](#timeouts): that ordering
31//! holds only for callbacks that return within their budget.
32//!
33//! A callback observes the *net* change across one processed message, and fires
34//! for local commits as well as remote ones — an implementation that reacts by
35//! writing must make its merge idempotent, or it will chase its own echo.
36//!
37//! # Timeouts
38//!
39//! Each callback is given [`UnstableChangeCallbacks::app_data_timeout`] to
40//! return. A host that overruns it is abandoned: the expiry is logged and the
41//! rest of that batch is dropped, and neither the sync nor the stream fails,
42//! because the change being reported is already durably committed and the
43//! callback is advisory. Nothing is permanently lost — merges are idempotent,
44//! so the next change re-triggers one from current state.
45//!
46//! Abandoning is not cancelling. libxmtp drops the future and stops waiting;
47//! whether the host's own work stops is up to the binding. uniffi notifies the
48//! foreign side that the future was dropped, which its Kotlin and Swift
49//! bindings can wire to cancelling the task, whereas a JS promise behind napi
50//! or wasm-bindgen keeps running to completion — or never resolves — with
51//! nothing left listening.
52//!
53//! That has a consequence worth designing around: on a binding that cannot
54//! cancel, an abandoned callback may still publish *after* a later one already
55//! did, landing a merge derived from state that has since moved on. The budget
56//! bounds how long sync waits; it cannot unwind work the host has already
57//! started. **Pass the compare-and-swap guard** — `update_app_data(merged,
58//! Some(value_you_were_handed))` — so a late write is superseded at publish
59//! time instead of clobbering the newer one. Hosts that publish unguarded get
60//! last-writer-wins, and after a timeout "last" is not necessarily "latest".
61//!
62//! The budget also only bounds callbacks that *yield*. A handler that blocks
63//! its thread — synchronous FFI work, a blocking lock, `Thread.sleep` — stalls
64//! the task the timer lives on, so the timeout cannot fire and sync waits as
65//! long as the host does. This is inherent to async: a future that never
66//! returns from `poll` cannot be timed out from inside the same runtime.
67//! Callbacks must not block; do blocking work on the host's own executor and
68//! await its completion.
69//!
70//! # Stability
71//!
72//! Pre-release. The shape of the payloads and of the registry may change
73//! without a major version bump until this graduates onto the stable client
74//! surface.
75
76use std::sync::Arc;
77use xmtp_common::{MaybeSend, MaybeSync, time::Duration};
78
79/// How long a single `app_data` callback may run before it is abandoned.
80///
81/// Sized against the round trip the callback exists to perform: merge, then
82/// publish the result with `update_app_data`, which commits, publishes, and
83/// waits for the intent to resolve — a few seconds on a poor mobile network.
84/// Ten leaves room for that while keeping the worst case a host can inflict on
85/// its own `sync()` call short enough to sit behind a spinner. Raising it buys
86/// slow networks more headroom at the cost of a longer visible stall; the
87/// balance is why this is a field rather than a hard-coded constant.
88pub const DEFAULT_APP_DATA_CALLBACK_TIMEOUT: Duration = Duration::from_secs(10);
89
90/// A change to a group's opaque `app_data` slot, observed after it was applied
91/// to local state.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct AppDataChange {
94 /// The group whose `app_data` changed.
95 pub group_id: Vec<u8>,
96 /// Value before the message was processed. `None` when the group had no
97 /// `app_data` set (or predates the field).
98 pub old_value: Option<String>,
99 /// Value after the message was processed. `None` when the field was
100 /// cleared.
101 pub new_value: Option<String>,
102}
103
104/// Notified whenever a processed message changed a group's `app_data`.
105///
106/// Async so an implementation can do the read-modify-write of a semantic merge
107/// — including publishing the merged result — before returning.
108///
109/// Two requirements on an implementation, both from
110/// [the module's timeout rules](self#timeouts):
111///
112/// - **Do not block the calling thread.** Await instead. A handler that blocks
113/// cannot be timed out, and stalls the group's sync for as long as it runs.
114/// - **Publish with the compare-and-swap guard** if it publishes at all —
115/// `update_app_data(merged, Some(change.new_value))`. A handler abandoned at
116/// the budget may still be running, and the guard is what stops its late
117/// write from overwriting a newer one.
118#[xmtp_common::async_trait]
119pub trait AppDataChangeCallback: MaybeSend + MaybeSync {
120 async fn on_app_data_changed(&self, change: AppDataChange);
121}
122
123/// The set of change callbacks registered on a client.
124///
125/// Cloned into [`crate::context::XmtpMlsLocalContext`] at build time; an unset
126/// field costs a single `Option` check on the message-processing path.
127#[derive(Clone)]
128pub struct UnstableChangeCallbacks {
129 /// Fires when a processed message changed the group's `app_data`.
130 pub app_data: Option<Arc<dyn AppDataChangeCallback>>,
131 /// How long [`Self::app_data`] may run before it is abandoned. Defaults to
132 /// [`DEFAULT_APP_DATA_CALLBACK_TIMEOUT`].
133 ///
134 /// Deliberately *not* mirrored on the FFI registries yet: no SDK caller has
135 /// asked to tune it, and exposing it costs a field on each of the uniffi,
136 /// napi, and wasm records plus the SDK surfaces above them. It lives here
137 /// so tests can shorten it, and so the knob is already additive the day
138 /// someone does ask.
139 pub app_data_timeout: Duration,
140 // Future fields (name, description, image_url, admin_list, permissions,
141 // disappearing_settings) go here. Each must default to `None`, and each
142 // FFI mirror must carry a binding-level default, so adding one stays
143 // non-breaking for compiled SDK callers.
144}
145
146impl Default for UnstableChangeCallbacks {
147 /// Hand-written rather than derived: the derive would default
148 /// `app_data_timeout` to [`Duration::ZERO`], which expires every callback
149 /// before it starts.
150 fn default() -> Self {
151 Self {
152 app_data: None,
153 app_data_timeout: DEFAULT_APP_DATA_CALLBACK_TIMEOUT,
154 }
155 }
156}
157
158impl UnstableChangeCallbacks {
159 /// Whether anything is watching `app_data`. Gates the before/after
160 /// snapshot in message processing so unregistered clients pay nothing.
161 pub fn watches_app_data(&self) -> bool {
162 self.app_data.is_some()
163 }
164}
165
166impl std::fmt::Debug for UnstableChangeCallbacks {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("UnstableChangeCallbacks")
169 .field("app_data", &self.app_data.is_some())
170 .field("app_data_timeout", &self.app_data_timeout)
171 .finish()
172 }
173}