Skip to main content

xmtp_mls/
task_handlers.rs

1//! The client's durable task handlers: one exhaustive dispatch over the
2//! `Task` oneof, run by the core task runner.
3
4use crate::{
5    context::XmtpSharedContext,
6    groups::MlsGroup,
7    worker::{
8        NeedsDbReconnect,
9        device_sync::{DeviceSyncClient, DeviceSyncError},
10        key_package_maintenance as kp,
11        metrics::WorkerMetrics,
12        tasks::{TaskHandlers, TaskOutcome, apply_pull_in},
13    },
14};
15use prost::Message;
16use std::sync::Arc;
17use xmtp_common::RetryableError;
18use xmtp_configuration::{KEY_PACKAGE_ROTATION_INTERVAL_NS, SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS};
19use xmtp_db::group::ConversationType;
20use xmtp_db::prelude::{QueryIdentity, QueryKeyPackageHistory};
21use xmtp_db::tasks::{QueryTasks, Task as DbTask};
22use xmtp_events::EventEnvelope;
23use xmtp_proto::xmtp::mls::database::{Task as TaskProto, task::Task as TaskKind};
24
25/// How far out to push a task whose kind this build does not understand. Long
26/// enough that an old client sharing the database does not spin on it, short
27/// enough that a newer client picks it up soon after it starts.
28pub(crate) const UNKNOWN_TASK_DEFER_NS: i64 = 60 * 60 * 1_000_000_000;
29
30#[derive(thiserror::Error, Debug)]
31pub enum TaskWorkerError {
32    #[error("generic storage error: {0}")]
33    Storage(#[from] xmtp_db::StorageError),
34    #[error("group error: {0}")]
35    Group(#[from] crate::groups::GroupError),
36    #[error("device sync error: {0}")]
37    DeviceSync(#[from] DeviceSyncError),
38    #[error("failed to load MLS group from store: {0}")]
39    LoadGroup(#[from] crate::mls_store::MlsStoreError),
40    #[error("invalid task data for {id}: {error}")]
41    InvalidTaskData { id: i64, error: prost::DecodeError },
42    #[error("invalid hash for {id}, expected: {expected}, got: {got}")]
43    InvalidHash {
44        id: i64,
45        expected: String,
46        got: String,
47    },
48    #[error(transparent)]
49    Conversion(#[from] xmtp_proto::ConversionError),
50    #[error("identity error: {0}")]
51    Identity(#[from] crate::identity::IdentityError),
52    #[error("key package maintenance error: {0}")]
53    KeyPackageMaintenance(
54        #[from] crate::worker::key_package_maintenance::KeyPackageMaintenanceError,
55    ),
56    #[error("notification task failed")]
57    Notification(#[from] crate::client::notifications::NotificationError),
58}
59
60impl RetryableError for TaskWorkerError {
61    #[deny(clippy::wildcard_enum_match_arm)]
62    fn is_retryable(&self) -> bool {
63        match self {
64            Self::Storage(e) => e.is_retryable(),
65            Self::Group(e) => e.is_retryable(),
66            Self::DeviceSync(e) => e.is_retryable(),
67            Self::LoadGroup(e) => e.is_retryable(),
68            Self::Identity(e) => e.is_retryable(),
69            Self::KeyPackageMaintenance(e) => e.is_retryable(),
70            Self::Notification(e) => e.is_retryable(),
71            Self::Conversion(_) | Self::InvalidTaskData { .. } | Self::InvalidHash { .. } => false,
72        }
73    }
74
75    /// The delay of a rate-limit rejection that failed this task turn.
76    fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
77        match self {
78            Self::Group(e) => e.rate_limit_delay(),
79            Self::DeviceSync(e) => e.rate_limit_delay(),
80            Self::LoadGroup(e) => e.rate_limit_delay(),
81            Self::Identity(e) => e.rate_limit_delay(),
82            Self::KeyPackageMaintenance(e) => e.rate_limit_delay(),
83            Self::Notification(e) => e.rate_limit_delay(),
84            Self::Storage(_)
85            | Self::Conversion(_)
86            | Self::InvalidTaskData { .. }
87            | Self::InvalidHash { .. } => None,
88        }
89    }
90}
91
92impl NeedsDbReconnect for TaskWorkerError {
93    fn restart_delay(&self) -> Option<xmtp_common::time::Duration> {
94        self.rate_limit_delay()
95    }
96
97    #[deny(clippy::wildcard_enum_match_arm)]
98    fn needs_db_reconnect(&self) -> bool {
99        match self {
100            TaskWorkerError::Storage(s) => s.db_needs_connection(),
101            TaskWorkerError::LoadGroup(e) => e.needs_db_reconnect(),
102            // Forward through GroupError's own classifier so a dropped pool hiding
103            // in a `Db`/`MlsStore` (not just `Storage`) variant still restarts the
104            // worker instead of being retried on a dead connection.
105            TaskWorkerError::Group(e) => e.needs_db_reconnect(),
106            TaskWorkerError::DeviceSync(e) => e.needs_db_reconnect(),
107            TaskWorkerError::InvalidTaskData { .. } => false,
108            TaskWorkerError::InvalidHash { .. } => false,
109            TaskWorkerError::Conversion(_) => false,
110            TaskWorkerError::Identity(e) => e.needs_db_reconnect(),
111            TaskWorkerError::KeyPackageMaintenance(e) => e.needs_db_reconnect(),
112            TaskWorkerError::Notification(
113                crate::client::notifications::NotificationError::Storage(e),
114            ) => e.db_needs_connection(),
115            TaskWorkerError::Notification(
116                crate::client::notifications::NotificationError::Group(e),
117            ) => e.needs_db_reconnect(),
118            TaskWorkerError::Notification(_) => false,
119        }
120    }
121}
122
123/// Runs every durable task kind this build knows, and keeps the notification
124/// task's deadline current as the runner sees changes.
125#[derive(Default)]
126pub(crate) struct MlsTaskHandlers;
127
128#[xmtp_common::async_trait]
129impl<Context> TaskHandlers<Context> for MlsTaskHandlers
130where
131    Context: XmtpSharedContext + 'static,
132{
133    type Error = TaskWorkerError;
134
135    // A new `Task` kind fails to compile here until it has a handler.
136    #[deny(clippy::wildcard_enum_match_arm)]
137    async fn dispatch(
138        &self,
139        context: &Context,
140        task: &DbTask,
141    ) -> Result<TaskOutcome, TaskWorkerError> {
142        #[cfg(any(test, feature = "test-hooks"))]
143        if let Some((work, rejection)) = test_hooks::rate_limit_override(&task.data_hash) {
144            xmtp_common::time::sleep(work).await;
145            return Err(TaskWorkerError::Identity(
146                crate::identity::IdentityError::ApiClient(xmtp_api::ApiError::Api(
147                    xmtp_proto::api::NetworkError::new(xmtp_api_grpc::error::GrpcError::Status(
148                        rejection,
149                    )),
150                )),
151            ));
152        }
153        let data_hash = xmtp_common::sha256_bytes(&task.data);
154        if task.data_hash != data_hash {
155            let expected = hex::encode(&data_hash);
156            let got = hex::encode(&task.data_hash);
157            tracing::warn!(
158                "Task {} data hash mismatch. Expected {expected}, got {got}",
159                task.id,
160            );
161        }
162        let task_proto = match TaskProto::decode(task.data.as_slice()) {
163            Ok(task_proto) => task_proto,
164            Err(e) => {
165                context.db().delete_task(task.id)?;
166                tracing::warn!("Task {} data decode error: {}", task.id, e);
167                return Ok(TaskOutcome::Done);
168            }
169        };
170        match task_proto.task {
171            Some(TaskKind::NotificationSync(_)) => {
172                return Ok(crate::worker::notifications::run(context).await?);
173            }
174            Some(TaskKind::ProcessPendingSelfRemove(pending)) => {
175                process_pending_self_remove(task, pending, context).await?;
176            }
177            Some(TaskKind::PullInDeadline(p)) => {
178                apply_pull_in(context, task, p)?;
179            }
180            Some(TaskKind::KpRotation(_)) => {
181                let now = xmtp_common::time::now_ns();
182                kp::rotate_if_needed(context).await?;
183                // Unconditional (no-op when nothing is marked): a backoff retry
184                // after rotate-succeeded/nudge-failed must still re-nudge.
185                kp::nudge_deletion(context)?;
186                // Advance to the LIVE rotation column (rotate reset it to +30d; a
187                // welcome nudge may have re-lowered it). Do NOT hardcode +30d.
188                let next = context
189                    .db()
190                    .next_key_package_rotation_ns()?
191                    .unwrap_or(now + KEY_PACKAGE_ROTATION_INTERVAL_NS);
192                return Ok(TaskOutcome::RescheduleAt(next));
193            }
194            Some(TaskKind::KpDeletion(_)) => {
195                kp::sweep_expired(context)?;
196                let now = xmtp_common::time::now_ns();
197                // Nothing pending: park one rotation interval out (deletions only
198                // arise from rotations; same constant as the KpRotation arm — the
199                // exact value isn't load-bearing, nudges pull the task in sooner).
200                let next = context
201                    .db()
202                    .min_key_package_delete_at_ns()?
203                    .unwrap_or(now + KEY_PACKAGE_ROTATION_INTERVAL_NS);
204                return Ok(TaskOutcome::RescheduleAt(next));
205            }
206            Some(TaskKind::AddMissingInstallations(add)) => {
207                return run_add_missing_installations(task, add, context).await;
208            }
209            // A kind this build cannot run: `KpLiveness` has no handler yet,
210            // and `None` is a newer build's kind (prost drops an unknown
211            // `oneof task` field). Deleting would drop work a newer client
212            // sharing this database still owes. `expires_at_ns` bounds an
213            // unclaimed row.
214            Some(TaskKind::KpLiveness(_)) | None => {
215                tracing::warn!(
216                    "Task {} has a kind this version does not handle. Deferring.",
217                    task.id
218                );
219                return Ok(TaskOutcome::RescheduleAt(
220                    xmtp_common::time::now_ns() + UNKNOWN_TASK_DEFER_NS,
221                ));
222            }
223        }
224        Ok(TaskOutcome::Done)
225    }
226
227    fn on_start(&self, context: &Context) -> Result<(), TaskWorkerError> {
228        Ok(crate::worker::notifications::wake(context)?)
229    }
230
231    fn on_events(
232        &self,
233        context: &Context,
234        _events: &[EventEnvelope<crate::core::events::InternalEvent>],
235    ) -> Result<(), TaskWorkerError> {
236        Ok(crate::worker::notifications::wake(context)?)
237    }
238}
239
240/// Run a `ProcessPendingSelfRemove` task: load the group and remove members
241/// who requested to leave (a no-op unless this client is super-admin).
242async fn process_pending_self_remove<Context: XmtpSharedContext>(
243    task: &DbTask,
244    pending: xmtp_proto::xmtp::mls::database::ProcessPendingSelfRemove,
245    context: &Context,
246) -> Result<(), TaskWorkerError> {
247    // A malformed group_id can never succeed — drop the task, don't retry.
248    let Ok(group_id) = xmtp_proto::types::GroupId::try_from(pending.group_id.as_slice()) else {
249        tracing::warn!(
250            "Task {} has a malformed group_id for ProcessPendingSelfRemove. Deleting.",
251            task.id
252        );
253        context.db().delete_task(task.id)?;
254        return Ok(());
255    };
256    match MlsGroup::load_by_id(context, &group_id) {
257        Ok(group) => {
258            // No-op unless super-admin; idempotent, so retries are safe.
259            group.process_pending_self_removals().await?;
260            Ok(())
261        }
262        Err(crate::mls_store::MlsStoreError::NotFound(_)) => {
263            tracing::debug!(
264                "Task {} targets a group that no longer exists. Deleting.",
265                task.id
266            );
267            context.db().delete_task(task.id)?;
268            Ok(())
269        }
270        // A DB/connection error is transient — let it retry.
271        Err(e) => Err(e.into()),
272    }
273}
274
275/// Run an `AddMissingInstallations` task: load the group and reconcile its
276/// membership with the inbox's latest identity state. Idempotent — the
277/// underlying group method no-ops when membership is already current, so
278/// retries and duplicate rows are safe. The selected sync group's task
279/// runs again after the retry interval while an own installation it can
280/// retry has no leaf there.
281async fn run_add_missing_installations<Context: XmtpSharedContext>(
282    task: &DbTask,
283    add: xmtp_proto::xmtp::mls::database::AddMissingInstallations,
284    context: &Context,
285) -> Result<TaskOutcome, TaskWorkerError> {
286    // A malformed group_id can never succeed — drop the task, don't retry.
287    let Ok(group_id) = xmtp_proto::types::GroupId::try_from(add.group_id.as_slice()) else {
288        tracing::warn!(
289            "Task {} has a malformed group_id for AddMissingInstallations. Deleting.",
290            task.id
291        );
292        context.db().delete_task(task.id)?;
293        return Ok(TaskOutcome::Done);
294    };
295    match MlsGroup::load_by_id(context, &group_id) {
296        // implements: SYNC-034, GMOD-055
297        Ok(group) if group.conversation_type == ConversationType::Sync => {
298            let metrics = Arc::new(WorkerMetrics::new(context.installation_id()));
299            let sync = DeviceSyncClient::new(context.clone(), metrics);
300            let retried = sync.retry_own_installations(&group).await;
301            #[cfg(any(test, feature = "test-hooks"))]
302            let retried = match test_hooks::take_sync_retry_failure() {
303                true => Err(DeviceSyncError::Conversion),
304                false => retried,
305            };
306            // A failed attempt also waits the retry interval, unless the
307            // runner must honor a rate limit or reconnect.
308            // implements: RATE-041
309            match retried {
310                Ok(false) => Ok(TaskOutcome::Done),
311                Err(e)
312                    if xmtp_common::RetryableError::rate_limit_delay(&e).is_some()
313                        || e.needs_db_reconnect() =>
314                {
315                    Err(e.into())
316                }
317                result => {
318                    if let Err(e) = result {
319                        tracing::warn!(error = %e, "sync group retry failed");
320                    }
321                    Ok(TaskOutcome::RescheduleAt(
322                        xmtp_common::time::now_ns() + SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS,
323                    ))
324                }
325            }
326        }
327        Ok(group) => {
328            group.add_missing_installations().await?;
329            Ok(TaskOutcome::Done)
330        }
331        Err(crate::mls_store::MlsStoreError::NotFound(_)) => {
332            tracing::debug!(
333                "Task {} targets a group that no longer exists. Deleting.",
334                task.id
335            );
336            context.db().delete_task(task.id)?;
337            Ok(TaskOutcome::Done)
338        }
339        // A DB/connection error is transient — let it retry.
340        Err(e) => Err(e.into()),
341    }
342}
343
344/// Client task-dispatch controls for tests. The dispatch reads them; tests
345/// set them. Each assumes process-per-test isolation.
346#[cfg(any(test, feature = "test-hooks"))]
347#[doc(hidden)]
348pub mod test_hooks {
349    use std::sync::Mutex;
350    use xmtp_common::time::Duration;
351
352    type RateLimitOverride = (Vec<u8>, Duration, tonic::Status);
353
354    static RATE_LIMIT_OVERRIDE: Mutex<Option<RateLimitOverride>> = Mutex::new(None);
355    static FAIL_SYNC_RETRY: Mutex<bool> = Mutex::new(false);
356
357    /// While set to `(target_data_hash, work, rejection)`, the client's task
358    /// dispatch works for `work`, then fails with the backend `rejection`, for
359    /// the matching task. `None` clears it.
360    pub fn set_rate_limit_override(value: Option<(Vec<u8>, Duration, tonic::Status)>) {
361        *RATE_LIMIT_OVERRIDE.lock().expect("task test hooks") = value;
362    }
363
364    /// The `(work, rejection)` that `set_rate_limit_override` set for the
365    /// task with `data_hash`.
366    pub fn rate_limit_override(data_hash: &[u8]) -> Option<(Duration, tonic::Status)> {
367        RATE_LIMIT_OVERRIDE
368            .lock()
369            .expect("task test hooks")
370            .as_ref()
371            .filter(|(hash, _, _)| hash.as_slice() == data_hash)
372            .map(|(_, work, rejection)| (*work, rejection.clone()))
373    }
374
375    /// Makes the next sync-group retry fail without a rate limit, once.
376    pub fn fail_next_sync_retry() {
377        *FAIL_SYNC_RETRY.lock().expect("task test hooks") = true;
378    }
379
380    /// Whether `fail_next_sync_retry` armed a failure; consumes it.
381    pub fn take_sync_retry_failure() -> bool {
382        std::mem::take(&mut *FAIL_SYNC_RETRY.lock().expect("task test hooks"))
383    }
384}
385
386#[cfg(test)]
387mod tests;