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