1use crate::{
2 context::XmtpSharedContext,
3 worker::{
4 NeedsDbReconnect, Worker, WorkerFactory, WorkerKind, device_sync::DeviceSyncError,
5 key_package_maintenance as kp,
6 },
7};
8use prost::Message;
9use std::sync::Arc;
10use xmtp_configuration::KEY_PACKAGE_ROTATION_INTERVAL_NS;
11use xmtp_db::prelude::{QueryIdentity, QueryKeyPackageHistory};
12use xmtp_db::tasks::{NewTask as DbNewTask, QueryTasks, Task as DbTask, TaskDataHash};
13use xmtp_db::{StorageError, diesel};
14use xmtp_proto::xmtp::mls::database::Task as TaskProto;
15
16const UNKNOWN_TASK_DEFER_NS: i64 = 60 * 60 * 1_000_000_000;
20
21#[derive(Debug, PartialEq, Eq)]
24pub(crate) enum TaskOutcome {
25 Done,
26 RescheduleAt(i64),
27}
28
29#[cfg(test)]
30pub(crate) mod test_hooks {
31 use std::sync::Mutex;
32 pub(crate) static RESCHEDULE_OVERRIDE: Mutex<Option<(Vec<u8>, i64)>> = Mutex::new(None);
35}
36
37#[derive(thiserror::Error, Debug)]
38pub enum TaskWorkerError {
39 #[error("generic storage error: {0}")]
40 Storage(#[from] xmtp_db::StorageError),
41 #[error("group error: {0}")]
42 Group(#[from] crate::groups::GroupError),
43 #[error("device sync error: {0}")]
44 DeviceSync(#[from] DeviceSyncError),
45 #[error("failed to load MLS group from store: {0}")]
46 LoadGroup(#[from] crate::mls_store::MlsStoreError),
47 #[error("invalid task data for {id}: {error}")]
48 InvalidTaskData { id: i64, error: prost::DecodeError },
49 #[error("invalid hash for {id}, expected: {expected}, got: {got}")]
50 InvalidHash {
51 id: i64,
52 expected: String,
53 got: String,
54 },
55 #[error("task runner receiver locked")]
56 ReceiverLocked,
57 #[error(transparent)]
58 Conversion(#[from] xmtp_proto::ConversionError),
59 #[error("identity error: {0}")]
60 Identity(#[from] crate::identity::IdentityError),
61 #[error("key package maintenance error: {0}")]
62 KeyPackageMaintenance(
63 #[from] crate::worker::key_package_maintenance::KeyPackageMaintenanceError,
64 ),
65 #[error("notification task failed")]
66 Notification(#[from] crate::client::notifications::NotificationError),
67}
68
69impl NeedsDbReconnect for TaskWorkerError {
70 fn needs_db_reconnect(&self) -> bool {
71 match self {
72 TaskWorkerError::Storage(s)
73 | TaskWorkerError::DeviceSync(DeviceSyncError::Storage(s)) => s.db_needs_connection(),
74 TaskWorkerError::LoadGroup(e) => e.needs_db_reconnect(),
75 TaskWorkerError::Group(e) => e.needs_db_reconnect(),
79 TaskWorkerError::DeviceSync(_) => false,
80 TaskWorkerError::InvalidTaskData { .. } => false,
81 TaskWorkerError::InvalidHash { .. } => false,
82 TaskWorkerError::ReceiverLocked => false,
83 TaskWorkerError::Conversion(_) => false,
84 TaskWorkerError::Identity(e) => e.needs_db_reconnect(),
85 TaskWorkerError::KeyPackageMaintenance(e) => e.needs_db_reconnect(),
86 TaskWorkerError::Notification(
87 crate::client::notifications::NotificationError::Storage(e),
88 ) => e.db_needs_connection(),
89 TaskWorkerError::Notification(
90 crate::client::notifications::NotificationError::Group(e),
91 ) => e.needs_db_reconnect(),
92 TaskWorkerError::Notification(_) => false,
93 }
94 }
95}
96
97pub enum TaskMessage {
99 New(DbNewTask),
101 Wake,
104 NotificationWake,
106}
107
108#[derive(Clone)]
109pub struct TaskWorkerChannels {
110 pub task_sender: tokio::sync::mpsc::UnboundedSender<TaskMessage>,
112 pub task_receiver: Arc<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<TaskMessage>>>,
113 pub(crate) notification_request: Arc<tokio::sync::Mutex<()>>,
115 pub(crate) notification_pending_topics: Arc<
119 parking_lot::Mutex<
120 std::collections::BTreeMap<Vec<u8>, xmtp_db::notifications::UploadedTopic>,
121 >,
122 >,
123 notification_revision: Arc<std::sync::atomic::AtomicUsize>,
124 notification_wake_pending: Arc<std::sync::atomic::AtomicBool>,
125}
126
127impl Default for TaskWorkerChannels {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl TaskWorkerChannels {
134 pub fn new() -> Self {
135 let (task_sender, task_receiver) = tokio::sync::mpsc::unbounded_channel();
136 Self {
137 task_sender,
138 task_receiver: Arc::new(tokio::sync::Mutex::new(task_receiver)),
139 notification_request: Default::default(),
140 notification_pending_topics: Default::default(),
141 notification_revision: Default::default(),
142 notification_wake_pending: Default::default(),
143 }
144 }
145 pub fn send(&self, new_task: DbNewTask) {
146 self.task_sender
147 .send(TaskMessage::New(new_task))
148 .expect("Task receiver is owned by same struct");
149 }
150 pub fn wake(&self) {
153 self.task_sender
154 .send(TaskMessage::Wake)
155 .expect("Task receiver is owned by same struct");
156 }
157
158 pub fn wake_notifications(&self) {
160 use std::sync::atomic::Ordering;
161 self.notification_revision.fetch_add(1, Ordering::AcqRel);
164 if !self.notification_wake_pending.swap(true, Ordering::AcqRel) {
165 let _ = self.task_sender.send(TaskMessage::NotificationWake);
166 }
167 }
168
169 pub(crate) fn notification_revision(&self) -> usize {
170 self.notification_revision
171 .load(std::sync::atomic::Ordering::Acquire)
172 }
173}
174
175pub(crate) fn enqueue_pull_in<Context: XmtpSharedContext>(
182 context: &Context,
183 target_data_hash: TaskDataHash,
184 not_later_than_ns: i64,
185 expires_at_ns: i64,
186) -> Result<(), xmtp_db::StorageError> {
187 let now = xmtp_common::time::now_ns();
188 let task = xmtp_db::tasks::NewTask::builder()
189 .originating_message_sequence_id(0)
190 .next_attempt_at_ns(now) .expires_at_ns(expires_at_ns)
192 .max_attempts(i32::MAX) .build(xmtp_proto::xmtp::mls::database::Task {
194 task: Some(xmtp_proto::xmtp::mls::database::task::Task::PullInDeadline(
195 xmtp_proto::xmtp::mls::database::PullInDeadline {
196 target_data_hash: target_data_hash.to_vec(),
197 not_later_than_ns,
198 },
199 )),
200 })?;
201 context.db().create_or_ignore_task(task)?;
202 context.task_channels().wake();
203 Ok(())
204}
205
206pub struct Factory<Context> {
207 context: Context,
208}
209
210impl<Context> WorkerFactory for Factory<Context>
211where
212 Context: XmtpSharedContext + 'static,
213{
214 fn kind(&self) -> WorkerKind {
215 WorkerKind::TaskRunner
216 }
217
218 fn create(
219 &self,
220 _metrics: Option<crate::worker::DynMetrics>,
221 ) -> (
222 crate::worker::BoxedWorker,
223 Option<crate::worker::DynMetrics>,
224 ) {
225 let worker = TaskWorker::new(self.context.clone());
226 (Box::new(worker) as Box<_>, None)
227 }
228}
229
230pub struct TaskWorker<Context> {
231 context: Context,
232 channels: TaskWorkerChannels,
233}
234
235#[xmtp_common::async_trait]
236impl<Context> Worker for TaskWorker<Context>
237where
238 Context: XmtpSharedContext + 'static,
239{
240 fn kind(&self) -> WorkerKind {
241 WorkerKind::TaskRunner
242 }
243
244 async fn run_tasks(&mut self) -> Result<(), Box<dyn NeedsDbReconnect>> {
245 self.run().await.map_err(|e| Box::new(e) as _)
246 }
247
248 fn factory<C>(context: C) -> impl WorkerFactory + 'static
249 where
250 Self: Sized,
251 C: XmtpSharedContext + 'static,
252 {
253 Factory { context }
254 }
255}
256
257impl<Context> TaskWorker<Context>
258where
259 Context: XmtpSharedContext + 'static,
260{
261 pub fn new(context: Context) -> Self {
262 let channels = context.task_channels().clone();
263 Self { context, channels }
264 }
265 pub async fn run(&mut self) -> Result<(), TaskWorkerError> {
266 let mut receiver = match self.channels.task_receiver.try_lock() {
267 Ok(receiver) => receiver,
268 Err(_) => return Err(TaskWorkerError::ReceiverLocked),
269 };
270 crate::worker::notifications::wake(&self.context)?;
271 loop {
272 let next_task = self.context.db().get_next_task()?;
273 let next_wakeup = Self::next_wakeup(
274 next_task.as_ref().map(|t| t.next_attempt_at_ns),
275 xmtp_common::time::now_ns(),
276 );
277 tokio::select! {
278 msg = receiver.recv() => {
279 match msg.expect("Task sender is owned by the task worker") {
282 TaskMessage::New(task) => { self.context.db().create_task(task)?; }
283 TaskMessage::NotificationWake => {
284 self.channels.notification_wake_pending.store(false, std::sync::atomic::Ordering::Release);
285 crate::worker::notifications::wake(&self.context)?;
286 }
287 TaskMessage::Wake => {}
288 }
289 }
290 () = xmtp_common::time::sleep(next_wakeup) => {
291 if let Some(task) = next_task {
292 Self::run_and_reschedule_task(task, &self.context).await?;
293 }
294 }
295 }
296 }
297 }
298 #[tracing::instrument(skip_all, fields(worker = "TaskRunner", operation = "worker_turn"))]
299 pub(crate) async fn run_and_reschedule_task(
300 task: DbTask,
301 context: &Context,
302 ) -> Result<(), TaskWorkerError> {
303 let now = xmtp_common::time::now_ns();
304 if task.expires_at_ns < now || task.attempts >= task.max_attempts {
305 context.db().delete_task(task.id)?;
306 return Ok(());
307 }
308 if task.next_attempt_at_ns > now {
309 tracing::debug!(
312 task_id = task.id,
313 "Task {} called before next attempt at {}. Now: {now}",
314 task.id,
315 task.next_attempt_at_ns
316 );
317 return Ok(());
318 }
319 match Self::run_task(&task, context).await {
320 Ok(TaskOutcome::Done) => {
321 context.db().delete_task(task.id)?;
322 }
323 Ok(TaskOutcome::RescheduleAt(t)) => {
324 match context.db().update_task(task.id, 0, now, t) {
327 Ok(_) => {}
328 Err(StorageError::DieselResult(diesel::result::Error::NotFound)) => {
329 tracing::debug!("Task {} vanished before reschedule; skipping", task.id);
330 }
331 Err(e) => return Err(e.into()),
332 }
333 }
334 Err(error) => {
335 let attempts = task.attempts + 1;
336 let attempt_scaling_factor = (task.backoff_scaling_factor as f64).powi(attempts);
337 let next_attempt_duration = (((task.initial_backoff_duration_ns as f64)
338 * attempt_scaling_factor) as i64)
339 .min(task.max_backoff_duration_ns);
340 let retry_from =
341 if task.data_hash == crate::worker::notifications::task_hash().as_ref() {
342 xmtp_common::time::now_ns()
343 } else {
344 now
345 };
346 let next_attempt_at_ns = retry_from.saturating_add(next_attempt_duration);
347 tracing::warn!(%error, "Task {} retry failed. Retrying in {next_attempt_duration}ns", task.id);
348 match context
349 .db()
350 .update_task(task.id, attempts, now, next_attempt_at_ns)
351 {
352 Ok(_) => {}
353 Err(StorageError::DieselResult(diesel::result::Error::NotFound)) => {
357 tracing::debug!("Task {} vanished before reschedule; skipping", task.id);
358 }
359 Err(e) => return Err(e.into()),
360 }
361 }
362 }
363 Ok(())
364 }
365 async fn run_task(task: &DbTask, context: &Context) -> Result<TaskOutcome, TaskWorkerError> {
366 #[cfg(test)]
367 if let Some((hash, t)) = test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap().clone()
368 && task.data_hash == hash
369 {
370 return Ok(TaskOutcome::RescheduleAt(t));
371 }
372 let data_hash = xmtp_common::sha256_bytes(&task.data);
373 if task.data_hash != data_hash {
374 let expected = hex::encode(&data_hash);
375 let got = hex::encode(&task.data_hash);
376 tracing::warn!(
377 "Task {} data hash mismatch. Expected {expected}, got {got}",
378 task.id,
379 );
380 }
381 let task_proto = match TaskProto::decode(task.data.as_slice()) {
382 Ok(task_proto) => task_proto,
383 Err(e) => {
384 context.db().delete_task(task.id)?;
385 tracing::warn!("Task {} data decode error: {}", task.id, e);
386 return Ok(TaskOutcome::Done);
387 }
388 };
389 match task_proto.task {
390 Some(xmtp_proto::xmtp::mls::database::task::Task::NotificationSync(_)) => {
391 return Ok(crate::worker::notifications::run(context).await?);
392 }
393 Some(xmtp_proto::xmtp::mls::database::task::Task::ProcessPendingSelfRemove(
394 pending,
395 )) => {
396 Self::process_pending_self_remove(task, pending, context).await?;
397 }
398 Some(xmtp_proto::xmtp::mls::database::task::Task::PullInDeadline(p)) => {
399 let matched = match TaskDataHash::try_from(p.target_data_hash.as_slice()) {
403 Ok(h) => context
404 .db()
405 .pull_in_task_deadline(&h, p.not_later_than_ns)?,
406 Err(_) => false, };
408 if !matched {
409 tracing::warn!(
415 target_data_hash = hex::encode(&p.target_data_hash),
416 "pull-in target missing; dropping nudge for task {}",
417 task.id
418 );
419 }
420 }
421 Some(xmtp_proto::xmtp::mls::database::task::Task::KpRotation(_)) => {
422 let now = xmtp_common::time::now_ns();
423 kp::rotate_if_needed(context).await?;
424 kp::nudge_deletion(context)?;
427 let next = context
430 .db()
431 .next_key_package_rotation_ns()?
432 .unwrap_or(now + KEY_PACKAGE_ROTATION_INTERVAL_NS);
433 return Ok(TaskOutcome::RescheduleAt(next));
434 }
435 Some(xmtp_proto::xmtp::mls::database::task::Task::KpDeletion(_)) => {
436 kp::sweep_expired(context)?;
437 let now = xmtp_common::time::now_ns();
438 let next = context
442 .db()
443 .min_key_package_delete_at_ns()?
444 .unwrap_or(now + KEY_PACKAGE_ROTATION_INTERVAL_NS);
445 return Ok(TaskOutcome::RescheduleAt(next));
446 }
447 Some(xmtp_proto::xmtp::mls::database::task::Task::AddMissingInstallations(add)) => {
448 Self::run_add_missing_installations(task, add, context).await?;
449 }
450 Some(xmtp_proto::xmtp::mls::database::task::Task::KpLiveness(_)) => {
451 tracing::warn!(
459 "Task {} is a KpLiveness task, which this version does not handle. Deferring.",
460 task.id
461 );
462 return Ok(TaskOutcome::RescheduleAt(
463 xmtp_common::time::now_ns() + UNKNOWN_TASK_DEFER_NS,
464 ));
465 }
466 None => {
467 tracing::error!("Task {} has no data. Deleting.", task.id);
468 context.db().delete_task(task.id)?;
469 }
470 }
471 Ok(TaskOutcome::Done)
472 }
473 fn next_wakeup(
474 next_attempt_at_ns: Option<i64>,
475 now: i64,
477 ) -> xmtp_common::time::Duration {
478 use xmtp_common::r#const::NS_IN_DAY;
479 let now_plus_one_day = now.saturating_add(NS_IN_DAY);
480 let next_task_wakeup = next_attempt_at_ns.unwrap_or(i64::MAX).min(now_plus_one_day);
481 if now > next_task_wakeup {
482 xmtp_common::time::Duration::from_nanos(0)
483 } else {
484 std::time::Duration::from_nanos((next_task_wakeup - now) as u64)
485 }
486 }
487
488 async fn process_pending_self_remove(
491 task: &DbTask,
492 pending: xmtp_proto::xmtp::mls::database::ProcessPendingSelfRemove,
493 context: &Context,
494 ) -> Result<(), TaskWorkerError> {
495 let Ok(group_id) = xmtp_proto::types::GroupId::try_from(pending.group_id.as_slice()) else {
497 tracing::warn!(
498 "Task {} has a malformed group_id for ProcessPendingSelfRemove. Deleting.",
499 task.id
500 );
501 context.db().delete_task(task.id)?;
502 return Ok(());
503 };
504 match crate::mls_store::MlsStore::new(context.clone()).group(&group_id) {
505 Ok(group) => {
506 group.process_pending_self_removals().await?;
508 Ok(())
509 }
510 Err(crate::mls_store::MlsStoreError::NotFound(_)) => {
511 tracing::debug!(
512 "Task {} targets a group that no longer exists. Deleting.",
513 task.id
514 );
515 context.db().delete_task(task.id)?;
516 Ok(())
517 }
518 Err(e) => Err(e.into()),
520 }
521 }
522
523 async fn run_add_missing_installations(
528 task: &DbTask,
529 add: xmtp_proto::xmtp::mls::database::AddMissingInstallations,
530 context: &Context,
531 ) -> Result<(), TaskWorkerError> {
532 let Ok(group_id) = xmtp_proto::types::GroupId::try_from(add.group_id.as_slice()) else {
534 tracing::warn!(
535 "Task {} has a malformed group_id for AddMissingInstallations. Deleting.",
536 task.id
537 );
538 context.db().delete_task(task.id)?;
539 return Ok(());
540 };
541 match crate::mls_store::MlsStore::new(context.clone()).group(&group_id) {
542 Ok(group) => {
543 group.add_missing_installations().await?;
544 Ok(())
545 }
546 Err(crate::mls_store::MlsStoreError::NotFound(_)) => {
547 tracing::debug!(
548 "Task {} targets a group that no longer exists. Deleting.",
549 task.id
550 );
551 context.db().delete_task(task.id)?;
552 Ok(())
553 }
554 Err(e) => Err(e.into()),
556 }
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::tester;
564 use crate::worker::{WorkerConfig, WorkerKind};
565 use xmtp_db::tasks::{NewTask, data_hash_for};
566 use xmtp_proto::xmtp::mls::database::{Task as TaskProto, task::Task as TaskKind};
567
568 fn unique_proto() -> TaskProto {
571 TaskProto {
572 task: Some(TaskKind::PullInDeadline(
573 xmtp_proto::xmtp::mls::database::PullInDeadline {
574 target_data_hash: xmtp_common::rand_vec::<32>(),
575 not_later_than_ns: 0,
576 },
577 )),
578 }
579 }
580
581 fn no_runner_cfg() -> WorkerConfig {
582 let mut cfg = WorkerConfig::default();
583 cfg.enabled.insert(WorkerKind::TaskRunner, false);
584 cfg
585 }
586
587 fn seed(
589 db: &impl QueryTasks,
590 proto: TaskProto,
591 next: i64,
592 expires: i64,
593 attempts: i32,
594 max: i32,
595 ) -> xmtp_db::tasks::Task {
596 let hash = data_hash_for(&proto);
597 let task = NewTask::builder()
598 .originating_message_sequence_id(0)
599 .next_attempt_at_ns(next)
600 .expires_at_ns(expires)
601 .attempts(attempts)
602 .max_attempts(max)
603 .build(proto)
604 .unwrap();
605 db.create_or_ignore_task(task).unwrap();
606 db.get_tasks()
607 .unwrap()
608 .into_iter()
609 .find(|t| t.data_hash == hash.as_ref())
610 .unwrap()
611 }
612
613 #[xmtp_common::test(unwrap_try = true)]
614 async fn done_deletes() {
615 tester!(alix, worker_config: no_runner_cfg());
616 let db = alix.context.db();
617 let now = xmtp_common::time::now_ns();
618 let row = seed(&db, unique_proto(), now - 1, i64::MAX, 0, i32::MAX);
619 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
620 assert!(db.get_tasks()?.is_empty(), "Done task must be deleted");
621 }
622
623 #[xmtp_common::test(unwrap_try = true)]
624 async fn add_missing_installations_missing_group_deletes_task() {
625 tester!(alix, worker_config: no_runner_cfg());
626 let db = alix.context.db();
627 let now = xmtp_common::time::now_ns();
628 let proto = TaskProto {
629 task: Some(TaskKind::AddMissingInstallations(
630 xmtp_proto::xmtp::mls::database::AddMissingInstallations {
631 group_id: xmtp_common::rand_vec::<16>(),
632 },
633 )),
634 };
635 let row = seed(&db, proto, now - 1, i64::MAX, 0, 3);
636 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
637 assert!(
638 db.get_tasks()?.is_empty(),
639 "task for a nonexistent group must be deleted, not retried"
640 );
641 }
642
643 #[xmtp_common::test(unwrap_try = true)]
644 async fn recurring_task_advances_and_does_not_hot_loop() {
645 tester!(alix, worker_config: no_runner_cfg());
646 let db = alix.context.db();
647 let now = xmtp_common::time::now_ns();
648 let proto = unique_proto();
649 let row = seed(&db, proto.clone(), now - 1, i64::MAX, 5, i32::MAX);
650 let target = now + xmtp_common::NS_IN_DAY;
651 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() =
652 Some((data_hash_for(&proto).to_vec(), target));
653
654 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
655
656 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() = None;
657 let after = db.get_tasks()?.pop().expect("recurring row must survive");
658 assert_eq!(
659 after.next_attempt_at_ns, target,
660 "deadline must ADVANCE, not stay past-due"
661 );
662 assert_eq!(after.attempts, 0, "success resets the backoff counter");
663 }
664
665 #[xmtp_common::test(unwrap_try = true)]
666 async fn never_expire_seed_survives_reaper() {
667 tester!(alix, worker_config: no_runner_cfg());
668 let db = alix.context.db();
669 let now = xmtp_common::time::now_ns();
670 let proto = unique_proto();
671 let row = seed(&db, proto.clone(), now - 1, i64::MAX, 1_000_000, i32::MAX);
674 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() =
675 Some((data_hash_for(&proto).to_vec(), now + xmtp_common::NS_IN_DAY));
676
677 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
678
679 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() = None;
680 let after = db.get_tasks()?;
681 assert_eq!(after.len(), 1, "never-expire seed must not be reaped");
682 assert_eq!(
683 after[0].next_attempt_at_ns,
684 now + xmtp_common::NS_IN_DAY,
685 "seed must have been rescheduled to the target deadline"
686 );
687 }
688
689 #[xmtp_common::test(unwrap_try = true)]
690 async fn not_yet_due_task_is_not_run_early() {
691 tester!(alix, worker_config: no_runner_cfg());
692 let db = alix.context.db();
693 let now = xmtp_common::time::now_ns();
694 let proto = unique_proto();
695 let far = now + 30 * xmtp_common::NS_IN_DAY;
696 let row = seed(&db, proto.clone(), far, i64::MAX, 0, i32::MAX);
697 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() =
699 Some((data_hash_for(&proto).to_vec(), now));
700
701 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
702
703 *test_hooks::RESCHEDULE_OVERRIDE.lock().unwrap() = None;
704 let after = db.get_tasks()?.pop().unwrap();
705 assert_eq!(
706 after.next_attempt_at_ns, far,
707 "not-yet-due task must not run on a 1-day-cap wake"
708 );
709 assert_eq!(
710 after.attempts, 0,
711 "not-yet-due task must not have its attempts incremented"
712 );
713 }
714
715 #[xmtp_common::test(unwrap_try = true)]
716 async fn pull_in_arm_lowers_existing_target() {
717 tester!(alix, worker_config: no_runner_cfg());
718 let db = alix.context.db();
719 let now = xmtp_common::time::now_ns();
720 let target_proto = unique_proto();
722 let target_hash = data_hash_for(&target_proto);
723 let far = now + 30 * xmtp_common::NS_IN_DAY;
724 seed(&db, target_proto, far, i64::MAX, 0, i32::MAX);
725 enqueue_pull_in(&alix.context, target_hash, now + 1_000, i64::MAX)?;
727 let pull_in_row = db
728 .get_tasks()?
729 .into_iter()
730 .find(|t| t.data_hash != target_hash.as_ref())
731 .expect("pull-in row exists");
732
733 TaskWorker::run_and_reschedule_task(pull_in_row, &alix.context).await?;
734
735 let rows = db.get_tasks()?;
736 let target = rows
737 .iter()
738 .find(|t| t.data_hash == target_hash.as_ref())
739 .expect("target survives");
740 assert_eq!(
741 target.next_attempt_at_ns,
742 now + 1_000,
743 "arm must lower the target to the ceiling"
744 );
745 assert_eq!(rows.len(), 1, "applied pull-in must self-delete");
746 }
747
748 #[xmtp_common::test(unwrap_try = true)]
749 async fn pull_in_task_runs_and_pulls_in() {
750 tester!(alix); let db = alix.context.db();
752 let now = xmtp_common::time::now_ns();
753 let proto = unique_proto();
754 let ceiling = now + xmtp_common::NS_IN_DAY;
759 let far = now + 30 * xmtp_common::NS_IN_DAY;
760 seed(&db, proto.clone(), far, i64::MAX, 0, i32::MAX);
761 let hash = data_hash_for(&proto);
762
763 enqueue_pull_in(&alix.context, hash, ceiling, i64::MAX)?;
764
765 let mut pulled = false;
768 for _ in 0..50u32 {
769 xmtp_common::time::sleep(std::time::Duration::from_millis(200)).await;
770 let rows = db.get_tasks()?;
771 let target_ok = rows
772 .iter()
773 .any(|t| t.data_hash == hash.as_ref() && t.next_attempt_at_ns == ceiling);
774 let pull_in_gone = !rows.iter().any(|t| {
775 t.data_hash != hash.as_ref() && t.next_attempt_at_ns <= xmtp_common::time::now_ns()
776 });
777 if target_ok && pull_in_gone {
778 pulled = true;
779 break;
780 }
781 }
782 assert!(
783 pulled,
784 "worker must apply the pull-in and lower the target deadline"
785 );
786 }
787}