1#[cfg(all(test, not(target_arch = "wasm32")))]
4use std::sync::atomic::AtomicBool;
5#[cfg(test)]
6use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
7use std::{
8 collections::{HashMap, HashSet},
9 path::PathBuf,
10 sync::{Arc, LazyLock, Weak},
11 time::Duration,
12};
13
14use futures::StreamExt as _;
15use parking_lot::Mutex;
16use prost::Message as _;
17use sha2::{Digest as _, Sha256};
18use tokio::sync::{Mutex as AsyncMutex, OnceCell, OwnedMutexGuard, watch};
19use tokio_util::sync::CancellationToken;
20use xmtp_attachments::{
21 AttachmentDecoder, AttachmentError, AttachmentFailureCause as Cause, AttachmentOptions,
22 DownloadSink as _, GcmDecryptor, GcmEncryptor, KeyMaterial, LocalStore, StoreMoveError,
23 StoreWriter, Transfer, UploadRequest, attachment_key, ciphertext_len, download_cap,
24 encoded_prefix, plaintext_rel_path, remote_attachment, retained_fields_fit, staged_path,
25 temporary_path,
26};
27use xmtp_common::{RetryableError as _, time::now_ns};
28use xmtp_content_types::remote_attachment::RemoteAttachment;
29use xmtp_db::{
30 attachments::{PendingAttachmentOutcome, StoredPendingAttachment},
31 prelude::*,
32};
33
34impl crate::worker::NeedsDbReconnect for AttachmentClientError {
35 fn needs_db_reconnect(&self) -> bool {
36 false
37 }
38}
39use xmtp_events::{AttachmentFailed, AttachmentRef, ClientEvent, EventWriter as _};
40use xmtp_proto::{api::grpc_status, backend_v1::CreateUploadRequest};
41
42use crate::{client::Client, context::XmtpSharedContext};
43
44const DEFAULT_MAX_PENDING_AGE: Duration = Duration::from_secs(86_400);
45const RECONCILE_AGE: Duration = Duration::from_secs(3_600);
46const CHUNK: usize = 64 * 1024;
47const LEASE_DURATION: Duration = Duration::from_secs(120);
48const LEASE_RENEW: Duration = Duration::from_secs(30);
49const LEASE_POLL: Duration = Duration::from_secs(1);
50#[cfg(test)]
51static FAIL_NEXT_RECONCILES: AtomicUsize = AtomicUsize::new(0);
52static PUBLICATION_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<AsyncMutex<()>>>>> =
53 LazyLock::new(|| Mutex::new(HashMap::new()));
54#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
55static CREATE_MOVE_PAUSE: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>> =
56 Mutex::new(None);
57#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
58static DOWNLOAD_MOVE_PAUSE: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>> =
59 Mutex::new(None);
60#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
61static DOWNLOAD_TEST_BODY: Mutex<Option<Vec<u8>>> = Mutex::new(None);
62
63#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
64#[doc(hidden)]
65pub fn pause_next_create_move(entered: Arc<tokio::sync::Notify>, resume: Arc<tokio::sync::Notify>) {
66 *CREATE_MOVE_PAUSE.lock() = Some((entered, resume));
67}
68
69#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
70#[doc(hidden)]
71pub fn pause_next_download_move(
72 entered: Arc<tokio::sync::Notify>,
73 resume: Arc<tokio::sync::Notify>,
74) {
75 *DOWNLOAD_MOVE_PAUSE.lock() = Some((entered, resume));
76}
77
78#[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
79#[doc(hidden)]
80pub fn set_next_download_body(body: Vec<u8>) {
81 *DOWNLOAD_TEST_BODY.lock() = Some(body);
82}
83
84fn shared_publication_lock(dir: Option<&PathBuf>) -> Arc<AsyncMutex<()>> {
85 let Some(dir) = dir else {
86 return Arc::new(AsyncMutex::new(()));
87 };
88 let path = dir.clone();
89 let mut locks = PUBLICATION_LOCKS.lock();
90 if let Some(lock) = locks.get(&path).and_then(Weak::upgrade) {
91 return lock;
92 }
93 locks.retain(|_, lock| lock.strong_count() != 0);
94 let lock = Arc::new(AsyncMutex::new(()));
95 locks.insert(path, Arc::downgrade(&lock));
96 lock
97}
98
99fn pending_row_matches_key(row: &StoredPendingAttachment, key: &str) -> bool {
100 RemoteAttachment::decode(row.remote_attachment.as_slice())
101 .ok()
102 .and_then(|remote| attachment_key(&remote).ok())
103 .is_some_and(|stored_key| stored_key == key)
104}
105
106#[derive(Clone, Copy)]
107struct LeaseTiming {
108 duration: Duration,
109 renew: Duration,
110 poll: Duration,
111}
112
113impl Default for LeaseTiming {
114 fn default() -> Self {
115 Self {
116 duration: LEASE_DURATION,
117 renew: LEASE_RENEW,
118 poll: LEASE_POLL,
119 }
120 }
121}
122
123impl LeaseTiming {
124 fn duration_ns(self) -> i64 {
125 self.duration.as_nanos().min(i64::MAX as u128) as i64
126 }
127
128 #[cfg(all(test, not(target_arch = "wasm32")))]
129 fn for_test(duration: Duration, renew: Duration, poll: Duration) -> Self {
130 Self {
131 duration,
132 renew,
133 poll,
134 }
135 }
136}
137
138#[derive(Clone, Debug)]
139pub enum AttachmentSource {
140 Path {
141 path: PathBuf,
142 filename: Option<String>,
143 mime_type: String,
144 },
145 Bytes {
146 bytes: Vec<u8>,
147 filename: Option<String>,
148 mime_type: String,
149 },
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum CredentialFailureKind {
154 CredentialRejected,
155 CallbackFailed,
156 Exhausted,
157 MissingCredential,
158}
159
160impl CredentialFailureKind {
161 pub const ALL: [Self; 4] = [
162 Self::CredentialRejected,
163 Self::CallbackFailed,
164 Self::Exhausted,
165 Self::MissingCredential,
166 ];
167
168 pub const fn as_str(self) -> &'static str {
169 match self {
170 Self::CredentialRejected => "credential_rejected",
171 Self::CallbackFailed => "callback_failed",
172 Self::Exhausted => "exhausted",
173 Self::MissingCredential => "missing_credential",
174 }
175 }
176
177 pub fn parse(value: &str) -> Option<Self> {
178 match value {
179 "credential_rejected" => Some(Self::CredentialRejected),
180 "callback_failed" => Some(Self::CallbackFailed),
181 "exhausted" => Some(Self::Exhausted),
182 "missing_credential" => Some(Self::MissingCredential),
183 _ => None,
184 }
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
190#[error("attachment failure: {}", cause.as_str())]
191pub struct AttachmentClientError {
192 pub cause: Cause,
193 pub credential_kind: Option<CredentialFailureKind>,
194 pub retryable: bool,
195 pub missing_scope: bool,
197 pub http_status: Option<u16>,
199}
200
201impl AttachmentClientError {
202 fn new(cause: Cause) -> Self {
203 Self {
204 cause,
205 credential_kind: None,
206 retryable: false,
207 missing_scope: false,
208 http_status: None,
209 }
210 }
211}
212
213impl From<AttachmentError> for AttachmentClientError {
214 fn from(error: AttachmentError) -> Self {
215 Self {
216 http_status: error.http_status,
217 ..Self::new(error.cause)
218 }
219 }
220}
221
222impl From<StoreMoveError> for AttachmentClientError {
223 fn from(error: StoreMoveError) -> Self {
224 match error {
225 StoreMoveError::DestinationExists => Self::new(Cause::LocalStorage),
226 StoreMoveError::Other(error) => error.into(),
227 }
228 }
229}
230
231fn api_error(error: xmtp_api::ApiError) -> AttachmentClientError {
232 if let xmtp_api::ApiError::Auth(auth) = &error {
233 use xmtp_proto::api::AuthError;
234 let credential_kind = match auth {
235 AuthError::CredentialRejected { .. } => CredentialFailureKind::CredentialRejected,
236 AuthError::CallbackFailed { .. } => CredentialFailureKind::CallbackFailed,
237 AuthError::Exhausted | AuthError::ExhaustedAfterAttempt => {
238 CredentialFailureKind::Exhausted
239 }
240 AuthError::MissingCredential => CredentialFailureKind::MissingCredential,
241 };
242 return AttachmentClientError {
243 cause: Cause::Credential,
244 credential_kind: Some(credential_kind),
245 retryable: auth.is_retryable(),
246 missing_scope: false,
247 http_status: None,
248 };
249 }
250 if grpc_status(&error).is_some_and(|status| status.code() == tonic::Code::PermissionDenied) {
251 return AttachmentClientError {
252 missing_scope: true,
253 ..AttachmentClientError::new(Cause::Credential)
254 };
255 }
256 let rejected = grpc_status(&error).is_some_and(|status| {
257 matches!(
258 status.code(),
259 tonic::Code::InvalidArgument | tonic::Code::OutOfRange | tonic::Code::Unimplemented
260 )
261 });
262 AttachmentClientError::new(if rejected {
263 Cause::BackendRejected
264 } else {
265 Cause::BackendUnavailable
266 })
267}
268
269fn attachment_reference(remote: &RemoteAttachment, key: &str) -> AttachmentRef {
270 AttachmentRef {
271 attachment_key: key.to_owned(),
272 url: remote.url.clone(),
273 content_digest: remote.content_digest.clone(),
274 }
275}
276
277struct DecoderSink {
278 writer: StoreWriter,
279 hash: Sha256,
280 cipher: GcmDecryptor,
281 decoder: AttachmentDecoder,
282}
283
284#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
285#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
286impl xmtp_attachments::DownloadSink for DecoderSink {
287 async fn write(&mut self, bytes: &[u8]) -> Result<(), AttachmentError> {
288 self.hash.update(bytes);
289 let mut plaintext = Vec::with_capacity(bytes.len());
290 self.cipher.update(bytes, &mut plaintext)?;
291 for content in self.decoder.push(&plaintext)? {
292 self.writer.write_content(content).await?;
293 }
294 Ok(())
295 }
296}
297
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub enum PendingAttachmentStatus {
300 Waiting,
301 Uploading,
302 Complete,
303 Failed(AttachmentClientError),
304}
305
306fn failure_from_row(row: &StoredPendingAttachment) -> AttachmentClientError {
307 let cause = row
308 .failure_cause
309 .as_deref()
310 .and_then(Cause::parse)
311 .unwrap_or_else(|| {
312 tracing::warn!(stored = ?row.failure_cause, "unknown stored attachment failure cause");
313 Cause::LocalStorage
314 });
315 let credential_kind = row.failure_credential_kind.as_deref().and_then(|stored| {
316 let parsed = CredentialFailureKind::parse(stored);
317 if parsed.is_none() {
318 tracing::warn!(stored, "unknown stored attachment credential failure kind");
319 }
320 parsed
321 });
322 AttachmentClientError {
323 cause,
324 credential_kind,
325 retryable: row.failure_retryable.unwrap_or(false),
326 missing_scope: row.failure_missing_scope.unwrap_or(false),
327 http_status: row
328 .failure_http_status
329 .and_then(|status| u16::try_from(status).ok()),
330 }
331}
332
333fn status_from_row(row: &StoredPendingAttachment, now: i64) -> PendingAttachmentStatus {
334 match row.effective_status(now) {
335 "waiting" => PendingAttachmentStatus::Waiting,
336 "uploading" => PendingAttachmentStatus::Uploading,
337 "complete" => PendingAttachmentStatus::Complete,
338 "failed" => PendingAttachmentStatus::Failed(failure_from_row(row)),
339 _ => PendingAttachmentStatus::Failed(AttachmentClientError::new(Cause::LocalStorage)),
340 }
341}
342
343fn outcome_write_is_retryable(error: &xmtp_db::StorageError) -> bool {
344 use xmtp_db::{ConnectionError, PlatformStorageError, StorageError, diesel::result::Error};
345
346 fn diesel_locked(error: &Error) -> bool {
347 let Error::DatabaseError(_, information) = error else {
348 return false;
349 };
350 matches!(
351 information.message(),
352 "database is locked"
353 | "database table is locked"
354 | "database schema is locked"
355 | "database is busy"
356 )
357 }
358
359 fn platform_transient(error: &PlatformStorageError) -> bool {
360 match error {
361 PlatformStorageError::DieselResult(error) => diesel_locked(error),
362 #[cfg(not(target_arch = "wasm32"))]
363 PlatformStorageError::Pool(_)
364 | PlatformStorageError::DbConnection(_)
365 | PlatformStorageError::PoolNeedsConnection
366 | PlatformStorageError::DatabaseLocked
367 | PlatformStorageError::DieselConnect(_) => true,
368 #[cfg(target_arch = "wasm32")]
369 PlatformStorageError::SAH(_)
370 | PlatformStorageError::Connection(_)
371 | PlatformStorageError::Disconnected
372 | PlatformStorageError::DatabaseInUse => true,
373 _ => false,
374 }
375 }
376
377 match error {
378 StorageError::DieselResult(error)
379 | StorageError::Connection(ConnectionError::Database(error)) => diesel_locked(error),
380 StorageError::Platform(error)
381 | StorageError::Connection(ConnectionError::Platform(error)) => platform_transient(error),
382 StorageError::DieselConnect(_) => true,
383 _ => false,
384 }
385}
386
387struct PendingShared {
388 state: AsyncMutex<()>,
389 watch: watch::Sender<PendingAttachmentStatus>,
390 lease: Mutex<Option<([u8; 16], i64)>>,
391 attempt: Mutex<Option<Arc<PendingAttempt>>>,
392 cancel: Mutex<CancellationToken>,
393}
394
395struct RestorePendingCancel(Option<Arc<PendingShared>>);
396
397impl Drop for RestorePendingCancel {
398 fn drop(&mut self) {
399 if let Some(shared) = self.0.take() {
400 *shared.cancel.lock() = CancellationToken::new();
401 }
402 }
403}
404
405struct PendingAttempt {
406 outcome: watch::Sender<Option<Result<(), AttachmentClientError>>>,
407 done: CancellationToken,
408}
409
410struct PendingAttemptDoneGuard {
411 shared: Arc<PendingShared>,
412 attempt: Arc<PendingAttempt>,
413}
414
415impl Drop for PendingAttemptDoneGuard {
416 fn drop(&mut self) {
417 let mut active = self.shared.attempt.lock();
418 let was_active = active
419 .as_ref()
420 .is_some_and(|current| Arc::ptr_eq(current, &self.attempt));
421 if was_active {
422 *self.shared.lease.lock() = None;
423 *active = None;
424 }
425 drop(active);
426 if was_active {
427 let status = self.shared.watch.borrow().clone();
428 self.shared.watch.send_replace(status);
429 }
430 self.attempt.done.cancel();
431 }
432}
433
434impl PendingAttempt {
435 fn new() -> Self {
436 let (outcome, _) = watch::channel(None);
437 Self {
438 outcome,
439 done: CancellationToken::new(),
440 }
441 }
442}
443
444impl PendingShared {
445 fn new() -> Self {
446 let (watch, _) = watch::channel(PendingAttachmentStatus::Waiting);
447 Self {
448 state: AsyncMutex::new(()),
449 watch,
450 lease: Mutex::new(None),
451 attempt: Mutex::new(None),
452 cancel: Mutex::new(CancellationToken::new()),
453 }
454 }
455
456 fn cancel_token(&self) -> CancellationToken {
457 self.cancel.lock().clone()
458 }
459}
460
461#[derive(Clone, Debug, PartialEq, Eq)]
462pub struct DownloadedAttachment {
463 pub path: PathBuf,
464 pub mime_type: Option<String>,
465 pub filename: Option<String>,
466}
467
468#[derive(Clone, Debug, PartialEq, Eq)]
469pub struct LocalAttachment {
470 pub path: String,
471 pub created_at_ns: i64,
472}
473
474struct DownloadShared {
475 outcome: watch::Sender<Option<Result<DownloadedAttachment, AttachmentClientError>>>,
476 cancel: CancellationToken,
477}
478
479struct DownloadAttemptGuard<Context: XmtpSharedContext + 'static> {
480 context: Context,
481 remote: RemoteAttachment,
482 relative: String,
483 key: String,
484 shared: Arc<DownloadShared>,
485 publication: Option<tokio::sync::OwnedMutexGuard<()>>,
486 finished: bool,
487}
488
489async fn publish_download_outcome<Context: XmtpSharedContext>(
490 context: &Context,
491 remote: &RemoteAttachment,
492 relative: &str,
493 key: &str,
494 shared: &Arc<DownloadShared>,
495 publication: Option<tokio::sync::OwnedMutexGuard<()>>,
496 result: Result<DownloadedAttachment, AttachmentClientError>,
497) {
498 let runtime = context.attachment_runtime();
499 let _publication = match publication {
500 Some(guard) => guard,
501 None => runtime.publication_lock.clone().lock_owned().await,
502 };
503 let lock = runtime.event_lock(key);
504 let _guard = lock.lock().await;
505 if shared.outcome.borrow().is_some() {
506 return;
507 }
508 let reference = attachment_reference(remote, key);
509 let event = match &result {
510 Ok(_) => ClientEvent::AttachmentDownloadCompleted(reference),
511 Err(error) => ClientEvent::AttachmentDownloadFailed(AttachmentFailed {
512 attachment_key: key.to_owned(),
513 url: reference.url,
514 content_digest: reference.content_digest,
515 cause: error.cause.as_str().to_owned(),
516 }),
517 };
518 context.events().emit(Some(event), None);
519 let mut downloads = runtime.downloads.lock();
520 if downloads
521 .get(relative)
522 .is_some_and(|current| Arc::ptr_eq(current, shared))
523 {
524 downloads.remove(relative);
525 }
526 shared.outcome.send_replace(Some(result));
527}
528
529impl<Context: XmtpSharedContext + 'static> DownloadAttemptGuard<Context> {
530 async fn finish(&mut self, result: Result<DownloadedAttachment, AttachmentClientError>) {
531 publish_download_outcome(
532 &self.context,
533 &self.remote,
534 &self.relative,
535 &self.key,
536 &self.shared,
537 self.publication.take(),
538 result,
539 )
540 .await;
541 self.finished = true;
542 }
543}
544
545impl<Context: XmtpSharedContext + 'static> Drop for DownloadAttemptGuard<Context> {
546 fn drop(&mut self) {
547 if self.finished {
548 return;
549 }
550 let context = self.context.clone();
551 let remote = self.remote.clone();
552 let relative = self.relative.clone();
553 let key = self.key.clone();
554 let shared = self.shared.clone();
555 let publication = self.publication.take();
556 drop(xmtp_common::task::spawn(async move {
557 publish_download_outcome(
558 &context,
559 &remote,
560 &relative,
561 &key,
562 &shared,
563 publication,
564 Err(AttachmentClientError::new(Cause::LocalStorage)),
565 )
566 .await;
567 }));
568 }
569}
570
571struct DeleteInProgress<'a> {
572 paths: &'a Mutex<HashMap<String, usize>>,
573 path: String,
574}
575
576impl<'a> DeleteInProgress<'a> {
577 fn new(paths: &'a Mutex<HashMap<String, usize>>, path: String) -> Self {
578 *paths.lock().entry(path.clone()).or_default() += 1;
579 Self { paths, path }
580 }
581}
582
583impl Drop for DeleteInProgress<'_> {
584 fn drop(&mut self) {
585 let mut paths = self.paths.lock();
586 if let Some(count) = paths.get_mut(&self.path) {
587 *count -= 1;
588 if *count == 0 {
589 paths.remove(&self.path);
590 }
591 }
592 }
593}
594
595impl DownloadShared {
596 fn new() -> Self {
597 let (outcome, _) = watch::channel(None);
598 Self {
599 outcome,
600 cancel: CancellationToken::new(),
601 }
602 }
603}
604
605#[derive(Clone, Default)]
606struct CreateRollbackState {
607 plain_temp: Option<String>,
608 staged_temp: Option<String>,
609 final_plain: Option<String>,
610 final_staged: Option<String>,
611 created_key_dir: Option<String>,
612 local_row: Option<String>,
613 pending_row: Option<String>,
614}
615
616async fn rollback_create<Context: XmtpSharedContext>(
617 context: &Context,
618 store: &Arc<dyn LocalStore>,
619 state: &CreateRollbackState,
620) {
621 if let Some(digest) = &state.pending_row {
622 let _ = context.db().delete_pending_attachment(digest);
623 }
624 if let Some(path) = &state.local_row {
625 let _ = context.db().delete_local_attachment(path);
626 }
627 for path in [
628 &state.plain_temp,
629 &state.staged_temp,
630 &state.final_plain,
631 &state.final_staged,
632 ]
633 .into_iter()
634 .flatten()
635 {
636 let _ = store.remove_file(path).await;
637 }
638 if let Some(key_dir) = &state.created_key_dir {
639 let _ = store.remove_empty_dir(key_dir).await;
640 }
641}
642
643struct CreateRollbackGuard<Context: XmtpSharedContext + 'static> {
644 context: Context,
645 store: Arc<dyn LocalStore>,
646 state: CreateRollbackState,
647 publication: Option<OwnedMutexGuard<()>>,
648 #[cfg(target_arch = "wasm32")]
649 in_flight_move: Option<InFlightCreateMove>,
650 committed: bool,
651}
652
653#[derive(Clone, Copy)]
654enum CreateMoveKind {
655 Plain,
656 Staged,
657}
658
659#[cfg(target_arch = "wasm32")]
660struct InFlightCreateMove {
661 receiver: tokio::sync::oneshot::Receiver<Result<(), StoreMoveError>>,
662 destination: String,
663 kind: CreateMoveKind,
664}
665
666#[cfg(target_arch = "wasm32")]
667impl InFlightCreateMove {
668 fn record(&self, state: &mut CreateRollbackState) {
669 match self.kind {
670 CreateMoveKind::Plain => {
671 state.plain_temp = None;
672 state.final_plain = Some(self.destination.clone());
673 }
674 CreateMoveKind::Staged => {
675 state.staged_temp = None;
676 state.final_staged = Some(self.destination.clone());
677 }
678 }
679 }
680}
681
682impl<Context: XmtpSharedContext + 'static> CreateRollbackGuard<Context> {
683 #[cfg(not(target_arch = "wasm32"))]
684 fn record_move(&mut self, kind: CreateMoveKind, destination: &str) {
685 match kind {
686 CreateMoveKind::Plain => {
687 self.state.plain_temp = None;
688 self.state.final_plain = Some(destination.to_owned());
689 }
690 CreateMoveKind::Staged => {
691 self.state.staged_temp = None;
692 self.state.final_staged = Some(destination.to_owned());
693 }
694 }
695 }
696
697 async fn rename_owned(
698 &mut self,
699 from: &str,
700 to: &str,
701 kind: CreateMoveKind,
702 ) -> Result<(), StoreMoveError> {
703 #[cfg(not(target_arch = "wasm32"))]
704 {
705 let result = self.store.rename(from, to).await;
706 if result.is_ok() {
707 self.record_move(kind, to);
708 }
709 result
710 }
711 #[cfg(target_arch = "wasm32")]
712 {
713 let store = self.store.clone();
714 let source = from.to_owned();
715 let destination = to.to_owned();
716 let (sender, receiver) = tokio::sync::oneshot::channel();
717 drop(xmtp_common::task::spawn(async move {
718 let result = store.rename(&source, &destination).await;
719 #[cfg(feature = "test-utils")]
720 if matches!(kind, CreateMoveKind::Plain) && result.is_ok() {
721 let pause = CREATE_MOVE_PAUSE.lock().take();
722 if let Some((entered, resume)) = pause {
723 entered.notify_one();
724 resume.notified().await;
725 }
726 }
727 let _ = sender.send(result);
728 }));
729 self.in_flight_move = Some(InFlightCreateMove {
730 receiver,
731 destination: to.to_owned(),
732 kind,
733 });
734 let result = match self.in_flight_move.as_mut() {
735 Some(moving) => (&mut moving.receiver).await.unwrap_or_else(|_| {
736 Err(StoreMoveError::Other(AttachmentError::new(
737 Cause::LocalStorage,
738 )))
739 }),
740 None => Err(StoreMoveError::Other(AttachmentError::new(
741 Cause::LocalStorage,
742 ))),
743 };
744 if let Some(moving) = self.in_flight_move.take()
745 && result.is_ok()
746 {
747 moving.record(&mut self.state);
748 }
749 result
750 }
751 }
752
753 async fn rollback(&mut self) {
754 #[cfg(target_arch = "wasm32")]
755 if let Some(mut moving) = self.in_flight_move.take()
756 && matches!((&mut moving.receiver).await, Ok(Ok(())))
757 {
758 moving.record(&mut self.state);
759 }
760 rollback_create(&self.context, &self.store, &self.state).await;
761 self.committed = true;
762 }
763}
764
765impl<Context: XmtpSharedContext + 'static> Drop for CreateRollbackGuard<Context> {
766 fn drop(&mut self) {
767 if self.committed {
768 return;
769 }
770 let context = self.context.clone();
771 let store = self.store.clone();
772 let state = self.state.clone();
773 #[cfg(target_arch = "wasm32")]
774 let mut state = state;
775 let publication = self.publication.take();
776 #[cfg(target_arch = "wasm32")]
777 let in_flight_move = self.in_flight_move.take();
778 drop(xmtp_common::task::spawn(async move {
779 let _publication = publication;
780 #[cfg(target_arch = "wasm32")]
781 if let Some(mut moving) = in_flight_move
782 && matches!((&mut moving.receiver).await, Ok(Ok(())))
783 {
784 moving.record(&mut state);
785 }
786 rollback_create(&context, &store, &state).await;
787 }));
788 }
789}
790
791#[doc(hidden)]
792pub struct AttachmentRuntime {
793 pub(crate) store: Option<Arc<dyn LocalStore>>,
794 pub(crate) dir: Option<PathBuf>,
795 pub(crate) options: AttachmentOptions,
796 pending: Mutex<HashMap<String, Weak<PendingShared>>>,
797 downloads: Mutex<HashMap<String, Arc<DownloadShared>>>,
798 deleting: Mutex<HashMap<String, usize>>,
799 event_locks: Mutex<HashMap<String, Weak<AsyncMutex<()>>>>,
800 publication_lock: Arc<AsyncMutex<()>>,
801 reconciled: OnceCell<()>,
802 lease_timing: Mutex<LeaseTiming>,
803 #[cfg(test)]
804 sweep_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
805 #[cfg(test)]
806 delete_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
807 #[cfg(all(test, not(target_arch = "wasm32")))]
808 outcome_lock_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
809 #[cfg(all(test, not(target_arch = "wasm32")))]
810 fail_next_staged_removal: AtomicBool,
811 #[cfg(all(test, not(target_arch = "wasm32")))]
812 fail_next_download_cleanup: AtomicBool,
813 #[cfg(all(test, not(target_arch = "wasm32")))]
814 create_move_pause: Mutex<Option<Arc<CreateMovePause>>>,
815 #[cfg(all(test, not(target_arch = "wasm32")))]
816 create_publish_pause: Mutex<Option<Arc<CreatePublishPause>>>,
817 #[cfg(all(test, not(target_arch = "wasm32")))]
818 attempt_panic_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
819 #[cfg(all(test, not(target_arch = "wasm32")))]
820 download_panic_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
821 #[cfg(all(test, not(target_arch = "wasm32")))]
822 download_move_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
823 #[cfg(all(test, not(target_arch = "wasm32")))]
824 download_publish_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
825 #[cfg(all(test, not(target_arch = "wasm32")))]
826 download_completion_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
827 #[cfg(all(test, not(target_arch = "wasm32")))]
828 download_failure_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
829 #[cfg(all(test, not(target_arch = "wasm32")))]
830 download_existing_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
831 #[cfg(all(test, not(target_arch = "wasm32")))]
832 reconcile_snapshot_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
833 #[cfg(test)]
834 outcome_write_errors: AtomicUsize,
835 #[cfg(test)]
836 lease_extension_errors: AtomicUsize,
837}
838
839#[cfg(all(test, not(target_arch = "wasm32")))]
840struct CreateMovePause {
841 path: Mutex<Option<String>>,
842 staged_path: Mutex<Option<String>>,
843 entered: tokio::sync::Notify,
844 resume: tokio::sync::Notify,
845}
846
847#[cfg(all(test, not(target_arch = "wasm32")))]
848struct CreatePublishPause {
849 after_staged: bool,
850 path: Mutex<Option<String>>,
851 staged_path: Mutex<Option<String>>,
852 entered: tokio::sync::Notify,
853 resume: tokio::sync::Notify,
854}
855
856impl Default for AttachmentRuntime {
857 fn default() -> Self {
858 Self {
859 store: None,
860 dir: None,
861 options: AttachmentOptions::default(),
862 pending: Mutex::new(HashMap::new()),
863 downloads: Mutex::new(HashMap::new()),
864 deleting: Mutex::new(HashMap::new()),
865 event_locks: Mutex::new(HashMap::new()),
866 publication_lock: Arc::new(AsyncMutex::new(())),
867 reconciled: OnceCell::new(),
868 lease_timing: Mutex::new(LeaseTiming::default()),
869 #[cfg(test)]
870 sweep_pause: Mutex::new(None),
871 #[cfg(test)]
872 delete_pause: Mutex::new(None),
873 #[cfg(all(test, not(target_arch = "wasm32")))]
874 outcome_lock_pause: Mutex::new(None),
875 #[cfg(all(test, not(target_arch = "wasm32")))]
876 fail_next_staged_removal: AtomicBool::new(false),
877 #[cfg(all(test, not(target_arch = "wasm32")))]
878 fail_next_download_cleanup: AtomicBool::new(false),
879 #[cfg(all(test, not(target_arch = "wasm32")))]
880 create_move_pause: Mutex::new(None),
881 #[cfg(all(test, not(target_arch = "wasm32")))]
882 create_publish_pause: Mutex::new(None),
883 #[cfg(all(test, not(target_arch = "wasm32")))]
884 attempt_panic_pause: Mutex::new(None),
885 #[cfg(all(test, not(target_arch = "wasm32")))]
886 download_panic_pause: Mutex::new(None),
887 #[cfg(all(test, not(target_arch = "wasm32")))]
888 download_move_pause: Mutex::new(None),
889 #[cfg(all(test, not(target_arch = "wasm32")))]
890 download_publish_pause: Mutex::new(None),
891 #[cfg(all(test, not(target_arch = "wasm32")))]
892 download_completion_pause: Mutex::new(None),
893 #[cfg(all(test, not(target_arch = "wasm32")))]
894 download_failure_pause: Mutex::new(None),
895 #[cfg(all(test, not(target_arch = "wasm32")))]
896 download_existing_pause: Mutex::new(None),
897 #[cfg(all(test, not(target_arch = "wasm32")))]
898 reconcile_snapshot_pause: Mutex::new(None),
899 #[cfg(test)]
900 outcome_write_errors: AtomicUsize::new(0),
901 #[cfg(test)]
902 lease_extension_errors: AtomicUsize::new(0),
903 }
904 }
905}
906
907impl AttachmentRuntime {
908 pub(crate) async fn ensure_reconciled<Context: XmtpSharedContext>(
909 &self,
910 context: &Context,
911 ) -> Result<(), AttachmentClientError> {
912 self.reconciled
913 .get_or_try_init(|| self.reconcile(context))
914 .await
915 .map(|_| ())
916 }
917
918 pub(crate) async fn new(
919 dir: Option<PathBuf>,
920 options: AttachmentOptions,
921 ) -> Result<Self, AttachmentClientError> {
922 let store: Option<Arc<dyn LocalStore>> = match dir.as_ref() {
923 None => None,
924 #[cfg(not(target_arch = "wasm32"))]
925 Some(path) => Some(Arc::new(xmtp_attachments::NativeStore::new(path).await?)),
926 #[cfg(target_arch = "wasm32")]
927 Some(path) => Some(Arc::new(
928 xmtp_attachments::OpfsStore::new(&path.to_string_lossy()).await?,
929 )),
930 };
931 #[cfg(not(target_arch = "wasm32"))]
932 let lock_path = match dir.as_ref() {
933 Some(path) => Some(
934 tokio::fs::canonicalize(path)
935 .await
936 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?,
937 ),
938 None => None,
939 };
940 #[cfg(target_arch = "wasm32")]
941 let lock_path = dir.clone();
942 let publication_lock = shared_publication_lock(lock_path.as_ref());
943 Ok(Self {
944 store,
945 dir,
946 options,
947 pending: Mutex::new(HashMap::new()),
948 downloads: Mutex::new(HashMap::new()),
949 deleting: Mutex::new(HashMap::new()),
950 event_locks: Mutex::new(HashMap::new()),
951 publication_lock,
952 reconciled: OnceCell::new(),
953 lease_timing: Mutex::new(LeaseTiming::default()),
954 #[cfg(test)]
955 sweep_pause: Mutex::new(None),
956 #[cfg(test)]
957 delete_pause: Mutex::new(None),
958 #[cfg(all(test, not(target_arch = "wasm32")))]
959 outcome_lock_pause: Mutex::new(None),
960 #[cfg(all(test, not(target_arch = "wasm32")))]
961 fail_next_staged_removal: AtomicBool::new(false),
962 #[cfg(all(test, not(target_arch = "wasm32")))]
963 fail_next_download_cleanup: AtomicBool::new(false),
964 #[cfg(all(test, not(target_arch = "wasm32")))]
965 create_move_pause: Mutex::new(None),
966 #[cfg(all(test, not(target_arch = "wasm32")))]
967 create_publish_pause: Mutex::new(None),
968 #[cfg(all(test, not(target_arch = "wasm32")))]
969 attempt_panic_pause: Mutex::new(None),
970 #[cfg(all(test, not(target_arch = "wasm32")))]
971 download_panic_pause: Mutex::new(None),
972 #[cfg(all(test, not(target_arch = "wasm32")))]
973 download_move_pause: Mutex::new(None),
974 #[cfg(all(test, not(target_arch = "wasm32")))]
975 download_publish_pause: Mutex::new(None),
976 #[cfg(all(test, not(target_arch = "wasm32")))]
977 download_completion_pause: Mutex::new(None),
978 #[cfg(all(test, not(target_arch = "wasm32")))]
979 download_failure_pause: Mutex::new(None),
980 #[cfg(all(test, not(target_arch = "wasm32")))]
981 download_existing_pause: Mutex::new(None),
982 #[cfg(all(test, not(target_arch = "wasm32")))]
983 reconcile_snapshot_pause: Mutex::new(None),
984 #[cfg(test)]
985 outcome_write_errors: AtomicUsize::new(0),
986 #[cfg(test)]
987 lease_extension_errors: AtomicUsize::new(0),
988 })
989 }
990
991 fn store(&self) -> Result<&Arc<dyn LocalStore>, AttachmentClientError> {
992 self.store
993 .as_ref()
994 .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))
995 }
996
997 fn shared(&self, digest: &str) -> Arc<PendingShared> {
998 let mut pending = self.pending.lock();
999 if let Some(shared) = pending.get(digest).and_then(Weak::upgrade) {
1000 return shared;
1001 }
1002 pending.retain(|_, shared| shared.strong_count() != 0);
1003 let shared = Arc::new(PendingShared::new());
1004 pending.insert(digest.to_owned(), Arc::downgrade(&shared));
1005 shared
1006 }
1007
1008 fn event_lock(&self, key: &str) -> Arc<AsyncMutex<()>> {
1009 let mut locks = self.event_locks.lock();
1010 if let Some(lock) = locks.get(key).and_then(Weak::upgrade) {
1011 return lock;
1012 }
1013 locks.retain(|_, lock| lock.strong_count() != 0);
1014 let lock = Arc::new(AsyncMutex::new(()));
1015 locks.insert(key.to_owned(), Arc::downgrade(&lock));
1016 lock
1017 }
1018
1019 fn cutoff(&self) -> i64 {
1020 let age = self
1021 .options
1022 .max_pending_age
1023 .unwrap_or(DEFAULT_MAX_PENDING_AGE);
1024 now_ns().saturating_sub(age.as_nanos().min(i64::MAX as u128) as i64)
1025 }
1026
1027 pub(crate) async fn sweep<Context: XmtpSharedContext>(
1029 &self,
1030 context: &Context,
1031 ) -> Result<(), AttachmentClientError> {
1032 let Some(store) = &self.store else {
1033 return Ok(());
1034 };
1035 let rows = context
1036 .db()
1037 .pending_attachment_sweep_candidates(self.cutoff())
1038 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1039 for digest in rows {
1040 let shared = self.shared(&digest);
1041 let _state = shared.state.lock().await;
1042 #[cfg(test)]
1043 {
1044 let pause = self.sweep_pause.lock().clone();
1045 if let Some((entered, resume)) = pause {
1046 entered.notify_one();
1047 resume.notified().await;
1048 }
1049 }
1050 let deleted = context
1051 .db()
1052 .sweep_pending_attachment(&digest, self.cutoff(), now_ns())
1053 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1054 if deleted == 0 {
1055 continue;
1056 }
1057 let path = staged_path(&digest)?;
1058 if store.exists(&path).await? {
1059 store.remove_file(&path).await?;
1060 }
1061 self.pending.lock().remove(&digest);
1062 }
1063 Ok(())
1064 }
1065
1066 pub(crate) async fn reconcile<Context: XmtpSharedContext>(
1067 &self,
1068 context: &Context,
1069 ) -> Result<(), AttachmentClientError> {
1070 let _publication = self.publication_lock.lock().await;
1071 #[cfg(test)]
1072 if FAIL_NEXT_RECONCILES
1073 .fetch_update(AtomicOrdering::SeqCst, AtomicOrdering::SeqCst, |count| {
1074 count.checked_sub(1)
1075 })
1076 .is_ok()
1077 {
1078 return Err(AttachmentClientError::new(Cause::LocalStorage));
1079 }
1080 let Some(store) = &self.store else {
1081 return Ok(());
1082 };
1083 let pending: HashMap<_, _> = context
1084 .db()
1085 .list_pending_attachments_since(0)
1086 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1087 .into_iter()
1088 .map(|row| (row.content_digest, row.status))
1089 .collect();
1090 let records = context
1091 .db()
1092 .list_local_attachments()
1093 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1094 let recorded: HashSet<_> = records.iter().map(|row| row.path.as_str()).collect();
1095 let files = store.list_files().await?;
1096 let present: HashSet<_> = files.iter().map(|file| file.path.as_str()).collect();
1097 #[cfg(all(test, not(target_arch = "wasm32")))]
1098 {
1099 let pause = self.reconcile_snapshot_pause.lock().clone();
1100 if let Some((entered, resume)) = pause {
1101 entered.notify_one();
1102 resume.notified().await;
1103 }
1104 }
1105 let now = now_ns();
1106 let cutoff = now.saturating_sub(RECONCILE_AGE.as_nanos() as i64);
1107 let staged_age = self
1108 .options
1109 .max_pending_age
1110 .unwrap_or(DEFAULT_MAX_PENDING_AGE)
1111 .min(RECONCILE_AGE);
1112 let staged_cutoff = now.saturating_sub(staged_age.as_nanos() as i64);
1113 for file in &files {
1114 if file.path.starts_with(".tmp/") {
1115 if file.modified_at_ns < cutoff {
1116 store.remove_file(&file.path).await?;
1117 }
1118 } else if let Some(digest) = file.path.strip_prefix(".staged/") {
1119 if pending
1120 .get(digest)
1121 .is_some_and(|status| status == "complete")
1122 || (!pending.contains_key(digest) && file.modified_at_ns < staged_cutoff)
1123 {
1124 store.remove_file(&file.path).await?;
1125 }
1126 } else if file.path.split_once('/').is_some_and(|(key, name)| {
1127 key.len() == 64
1128 && key
1129 .bytes()
1130 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1131 && !name.is_empty()
1132 && !name.contains('/')
1133 }) && !recorded.contains(file.path.as_str())
1134 {
1135 context
1136 .db()
1137 .insert_or_ignore_local_attachment(&file.path, file.modified_at_ns, None, None)
1138 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1139 }
1140 }
1141 for record in records {
1142 if !present.contains(record.path.as_str()) {
1143 context
1144 .db()
1145 .delete_local_attachment(&record.path)
1146 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1147 }
1148 }
1149 Ok(())
1150 }
1151}
1152
1153pub struct Attachments<Context> {
1154 context: Context,
1155}
1156
1157impl<Context: XmtpSharedContext> Client<Context> {
1158 pub fn attachments(&self) -> Attachments<Context> {
1159 Attachments {
1160 context: self.context.clone(),
1161 }
1162 }
1163}
1164
1165impl<Context: XmtpSharedContext> Attachments<Context> {
1166 fn runtime(&self) -> &Arc<AttachmentRuntime> {
1167 self.context.attachment_runtime()
1168 }
1169
1170 pub fn offered(&self) -> bool {
1172 self.context
1173 .server_configuration()
1174 .configuration()
1175 .attachments
1176 .is_some()
1177 }
1178
1179 pub fn local_path(&self, remote: &RemoteAttachment) -> Result<PathBuf, AttachmentClientError> {
1181 let relative = plaintext_rel_path(remote)?;
1182 let dir = self
1183 .runtime()
1184 .dir
1185 .as_ref()
1186 .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
1187 Ok(dir.join(relative))
1188 }
1189
1190 pub async fn list_local(&self) -> Result<Vec<LocalAttachment>, AttachmentClientError> {
1191 self.runtime().ensure_reconciled(&self.context).await?;
1192 let _publication = self.runtime().publication_lock.lock().await;
1193 let store = self.runtime().store()?;
1194 let rows = self
1195 .context
1196 .db()
1197 .list_local_attachments()
1198 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1199 let mut local = Vec::with_capacity(rows.len());
1200 for row in rows {
1201 if store.exists(&row.path).await? && store.is_regular_file(&row.path).await? {
1202 local.push(LocalAttachment {
1203 path: row.path,
1204 created_at_ns: row.created_at_ns,
1205 });
1206 }
1207 }
1208 Ok(local)
1209 }
1210
1211 pub async fn download(
1213 &self,
1214 remote: &RemoteAttachment,
1215 ) -> Result<DownloadedAttachment, AttachmentClientError> {
1216 self.runtime().ensure_reconciled(&self.context).await?;
1217 let relative = plaintext_rel_path(remote)?;
1218 let path = self.local_path(remote)?;
1219 let key = attachment_key(remote)?;
1220 let store = self.runtime().store()?;
1221 let lock = self.runtime().event_lock(&key);
1222 let publication = self.runtime().publication_lock.lock().await;
1224 let shared = {
1225 let _guard = lock.lock().await;
1226 if self.runtime().deleting.lock().contains_key(&key) {
1227 return Err(AttachmentClientError::new(Cause::Deleted));
1228 }
1229 if store.exists(&relative).await? {
1230 if !store.is_regular_file(&relative).await? {
1231 return Err(AttachmentClientError::new(Cause::LocalStorage));
1232 }
1233 #[cfg(all(test, not(target_arch = "wasm32")))]
1234 let pause = { self.runtime().download_existing_pause.lock().take() };
1235 #[cfg(all(test, not(target_arch = "wasm32")))]
1236 if let Some((entered, resume)) = pause {
1237 entered.notify_one();
1238 resume.notified().await;
1239 }
1240 self.context
1241 .db()
1242 .insert_or_ignore_local_attachment(&relative, now_ns(), None, None)
1243 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1244 let record = self
1245 .context
1246 .db()
1247 .get_local_attachment(&relative)
1248 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1249 .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
1250 return Ok(DownloadedAttachment {
1251 path,
1252 mime_type: record.mime_type,
1253 filename: record.filename,
1254 });
1255 }
1256 if let Some(shared) = self.runtime().downloads.lock().get(&relative).cloned() {
1257 shared
1258 } else {
1259 let shared = Arc::new(DownloadShared::new());
1260 self.runtime()
1261 .downloads
1262 .lock()
1263 .insert(relative.clone(), shared.clone());
1264 let reference = attachment_reference(remote, &key);
1265 self.context.events().emit(
1266 Some(ClientEvent::AttachmentDownloadStarted(reference)),
1267 None,
1268 );
1269 let task = Attachments {
1270 context: self.context.context_ref().clone(),
1271 };
1272 let attempt = DownloadAttemptGuard {
1273 context: task.context.clone(),
1274 remote: remote.clone(),
1275 relative: relative.clone(),
1276 key: key.clone(),
1277 shared: shared.clone(),
1278 publication: None,
1279 finished: false,
1280 };
1281 drop(xmtp_common::task::spawn(async move {
1282 let mut attempt = attempt;
1283 #[cfg(all(test, not(target_arch = "wasm32")))]
1284 let panic_pause = { task.runtime().download_panic_pause.lock().take() };
1285 #[cfg(all(test, not(target_arch = "wasm32")))]
1286 if let Some((entered, resume)) = panic_pause {
1287 entered.notify_one();
1288 resume.notified().await;
1289 panic!("forced download attempt panic");
1290 }
1291 let result = task
1292 .download_once(
1293 &attempt.remote,
1294 &attempt.relative,
1295 &attempt.shared.cancel,
1296 &mut attempt.publication,
1297 )
1298 .await;
1299 #[cfg(all(test, not(target_arch = "wasm32")))]
1300 let completion_pause = result
1301 .is_ok()
1302 .then(|| task.runtime().download_completion_pause.lock().take())
1303 .flatten();
1304 #[cfg(all(test, not(target_arch = "wasm32")))]
1305 if let Some((entered, resume)) = completion_pause {
1306 entered.notify_one();
1307 resume.notified().await;
1308 }
1309 #[cfg(all(test, not(target_arch = "wasm32")))]
1310 let failure_pause = result
1311 .is_err()
1312 .then(|| task.runtime().download_failure_pause.lock().take())
1313 .flatten();
1314 #[cfg(all(test, not(target_arch = "wasm32")))]
1315 if let Some((entered, resume)) = failure_pause {
1316 entered.notify_one();
1317 resume.notified().await;
1318 }
1319 attempt.finish(result).await;
1320 }));
1321 shared
1322 }
1323 };
1324 drop(publication);
1325 let mut outcome = shared.outcome.subscribe();
1326 loop {
1327 let current = outcome.borrow_and_update().clone();
1328 if let Some(result) = current {
1329 return result;
1330 }
1331 outcome
1332 .changed()
1333 .await
1334 .map_err(|_| AttachmentClientError::new(Cause::Network))?;
1335 }
1336 }
1337
1338 async fn download_once(
1339 &self,
1340 remote: &RemoteAttachment,
1341 relative: &str,
1342 cancel: &CancellationToken,
1343 publication_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
1344 ) -> Result<DownloadedAttachment, AttachmentClientError> {
1345 let store = self.runtime().store()?;
1346 let material = KeyMaterial::from_remote(remote)?;
1347 let suffix = hex::encode(xmtp_common::rand_array::<16>());
1348 let content_tmp = temporary_path(&format!("{suffix}-content"))?;
1349 let decoded_tmp = temporary_path(&format!("{suffix}-decoded"))?;
1350 let result = tokio::select! {
1351 biased;
1352 _ = cancel.cancelled() => Err(AttachmentClientError::new(Cause::Deleted)),
1353 result = async {
1354 let writer = store.create_temp(&content_tmp).await?;
1355 let mut sink = DecoderSink {
1356 writer, hash: Sha256::new(),
1357 cipher: GcmDecryptor::new(&material), decoder: AttachmentDecoder::new(),
1358 };
1359 let snapshot_limit = self.context.server_configuration().configuration().attachments
1360 .as_ref().map_or(xmtp_configuration::BACKEND_DEFAULT_MAX_UPLOAD_BYTES, |offer| offer.max_upload_bytes);
1361 let cap = download_cap(remote.content_length.map(u64::from), snapshot_limit, &self.runtime().options);
1362 #[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
1363 let test_body = { DOWNLOAD_TEST_BODY.lock().take() };
1364 #[cfg(all(target_arch = "wasm32", feature = "test-utils"))]
1365 if let Some(body) = test_body {
1366 sink.write(&body).await?;
1367 } else {
1368 Transfer::new(self.runtime().options.clone())?.get(&remote.url, cap, &mut sink).await?;
1369 }
1370 #[cfg(not(all(target_arch = "wasm32", feature = "test-utils")))]
1371 Transfer::new(self.runtime().options.clone())?.get(&remote.url, cap, &mut sink).await?;
1372 let DecoderSink { mut writer, hash, cipher, decoder } = sink;
1373 store.sync(&mut writer).await?;
1374 drop(writer);
1375 if hex::encode(hash.finalize()) != remote.content_digest {
1376 return Err(AttachmentClientError::new(Cause::DigestMismatch));
1377 }
1378 cipher.finish()?;
1379 let meta = store.finish_decode(decoder, &content_tmp, &decoded_tmp).await?;
1380 let final_tmp = if meta.compressed { &decoded_tmp } else { &content_tmp };
1381 #[cfg(all(test, not(target_arch = "wasm32")))]
1382 {
1383 let pause = self.runtime().download_move_pause.lock().clone();
1384 if let Some((entered, resume)) = pause {
1385 entered.notify_one();
1386 resume.notified().await;
1387 }
1388 }
1389 let publication = self.runtime().publication_lock.clone().lock_owned().await;
1390 #[cfg(not(target_arch = "wasm32"))]
1391 let (move_result, move_publication) = (store.rename(final_tmp, relative).await, publication);
1392 #[cfg(target_arch = "wasm32")]
1393 let (move_result, move_publication) = {
1394 let store = store.clone();
1396 let source = final_tmp.to_owned();
1397 let destination = relative.to_owned();
1398 let (sender, receiver) = tokio::sync::oneshot::channel();
1399 drop(xmtp_common::task::spawn(async move {
1400 let result = store.rename(&source, &destination).await;
1401 #[cfg(feature = "test-utils")]
1402 if result.is_ok() {
1403 let pause = { DOWNLOAD_MOVE_PAUSE.lock().take() };
1404 if let Some((entered, resume)) = pause {
1405 entered.notify_one();
1406 resume.notified().await;
1407 }
1408 }
1409 let _ = sender.send((result, publication));
1410 }));
1411 receiver
1412 .await
1413 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1414 };
1415 *publication_guard = Some(move_publication);
1416 match move_result {
1417 Ok(()) => {}
1418 Err(StoreMoveError::DestinationExists) => {
1419 if !store.is_regular_file(relative).await? {
1420 return Err(AttachmentClientError::new(Cause::LocalStorage));
1421 }
1422 let record = self.context.db()
1425 .insert_or_ignore_local_attachment(relative, now_ns(), None, None)
1426 .and_then(|()| self.context.db().get_local_attachment(relative));
1427 let (mime_type, filename) = match record {
1428 Ok(Some(record)) => (record.mime_type, record.filename),
1429 _ => (Some(meta.mime_type), meta.filename),
1430 };
1431 return Ok(DownloadedAttachment {
1432 path: self.local_path(remote)?, mime_type, filename,
1433 });
1434 }
1435 Err(error) => return Err(error.into()),
1436 }
1437 #[cfg(all(test, not(target_arch = "wasm32")))]
1438 let publish_pause = { self.runtime().download_publish_pause.lock().take() };
1439 #[cfg(all(test, not(target_arch = "wasm32")))]
1440 if let Some((entered, resume)) = publish_pause {
1441 entered.notify_one();
1442 resume.notified().await;
1443 }
1444 if self.context.db().insert_or_ignore_local_attachment(
1445 relative, now_ns(), Some(meta.mime_type.clone()), meta.filename.clone()
1446 )
1447 .is_err() {
1448 #[cfg(all(test, not(target_arch = "wasm32")))]
1449 let removal = if self.runtime().fail_next_download_cleanup.swap(false, AtomicOrdering::SeqCst) {
1450 Err(AttachmentError::new(Cause::LocalStorage))
1451 } else {
1452 store.remove_file(relative).await
1453 };
1454 #[cfg(not(all(test, not(target_arch = "wasm32"))))]
1455 let removal = store.remove_file(relative).await;
1456 match removal {
1459 Ok(()) => return Err(AttachmentClientError::new(Cause::LocalStorage)),
1460 Err(error) => tracing::warn!(%error, "could not remove downloaded file after metadata write failed"),
1461 }
1462 }
1463 Ok(DownloadedAttachment {
1464 path: self.local_path(remote)?, mime_type: Some(meta.mime_type), filename: meta.filename,
1465 })
1466 } => result,
1467 };
1468 for temp in [&content_tmp, &decoded_tmp] {
1469 if store.exists(temp).await.unwrap_or(false) {
1470 let _ = store.remove_file(temp).await;
1471 }
1472 }
1473 result
1474 }
1475
1476 pub async fn delete_local(
1478 &self,
1479 remote: &RemoteAttachment,
1480 ) -> Result<(), AttachmentClientError> {
1481 let task = Attachments {
1482 context: self.context.context_ref().clone(),
1483 };
1484 let remote = remote.clone();
1485 let (sender, receiver) = tokio::sync::oneshot::channel();
1486 drop(xmtp_common::task::spawn(async move {
1487 let result = task.delete_local_inner(&remote).await;
1488 let _ = sender.send(result);
1489 }));
1490 receiver
1491 .await
1492 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1493 }
1494
1495 async fn delete_local_inner(
1496 &self,
1497 remote: &RemoteAttachment,
1498 ) -> Result<(), AttachmentClientError> {
1499 let key = attachment_key(remote)?;
1500 let staged = staged_path(&remote.content_digest)?;
1501 let store = self.runtime().store()?;
1502 let lock = self.runtime().event_lock(&key);
1503 let (upload, upload_attempt, downloads, _deleting) = {
1504 let _guard = lock.lock().await;
1505 let deleting = DeleteInProgress::new(&self.runtime().deleting, key.clone());
1507 let pending_matches = self
1508 .context
1509 .db()
1510 .get_pending_attachment(&remote.content_digest)
1511 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1512 .is_some_and(|row| pending_row_matches_key(&row, &key));
1513 let upload = pending_matches
1514 .then(|| {
1515 self.runtime()
1516 .pending
1517 .lock()
1518 .get(&remote.content_digest)
1519 .and_then(Weak::upgrade)
1520 })
1521 .flatten();
1522 let prefix = format!("{key}/");
1523 let downloads: Vec<_> = self
1524 .runtime()
1525 .downloads
1526 .lock()
1527 .iter()
1528 .filter(|(path, _)| path.starts_with(&prefix))
1529 .map(|(_, shared)| shared.clone())
1530 .collect();
1531 let upload_attempt = upload
1532 .as_ref()
1533 .filter(|shared| shared.lease.lock().is_some())
1534 .and_then(|shared| shared.attempt.lock().clone());
1535 if let Some(shared) = &upload {
1536 shared.cancel.lock().cancel();
1537 }
1538 for shared in &downloads {
1539 shared.cancel.cancel();
1540 }
1541 (upload, upload_attempt, downloads, deleting)
1542 };
1543 #[cfg(test)]
1544 {
1545 let pause = self.runtime().delete_pause.lock().clone();
1546 if let Some((entered, resume)) = pause {
1547 entered.notify_one();
1548 resume.notified().await;
1549 }
1550 }
1551 let cancelled_upload = upload_attempt.is_some();
1552 if let Some(attempt) = upload_attempt {
1553 attempt.done.cancelled().await;
1554 }
1555 for shared in downloads {
1556 let mut outcome = shared.outcome.subscribe();
1557 while outcome.borrow_and_update().clone().is_none() {
1558 outcome
1559 .changed()
1560 .await
1561 .map_err(|_| AttachmentClientError::new(Cause::Network))?;
1562 }
1563 }
1564 let mut restore_cancel = RestorePendingCancel(upload.clone());
1565 let _publication = self.runtime().publication_lock.lock().await;
1566 let _guard = lock.lock().await;
1567 let mut emitted = false;
1568 let mut emit_deleted = || {
1569 if !emitted {
1570 self.context.events().emit(
1571 Some(ClientEvent::AttachmentDeleted(attachment_reference(
1572 remote, &key,
1573 ))),
1574 None,
1575 );
1576 emitted = true;
1577 }
1578 };
1579 let pending_row = self
1582 .context
1583 .db()
1584 .get_pending_attachment(&remote.content_digest)
1585 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1586 let key_exists = store.prepare_remove_dir(&key).await?;
1587 let deleted_pending =
1588 if let Some(row) = pending_row.filter(|row| pending_row_matches_key(row, &key)) {
1589 self.context
1590 .db()
1591 .delete_pending_attachment_if_remote_matches(
1592 &remote.content_digest,
1593 &row.remote_attachment,
1594 )
1595 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1596 != 0
1597 } else {
1598 false
1599 };
1600 if deleted_pending {
1601 restore_cancel.0 = None;
1602 if cancelled_upload {
1603 let deleted = AttachmentClientError::new(Cause::Deleted);
1604 if let Some(shared) = &upload {
1605 shared
1606 .watch
1607 .send_replace(PendingAttachmentStatus::Failed(deleted));
1608 }
1609 let reference = attachment_reference(remote, &key);
1610 self.context.events().emit(
1611 Some(ClientEvent::AttachmentUploadFailed(AttachmentFailed {
1612 attachment_key: key.clone(),
1613 url: reference.url,
1614 content_digest: reference.content_digest,
1615 cause: Cause::Deleted.as_str().to_owned(),
1616 })),
1617 None,
1618 );
1619 }
1620 }
1621 if key_exists {
1622 if let Err(error) = store.remove_dir_all(&key).await {
1623 if error.removed_any {
1625 emit_deleted();
1626 }
1627 return Err(error.error.into());
1628 }
1629 emit_deleted();
1630 }
1631 if deleted_pending && store.exists(&staged).await? {
1632 store.remove_file(&staged).await?;
1633 emit_deleted();
1634 }
1635 let removed_rows = self
1636 .context
1637 .db()
1638 .delete_local_attachments_in_dir(&key)
1639 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1640 if removed_rows != 0 {
1641 emit_deleted();
1642 }
1643 if deleted_pending {
1644 self.runtime().pending.lock().remove(&remote.content_digest);
1645 }
1646 Ok(())
1647 }
1648
1649 pub async fn create(
1651 &self,
1652 source: AttachmentSource,
1653 ) -> Result<PendingAttachment<Context>, AttachmentClientError> {
1654 let offer = self
1655 .context
1656 .server_configuration()
1657 .configuration()
1658 .attachments
1659 .as_ref()
1660 .ok_or_else(|| AttachmentClientError::new(Cause::NotOffered))?;
1661 let store = self.runtime().store()?;
1662 let fallback = match &source {
1663 AttachmentSource::Path {
1664 path,
1665 filename: None,
1666 ..
1667 } => path
1668 .file_name()
1669 .map(|name| name.to_string_lossy().into_owned()),
1670 _ => None,
1671 };
1672 let (source_filename, source_mime_type) = match &source {
1673 AttachmentSource::Path {
1674 filename,
1675 mime_type,
1676 ..
1677 }
1678 | AttachmentSource::Bytes {
1679 filename,
1680 mime_type,
1681 ..
1682 } => (
1683 filename.as_deref().or(fallback.as_deref()),
1684 mime_type.as_str(),
1685 ),
1686 };
1687 if !retained_fields_fit(source_filename, source_mime_type) {
1688 return Err(AttachmentClientError::new(Cause::TooLarge));
1689 }
1690 let filename = source_filename.map(str::to_owned);
1691 let mime_type = source_mime_type.to_owned();
1692 #[cfg(not(target_arch = "wasm32"))]
1693 let mut native_source_file: Option<tokio::fs::File> = None;
1694 let size = match &source {
1695 AttachmentSource::Path { path, .. } => {
1696 #[cfg(target_arch = "wasm32")]
1697 {
1698 let source_store = xmtp_attachments::OpfsStore::new_root()
1699 .await
1700 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1701 let source_path = path.to_string_lossy().into_owned();
1702 source_store
1703 .open_read(&source_path)
1704 .await
1705 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?
1706 .len()
1707 }
1708 #[cfg(not(target_arch = "wasm32"))]
1709 {
1710 #[cfg(unix)]
1711 use std::os::unix::fs::OpenOptionsExt as _;
1712 let mut options = std::fs::OpenOptions::new();
1713 options.read(true);
1714 #[cfg(unix)]
1715 options.custom_flags(libc::O_NONBLOCK);
1716 let file = options
1717 .open(path)
1718 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1719 let metadata = file
1720 .metadata()
1721 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1722 if !metadata.is_file() {
1723 return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1724 }
1725 native_source_file = Some(tokio::fs::File::from_std(file));
1726 metadata.len()
1727 }
1728 }
1729 AttachmentSource::Bytes { bytes, .. } => bytes.len() as u64,
1730 };
1731 let prefix = encoded_prefix(filename.as_deref(), &mime_type, size);
1732 let length = ciphertext_len(prefix.len(), size);
1733 if length > offer.max_upload_bytes || length > u32::MAX as u64 {
1734 return Err(AttachmentClientError::new(Cause::TooLarge));
1735 }
1736 let id: [u8; 16] = xmtp_common::rand_array();
1737 let plain_temp = temporary_path(&format!("{}-plain", hex::encode(id)))?;
1738 let staged_temp = temporary_path(&format!("{}-staged", hex::encode(id)))?;
1739 let publication = self.runtime().publication_lock.clone().lock_owned().await;
1740 let mut rollback = CreateRollbackGuard {
1741 context: self.context.context_ref().clone(),
1742 store: store.clone(),
1743 state: CreateRollbackState {
1744 plain_temp: Some(plain_temp.clone()),
1745 staged_temp: Some(staged_temp.clone()),
1746 ..Default::default()
1747 },
1748 publication: Some(publication),
1749 #[cfg(target_arch = "wasm32")]
1750 in_flight_move: None,
1751 committed: false,
1752 };
1753 let result: Result<PendingAttachment<Context>, AttachmentClientError> = async {
1754 let mut plain = store.create_temp(&plain_temp).await?;
1755 let mut staged = store.create_temp(&staged_temp).await?;
1756 let material = KeyMaterial::random();
1757 let mut encryptor = GcmEncryptor::new(&material);
1758 let mut hash = Sha256::new();
1759 let mut encrypted = Vec::with_capacity(CHUNK + prefix.len());
1760 encryptor.update(&prefix, &mut encrypted)?;
1761 staged.write(&encrypted).await?;
1762 hash.update(&encrypted);
1763 encrypted.clear();
1764 match source {
1765 AttachmentSource::Path { path, .. } => {
1766 #[cfg(not(target_arch = "wasm32"))]
1767 {
1768 use tokio::io::AsyncReadExt as _;
1769 let _ = path;
1770 let mut file = native_source_file
1771 .take()
1772 .ok_or_else(|| AttachmentClientError::new(Cause::SourceUnreadable))?;
1773 let mut chunk = [0u8; CHUNK];
1774 let mut read_total = 0u64;
1775 loop {
1776 let count = file
1777 .read(&mut chunk)
1778 .await
1779 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1780 if count == 0 {
1781 break;
1782 }
1783 read_total += count as u64;
1784 if read_total > size {
1785 return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1786 }
1787 plain.write(&chunk[..count]).await?;
1788 encryptor.update(&chunk[..count], &mut encrypted)?;
1789 staged.write(&encrypted).await?;
1790 hash.update(&encrypted);
1791 encrypted.clear();
1792 }
1793 if read_total != size {
1794 return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1795 }
1796 }
1797 #[cfg(target_arch = "wasm32")]
1798 {
1799 let source_store = xmtp_attachments::OpfsStore::new_root()
1800 .await
1801 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1802 let source_path = path.to_string_lossy().into_owned();
1803 let file = source_store
1804 .open_read(&source_path)
1805 .await
1806 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1807 let mut read_total = 0u64;
1808 loop {
1809 let chunk = file
1810 .read_chunk(read_total, CHUNK)
1811 .await
1812 .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1813 if chunk.is_empty() {
1814 break;
1815 }
1816 read_total += chunk.len() as u64;
1817 if read_total > size {
1818 return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1819 }
1820 plain.write(&chunk).await?;
1821 encryptor.update(&chunk, &mut encrypted)?;
1822 staged.write(&encrypted).await?;
1823 hash.update(&encrypted);
1824 encrypted.clear();
1825 }
1826 if read_total != size {
1827 return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1828 }
1829 }
1830 }
1831 AttachmentSource::Bytes { bytes, .. } => {
1832 for chunk in bytes.chunks(CHUNK) {
1833 plain.write(chunk).await?;
1834 encryptor.update(chunk, &mut encrypted)?;
1835 staged.write(&encrypted).await?;
1836 hash.update(&encrypted);
1837 encrypted.clear();
1838 }
1839 }
1840 }
1841 let tag = encryptor.finish();
1842 staged.write(&tag).await?;
1843 hash.update(tag);
1844 store.sync(&mut plain).await?;
1845 store.sync(&mut staged).await?;
1846 drop(plain);
1847 drop(staged);
1848 let hex_digest = hex::encode(hash.finalize());
1849 let remote = remote_attachment(
1850 &offer.base_url,
1851 &hex_digest,
1852 &material,
1853 length as u32,
1854 filename.as_deref(),
1855 );
1856 let local = plaintext_rel_path(&remote)?;
1857 let ciphertext = staged_path(&hex_digest)?;
1858 #[cfg(all(test, not(target_arch = "wasm32")))]
1859 let create_pause = { self.runtime().create_move_pause.lock().clone() };
1860 #[cfg(all(test, not(target_arch = "wasm32")))]
1861 if let Some(pause) = create_pause {
1862 *pause.path.lock() = Some(local.clone());
1863 *pause.staged_path.lock() = Some(ciphertext.clone());
1864 pause.entered.notify_one();
1865 pause.resume.notified().await;
1866 }
1867 let key_dir = attachment_key(&remote)?;
1868 if store.create_dir_if_absent(&key_dir).await? {
1869 rollback.state.created_key_dir = Some(key_dir);
1870 }
1871 match rollback
1872 .rename_owned(&plain_temp, &local, CreateMoveKind::Plain)
1873 .await
1874 {
1875 Ok(()) => {}
1876 Err(StoreMoveError::DestinationExists) => {
1877 if !store.is_regular_file(&local).await? {
1878 return Err(AttachmentClientError::new(Cause::LocalStorage));
1879 }
1880 store.remove_file(&plain_temp).await?;
1881 rollback.state.plain_temp = None;
1882 }
1883 Err(error) => return Err(error.into()),
1884 }
1885 #[cfg(all(test, not(target_arch = "wasm32")))]
1886 let publish_pause = { self.runtime().create_publish_pause.lock().clone() };
1887 #[cfg(all(test, not(target_arch = "wasm32")))]
1888 if let Some(pause) = publish_pause
1889 && !pause.after_staged
1890 {
1891 *pause.path.lock() = Some(local.clone());
1892 *pause.staged_path.lock() = Some(ciphertext.clone());
1893 pause.entered.notify_one();
1894 pause.resume.notified().await;
1895 }
1896 match rollback
1897 .rename_owned(&staged_temp, &ciphertext, CreateMoveKind::Staged)
1898 .await
1899 {
1900 Ok(()) => {}
1901 Err(StoreMoveError::DestinationExists) => {
1902 let valid = match store.open_read(&ciphertext).await {
1903 Ok(file) => matches!(
1904 file.sha256(length).await,
1905 Ok((digest, stored_length))
1906 if stored_length == length && hex::encode(digest) == hex_digest
1907 ),
1908 Err(_) => false,
1909 };
1910 if valid {
1911 store.remove_file(&staged_temp).await?;
1912 rollback.state.staged_temp = None;
1913 } else {
1914 store.replace(&staged_temp, &ciphertext).await?;
1915 rollback.state.staged_temp = None;
1916 rollback.state.final_staged = Some(ciphertext.clone());
1917 }
1918 }
1919 Err(error) => return Err(error.into()),
1920 }
1921 #[cfg(all(test, not(target_arch = "wasm32")))]
1922 let publish_pause = { self.runtime().create_publish_pause.lock().clone() };
1923 #[cfg(all(test, not(target_arch = "wasm32")))]
1924 if let Some(pause) = publish_pause
1925 && pause.after_staged
1926 {
1927 *pause.path.lock() = Some(local.clone());
1928 *pause.staged_path.lock() = Some(ciphertext.clone());
1929 pause.entered.notify_one();
1930 pause.resume.notified().await;
1931 }
1932 let db = self.context.db();
1933 let inserted = db
1934 .insert_local_attachment_if_absent(
1935 &local,
1936 now_ns(),
1937 Some(mime_type.clone()),
1938 filename.clone(),
1939 )
1940 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1941 if inserted {
1942 rollback.state.local_row = Some(local.clone());
1943 }
1944 let inserted = db
1945 .insert_pending_attachment_if_absent(&hex_digest, &remote.encode_to_vec(), now_ns())
1946 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1947 if !inserted {
1948 return Err(AttachmentClientError::new(Cause::LocalStorage));
1949 }
1950 rollback.state.pending_row = Some(hex_digest.clone());
1951 Ok(PendingAttachment {
1952 context: self.context.clone(),
1953 remote,
1954 shared: self.runtime().shared(&hex_digest),
1955 })
1956 }
1957 .await;
1958 if result.is_err() {
1959 rollback.rollback().await;
1960 } else {
1961 rollback.committed = true;
1962 }
1963 result
1964 }
1965
1966 pub async fn pending(
1968 &self,
1969 remote: &RemoteAttachment,
1970 ) -> Result<PendingAttachment<Context>, AttachmentClientError> {
1971 self.runtime().ensure_reconciled(&self.context).await?;
1972 let row = self
1973 .context
1974 .db()
1975 .get_pending_attachment(&remote.content_digest)
1976 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1977 .filter(|row| {
1978 row.created_at_ns >= self.runtime().cutoff()
1979 || row.status == "uploading"
1980 && row.lease_expires_at_ns.is_some_and(|end| end >= now_ns())
1981 })
1982 .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
1983 let remote = RemoteAttachment::decode(row.remote_attachment.as_slice())
1984 .map_err(|_| AttachmentClientError::new(Cause::StagedUnusable))?;
1985 Ok(PendingAttachment {
1986 context: self.context.clone(),
1987 shared: self.runtime().shared(&remote.content_digest),
1988 remote,
1989 })
1990 }
1991
1992 pub async fn list_pending(
1993 &self,
1994 ) -> Result<Vec<PendingAttachment<Context>>, AttachmentClientError> {
1995 self.runtime().ensure_reconciled(&self.context).await?;
1996 self.context
1997 .db()
1998 .list_pending_attachments_since(0)
1999 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
2000 .into_iter()
2001 .filter(|row| {
2002 row.status != "complete"
2003 && (row.created_at_ns >= self.runtime().cutoff()
2004 || row.status == "uploading"
2005 && row.lease_expires_at_ns.is_some_and(|end| end >= now_ns()))
2006 })
2007 .map(|row| {
2008 let remote = RemoteAttachment::decode(row.remote_attachment.as_slice())
2009 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
2010 Ok(PendingAttachment {
2011 context: self.context.clone(),
2012 shared: self.runtime().shared(&remote.content_digest),
2013 remote,
2014 })
2015 })
2016 .collect()
2017 }
2018}
2019
2020pub struct PendingAttachment<Context> {
2021 context: Context,
2022 remote: RemoteAttachment,
2023 shared: Arc<PendingShared>,
2024}
2025
2026impl<Context: Clone> Clone for PendingAttachment<Context> {
2027 fn clone(&self) -> Self {
2028 Self {
2029 context: self.context.clone(),
2030 remote: self.remote.clone(),
2031 shared: self.shared.clone(),
2032 }
2033 }
2034}
2035
2036impl<Context: XmtpSharedContext> PendingAttachment<Context> {
2037 pub fn remote_attachment(&self) -> &RemoteAttachment {
2038 &self.remote
2039 }
2040
2041 pub fn local_path(&self) -> Result<PathBuf, AttachmentClientError> {
2042 let dir = self
2043 .context
2044 .attachment_runtime()
2045 .dir
2046 .as_ref()
2047 .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
2048 Ok(dir.join(plaintext_rel_path(&self.remote)?))
2049 }
2050
2051 pub fn status(&self) -> PendingAttachmentStatus {
2052 if self
2053 .shared
2054 .lease
2055 .lock()
2056 .as_ref()
2057 .is_some_and(|(_, until)| now_ns() < *until)
2058 && matches!(
2059 *self.shared.watch.borrow(),
2060 PendingAttachmentStatus::Uploading
2061 )
2062 {
2063 return PendingAttachmentStatus::Uploading;
2064 }
2065 match self.record() {
2066 Ok(Some(row)) => status_from_row(&row, now_ns()),
2067 Ok(None) => PendingAttachmentStatus::Failed(AttachmentClientError::new(
2068 if self.shared.cancel_token().is_cancelled() {
2069 Cause::Deleted
2070 } else {
2071 Cause::StagedUnusable
2072 },
2073 )),
2074 Err(_) => self.shared.watch.borrow().clone(),
2075 }
2076 }
2077
2078 pub fn watch_status(&self) -> watch::Receiver<PendingAttachmentStatus> {
2079 self.shared.watch.send_replace(self.status());
2080 let receiver = self.shared.watch.subscribe();
2081 let pending = PendingAttachment {
2082 context: self.context.context_ref().clone(),
2083 remote: self.remote.clone(),
2084 shared: self.shared.clone(),
2085 };
2086 drop(xmtp_common::task::spawn(async move {
2087 while pending.shared.watch.receiver_count() > 0 {
2088 let poll = pending
2089 .context
2090 .attachment_runtime()
2091 .lease_timing
2092 .lock()
2093 .poll;
2094 xmtp_common::time::sleep(poll).await;
2095 let status = pending.status();
2096 if *pending.shared.watch.borrow() != status {
2097 pending.shared.watch.send_replace(status);
2098 }
2099 }
2100 }));
2101 receiver
2102 }
2103
2104 fn reference(&self) -> AttachmentRef {
2105 AttachmentRef {
2106 attachment_key: xmtp_attachments::attachment_key(&self.remote).unwrap_or_default(),
2107 url: self.remote.url.clone(),
2108 content_digest: self.remote.content_digest.clone(),
2109 }
2110 }
2111
2112 fn record(&self) -> Result<Option<StoredPendingAttachment>, AttachmentClientError> {
2113 self.context
2114 .db()
2115 .get_pending_attachment(&self.remote.content_digest)
2116 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))
2117 }
2118
2119 async fn wait_for_record_change(
2120 &self,
2121 local_attempt: Option<Arc<PendingAttempt>>,
2122 ) -> Result<PendingAttachmentStatus, AttachmentClientError> {
2123 let mut watch = self.shared.watch.subscribe();
2124 let mut attempt_watch = local_attempt.map(|attempt| attempt.outcome.subscribe());
2125 loop {
2126 if let Some(result) = attempt_watch
2127 .as_ref()
2128 .and_then(|receiver| receiver.borrow().clone())
2129 {
2130 return Ok(match result {
2131 Ok(()) => PendingAttachmentStatus::Complete,
2132 Err(error) => PendingAttachmentStatus::Failed(error),
2133 });
2134 }
2135 match self.record() {
2136 Ok(Some(row)) => {
2137 let status = status_from_row(&row, now_ns());
2138 if status != PendingAttachmentStatus::Uploading
2139 && self.shared.lease.lock().is_none()
2140 {
2141 self.shared.watch.send_replace(status.clone());
2142 return Ok(status);
2143 }
2144 }
2145 Ok(None) => {
2146 return Err(AttachmentClientError::new(Cause::Deleted));
2147 }
2148 Err(error) => {
2149 tracing::warn!(%error, "pending attachment status read will be retried")
2150 }
2151 }
2152 let poll = self.context.attachment_runtime().lease_timing.lock().poll;
2153 tokio::select! {
2154 _ = xmtp_common::time::sleep(poll) => {},
2155 changed = watch.changed() => {
2156 if changed.is_err() {
2157 return Err(AttachmentClientError::new(Cause::LocalStorage));
2158 }
2159 }
2160 changed = async {
2161 if let Some(receiver) = attempt_watch.as_mut() {
2162 receiver.changed().await
2163 } else {
2164 std::future::pending().await
2165 }
2166 } => {
2167 if changed.is_err() {
2168 attempt_watch = None;
2169 }
2170 }
2171 }
2172 }
2173 }
2174
2175 pub async fn upload(&self) -> Result<(), AttachmentClientError> {
2176 loop {
2177 let mut claim_missed = false;
2178 let (should_wait, local_attempt) = {
2179 let _state = self.shared.state.lock().await;
2180 let row = self
2181 .record()?
2182 .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2183 match status_from_row(&row, now_ns()) {
2184 PendingAttachmentStatus::Complete => return Ok(()),
2185 PendingAttachmentStatus::Failed(error)
2186 if error.cause == Cause::BackendRejected =>
2187 {
2188 return Err(error);
2189 }
2190 PendingAttachmentStatus::Uploading => {
2191 (true, self.shared.attempt.lock().clone())
2192 }
2193 PendingAttachmentStatus::Waiting | PendingAttachmentStatus::Failed(_)
2194 if self.shared.lease.lock().is_some() =>
2195 {
2196 (true, self.shared.attempt.lock().clone())
2197 }
2198 PendingAttachmentStatus::Waiting | PendingAttachmentStatus::Failed(_) => {
2199 let token = xmtp_common::rand_array::<16>();
2200 let now = now_ns();
2201 let timing = *self.context.attachment_runtime().lease_timing.lock();
2202 let event_lock = self
2203 .context
2204 .attachment_runtime()
2205 .event_lock(&self.reference().attachment_key);
2206 let _event_guard = event_lock.lock().await;
2207 let claimed = self
2208 .context
2209 .db()
2210 .claim_pending_attachment(
2211 &self.remote.content_digest,
2212 &token,
2213 now,
2214 timing.duration_ns(),
2215 )
2216 .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
2217 if claimed == 0 {
2218 claim_missed = true;
2219 (false, None)
2220 } else {
2221 let attempt = Arc::new(PendingAttempt::new());
2222 *self.shared.attempt.lock() = Some(attempt.clone());
2223 *self.shared.lease.lock() =
2224 Some((token, now.saturating_add(timing.duration_ns())));
2225 self.shared
2226 .watch
2227 .send_replace(PendingAttachmentStatus::Uploading);
2228 self.context.events().emit(
2229 Some(ClientEvent::AttachmentUploadStarted(self.reference())),
2230 None,
2231 );
2232 let pending = PendingAttachment {
2233 context: self.context.context_ref().clone(),
2234 remote: self.remote.clone(),
2235 shared: self.shared.clone(),
2236 };
2237 let local_attempt = attempt.clone();
2239 let done_guard = PendingAttemptDoneGuard {
2240 shared: self.shared.clone(),
2241 attempt: attempt.clone(),
2242 };
2243 drop(xmtp_common::task::spawn(async move {
2244 let _done_guard = done_guard;
2245 pending.run_attempt(token, attempt).await;
2246 }));
2247 (true, Some(local_attempt))
2248 }
2249 }
2250 }
2251 };
2252 if claim_missed {
2253 let row = self
2254 .record()?
2255 .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2256 match status_from_row(&row, now_ns()) {
2257 PendingAttachmentStatus::Complete => return Ok(()),
2258 PendingAttachmentStatus::Failed(error) => return Err(error),
2259 PendingAttachmentStatus::Waiting => continue,
2260 PendingAttachmentStatus::Uploading => {}
2261 }
2262 }
2263 if should_wait || claim_missed {
2264 match self.wait_for_record_change(local_attempt).await? {
2265 PendingAttachmentStatus::Complete => return Ok(()),
2266 PendingAttachmentStatus::Failed(error) => return Err(error),
2267 PendingAttachmentStatus::Waiting => continue,
2268 PendingAttachmentStatus::Uploading => continue,
2269 }
2270 }
2271 }
2272 }
2273
2274 fn lease_is_current(&self, token: &[u8; 16]) -> bool {
2275 self.shared
2276 .lease
2277 .lock()
2278 .as_ref()
2279 .is_some_and(|(owner, end)| owner == token && now_ns() < *end)
2280 }
2281
2282 async fn run_attempt(&self, token: [u8; 16], attempt: Arc<PendingAttempt>) {
2283 let cancel = self.shared.cancel_token();
2284 #[cfg(all(test, not(target_arch = "wasm32")))]
2285 {
2286 let pause = self
2287 .context
2288 .attachment_runtime()
2289 .attempt_panic_pause
2290 .lock()
2291 .clone();
2292 if let Some((entered, resume)) = pause {
2293 entered.notify_one();
2294 resume.notified().await;
2295 panic!("forced attachment upload panic");
2296 }
2297 }
2298 let timing = *self.context.attachment_runtime().lease_timing.lock();
2299 let mut tick = Box::pin(xmtp_common::time::interval_stream(timing.renew));
2300 #[cfg(not(target_arch = "wasm32"))]
2301 tick.next().await;
2302 let mut transfer = Box::pin(self.upload_once(&token));
2303 let result = loop {
2304 tokio::select! {
2305 biased;
2306 _ = cancel.cancelled() => break Err(AttachmentClientError::new(Cause::Deleted)),
2307 result = &mut transfer => break result,
2308 _ = tick.next() => {
2309 let now = now_ns();
2310 let extension = self.context.db().extend_pending_attachment(
2311 &self.remote.content_digest, &token, now, timing.duration_ns()
2312 );
2313 match extension {
2314 Ok(1) => *self.shared.lease.lock() = Some((token, now.saturating_add(timing.duration_ns()))),
2315 Ok(_) => {
2316 self.lost_lease(&attempt).await;
2317 return;
2318 }
2319 Err(error) => {
2320 #[cfg(test)]
2321 self.context.attachment_runtime().lease_extension_errors.fetch_add(1, AtomicOrdering::SeqCst);
2322 tracing::warn!(%error, "attachment lease extension will be retried");
2323 },
2324 }
2325 }
2326 }
2327 };
2328 drop(transfer);
2329 self.finish_attempt(&token, result, &attempt).await;
2330 }
2331
2332 fn end_local_attempt(
2333 &self,
2334 attempt: &Arc<PendingAttempt>,
2335 result: Option<&Result<(), AttachmentClientError>>,
2336 ) {
2337 let mut active = self.shared.attempt.lock();
2338 if active
2339 .as_ref()
2340 .is_some_and(|current| Arc::ptr_eq(current, attempt))
2341 {
2342 *active = None;
2343 }
2344 drop(active);
2345 if let Some(result) = result {
2346 attempt.outcome.send_replace(Some(result.clone()));
2347 }
2348 attempt.done.cancel();
2349 }
2350
2351 fn end_cancelled_attempt(&self, attempt: &Arc<PendingAttempt>) {
2352 *self.shared.lease.lock() = None;
2353 let deleted = Err(AttachmentClientError::new(Cause::Deleted));
2354 self.end_local_attempt(attempt, Some(&deleted));
2355 }
2356
2357 async fn lost_lease(&self, attempt: &Arc<PendingAttempt>) {
2358 *self.shared.lease.lock() = None;
2359 {
2360 let event_lock = self
2365 .context
2366 .attachment_runtime()
2367 .event_lock(&self.reference().attachment_key);
2368 let _event_guard = event_lock.lock().await;
2369 match self.record() {
2370 Ok(Some(row)) => {
2371 self.shared
2372 .watch
2373 .send_replace(status_from_row(&row, now_ns()));
2374 }
2375 Ok(None) => {
2376 self.publish_upload_outcome(&Err(AttachmentClientError::new(Cause::Deleted)));
2377 }
2378 Err(error) => {
2379 tracing::warn!(%error, "lost attachment lease status read will be retried")
2380 }
2381 }
2382 }
2383 self.end_local_attempt(attempt, None);
2384 }
2385
2386 async fn finish_attempt(
2387 &self,
2388 token: &[u8; 16],
2389 result: Result<(), AttachmentClientError>,
2390 attempt: &Arc<PendingAttempt>,
2391 ) {
2392 let cancel = self.shared.cancel_token();
2393 let mut delay = Duration::from_millis(100);
2394 let mut retrying = false;
2395 let timing = *self.context.attachment_runtime().lease_timing.lock();
2396 let mut renew = Box::pin(xmtp_common::time::interval_stream(timing.renew));
2397 #[cfg(not(target_arch = "wasm32"))]
2398 renew.next().await;
2399 loop {
2400 if retrying && cancel.is_cancelled() {
2401 self.end_cancelled_attempt(attempt);
2402 return;
2403 }
2404 #[cfg(all(test, not(target_arch = "wasm32")))]
2405 let outcome_pause = self
2406 .context
2407 .attachment_runtime()
2408 .outcome_lock_pause
2409 .lock()
2410 .take();
2411 #[cfg(all(test, not(target_arch = "wasm32")))]
2412 if let Some((entered, resume)) = outcome_pause {
2413 entered.notify_one();
2414 resume.notified().await;
2415 }
2416 let event_lock = self
2417 .context
2418 .attachment_runtime()
2419 .event_lock(&self.reference().attachment_key);
2420 let _event_guard = event_lock.lock().await;
2421 if cancel.is_cancelled() {
2422 drop(_event_guard);
2423 self.end_cancelled_attempt(attempt);
2424 return;
2425 }
2426 let recorded = match &result {
2427 Ok(()) => PendingAttachmentOutcome::Complete,
2428 Err(error) => PendingAttachmentOutcome::Failed {
2429 cause: error.cause.as_str(),
2430 credential_kind: error.credential_kind.map(CredentialFailureKind::as_str),
2431 retryable: Some(error.retryable),
2432 missing_scope: Some(error.missing_scope),
2433 http_status: error.http_status,
2434 },
2435 };
2436 let outcome = self.context.db().finish_pending_attachment(
2437 &self.remote.content_digest,
2438 token,
2439 now_ns(),
2440 recorded,
2441 );
2442 #[cfg(test)]
2443 if outcome.is_err() {
2444 self.context
2445 .attachment_runtime()
2446 .outcome_write_errors
2447 .fetch_add(1, AtomicOrdering::SeqCst);
2448 }
2449 match outcome {
2450 Ok(1) => {
2451 *self.shared.lease.lock() = None;
2452 if result.is_ok()
2453 && let Ok(store) = self.context.attachment_runtime().store()
2454 && let Ok(path) = staged_path(&self.remote.content_digest)
2455 {
2456 #[cfg(all(test, not(target_arch = "wasm32")))]
2457 let removal = if self
2458 .context
2459 .attachment_runtime()
2460 .fail_next_staged_removal
2461 .swap(false, AtomicOrdering::SeqCst)
2462 {
2463 Err(AttachmentError::new(Cause::LocalStorage))
2464 } else {
2465 store.remove_file(&path).await
2466 };
2467 #[cfg(not(all(test, not(target_arch = "wasm32"))))]
2468 let removal = store.remove_file(&path).await;
2469 if let Err(error) = removal {
2470 tracing::warn!(%error, "staged ciphertext cleanup will be retried by reconciliation");
2471 }
2472 }
2473 self.publish_upload_outcome(&result);
2474 self.end_local_attempt(attempt, Some(&result));
2475 return;
2476 }
2477 Ok(_) => {
2478 drop(_event_guard);
2479 self.lost_lease(attempt).await;
2480 return;
2481 }
2482 Err(error) if outcome_write_is_retryable(&error) => {
2483 retrying = true;
2484 tracing::warn!(%error, "attachment outcome write will be retried");
2485 }
2486 Err(error) => {
2487 tracing::error!(%error, "attachment outcome write cannot be retried");
2488 *self.shared.lease.lock() = None;
2491 let failed = Err(AttachmentClientError::new(Cause::LocalStorage));
2492 self.end_local_attempt(attempt, Some(&failed));
2493 return;
2494 }
2495 }
2496 drop(_event_guard);
2497 let retry_delay = xmtp_common::time::sleep(delay);
2498 tokio::pin!(retry_delay);
2499 loop {
2500 tokio::select! {
2501 biased;
2502 _ = cancel.cancelled() => {
2503 self.end_cancelled_attempt(attempt);
2504 return;
2505 }
2506 _ = &mut retry_delay => break,
2507 _ = renew.next() => {
2508 let now = now_ns();
2509 let extension = self.context.db().extend_pending_attachment(
2510 &self.remote.content_digest,
2511 token,
2512 now,
2513 timing.duration_ns(),
2514 );
2515 match extension {
2516 Ok(1) => *self.shared.lease.lock() =
2517 Some((*token, now.saturating_add(timing.duration_ns()))),
2518 Ok(_) => {
2519 self.lost_lease(attempt).await;
2520 return;
2521 }
2522 Err(error) => {
2523 #[cfg(test)]
2524 self.context
2525 .attachment_runtime()
2526 .lease_extension_errors
2527 .fetch_add(1, AtomicOrdering::SeqCst);
2528 tracing::warn!(%error, "attachment lease extension will be retried");
2529 }
2530 }
2531 }
2532 }
2533 }
2534 delay = delay.saturating_mul(2).min(Duration::from_secs(5));
2535 }
2536 }
2537
2538 fn publish_upload_outcome(&self, result: &Result<(), AttachmentClientError>) {
2539 let next = match result {
2540 Ok(()) => PendingAttachmentStatus::Complete,
2541 Err(error) => PendingAttachmentStatus::Failed(error.clone()),
2542 };
2543 self.shared.watch.send_replace(next);
2544 let reference = self.reference();
2545 let event = match result {
2546 Ok(()) => ClientEvent::AttachmentUploadCompleted(reference),
2547 Err(error) => ClientEvent::AttachmentUploadFailed(AttachmentFailed {
2548 attachment_key: reference.attachment_key,
2549 url: reference.url,
2550 content_digest: reference.content_digest,
2551 cause: error.cause.as_str().to_owned(),
2552 }),
2553 };
2554 self.context.events().emit(Some(event), None);
2555 }
2556
2557 async fn upload_once(&self, token: &[u8; 16]) -> Result<(), AttachmentClientError> {
2558 self.context
2559 .server_configuration()
2560 .check()
2561 .map_err(|_| AttachmentClientError::new(Cause::ConnectionBlocked))?;
2562 let runtime = self.context.attachment_runtime();
2563 let store = runtime.store()?;
2564 let path = staged_path(&self.remote.content_digest)?;
2565 let storage_error = |_| AttachmentClientError {
2566 retryable: true,
2567 ..AttachmentClientError::new(Cause::LocalStorage)
2568 };
2569 if !store.exists(&path).await.map_err(storage_error)? {
2570 return Err(AttachmentClientError::new(Cause::StagedUnusable));
2571 }
2572 let expected_length = self
2573 .remote
2574 .content_length
2575 .map(u64::from)
2576 .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2577 let staged = store.open_read(&path).await.map_err(storage_error)?;
2578 let (digest, length) = staged
2579 .sha256(expected_length)
2580 .await
2581 .map_err(storage_error)?;
2582 if hex::encode(digest) != self.remote.content_digest || length != expected_length {
2583 return Err(AttachmentClientError::new(Cause::StagedUnusable));
2584 }
2585 if !self.lease_is_current(token) {
2586 return Err(AttachmentClientError::new(Cause::Network));
2587 }
2588 let response = self
2589 .context
2590 .api()
2591 .create_upload(CreateUploadRequest {
2592 content_digest: digest.to_vec(),
2593 content_length: length,
2594 })
2595 .await
2596 .map_err(api_error)?;
2597 let request = UploadRequest {
2598 method: response.method,
2599 url: response.url,
2600 headers: response
2601 .headers
2602 .into_iter()
2603 .map(|header| (header.name, header.value))
2604 .collect(),
2605 expires_in_seconds: response.expires_in_seconds,
2606 };
2607 if !self.lease_is_current(token) {
2608 return Err(AttachmentClientError::new(Cause::Network));
2609 }
2610 let transfer = Transfer::new(runtime.options.clone())?;
2611 transfer.put(&request, staged).await?;
2612 Ok(())
2613 }
2614}
2615
2616pub(crate) mod cleanup {
2617 use super::*;
2618 use crate::worker::{BoxedWorker, DynMetrics, Worker, WorkerFactory, WorkerKind, WorkerResult};
2619
2620 pub struct AttachmentCleanup<Context> {
2621 context: Context,
2622 }
2623 struct Factory<Context> {
2624 context: Context,
2625 }
2626
2627 impl<Context: XmtpSharedContext + 'static> WorkerFactory for Factory<Context> {
2628 fn kind(&self) -> WorkerKind {
2629 WorkerKind::AttachmentCleanup
2630 }
2631 fn create(&self, metrics: Option<DynMetrics>) -> (BoxedWorker, Option<DynMetrics>) {
2632 (
2633 Box::new(AttachmentCleanup {
2634 context: self.context.clone(),
2635 }),
2636 metrics,
2637 )
2638 }
2639 }
2640
2641 #[xmtp_common::async_trait]
2642 impl<Context: XmtpSharedContext + 'static> Worker for AttachmentCleanup<Context> {
2643 fn kind(&self) -> WorkerKind {
2644 WorkerKind::AttachmentCleanup
2645 }
2646 fn factory<C>(context: C) -> impl WorkerFactory + 'static
2647 where
2648 Self: Sized,
2649 C: XmtpSharedContext + 'static,
2650 {
2651 Factory { context }
2652 }
2653 async fn run_tasks(&mut self) -> WorkerResult<()> {
2654 loop {
2655 let (interval, _) = self
2656 .context
2657 .worker_interval(WorkerKind::AttachmentCleanup, Duration::from_secs(3600));
2658 xmtp_common::time::sleep(interval.min(Duration::from_secs(3600))).await;
2659 let runtime = self.context.attachment_runtime();
2660 if let Err(error) = runtime.sweep(&self.context).await {
2661 tracing::warn!(%error, "attachment expiry sweep failed");
2662 }
2663 if let Err(error) = runtime.reconcile(&self.context).await {
2664 tracing::warn!(%error, "attachment reconciliation failed");
2665 }
2666 }
2667 }
2668 }
2669}
2670
2671#[cfg(all(test, not(target_arch = "wasm32")))]
2672mod tests;