1pub use crate::task_channels::TaskWorkerChannels;
2use crate::{
3 context::CoreContext,
4 events::InternalEvent,
5 worker::{
6 BoxedWorker, EVENT_BUFFER, NeedsDbReconnect, NewWorker, Worker, WorkerFactory, WorkerKind,
7 },
8};
9use std::{marker::PhantomData, sync::Arc};
10use xmtp_common::{MaybeSend, MaybeSync, RetryableError};
11use xmtp_db::tasks::{QueryTasks, Task as DbTask, TaskDataHash};
12use xmtp_db::{StorageError, diesel};
13use xmtp_events::{EventEnvelope, EventFilter, EventKind, Subscription};
14use xmtp_proto::xmtp::mls::database::PullInDeadline;
15
16#[cfg(any(test, feature = "test-utils"))]
18#[doc(hidden)]
19pub mod test_support {
20 use super::{DbTask, PullInDeadline, StorageError, TaskHandlers, TaskWorker};
21 use crate::context::CoreContext;
22 use xmtp_db::tasks::{NewTask, QueryTasks, TaskDataHash, data_hash_for};
23 use xmtp_proto::xmtp::mls::database::{Task as TaskProto, task::Task as TaskKind};
24
25 pub async fn run_and_reschedule_task<Context, Handlers>(
28 worker: &TaskWorker<Context, Handlers>,
29 task: DbTask,
30 ) -> Result<(), Handlers::Error>
31 where
32 Context: CoreContext,
33 Handlers: TaskHandlers<Context>,
34 {
35 worker.run_and_reschedule_task(task).await
36 }
37
38 pub fn unique_proto() -> TaskProto {
41 TaskProto {
42 task: Some(TaskKind::PullInDeadline(PullInDeadline {
43 target_data_hash: xmtp_common::rand_vec::<32>(),
44 not_later_than_ns: 0,
45 })),
46 }
47 }
48
49 pub fn seed(
51 db: &impl QueryTasks,
52 proto: TaskProto,
53 next: i64,
54 expires: i64,
55 attempts: i32,
56 max: i32,
57 ) -> DbTask {
58 let hash = data_hash_for(&proto);
59 let task = NewTask::builder()
60 .originating_message_sequence_id(0)
61 .next_attempt_at_ns(next)
62 .expires_at_ns(expires)
63 .attempts(attempts)
64 .max_attempts(max)
65 .build(proto)
66 .expect("a task row");
67 db.create_or_ignore_task(task).expect("a stored task");
68 db.get_tasks()
69 .expect("the stored tasks")
70 .into_iter()
71 .find(|t| t.data_hash == hash.as_ref())
72 .expect("the seeded task")
73 }
74
75 pub fn enqueue_pull_in<Context: CoreContext>(
77 context: &Context,
78 target_data_hash: TaskDataHash,
79 not_later_than_ns: i64,
80 expires_at_ns: i64,
81 ) -> Result<(), StorageError> {
82 super::enqueue_pull_in(context, target_data_hash, not_later_than_ns, expires_at_ns)
83 }
84}
85
86#[derive(Debug, PartialEq, Eq)]
89pub enum TaskOutcome {
90 Done,
91 RescheduleAt(i64),
92}
93
94#[cfg(any(test, feature = "test-hooks"))]
97#[doc(hidden)]
98pub mod test_hooks {
99 use std::sync::Mutex;
100
101 static FAIL_RESCHEDULE: Mutex<bool> = Mutex::new(false);
102 type ScheduledWitness = (Vec<u8>, tokio::sync::oneshot::Sender<()>);
103 static TASK_SCHEDULED_CONSUMED: Mutex<Option<ScheduledWitness>> = Mutex::new(None);
104
105 pub fn fail_next_reschedule() {
107 *FAIL_RESCHEDULE.lock().expect("task test hooks") = true;
108 }
109
110 pub(super) fn take_reschedule_failure() -> bool {
111 std::mem::take(&mut *FAIL_RESCHEDULE.lock().expect("task test hooks"))
112 }
113
114 pub fn on_task_scheduled_consumed(
117 installation_id: Vec<u8>,
118 consumed: tokio::sync::oneshot::Sender<()>,
119 ) {
120 *TASK_SCHEDULED_CONSUMED.lock().expect("task test hooks") =
121 Some((installation_id, consumed));
122 }
123
124 pub(super) fn take_task_scheduled_witness(
125 installation_id: &[u8],
126 ) -> Option<tokio::sync::oneshot::Sender<()>> {
127 let mut hook = TASK_SCHEDULED_CONSUMED.lock().expect("task test hooks");
128 if hook
129 .as_ref()
130 .is_some_and(|(installation, _)| installation.as_slice() == installation_id)
131 {
132 hook.take().map(|(_, consumed)| consumed)
133 } else {
134 None
135 }
136 }
137}
138
139#[xmtp_common::async_trait]
142pub trait TaskHandlers<Context>: MaybeSend + MaybeSync {
143 type Error: RetryableError
146 + NeedsDbReconnect
147 + From<StorageError>
148 + std::error::Error
149 + MaybeSend
150 + MaybeSync
151 + 'static;
152
153 async fn dispatch(&self, context: &Context, task: &DbTask) -> Result<TaskOutcome, Self::Error>;
156
157 fn on_start(&self, _context: &Context) -> Result<(), Self::Error> {
159 Ok(())
160 }
161
162 fn on_events(
166 &self,
167 _context: &Context,
168 _events: &[EventEnvelope<InternalEvent>],
169 ) -> Result<(), Self::Error> {
170 Ok(())
171 }
172}
173
174pub fn enqueue_pull_in<Context: CoreContext>(
181 context: &Context,
182 target_data_hash: TaskDataHash,
183 not_later_than_ns: i64,
184 expires_at_ns: i64,
185) -> Result<(), xmtp_db::StorageError> {
186 let now = xmtp_common::time::now_ns();
187 let task = xmtp_db::tasks::NewTask::builder()
188 .originating_message_sequence_id(0)
189 .next_attempt_at_ns(now) .expires_at_ns(expires_at_ns)
191 .max_attempts(i32::MAX) .build(xmtp_proto::xmtp::mls::database::Task {
193 task: Some(xmtp_proto::xmtp::mls::database::task::Task::PullInDeadline(
194 PullInDeadline {
195 target_data_hash: target_data_hash.to_vec(),
196 not_later_than_ns,
197 },
198 )),
199 })?;
200 context.db().create_or_ignore_task(task)?;
201 use xmtp_events::EventWriter;
202 context
203 .events()
204 .emit(None, Some(InternalEvent::TaskScheduled));
205 Ok(())
206}
207
208pub fn apply_pull_in<Context: CoreContext>(
210 context: &Context,
211 task: &DbTask,
212 pull_in: PullInDeadline,
213) -> Result<(), StorageError> {
214 let matched = match TaskDataHash::try_from(pull_in.target_data_hash.as_slice()) {
218 Ok(h) => {
219 let backing_off = context
223 .db()
224 .get_tasks()?
225 .iter()
226 .any(|target| target.data_hash == h.as_ref() && target.attempts > 0);
227 backing_off
228 || context
229 .db()
230 .pull_in_task_deadline(&h, pull_in.not_later_than_ns)?
231 }
232 Err(_) => false, };
234 if !matched {
235 tracing::warn!(
241 target_data_hash = hex::encode(&pull_in.target_data_hash),
242 "pull-in target missing; dropping nudge for task {}",
243 task.id
244 );
245 }
246 Ok(())
247}
248
249fn event_interest() -> (EventFilter<InternalEvent>, Option<usize>) {
252 let filter = EventFilter::new([
253 EventKind::ConsentChanged,
254 EventKind::ConversationJoined,
255 EventKind::ConversationRemoved,
256 EventKind::HmacKeysUpdated,
257 EventKind::ArchiveRestored,
260 ])
261 .with_internal(|event| {
262 matches!(
263 event,
264 InternalEvent::TaskScheduled
265 | InternalEvent::NotificationSettingsChanged
266 | InternalEvent::GroupJoined { .. }
267 | InternalEvent::PreferencesChanged { .. }
268 )
269 });
270 (filter, Some(EVENT_BUFFER))
271}
272
273pub struct Factory<Context, Handlers> {
274 context: Context,
275 handlers: PhantomData<fn() -> Handlers>,
276}
277
278impl<Context, Handlers> WorkerFactory for Factory<Context, Handlers>
279where
280 Context: CoreContext + 'static,
281 Handlers: TaskHandlers<Context> + Default + 'static,
282{
283 fn kind(&self) -> WorkerKind {
284 WorkerKind::TaskRunner
285 }
286
287 fn event_interest(&self) -> Option<(EventFilter<InternalEvent>, Option<usize>)> {
288 Some(event_interest())
289 }
290
291 fn create(&self) -> BoxedWorker {
292 Box::new(TaskWorker::new(self.context.clone(), Handlers::default()))
293 }
294}
295
296pub struct TaskWorker<Context, Handlers> {
297 context: Context,
298 handlers: Handlers,
299 subscription: Option<Arc<Subscription<InternalEvent>>>,
300}
301
302impl<Context, Handlers> NewWorker<Context> for TaskWorker<Context, Handlers>
303where
304 Context: CoreContext + 'static,
305 Handlers: TaskHandlers<Context> + Default + 'static,
306{
307 fn factory(context: Context) -> impl WorkerFactory + 'static {
308 Factory::<Context, Handlers> {
309 context,
310 handlers: PhantomData,
311 }
312 }
313}
314
315#[xmtp_common::async_trait]
316impl<Context, Handlers> Worker for TaskWorker<Context, Handlers>
317where
318 Context: CoreContext + 'static,
319 Handlers: TaskHandlers<Context> + 'static,
320{
321 fn kind(&self) -> WorkerKind {
322 WorkerKind::TaskRunner
323 }
324
325 fn set_subscription(&mut self, subscription: Arc<Subscription<InternalEvent>>) {
326 self.subscription = Some(subscription);
327 }
328
329 async fn run_tasks(&mut self) -> Result<(), Box<dyn NeedsDbReconnect>> {
330 self.run().await.map_err(|e| Box::new(e) as _)
331 }
332}
333
334impl<Context, Handlers> TaskWorker<Context, Handlers>
335where
336 Context: CoreContext,
337 Handlers: TaskHandlers<Context>,
338{
339 pub fn new(context: Context, handlers: Handlers) -> Self {
340 Self {
341 context,
342 handlers,
343 subscription: None,
344 }
345 }
346
347 #[cfg(any(test, feature = "test-hooks"))]
348 fn record_task_fact(&self, event: &xmtp_events::EventEnvelope<InternalEvent>) {
349 if !matches!(&event.internal, Some(InternalEvent::TaskScheduled)) {
350 return;
351 }
352 let witness =
353 test_hooks::take_task_scheduled_witness(&self.context.installation_id().to_vec());
354 if let Some(received) = witness {
355 let _ = received.send(());
356 }
357 }
358
359 pub async fn run(&mut self) -> Result<(), Handlers::Error> {
360 if self.subscription.is_none() {
361 let (filter, depth) = event_interest();
362 self.subscription = Some(Arc::new(self.context.events().subscribe(filter, depth)));
363 }
364 let subscription = self
365 .subscription
366 .as_ref()
367 .expect("subscription is initialized")
368 .clone();
369 self.handlers.on_start(&self.context)?;
370 loop {
371 let drained = subscription.drain();
372 #[cfg(any(test, feature = "test-hooks"))]
373 for event in &drained {
374 self.record_task_fact(event);
375 }
376 self.forward_events(&drained)?;
377 let next_task = self.context.db().get_next_task()?;
378 let next_wakeup = Self::next_wakeup(
379 next_task.as_ref().map(|t| t.next_attempt_at_ns),
380 xmtp_common::time::now_ns(),
381 );
382 tokio::select! {
383 event = subscription.next() => {
384 let Some(event) = event else { break Ok(()); };
385 #[cfg(any(test, feature = "test-hooks"))]
386 self.record_task_fact(&event);
387 let mut events = vec![event];
388 events.extend(subscription.drain());
389 self.forward_events(&events)?;
390 }
391 () = xmtp_common::time::sleep(next_wakeup) => {
392 if let Some(task) = next_task {
393 self.run_and_reschedule_task(task).await?;
394 }
395 }
396 }
397 }
398 }
399
400 fn forward_events(
403 &self,
404 events: &[EventEnvelope<InternalEvent>],
405 ) -> Result<(), Handlers::Error> {
406 if events
407 .iter()
408 .any(|event| !matches!(event.internal, Some(InternalEvent::TaskScheduled)))
409 {
410 self.handlers.on_events(&self.context, events)?;
411 }
412 Ok(())
413 }
414
415 #[tracing::instrument(skip_all, fields(worker = "TaskRunner", operation = "worker_turn"))]
416 pub(crate) async fn run_and_reschedule_task(
417 &self,
418 task: DbTask,
419 ) -> Result<(), Handlers::Error> {
420 let context = &self.context;
421 let now = xmtp_common::time::now_ns();
422 if task.expires_at_ns < now || task.attempts >= task.max_attempts {
423 context.db().delete_task(task.id)?;
424 return Ok(());
425 }
426 if task.next_attempt_at_ns > now {
427 tracing::debug!(
430 task_id = task.id,
431 "Task {} called before next attempt at {}. Now: {now}",
432 task.id,
433 task.next_attempt_at_ns
434 );
435 return Ok(());
436 }
437 match self.handlers.dispatch(context, &task).await {
438 Ok(TaskOutcome::Done) => {
439 context.db().delete_task(task.id)?;
440 }
441 Ok(TaskOutcome::RescheduleAt(t)) => {
442 match context.db().update_task(task.id, 0, now, t) {
445 Ok(_) => {}
446 Err(StorageError::DieselResult(diesel::result::Error::NotFound)) => {
447 tracing::debug!("Task {} vanished before reschedule; skipping", task.id);
448 }
449 Err(e) => return Err(e.into()),
450 }
451 }
452 Err(error) => {
453 let finished = xmtp_common::time::now_ns();
456 let mut attempts = task.attempts + 1;
457 let attempt_scaling_factor = (task.backoff_scaling_factor as f64).powi(attempts);
458 let next_attempt_duration = (((task.initial_backoff_duration_ns as f64)
459 * attempt_scaling_factor) as i64)
460 .min(task.max_backoff_duration_ns);
461 let backoff_at_ns = finished.saturating_add(next_attempt_duration);
462 let mut next_attempt_at_ns = backoff_at_ns;
463 let mut expires_at_ns = task.expires_at_ns;
464 if let Some(delay) = error.rate_limit_delay() {
473 attempts = task.attempts.max(1);
474 let until = finished
475 .saturating_add(i64::try_from(delay.as_nanos()).unwrap_or(i64::MAX));
476 next_attempt_at_ns = next_attempt_at_ns.max(until);
477 expires_at_ns = expires_at_ns
478 .saturating_add(next_attempt_at_ns.saturating_sub(backoff_at_ns));
479 }
480 let next_attempt_duration = next_attempt_at_ns.saturating_sub(finished);
481 tracing::warn!(%error, "Task {} retry failed. Retrying in {next_attempt_duration}ns", task.id);
482 #[cfg(any(test, feature = "test-hooks"))]
483 let fail_reschedule = test_hooks::take_reschedule_failure();
484 #[cfg(not(any(test, feature = "test-hooks")))]
485 let fail_reschedule = false;
486 let stored = if fail_reschedule {
487 Err(StorageError::DieselResult(
488 diesel::result::Error::BrokenTransactionManager,
489 ))
490 } else {
491 context.db().update_task_and_expiry(
492 task.id,
493 attempts,
494 now,
495 next_attempt_at_ns,
496 expires_at_ns,
497 )
498 };
499 match stored {
500 Ok(_) => {}
501 Err(StorageError::DieselResult(diesel::result::Error::NotFound)) => {
505 tracing::debug!("Task {} vanished before reschedule; skipping", task.id);
506 }
507 Err(e) if error.rate_limit_delay().is_some() => {
512 tracing::warn!(%e, "Task {} rate-limit hold not stored", task.id);
513 return Err(error);
514 }
515 Err(e) => return Err(e.into()),
516 }
517 }
518 }
519 Ok(())
520 }
521
522 fn next_wakeup(
523 next_attempt_at_ns: Option<i64>,
524 now: i64,
526 ) -> xmtp_common::time::Duration {
527 use xmtp_common::r#const::NS_IN_DAY;
528 let now_plus_one_day = now.saturating_add(NS_IN_DAY);
529 let next_task_wakeup = next_attempt_at_ns.unwrap_or(i64::MAX).min(now_plus_one_day);
530 if now > next_task_wakeup {
531 xmtp_common::time::Duration::from_nanos(0)
532 } else {
533 std::time::Duration::from_nanos((next_task_wakeup - now) as u64)
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::test_support::{seed, unique_proto};
541 use super::*;
542 use crate::identity::IdentityError;
543 use crate::test_context::real_context;
544 use prost::Message;
545 use xmtp_db::tasks::data_hash_for;
546 use xmtp_proto::xmtp::mls::database::{Task as TaskProto, task::Task as TaskKind};
547
548 struct PullInHandler;
551
552 #[xmtp_common::async_trait]
553 impl<Context: CoreContext> TaskHandlers<Context> for PullInHandler {
554 type Error = IdentityError;
555
556 async fn dispatch(
557 &self,
558 context: &Context,
559 task: &DbTask,
560 ) -> Result<TaskOutcome, IdentityError> {
561 let task_proto = TaskProto::decode(task.data.as_slice()).expect("a stored task");
562 let Some(TaskKind::PullInDeadline(pull_in)) = task_proto.task else {
563 panic!("only pull-in tasks are seeded");
564 };
565 apply_pull_in(context, task, pull_in)?;
566 Ok(TaskOutcome::Done)
567 }
568 }
569
570 struct RescheduleAtHandler {
572 data_hash: Vec<u8>,
573 target: i64,
574 }
575
576 #[xmtp_common::async_trait]
577 impl<Context: CoreContext> TaskHandlers<Context> for RescheduleAtHandler {
578 type Error = IdentityError;
579
580 async fn dispatch(
581 &self,
582 _context: &Context,
583 task: &DbTask,
584 ) -> Result<TaskOutcome, IdentityError> {
585 assert_eq!(task.data_hash, self.data_hash, "only one task is seeded");
586 Ok(TaskOutcome::RescheduleAt(self.target))
587 }
588 }
589
590 #[derive(Debug, thiserror::Error)]
593 enum TaskFixtureError {
594 #[error(transparent)]
595 Storage(#[from] StorageError),
596 #[error("invalid hash for {id}, expected: {expected}, got: {got}")]
597 InvalidHash {
598 id: i64,
599 expected: String,
600 got: String,
601 },
602 }
603
604 impl RetryableError for TaskFixtureError {
605 fn is_retryable(&self) -> bool {
606 false
607 }
608 }
609
610 impl NeedsDbReconnect for TaskFixtureError {
611 fn needs_db_reconnect(&self) -> bool {
612 false
613 }
614 }
615
616 struct FailAfter(xmtp_common::time::Duration);
618
619 #[xmtp_common::async_trait]
620 impl<Context: CoreContext> TaskHandlers<Context> for FailAfter {
621 type Error = TaskFixtureError;
622
623 async fn dispatch(
624 &self,
625 _context: &Context,
626 task: &DbTask,
627 ) -> Result<TaskOutcome, TaskFixtureError> {
628 xmtp_common::time::sleep(self.0).await;
629 Err(TaskFixtureError::InvalidHash {
630 id: task.id.into(),
631 expected: String::new(),
632 got: String::new(),
633 })
634 }
635 }
636
637 #[xmtp_common::test(unwrap_try = true)]
638 async fn done_deletes() {
639 let context = real_context().await;
640 let db = context.db();
641 let now = xmtp_common::time::now_ns();
642 let row = seed(&db, unique_proto(), now - 1, i64::MAX, 0, i32::MAX);
643 TaskWorker::new(context.clone(), PullInHandler)
644 .run_and_reschedule_task(row)
645 .await?;
646 assert!(db.get_tasks()?.is_empty(), "Done task must be deleted");
647 }
648
649 #[xmtp_common::test(unwrap_try = true)]
652 async fn failed_task_backs_off_from_run_finish() {
653 use xmtp_common::time::Duration;
654 let context = real_context().await;
655 let db = context.db();
656 let proto = unique_proto();
657 let hash = data_hash_for(&proto);
658 let row = seed(
659 &db,
660 proto,
661 xmtp_common::time::now_ns() - 1,
662 i64::MAX,
663 0,
664 i32::MAX,
665 );
666 let backoff = (row.initial_backoff_duration_ns as f64
667 * (row.backoff_scaling_factor as f64).powi(1)) as i64;
668 let work = Duration::from_secs(2);
669 let started = xmtp_common::time::now_ns();
670 TaskWorker::new(context.clone(), FailAfter(work))
671 .run_and_reschedule_task(row)
672 .await?;
673 let failed = db
674 .get_tasks()?
675 .into_iter()
676 .find(|t| t.data_hash == hash.as_ref())
677 .expect("the failed task is kept");
678 assert_eq!(failed.attempts, 1);
679 assert!(failed.next_attempt_at_ns >= started + work.as_nanos() as i64 + backoff);
680 }
681
682 #[xmtp_common::test(unwrap_try = true)]
683 async fn recurring_task_advances_and_does_not_hot_loop() {
684 let context = real_context().await;
685 let db = context.db();
686 let now = xmtp_common::time::now_ns();
687 let proto = unique_proto();
688 let row = seed(&db, proto.clone(), now - 1, i64::MAX, 1_000_000, i32::MAX);
691 let target = now + xmtp_common::NS_IN_DAY;
692 let handler = RescheduleAtHandler {
693 data_hash: data_hash_for(&proto).to_vec(),
694 target,
695 };
696
697 TaskWorker::new(context.clone(), handler)
698 .run_and_reschedule_task(row)
699 .await?;
700
701 let after = db.get_tasks()?.pop().expect("recurring row must survive");
702 assert_eq!(
703 after.next_attempt_at_ns, target,
704 "deadline must ADVANCE, not stay past-due"
705 );
706 assert_eq!(after.attempts, 0, "success resets the backoff counter");
707 }
708
709 #[xmtp_common::test(unwrap_try = true)]
710 async fn not_yet_due_task_is_not_run_early() {
711 let context = real_context().await;
712 let db = context.db();
713 let now = xmtp_common::time::now_ns();
714 let proto = unique_proto();
715 let far = now + 30 * xmtp_common::NS_IN_DAY;
716 let row = seed(&db, proto.clone(), far, i64::MAX, 0, i32::MAX);
717 let handler = RescheduleAtHandler {
719 data_hash: data_hash_for(&proto).to_vec(),
720 target: now,
721 };
722
723 TaskWorker::new(context.clone(), handler)
724 .run_and_reschedule_task(row)
725 .await?;
726
727 let after = db.get_tasks()?.pop().unwrap();
728 assert_eq!(
729 after.next_attempt_at_ns, far,
730 "not-yet-due task must not run on a 1-day-cap wake"
731 );
732 assert_eq!(
733 after.attempts, 0,
734 "not-yet-due task must not have its attempts incremented"
735 );
736 }
737}