1use 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
25pub(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 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 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#[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 #[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 kp::nudge_deletion(context)?;
186 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 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 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
240async 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 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 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 Err(e) => Err(e.into()),
272 }
273}
274
275async 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 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 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 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 Err(e) => Err(e.into()),
341 }
342}
343
344#[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 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 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 pub fn fail_next_sync_retry() {
377 *FAIL_SYNC_RETRY.lock().expect("task test hooks") = true;
378 }
379
380 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;