1use crate::context::MutationContext;
19use crate::control_update::{
20 read_upload_session_state, update_upload_session, UploadSessionUpdate,
21};
22use crate::engine::{
23 BeginDirectMultipartUploadTargetResponse, BeginDirectPutUploadTargetResponse,
24 DirectMultipartUploadTarget, DirectPutUploadTarget, MultipartPartTarget, MultipartPartTargets,
25};
26use crate::error::MetadataProjectionLoadError;
27use crate::error::{CoreError, Result};
28use crate::limits::{
29 COMPLETED_UPLOAD_RECEIPT_WINDOW_MS, CONTENTION_RETRY_LIMIT, MAX_MULTIPART_PARTS,
30 MAX_MULTIPART_PART_BYTES, MAX_SIGNED_PARTS_PER_REQUEST, MIN_MULTIPART_PART_BYTES,
31 UPLOAD_SESSION_LEASE_MS,
32};
33use crate::namespace::catalog::{load_namespace_content_store_id, VerifiedNamespaceCatalogEntry};
34use crate::namespace::control::load_namespace_head_control;
35use crate::storage::content::{
36 abort_unpublished_multipart_upload, delete_unpublished_content_object,
37 identify_streamed_payload, stage_bytes_under_content_id, stage_streamed_under_content_id,
38 verify_durable_content_checksum,
39};
40use crate::storage::content_admission::{
41 CompletedUploadReceipt, ContentAdmission, PreparedContent,
42};
43use bytes::Bytes;
44use loonfs_api::v0::{
45 AbortUploadResponse, BeginUploadRequest, BeginUploadResponse, CompleteUploadRequest,
46 CompleteUploadResponse, CompletedUploadPart, DirectMultipartContentClaim,
47 DirectMultipartUploadOptions, DirectPutContentClaim, UploadContentResponse, UploadMode,
48 UploadPartChecksumClaim, UploadSessionStatus, UploadStatusResponse,
49};
50use loonfs_api::wire::control::{
51 encode_control_object, ControlObjectKind, NamespaceState, UploadSessionEnvelope,
52 UploadSessionLifecycle, UploadSessionState, UploadSessionTransport,
53};
54use loonfs_api::{
55 ChecksumAlgorithm, ContentId, ContentRef, ContentRefKind, ContentStoreId, NamespaceId,
56 StorageChecksum, UploadId,
57};
58use loonfs_objectstore::keys::{content_blob, upload_session};
59use loonfs_objectstore::{
60 ByteStream, MultipartCompletion, MultipartPart, ObjectStore, PROVIDER_MULTIPART_PART_BYTES,
61};
62use std::num::NonZeroU64;
63
64pub(crate) async fn begin_upload<S: ObjectStore + ?Sized>(
65 store: &S,
66 namespace_id: &NamespaceId,
67 request: BeginUploadRequest,
68 context: &MutationContext,
69) -> Result<BeginUploadResponse> {
70 ensure_upload_namespace_available(store, namespace_id).await?;
71 if !matches!(request, BeginUploadRequest::ServiceProxied {}) {
72 return Err(CoreError::InvalidUploadContent(format!(
75 "{} requires a presigned URL issuer",
76 upload_mode_name(request.mode())
77 )));
78 }
79 let upload_id = create_upload_session(
80 store,
81 namespace_id,
82 NewUploadSession::service_proxied(),
83 context,
84 )
85 .await?;
86 Ok(BeginUploadResponse {
87 namespace_id: namespace_id.clone(),
88 upload_id,
89 mode: UploadMode::ServiceProxied,
90 direct_put: None,
91 direct_multipart: None,
92 })
93}
94
95fn upload_mode_name(mode: UploadMode) -> &'static str {
96 match mode {
97 UploadMode::ServiceProxied => "service_proxied",
98 UploadMode::DirectPut => "direct_put",
99 UploadMode::DirectMultipart => "direct_multipart",
100 }
101}
102
103pub(crate) async fn begin_direct_put_upload_target<S: ObjectStore + ?Sized>(
111 store: &S,
112 namespace_id: &NamespaceId,
113 claim: DirectPutContentClaim,
114 context: &MutationContext,
115) -> Result<BeginDirectPutUploadTargetResponse> {
116 ensure_upload_namespace_available(store, namespace_id).await?;
117 let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
118 let content_id = ContentId::generate();
119 let content_ref = direct_put_content_ref(content_id.clone(), &claim)?;
120 let object_key = content_blob(content_store_id.as_str(), &content_id);
121 let upload_id = create_upload_session(
122 store,
123 namespace_id,
124 NewUploadSession::direct_put(content_ref.clone()),
125 context,
126 )
127 .await?;
128 Ok(BeginDirectPutUploadTargetResponse {
129 namespace_id: namespace_id.clone(),
130 upload_id,
131 target: DirectPutUploadTarget {
132 content_ref,
133 object_key,
134 },
135 })
136}
137
138pub(crate) async fn begin_direct_multipart_upload_target<S: ObjectStore + ?Sized>(
153 store: &S,
154 namespace_id: &NamespaceId,
155 options: DirectMultipartUploadOptions,
156 context: &MutationContext,
157) -> Result<BeginDirectMultipartUploadTargetResponse> {
158 ensure_upload_namespace_available(store, namespace_id).await?;
159 let part_size_bytes = multipart_part_size(options.part_size_bytes)?;
160 let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
161 let content_id = ContentId::generate();
162 let object_key = content_blob(content_store_id.as_str(), &content_id);
163
164 let provider_upload_id = store
165 .create_multipart_upload(&object_key)
166 .await
167 .map_err(|err| CoreError::store(&object_key, &err))?;
168 let session = NewUploadSession::direct_multipart(
169 content_id.clone(),
170 &provider_upload_id,
171 part_size_bytes,
172 );
173 let upload_id = match create_upload_session(store, namespace_id, session, context).await {
174 Ok(upload_id) => upload_id,
175 Err(error) => {
176 abort_unpublished_multipart_upload(
177 store,
178 &content_store_id,
179 &content_id,
180 &provider_upload_id,
181 )
182 .await;
183 return Err(error);
184 }
185 };
186
187 Ok(BeginDirectMultipartUploadTargetResponse {
188 namespace_id: namespace_id.clone(),
189 upload_id,
190 target: DirectMultipartUploadTarget {
191 object_key,
192 part_size_bytes: part_size_bytes.get(),
193 },
194 })
195}
196
197fn multipart_part_size(requested: Option<u64>) -> Result<NonZeroU64> {
205 let part_size_bytes = requested.unwrap_or(PROVIDER_MULTIPART_PART_BYTES);
206 NonZeroU64::new(part_size_bytes)
207 .filter(|size| (MIN_MULTIPART_PART_BYTES..=MAX_MULTIPART_PART_BYTES).contains(&size.get()))
208 .ok_or_else(|| {
209 CoreError::InvalidUploadContent(format!(
210 "part_size_bytes must be between {MIN_MULTIPART_PART_BYTES} and \
211 {MAX_MULTIPART_PART_BYTES} bytes"
212 ))
213 })
214}
215
216pub(crate) async fn direct_multipart_part_targets<S: ObjectStore + ?Sized>(
224 store: &S,
225 namespace_id: &NamespaceId,
226 upload_id: &UploadId,
227 requested: &[UploadPartChecksumClaim],
228) -> Result<MultipartPartTargets> {
229 if requested.is_empty() {
230 return Err(CoreError::InvalidUploadContent(
231 "a part-signing request names at least one part".to_owned(),
232 ));
233 }
234 if requested.len() > MAX_SIGNED_PARTS_PER_REQUEST {
235 return Err(CoreError::InvalidUploadContent(format!(
236 "a part-signing request names at most {MAX_SIGNED_PARTS_PER_REQUEST} parts"
237 )));
238 }
239 let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
240 let session = read_upload_session_state(store, namespace_id, upload_id).await?;
241 if let Some(error) = terminal_session_error(&session.state, upload_id.clone()) {
242 return Err(error);
243 }
244 let provider_upload_id = multipart_session_upload(&session)?;
245
246 let mut parts = Vec::with_capacity(requested.len());
247 for claim in requested {
248 if claim.part_number == 0 || claim.part_number > MAX_MULTIPART_PARTS {
252 return Err(CoreError::InvalidUploadContent(format!(
253 "part {} is outside the provider's 1..={MAX_MULTIPART_PARTS} part range",
254 claim.part_number
255 )));
256 }
257 parts.push(MultipartPartTarget {
258 part_number: claim.part_number,
259 checksum: crc64nvme_claim(&claim.crc64nvme)?,
260 });
261 }
262
263 Ok(MultipartPartTargets {
264 object_key: content_blob(content_store_id.as_str(), &session.content_id),
265 provider_upload_id: provider_upload_id.to_owned(),
266 parts,
267 })
268}
269
270fn multipart_session_upload(session: &UploadSessionState) -> Result<&str> {
275 match &session.transport {
276 UploadSessionTransport::DirectMultipart {
277 provider_upload_id, ..
278 } => Ok(provider_upload_id),
279 UploadSessionTransport::ServiceProxied {} | UploadSessionTransport::DirectPut { .. } => {
280 Err(CoreError::InvalidUploadContent(
281 "this upload session is not a direct_multipart upload".to_owned(),
282 ))
283 }
284 }
285}
286
287fn direct_multipart_content_ref(
296 content_id: ContentId,
297 claim: &DirectMultipartContentClaim,
298) -> Result<ContentRef> {
299 let content_ref = ContentRef {
300 kind: ContentRefKind::BlobV1,
301 content_id,
302 size_bytes: claim.size_bytes,
303 storage_checksum: crc64nvme_claim(&claim.crc64nvme)?,
304 whole_file_sha256: None,
305 };
306 content_ref
307 .validate()
308 .map_err(|err| CoreError::InvalidUploadContent(err.to_string()))?;
309 Ok(content_ref)
310}
311
312fn crc64nvme_claim(value: &str) -> Result<StorageChecksum> {
313 let checksum = StorageChecksum {
314 algorithm: ChecksumAlgorithm::Crc64nvme,
315 value: value.to_owned(),
316 };
317 let width = ChecksumAlgorithm::Crc64nvme.value_bytes() * 2;
318 if checksum.value.len() != width
319 || !checksum
320 .value
321 .bytes()
322 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
323 {
324 return Err(CoreError::InvalidUploadContent(format!(
325 "crc64nvme must be {width} lowercase hex characters"
326 )));
327 }
328 Ok(checksum)
329}
330
331fn multipart_parts(parts: &[CompletedUploadPart]) -> Result<Vec<MultipartPart>> {
333 let mut previous = 0;
334 parts
335 .iter()
336 .map(|part| {
337 if part.part_number <= previous {
338 return Err(CoreError::InvalidUploadContent(
339 "completion lists each part once, in ascending part order".to_owned(),
340 ));
341 }
342 previous = part.part_number;
343 if part.etag.trim().is_empty() {
344 return Err(CoreError::InvalidUploadContent(format!(
345 "part {} carries no etag",
346 part.part_number
347 )));
348 }
349 Ok(MultipartPart {
350 part_number: part.part_number,
351 etag: part.etag.clone(),
352 checksum: crc64nvme_claim(&part.crc64nvme)?,
353 })
354 })
355 .collect()
356}
357
358fn direct_put_content_ref(
365 content_id: ContentId,
366 claim: &DirectPutContentClaim,
367) -> Result<ContentRef> {
368 let storage_checksum = StorageChecksum {
369 algorithm: ChecksumAlgorithm::Sha256,
370 value: claim.sha256.clone(),
371 };
372 let content_ref = ContentRef {
373 kind: ContentRefKind::BlobV1,
374 content_id,
375 size_bytes: claim.size_bytes,
376 whole_file_sha256: Some(storage_checksum.value.clone()),
377 storage_checksum,
378 };
379 content_ref
380 .validate()
381 .map_err(|err| CoreError::InvalidUploadContent(err.to_string()))?;
382 Ok(content_ref)
383}
384
385struct NewUploadSession {
390 content_id: ContentId,
392 transport: UploadSessionTransport,
394}
395
396impl NewUploadSession {
397 fn service_proxied() -> Self {
398 Self {
399 content_id: ContentId::generate(),
400 transport: UploadSessionTransport::ServiceProxied {},
401 }
402 }
403
404 fn direct_put(content_ref: ContentRef) -> Self {
405 Self {
406 content_id: content_ref.content_id.clone(),
407 transport: UploadSessionTransport::DirectPut {
408 promised_content: content_ref,
409 },
410 }
411 }
412
413 fn direct_multipart(
416 content_id: ContentId,
417 provider_upload_id: &str,
418 part_size_bytes: NonZeroU64,
419 ) -> Self {
420 Self {
421 content_id,
422 transport: UploadSessionTransport::DirectMultipart {
423 provider_upload_id: provider_upload_id.to_owned(),
424 part_size_bytes,
425 },
426 }
427 }
428}
429
430async fn create_upload_session<S: ObjectStore + ?Sized>(
431 store: &S,
432 namespace_id: &NamespaceId,
433 session: NewUploadSession,
434 context: &MutationContext,
435) -> Result<UploadId> {
436 let upload_id = UploadId::generate();
437 let state = UploadSessionState {
438 namespace_id: namespace_id.clone(),
439 upload_id: upload_id.clone(),
440 content_id: session.content_id,
441 created_at_ms: context.now_ms,
442 transport: session.transport,
443 state: UploadSessionLifecycle::Open {
444 expires_at_ms: context.now_ms.saturating_add(UPLOAD_SESSION_LEASE_MS),
445 staged_content: None,
446 },
447 };
448 let envelope = UploadSessionEnvelope::from_state(ControlObjectKind::UploadSession, state)
449 .map_err(|err| {
450 CoreError::Internal(format!("failed to build upload session envelope: {err}"))
451 })?;
452 let encoded = encode_control_object(&envelope).map_err(|err| {
453 CoreError::Internal(format!("failed to encode upload session envelope: {err}"))
454 })?;
455 let object_key = upload_session(namespace_id.as_str(), upload_id.as_str());
456 store
457 .put_if_absent(&object_key, Bytes::from(encoded))
458 .await
459 .map_err(|err| CoreError::store(&object_key, &err))?;
460 Ok(upload_id)
461}
462
463async fn ensure_upload_namespace_available<S: ObjectStore + ?Sized>(
467 store: &S,
468 namespace_id: &NamespaceId,
469) -> Result<()> {
470 let head = load_namespace_head_control(store, namespace_id)
471 .await
472 .map_err(|error| {
473 CoreError::MetadataProjection(MetadataProjectionLoadError::LoadHead(error))
474 })?
475 .state;
476 if head.state == NamespaceState::Deleted {
477 return Err(CoreError::NamespaceDeleted {
478 namespace_id: namespace_id.clone(),
479 });
480 }
481 Ok(())
482}
483
484fn terminal_session_error(
490 state: &UploadSessionLifecycle,
491 upload_id: UploadId,
492) -> Option<CoreError> {
493 match state {
494 UploadSessionLifecycle::Open { .. } => None,
495 UploadSessionLifecycle::Completed { .. } => {
496 Some(CoreError::UploadAlreadyCompleted { upload_id })
497 }
498 UploadSessionLifecycle::Aborted { .. } => Some(CoreError::UploadNotFound { upload_id }),
499 }
500}
501
502fn open_staging_slot<'a>(
508 state: &'a mut UploadSessionLifecycle,
509 upload_id: &UploadId,
510) -> Result<&'a mut Option<ContentRef>> {
511 match state {
512 UploadSessionLifecycle::Open { staged_content, .. } => Ok(staged_content),
513 UploadSessionLifecycle::Completed { .. } => Err(CoreError::UploadAlreadyCompleted {
514 upload_id: upload_id.clone(),
515 }),
516 UploadSessionLifecycle::Aborted { .. } => Err(CoreError::UploadNotFound {
517 upload_id: upload_id.clone(),
518 }),
519 }
520}
521
522fn staged_content(state: &UploadSessionLifecycle) -> Option<&ContentRef> {
525 match state {
526 UploadSessionLifecycle::Open { staged_content, .. } => staged_content.as_ref(),
527 UploadSessionLifecycle::Completed { .. } | UploadSessionLifecycle::Aborted { .. } => None,
528 }
529}
530
531fn transport_name(transport: &UploadSessionTransport) -> &'static str {
533 match transport {
534 UploadSessionTransport::ServiceProxied {} => "service_proxied",
535 UploadSessionTransport::DirectPut { .. } => "direct_put",
536 UploadSessionTransport::DirectMultipart { .. } => "direct_multipart",
537 }
538}
539
540pub(crate) async fn upload_content<S: ObjectStore + ?Sized>(
548 store: &S,
549 namespace_id: &NamespaceId,
550 upload_id: &UploadId,
551 bytes: &[u8],
552) -> Result<UploadContentResponse> {
553 let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
554
555 update_upload_session(
556 store,
557 namespace_id,
558 upload_id,
559 CONTENTION_RETRY_LIMIT,
560 |mut state| {
561 let content_store_id = content_store_id.clone();
562 let namespace_id = namespace_id.clone();
563 let upload_id = upload_id.to_owned();
564 async move {
565 if let Some(error) = terminal_session_error(&state.state, upload_id.clone()) {
566 return Err(error);
567 }
568 if !matches!(state.transport, UploadSessionTransport::ServiceProxied {}) {
569 return Err(CoreError::InvalidUploadContent(format!(
570 "{} sessions must be completed after using the presigned URLs",
571 transport_name(&state.transport)
572 )));
573 }
574
575 let content_ref = ContentRef::blob_v1(state.content_id.clone(), bytes);
576 if let Some(existing) = staged_content(&state.state) {
577 if existing == &content_ref {
578 return Ok(UploadSessionUpdate::Noop(UploadContentResponse {
579 namespace_id,
580 upload_id,
581 content_ref,
582 }));
583 }
584 return Err(CoreError::UploadContentConflict { upload_id });
585 }
586
587 let stored = stage_bytes_under_content_id(
588 store,
589 content_store_id,
590 state.content_id.clone(),
591 bytes,
592 )
593 .await?;
594 *open_staging_slot(&mut state.state, &upload_id)? =
595 Some(stored.content_ref.clone());
596
597 Ok(UploadSessionUpdate::Replace {
598 next: Box::new(state),
599 outcome: UploadContentResponse {
600 namespace_id,
601 upload_id,
602 content_ref: stored.content_ref,
603 },
604 })
605 }
606 },
607 )
608 .await
609}
610
611pub(crate) async fn upload_streamed_content<S: ObjectStore + ?Sized>(
623 store: &S,
624 namespace_id: &NamespaceId,
625 upload_id: &UploadId,
626 body: ByteStream,
627) -> Result<UploadContentResponse> {
628 let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
629 let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
630 if let Some(error) = terminal_session_error(&loaded.state, upload_id.clone()) {
631 return Err(error);
632 }
633 if !matches!(loaded.transport, UploadSessionTransport::ServiceProxied {}) {
634 return Err(CoreError::InvalidUploadContent(format!(
635 "{} sessions must be completed after using the presigned URLs",
636 transport_name(&loaded.transport)
637 )));
638 }
639
640 if let Some(staged) = staged_content(&loaded.state) {
645 let content_ref = identify_streamed_payload(loaded.content_id.clone(), body).await?;
646 if staged != &content_ref {
647 return Err(CoreError::UploadContentConflict {
648 upload_id: upload_id.clone(),
649 });
650 }
651 return Ok(UploadContentResponse {
652 namespace_id: namespace_id.clone(),
653 upload_id: upload_id.clone(),
654 content_ref,
655 });
656 }
657
658 let staged =
659 stage_streamed_under_content_id(store, content_store_id, loaded.content_id.clone(), body)
660 .await?;
661
662 update_upload_session(
663 store,
664 namespace_id,
665 upload_id,
666 CONTENTION_RETRY_LIMIT,
667 |mut state| {
668 let namespace_id = namespace_id.clone();
669 let upload_id = upload_id.to_owned();
670 let content_ref = staged.content_ref.clone();
671 let already_present = staged.already_present;
672 async move {
673 if let Some(error) = terminal_session_error(&state.state, upload_id.clone()) {
674 return Err(error);
675 }
676 let response = UploadContentResponse {
677 namespace_id,
678 upload_id: upload_id.clone(),
679 content_ref: content_ref.clone(),
680 };
681 match staged_content(&state.state) {
682 Some(existing) if existing == &content_ref => {
683 Ok(UploadSessionUpdate::Noop(response))
684 }
685 Some(_) => Err(CoreError::UploadContentConflict { upload_id }),
686 None if already_present => Err(CoreError::UploadContentConflict { upload_id }),
689 None => {
690 *open_staging_slot(&mut state.state, &upload_id)? = Some(content_ref);
691 Ok(UploadSessionUpdate::Replace {
692 next: Box::new(state),
693 outcome: response,
694 })
695 }
696 }
697 }
698 },
699 )
700 .await
701}
702
703pub(crate) async fn complete_upload<S: ObjectStore + ?Sized>(
711 store: &S,
712 namespace_id: &NamespaceId,
713 content_store_id: &ContentStoreId,
714 upload_id: &UploadId,
715 request: &CompleteUploadRequest,
716 context: &MutationContext,
717) -> Result<CompletedUpload> {
718 let now_ms = context.now_ms;
719 let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
720 if matches!(loaded.state, UploadSessionLifecycle::Aborted { .. }) {
723 return Err(CoreError::UploadNotFound {
724 upload_id: upload_id.clone(),
725 });
726 }
727 let plan = completion_plan(&loaded, request)?;
728 if let Some(completed) = completed_outcome(
729 &loaded.state,
730 namespace_id,
731 content_store_id,
732 upload_id,
733 Some(plan.requested()),
734 now_ms,
735 )? {
736 return Ok(completed);
737 }
738
739 let verified = match completion_outcome(store, content_store_id, plan).await? {
740 CompletionOutcome::Verified(content_ref) => content_ref,
741 CompletionOutcome::Unusable(reason) => {
747 if let Err(error) =
748 abort_upload(store, namespace_id, content_store_id, upload_id, context).await
749 {
750 tracing::warn!(
751 namespace_id = %namespace_id,
752 upload_id = %upload_id,
753 error = %error,
754 "failed to abandon an upload session whose completion did not verify"
755 );
756 }
757 return Err(CoreError::InvalidUploadContent(reason));
758 }
759 };
760
761 freeze_completed_session(
762 store,
763 namespace_id,
764 content_store_id,
765 upload_id,
766 &verified,
767 now_ms,
768 )
769 .await
770}
771
772async fn freeze_completed_session<S: ObjectStore + ?Sized>(
781 store: &S,
782 namespace_id: &NamespaceId,
783 content_store_id: &ContentStoreId,
784 upload_id: &UploadId,
785 verified: &ContentRef,
786 now_ms: u64,
787) -> Result<CompletedUpload> {
788 update_upload_session(
789 store,
790 namespace_id,
791 upload_id,
792 CONTENTION_RETRY_LIMIT,
793 |mut state| {
794 let namespace_id = namespace_id.clone();
795 let content_store_id = content_store_id.clone();
796 let upload_id = upload_id.to_owned();
797 let verified = verified.clone();
798 async move {
799 if let Some(completed) = completed_outcome(
803 &state.state,
804 &namespace_id,
805 &content_store_id,
806 &upload_id,
807 Some(&verified),
808 now_ms,
809 )? {
810 return Ok(UploadSessionUpdate::Noop(completed));
811 }
812
813 state.state = UploadSessionLifecycle::Completed {
817 completed_at_ms: now_ms,
818 content_ref: verified.clone(),
819 };
820 let outcome = completed_upload(
821 &namespace_id,
822 &content_store_id,
823 &upload_id,
824 &verified,
825 now_ms,
826 now_ms,
827 );
828 Ok(UploadSessionUpdate::Replace {
829 next: Box::new(state),
830 outcome,
831 })
832 }
833 },
834 )
835 .await
836}
837
838struct OwnedStagingSession {
841 upload_id: UploadId,
842 content_id: ContentId,
843}
844
845pub(crate) async fn stage_owned_bytes<S: ObjectStore + ?Sized>(
861 store: &S,
862 catalog: &VerifiedNamespaceCatalogEntry,
863 bytes: &[u8],
864 context: &MutationContext,
865) -> Result<PreparedContent> {
866 let session = open_owned_staging_session(store, catalog, context).await?;
867 let stored = stage_bytes_under_content_id(
868 store,
869 catalog.content_store_id().clone(),
870 session.content_id,
871 bytes,
872 )
873 .await?;
874 complete_owned_staging(
875 store,
876 catalog,
877 &session.upload_id,
878 stored.content_ref,
879 context,
880 )
881 .await
882}
883
884pub(crate) async fn stage_owned_stream<S: ObjectStore + ?Sized>(
890 store: &S,
891 catalog: &VerifiedNamespaceCatalogEntry,
892 body: ByteStream,
893 context: &MutationContext,
894) -> Result<PreparedContent> {
895 let session = open_owned_staging_session(store, catalog, context).await?;
896 let content_store_id = catalog.content_store_id().clone();
897 let staged =
898 stage_streamed_under_content_id(store, content_store_id, session.content_id, body).await?;
899 if staged.already_present {
900 return Err(CoreError::Internal(format!(
904 "content object `{}` already holds bytes under a freshly minted identity",
905 content_blob(
906 catalog.content_store_id().as_str(),
907 &staged.content_ref.content_id
908 )
909 )));
910 }
911 complete_owned_staging(
912 store,
913 catalog,
914 &session.upload_id,
915 staged.content_ref,
916 context,
917 )
918 .await
919}
920
921async fn open_owned_staging_session<S: ObjectStore + ?Sized>(
930 store: &S,
931 catalog: &VerifiedNamespaceCatalogEntry,
932 context: &MutationContext,
933) -> Result<OwnedStagingSession> {
934 let session = NewUploadSession::service_proxied();
941 let content_id = session.content_id.clone();
942 let upload_id = create_upload_session(store, catalog.namespace_id(), session, context).await?;
943 Ok(OwnedStagingSession {
944 upload_id,
945 content_id,
946 })
947}
948
949async fn complete_owned_staging<S: ObjectStore + ?Sized>(
962 store: &S,
963 catalog: &VerifiedNamespaceCatalogEntry,
964 upload_id: &UploadId,
965 content_ref: ContentRef,
966 context: &MutationContext,
967) -> Result<PreparedContent> {
968 Ok(freeze_completed_session(
969 store,
970 catalog.namespace_id(),
971 catalog.content_store_id(),
972 upload_id,
973 &content_ref,
974 context.now_ms,
975 )
976 .await?
977 .prepared)
978}
979
980pub(crate) async fn abort_upload<S: ObjectStore + ?Sized>(
988 store: &S,
989 namespace_id: &NamespaceId,
990 content_store_id: &ContentStoreId,
991 upload_id: &UploadId,
992 context: &MutationContext,
993) -> Result<AbortUploadResponse> {
994 let now_ms = context.now_ms;
995 let (response, abandoned) = update_upload_session(
996 store,
997 namespace_id,
998 upload_id,
999 CONTENTION_RETRY_LIMIT,
1000 |mut state| {
1001 let namespace_id = namespace_id.clone();
1002 let upload_id = upload_id.to_owned();
1003 async move {
1004 let aborted = |aborted_at_ms| AbortUploadResponse {
1005 namespace_id: namespace_id.clone(),
1006 upload_id: upload_id.clone(),
1007 aborted_at_ms,
1008 };
1009 match state.state {
1010 UploadSessionLifecycle::Aborted { aborted_at_ms } => {
1011 let abandoned = AbandonedUpload::of(&state);
1012 Ok(UploadSessionUpdate::Noop((
1013 aborted(aborted_at_ms),
1014 abandoned,
1015 )))
1016 }
1017 UploadSessionLifecycle::Completed { .. } => {
1021 Err(CoreError::UploadAlreadyCompleted { upload_id })
1022 }
1023 UploadSessionLifecycle::Open { .. } => {
1024 let abandoned = AbandonedUpload::of(&state);
1025 state.state = UploadSessionLifecycle::Aborted {
1026 aborted_at_ms: now_ms,
1027 };
1028 Ok(UploadSessionUpdate::Replace {
1029 next: Box::new(state),
1030 outcome: (aborted(now_ms), abandoned),
1031 })
1032 }
1033 }
1034 }
1035 },
1036 )
1037 .await?;
1038
1039 abandoned.release(store, content_store_id).await;
1040 Ok(response)
1041}
1042
1043#[derive(Debug, Clone, PartialEq, Eq)]
1049pub(crate) struct AbandonedUpload {
1050 content_id: ContentId,
1051 provider_multipart_upload_id: Option<String>,
1052}
1053
1054impl AbandonedUpload {
1055 pub(crate) fn of(state: &UploadSessionState) -> Self {
1056 let provider_multipart_upload_id = match &state.transport {
1057 UploadSessionTransport::DirectMultipart {
1058 provider_upload_id, ..
1059 } => Some(provider_upload_id.clone()),
1060 UploadSessionTransport::ServiceProxied {}
1061 | UploadSessionTransport::DirectPut { .. } => None,
1062 };
1063 Self {
1064 content_id: state.content_id.clone(),
1065 provider_multipart_upload_id,
1066 }
1067 }
1068
1069 pub(crate) async fn release<S: ObjectStore + ?Sized>(
1072 &self,
1073 store: &S,
1074 content_store_id: &ContentStoreId,
1075 ) {
1076 if let Some(provider_upload_id) = &self.provider_multipart_upload_id {
1077 abort_unpublished_multipart_upload(
1078 store,
1079 content_store_id,
1080 &self.content_id,
1081 provider_upload_id,
1082 )
1083 .await;
1084 }
1085 delete_unpublished_content_object(store, content_store_id, &self.content_id).await;
1086 }
1087}
1088
1089pub(crate) async fn read_upload_status<S: ObjectStore + ?Sized>(
1095 store: &S,
1096 namespace_id: &NamespaceId,
1097 content_store_id: &ContentStoreId,
1098 upload_id: &UploadId,
1099 now_ms: u64,
1100) -> Result<(UploadStatusResponse, Option<CompletedUploadReceipt>)> {
1101 let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
1102 let (status, receipt) = match loaded.state {
1103 UploadSessionLifecycle::Open { expires_at_ms, .. } => {
1104 (UploadSessionStatus::Open { expires_at_ms }, None)
1105 }
1106 UploadSessionLifecycle::Aborted { aborted_at_ms } => {
1107 (UploadSessionStatus::Aborted { aborted_at_ms }, None)
1108 }
1109 UploadSessionLifecycle::Completed {
1110 completed_at_ms,
1111 content_ref,
1112 } => (
1113 UploadSessionStatus::Completed {
1114 completed_at_ms,
1115 content_ref: content_ref.clone(),
1116 validated_content_token: None,
1117 },
1118 receipt_within_window(
1119 namespace_id,
1120 content_store_id,
1121 &content_ref,
1122 completed_at_ms,
1123 now_ms,
1124 ),
1125 ),
1126 };
1127 Ok((
1128 UploadStatusResponse {
1129 namespace_id: namespace_id.clone(),
1130 upload_id: upload_id.clone(),
1131 status,
1132 },
1133 receipt,
1134 ))
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq)]
1141pub struct CompletedUpload {
1142 pub response: CompleteUploadResponse,
1144 pub prepared: PreparedContent,
1146 pub receipt: Option<CompletedUploadReceipt>,
1149}
1150
1151fn completed_upload(
1152 namespace_id: &NamespaceId,
1153 content_store_id: &ContentStoreId,
1154 upload_id: &UploadId,
1155 content_ref: &ContentRef,
1156 completed_at_ms: u64,
1157 now_ms: u64,
1158) -> CompletedUpload {
1159 CompletedUpload {
1160 response: CompleteUploadResponse {
1161 namespace_id: namespace_id.clone(),
1162 upload_id: upload_id.clone(),
1163 content_ref: content_ref.clone(),
1164 validated_content_token: None,
1165 },
1166 prepared: PreparedContent::from_admission(ContentAdmission::for_durable_content_write(
1167 content_store_id.clone(),
1168 content_ref.clone(),
1169 )),
1170 receipt: receipt_within_window(
1171 namespace_id,
1172 content_store_id,
1173 content_ref,
1174 completed_at_ms,
1175 now_ms,
1176 ),
1177 }
1178}
1179
1180fn receipt_within_window(
1187 namespace_id: &NamespaceId,
1188 content_store_id: &ContentStoreId,
1189 content_ref: &ContentRef,
1190 completed_at_ms: u64,
1191 now_ms: u64,
1192) -> Option<CompletedUploadReceipt> {
1193 (now_ms.saturating_sub(completed_at_ms) < COMPLETED_UPLOAD_RECEIPT_WINDOW_MS).then(|| {
1194 CompletedUploadReceipt::for_completed_session(
1195 namespace_id.clone(),
1196 content_store_id.clone(),
1197 content_ref.clone(),
1198 )
1199 })
1200}
1201
1202fn completed_outcome(
1206 state: &UploadSessionLifecycle,
1207 namespace_id: &NamespaceId,
1208 content_store_id: &ContentStoreId,
1209 upload_id: &UploadId,
1210 expected: Option<&ContentRef>,
1211 now_ms: u64,
1212) -> Result<Option<CompletedUpload>> {
1213 match state {
1214 UploadSessionLifecycle::Open { .. } => Ok(None),
1215 UploadSessionLifecycle::Aborted { .. } => Err(CoreError::UploadNotFound {
1216 upload_id: upload_id.clone(),
1217 }),
1218 UploadSessionLifecycle::Completed {
1219 completed_at_ms,
1220 content_ref,
1221 } => {
1222 if expected.is_some_and(|expected| expected != content_ref) {
1223 return Err(CoreError::UploadAlreadyCompleted {
1224 upload_id: upload_id.clone(),
1225 });
1226 }
1227 Ok(Some(completed_upload(
1228 namespace_id,
1229 content_store_id,
1230 upload_id,
1231 content_ref,
1232 *completed_at_ms,
1233 now_ms,
1234 )))
1235 }
1236 }
1237}
1238
1239enum CompletionOutcome {
1241 Verified(ContentRef),
1243 Unusable(String),
1246}
1247
1248enum CompletionPlan<'a> {
1255 Proxied {
1258 requested: ContentRef,
1259 staged: Option<&'a ContentRef>,
1260 },
1261 DirectPut {
1264 requested: ContentRef,
1265 promised: &'a ContentRef,
1266 },
1267 DirectMultipart {
1270 requested: ContentRef,
1271 provider_upload_id: &'a str,
1272 parts: &'a [CompletedUploadPart],
1273 },
1274}
1275
1276impl CompletionPlan<'_> {
1277 fn requested(&self) -> &ContentRef {
1279 match self {
1280 Self::Proxied { requested, .. }
1281 | Self::DirectPut { requested, .. }
1282 | Self::DirectMultipart { requested, .. } => requested,
1283 }
1284 }
1285}
1286
1287fn completion_plan<'a>(
1299 session: &'a UploadSessionState,
1300 request: &'a CompleteUploadRequest,
1301) -> Result<CompletionPlan<'a>> {
1302 match (&session.transport, request) {
1303 (
1304 UploadSessionTransport::ServiceProxied {},
1305 CompleteUploadRequest::ContentRef { content_ref },
1306 ) => Ok(CompletionPlan::Proxied {
1307 requested: content_ref.clone(),
1308 staged: staged_content(&session.state),
1309 }),
1310 (
1311 UploadSessionTransport::DirectPut { promised_content },
1312 CompleteUploadRequest::ContentRef { content_ref },
1313 ) => Ok(CompletionPlan::DirectPut {
1314 requested: content_ref.clone(),
1315 promised: promised_content,
1316 }),
1317 (
1318 UploadSessionTransport::DirectMultipart {
1319 provider_upload_id, ..
1320 },
1321 CompleteUploadRequest::Multipart { multipart, parts },
1322 ) => Ok(CompletionPlan::DirectMultipart {
1323 requested: direct_multipart_content_ref(session.content_id.clone(), multipart)?,
1324 provider_upload_id,
1325 parts,
1326 }),
1327 (
1328 UploadSessionTransport::ServiceProxied {} | UploadSessionTransport::DirectPut { .. },
1329 CompleteUploadRequest::Multipart { .. },
1330 ) => Err(CoreError::InvalidUploadContent(format!(
1331 "{} completion carries no multipart claim",
1332 transport_name(&session.transport)
1333 ))),
1334 (
1335 UploadSessionTransport::DirectMultipart { .. },
1336 CompleteUploadRequest::ContentRef { .. },
1337 ) => Err(CoreError::InvalidUploadContent(
1338 "direct_multipart completion names no content ref: the server owns the identity \
1339 and reports it back"
1340 .to_owned(),
1341 )),
1342 }
1343}
1344
1345async fn completion_outcome<S: ObjectStore + ?Sized>(
1355 store: &S,
1356 content_store_id: &ContentStoreId,
1357 plan: CompletionPlan<'_>,
1358) -> Result<CompletionOutcome> {
1359 match plan {
1360 CompletionPlan::Proxied { requested, staged } => {
1361 let staged = staged.ok_or_else(|| {
1362 CoreError::InvalidUploadContent("upload content has not been staged".to_owned())
1363 })?;
1364 if staged != &requested {
1365 return Err(CoreError::InvalidUploadContent(
1366 "completed content ref does not match staged content".to_owned(),
1367 ));
1368 }
1369 Ok(CompletionOutcome::Verified(staged.clone()))
1370 }
1371 CompletionPlan::DirectPut {
1372 requested,
1373 promised,
1374 } => {
1375 if promised != &requested {
1376 return Err(CoreError::InvalidUploadContent(
1377 "completed content ref does not match the direct_put target".to_owned(),
1378 ));
1379 }
1380 match verify_durable_content_checksum(store, content_store_id, promised).await {
1381 Ok(()) => Ok(CompletionOutcome::Verified(promised.clone())),
1382 Err(err) => {
1383 delete_unpublished_content_object(
1386 store,
1387 content_store_id,
1388 &requested.content_id,
1389 )
1390 .await;
1391 Err(CoreError::InvalidUploadContent(err.to_string()))
1392 }
1393 }
1394 }
1395 CompletionPlan::DirectMultipart {
1396 requested,
1397 provider_upload_id,
1398 parts,
1399 } => {
1400 assemble_multipart_upload(
1401 store,
1402 content_store_id,
1403 provider_upload_id,
1404 parts,
1405 &requested,
1406 )
1407 .await
1408 }
1409 }
1410}
1411
1412async fn assemble_multipart_upload<S: ObjectStore + ?Sized>(
1427 store: &S,
1428 content_store_id: &ContentStoreId,
1429 provider_upload_id: &str,
1430 parts: &[CompletedUploadPart],
1431 expected: &ContentRef,
1432) -> Result<CompletionOutcome> {
1433 let parts = multipart_parts(parts)?;
1434 let object_key = content_blob(content_store_id.as_str(), &expected.content_id);
1435
1436 match store
1437 .complete_multipart_upload(
1438 &object_key,
1439 provider_upload_id,
1440 &parts,
1441 &expected.storage_checksum,
1442 )
1443 .await
1444 {
1445 Ok(MultipartCompletion::Assembled | MultipartCompletion::UnknownUpload) => {}
1449 Err(err) => {
1450 return Ok(CompletionOutcome::Unusable(format!(
1455 "multipart completion failed: {}",
1456 err.message()
1457 )));
1458 }
1459 }
1460
1461 match verify_durable_content_checksum(store, content_store_id, expected).await {
1462 Ok(()) => Ok(CompletionOutcome::Verified(expected.clone())),
1463 Err(err) => Ok(CompletionOutcome::Unusable(err.to_string())),
1464 }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use super::*;
1470 use crate::namespace::bootstrap::bootstrap_namespace;
1471 use loonfs_api::v0::BeginUploadRequest;
1472 use loonfs_objectstore::local_fs_store::LocalFsStore;
1473 use tempfile::tempdir;
1474
1475 const BYTES: &[u8] = b"terminal states\n";
1476
1477 fn context(now_ms: u64) -> MutationContext {
1478 MutationContext {
1479 writer_id: "upload-test".to_owned(),
1480 now_ms,
1481 }
1482 }
1483
1484 async fn staged_session(
1486 store: &LocalFsStore,
1487 context: &MutationContext,
1488 ) -> (NamespaceId, ContentStoreId, UploadId, ContentRef, String) {
1489 let namespace_id = NamespaceId::parse("demo").expect("namespace id");
1490 bootstrap_namespace(store, &namespace_id, context, false)
1491 .await
1492 .expect("bootstrap");
1493 let begin = begin_upload(
1494 store,
1495 &namespace_id,
1496 BeginUploadRequest::ServiceProxied {},
1497 context,
1498 )
1499 .await
1500 .expect("begin upload");
1501 let staged = upload_content(store, &namespace_id, &begin.upload_id, BYTES)
1502 .await
1503 .expect("stage upload");
1504 let content_store_id = load_namespace_content_store_id(store, &namespace_id)
1505 .await
1506 .expect("content store id");
1507 let content_key = content_blob(content_store_id.as_str(), &staged.content_ref.content_id);
1508 (
1509 namespace_id,
1510 content_store_id,
1511 begin.upload_id,
1512 staged.content_ref,
1513 content_key,
1514 )
1515 }
1516
1517 async fn complete(
1518 store: &LocalFsStore,
1519 namespace_id: &NamespaceId,
1520 content_store_id: &ContentStoreId,
1521 upload_id: &UploadId,
1522 content_ref: &ContentRef,
1523 context: &MutationContext,
1524 ) -> Result<CompletedUpload> {
1525 complete_upload(
1526 store,
1527 namespace_id,
1528 content_store_id,
1529 upload_id,
1530 &CompleteUploadRequest::for_content_ref(content_ref.clone()),
1531 context,
1532 )
1533 .await
1534 }
1535
1536 #[tokio::test]
1541 async fn a_completion_after_an_abort_fails_terminally_and_touches_nothing() {
1542 let temp_dir = tempdir().expect("tempdir");
1543 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1544 let setup = context(1_000);
1545 let (namespace_id, content_store_id, upload_id, content_ref, content_key) =
1546 staged_session(&store, &setup).await;
1547
1548 abort_upload(
1549 &store,
1550 &namespace_id,
1551 &content_store_id,
1552 &upload_id,
1553 &context(2_000),
1554 )
1555 .await
1556 .expect("abort");
1557 assert!(store.head(&content_key).await.expect("head").is_none());
1558
1559 let error = complete(
1560 &store,
1561 &namespace_id,
1562 &content_store_id,
1563 &upload_id,
1564 &content_ref,
1565 &context(3_000),
1566 )
1567 .await
1568 .expect_err("an aborted session cannot complete");
1569 assert!(matches!(error, CoreError::UploadNotFound { .. }));
1570
1571 let state = read_upload_session_state(&store, &namespace_id, &upload_id)
1572 .await
1573 .expect("session still readable");
1574 assert!(matches!(
1575 state.state,
1576 UploadSessionLifecycle::Aborted {
1577 aborted_at_ms: 2_000
1578 }
1579 ));
1580 assert!(store.head(&content_key).await.expect("head").is_none());
1581 }
1582
1583 #[tokio::test]
1587 async fn an_abort_after_completion_is_refused_and_keeps_the_content() {
1588 let temp_dir = tempdir().expect("tempdir");
1589 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1590 let setup = context(1_000);
1591 let (namespace_id, content_store_id, upload_id, content_ref, content_key) =
1592 staged_session(&store, &setup).await;
1593 complete(
1594 &store,
1595 &namespace_id,
1596 &content_store_id,
1597 &upload_id,
1598 &content_ref,
1599 &context(2_000),
1600 )
1601 .await
1602 .expect("complete");
1603
1604 let error = abort_upload(
1605 &store,
1606 &namespace_id,
1607 &content_store_id,
1608 &upload_id,
1609 &context(3_000),
1610 )
1611 .await
1612 .expect_err("a completed session cannot be aborted");
1613 assert!(matches!(error, CoreError::UploadAlreadyCompleted { .. }));
1614 assert!(
1615 store.head(&content_key).await.expect("head").is_some(),
1616 "a refused abort must not clean up published-able content"
1617 );
1618 }
1619
1620 #[tokio::test]
1623 async fn a_repeated_abort_reports_the_first_stamp() {
1624 let temp_dir = tempdir().expect("tempdir");
1625 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1626 let setup = context(1_000);
1627 let (namespace_id, content_store_id, upload_id, _content_ref, _content_key) =
1628 staged_session(&store, &setup).await;
1629
1630 let first = abort_upload(
1631 &store,
1632 &namespace_id,
1633 &content_store_id,
1634 &upload_id,
1635 &context(2_000),
1636 )
1637 .await
1638 .expect("first abort");
1639 let second = abort_upload(
1640 &store,
1641 &namespace_id,
1642 &content_store_id,
1643 &upload_id,
1644 &context(9_000),
1645 )
1646 .await
1647 .expect("repeated abort");
1648
1649 assert_eq!(first.aborted_at_ms, 2_000);
1650 assert_eq!(second, first);
1651 }
1652
1653 #[tokio::test]
1655 async fn staging_into_a_terminal_session_is_refused() {
1656 let temp_dir = tempdir().expect("tempdir");
1657 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1658 let setup = context(1_000);
1659 let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
1660 staged_session(&store, &setup).await;
1661 complete(
1662 &store,
1663 &namespace_id,
1664 &content_store_id,
1665 &upload_id,
1666 &content_ref,
1667 &context(2_000),
1668 )
1669 .await
1670 .expect("complete");
1671 let error = upload_content(&store, &namespace_id, &upload_id, BYTES)
1672 .await
1673 .expect_err("a completed session takes no more bytes");
1674 assert!(matches!(error, CoreError::UploadAlreadyCompleted { .. }));
1675
1676 let aborted = begin_upload(
1677 &store,
1678 &namespace_id,
1679 BeginUploadRequest::ServiceProxied {},
1680 &setup,
1681 )
1682 .await
1683 .expect("begin a second upload");
1684 abort_upload(
1685 &store,
1686 &namespace_id,
1687 &content_store_id,
1688 &aborted.upload_id,
1689 &context(3_000),
1690 )
1691 .await
1692 .expect("abort");
1693 let error = upload_content(&store, &namespace_id, &aborted.upload_id, BYTES)
1694 .await
1695 .expect_err("an aborted session takes no more bytes");
1696 assert!(matches!(error, CoreError::UploadNotFound { .. }));
1697 }
1698
1699 #[tokio::test]
1702 async fn only_a_completed_session_mints_a_receipt() {
1703 let temp_dir = tempdir().expect("tempdir");
1704 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1705 let setup = context(1_000);
1706 let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
1707 staged_session(&store, &setup).await;
1708
1709 let (open, receipt) =
1710 read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, 1_500)
1711 .await
1712 .expect("status of an open session");
1713 assert!(matches!(open.status, UploadSessionStatus::Open { .. }));
1714 assert!(receipt.is_none(), "an open session attests nothing");
1715
1716 complete(
1717 &store,
1718 &namespace_id,
1719 &content_store_id,
1720 &upload_id,
1721 &content_ref,
1722 &context(2_000),
1723 )
1724 .await
1725 .expect("complete");
1726 let (completed, receipt) =
1727 read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, 2_500)
1728 .await
1729 .expect("status of a completed session");
1730 assert!(matches!(
1731 completed.status,
1732 UploadSessionStatus::Completed { .. }
1733 ));
1734 assert_eq!(
1735 receipt.expect("a completed session mints").content_ref(),
1736 &content_ref
1737 );
1738
1739 let begin = begin_upload(
1741 &store,
1742 &namespace_id,
1743 BeginUploadRequest::ServiceProxied {},
1744 &setup,
1745 )
1746 .await
1747 .expect("begin second upload");
1748 abort_upload(
1749 &store,
1750 &namespace_id,
1751 &content_store_id,
1752 &begin.upload_id,
1753 &context(3_000),
1754 )
1755 .await
1756 .expect("abort");
1757 let (aborted, receipt) = read_upload_status(
1758 &store,
1759 &namespace_id,
1760 &content_store_id,
1761 &begin.upload_id,
1762 3_500,
1763 )
1764 .await
1765 .expect("status of an aborted session");
1766 assert!(matches!(
1767 aborted.status,
1768 UploadSessionStatus::Aborted { .. }
1769 ));
1770 assert!(receipt.is_none(), "an aborted session attests nothing");
1771 }
1772
1773 #[tokio::test]
1778 async fn a_completed_session_re_mints_until_its_receipt_window_closes() {
1779 let temp_dir = tempdir().expect("tempdir");
1780 let store = LocalFsStore::new(temp_dir.path()).expect("store");
1781 let setup = context(1_000);
1782 let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
1783 staged_session(&store, &setup).await;
1784 let completed_at_ms = 2_000;
1785 complete(
1786 &store,
1787 &namespace_id,
1788 &content_store_id,
1789 &upload_id,
1790 &content_ref,
1791 &context(completed_at_ms),
1792 )
1793 .await
1794 .expect("complete");
1795
1796 let much_later = completed_at_ms + COMPLETED_UPLOAD_RECEIPT_WINDOW_MS - 1;
1799 let (_, receipt) = read_upload_status(
1800 &store,
1801 &namespace_id,
1802 &content_store_id,
1803 &upload_id,
1804 much_later,
1805 )
1806 .await
1807 .expect("status inside the receipt window");
1808 assert_eq!(receipt.expect("still minting").content_ref(), &content_ref);
1809
1810 let past = completed_at_ms + COMPLETED_UPLOAD_RECEIPT_WINDOW_MS;
1811 let (status, receipt) =
1812 read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, past)
1813 .await
1814 .expect("status past the receipt window");
1815 assert!(matches!(status, UploadStatusResponse { .. }));
1816 assert!(
1817 receipt.is_none(),
1818 "past the window no receipt exists, which is what lets content GC decide"
1819 );
1820
1821 let replay = complete(
1823 &store,
1824 &namespace_id,
1825 &content_store_id,
1826 &upload_id,
1827 &content_ref,
1828 &context(past),
1829 )
1830 .await
1831 .expect("replay still succeeds");
1832 assert_eq!(replay.response.content_ref, content_ref);
1833 assert!(replay.receipt.is_none());
1834 }
1835}