Skip to main content

loonfs_core/protocol/
uploads.rs

1//! Durable upload sessions: begin, stage, complete, and abort content
2//! uploads, including direct-put targets that move bytes past the server.
3//!
4//! A session is `open`, then `completed` or `aborted`, and both of those are
5//! final. The compare-and-swap that lands one of them is the serialization
6//! point for the whole upload: provider state is cleaned strictly after the
7//! durable transition, never before it, so whichever transition wins is what
8//! happened and the loser reports a terminal error instead of undoing
9//! anything.
10//!
11//! Every content object in a namespace is written through a session, whether
12//! its bytes came from a remote peer or from the process doing the
13//! publishing (see [`stage_owned_bytes`]). That is what makes content
14//! collectable: the session record is the only thing that names an object
15//! before metadata does, so an object with no record would be reachable by
16//! nothing and reclaimable by nothing.
17
18use 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        // Not a shape check: the direct transports need a deployment that
73        // can presign, and this entry point is the one that cannot.
74        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
103/// Mints the content identity a direct upload will write to, and the
104/// reference that names it.
105///
106/// The client declares only what it can know — how many bytes and what they
107/// hash to. Identity is the server's, so a caller can never aim a presigned
108/// write at an object it chose. The reference returned here is the one the
109/// signed write, the completion check, and the later commit all name.
110pub(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
138/// Mints the content identity a direct multipart upload will assemble into,
139/// and opens the provider upload that will assemble it.
140///
141/// Nothing is claimed here. The session is opened for a payload whose
142/// length and digest the client may not know yet — reading from a pipe, or
143/// simply unwilling to read a large file twice — so all that is settled is
144/// the geometry. What the object turns out to be is claimed at completion,
145/// which is where it was always verified.
146///
147/// The provider upload is created before the session record, so the record
148/// is complete from birth and every later step — signing a part, completing,
149/// cleaning up — reads one durable object that already knows everything. A
150/// session record that fails to land takes the provider upload down with it,
151/// so the only thing a failure here can leave behind is nothing.
152pub(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
197/// Settles the part geometry one multipart session is opened with.
198///
199/// The bounds are the providers': no non-final part below 5 MiB, none above
200/// 5 GiB. The size a client picks is also what bounds its object, since a
201/// provider accepts at most [`MAX_MULTIPART_PARTS`] of them. The floor is
202/// well above zero, so what this returns can never be a geometry that cuts
203/// no bytes.
204fn 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
216/// Resolves the parts a client asked to be authorized against the session
217/// that owns them.
218///
219/// The server signs a part, it does not remember one: nothing durable is
220/// written here and nothing is read back later. Part bookkeeping stays with
221/// the client all the way to completion, exactly as it does in the
222/// provider's own multipart API.
223pub(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        // The only bound is the provider's own part-number range: the
249        // session never learned how long the payload would be, so there is
250        // no part count to check against.
251        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
270/// The provider upload a multipart session is bound to.
271///
272/// A multipart session always has one — the transport variant carries it —
273/// so the only thing left to say is that some other transport does not.
274fn 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
287/// Turns a client's multipart claim into the reference the assembled object
288/// is bound to.
289///
290/// There is no `whole_file_sha256`: nobody trustworthy hashes these bytes.
291/// The client's own digest is not evidence, and the provider assembles the
292/// object without ever computing a SHA-256 over it — so the CRC-64/NVME it
293/// does compute is the whole of the reference's evidence, and completion
294/// reads it back rather than believing the claim.
295fn 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
331/// Turns a client's part bookkeeping into what the provider assembles from.
332fn 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
358/// Turns a client's claim into the reference the write is bound to.
359///
360/// The digest is the client's, but it stops being a client claim the moment
361/// it is signed into the provider write: the provider refuses any body that
362/// does not hash to it, and completion re-checks the stored object against
363/// it. That is why the resulting reference may carry `whole_file_sha256`.
364fn 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
385/// What a session is opened with: everything decided before any byte moves.
386///
387/// The identity and the transport are settled together, so a session cannot
388/// be built holding one transport's details under another's name.
389struct NewUploadSession {
390    /// The content object this session will write, allocated up front.
391    content_id: ContentId,
392    /// How the bytes will reach it.
393    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    /// A multipart session records identity, the provider handle, and the
414    /// geometry — and nothing about the payload, which it has not been told.
415    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
463/// Admits an upload session only for a namespace that exists and still
464/// serves writes. The head is the whole existence check: absent means the
465/// namespace was never created, and the tombstone refuses.
466async 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
484/// How a terminal session answers an operation that needed it open.
485///
486/// A completed session is a conflict the caller can reason about; an aborted
487/// one reports the same absence the eventual physical deletion does, because
488/// it will never select content again.
489fn 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
502/// The staging slot of a session that may still take bytes.
503///
504/// The one state that accepts bytes is the one that holds what was staged,
505/// so asking for the slot and asking whether the session is still live are
506/// the same question, answered once.
507fn 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
522/// What an open session has already staged, or `None` for one that has
523/// staged nothing — or that is past staging entirely.
524fn 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
531/// What one session's transport is called in a message to its client.
532fn 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
540/// Stages bytes into a service-proxied session.
541///
542/// The bytes land under the identity the session allocated when it began,
543/// so re-sending the same bytes to the same session writes the same object
544/// rather than minting a second one. Two *different* sessions carrying
545/// identical bytes still get their own objects; sessions are where retry
546/// idempotency lives now, not the key space.
547pub(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
611/// Stages a streamed payload into a service-proxied session.
612///
613/// The bytes are hashed as they are forwarded and never held whole, which
614/// is the only difference from [`upload_content`]. That difference forces
615/// the shape: the write cannot happen inside a retried compare-and-swap
616/// closure, because a stream can only be read once. So the session is read,
617/// the payload is written, and only then is the record swapped — and the
618/// swap is where an idempotent re-send is told from a conflicting one, by
619/// comparing the digest this write produced against the one the session
620/// recorded. The store consumes the whole body before it reports a refused
621/// precondition, so that digest exists either way.
622pub(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    // A session that has already staged content must not have its object
641    // rewritten while the answer is being worked out. Reading the body
642    // without writing it decides the question: the same bytes are one
643    // upload arriving twice, and different bytes are a conflict either way.
644    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                    // Nothing is recorded yet, so an occupied key holds
687                    // bytes this session never acknowledged writing.
688                    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
703/// Completes an upload: verify the bytes, then make the completion durable.
704///
705/// The order is the contract. Verification happens before the
706/// compare-and-swap, so nothing is ever recorded as completed on the
707/// strength of a provider response alone; and the terminal states are
708/// checked before any provider call, so a completion arriving after an abort
709/// fails without touching the object the abort is cleaning up.
710pub(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    // An aborted session answers the same absence its physical deletion
721    // will, before anything about the request's shape is examined.
722    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        // The bytes that landed are not the bytes that were promised, and
742        // the provider upload that could have produced them is consumed.
743        // Nothing can rescue this session, so it stops here rather than
744        // waiting for its lease to pass: aborting is what deletes the wrong
745        // object and releases the provider state.
746        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
772/// Freezes a verified reference as one session's final word.
773///
774/// Every completion lands here, whatever established the reference above it:
775/// a remote peer's bytes proven against the object that came to rest, or an
776/// in-process staging write proven by the write this runtime performed
777/// itself. By this point both hold the same thing, so the transition that
778/// makes content publishable — and, from the other side, collectable — has
779/// one implementation.
780async 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                // A racing abort or a peer's completion may have landed
800                // between the read above and this swap. Whatever the durable
801                // record says now is what happened.
802                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                // The completed state is where a session's reference lives,
814                // and the only place: whatever the open state was holding
815                // is replaced by it rather than kept beside it.
816                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
838/// The session one in-process staging write fills, from the identity it
839/// allocated to the record that will hold its outcome.
840struct OwnedStagingSession {
841    upload_id: UploadId,
842    content_id: ContentId,
843}
844
845/// Stages bytes this runtime holds under a session that owns them.
846///
847/// The convenience write paths are both ends of an upload at once: the bytes
848/// are already here, so there is no target to hand out, no claim to check,
849/// and no receipt to mint — the publication that follows happens in this
850/// process and takes the reference directly. What they do not skip is the
851/// session, because that is the half of the lifecycle content garbage
852/// collection reads.
853///
854/// The session record is durable before the object exists. That ordering is
855/// the ownership guarantee rather than an optimization: a record written
856/// afterwards leaves a window in which a crash strands bytes nothing names,
857/// which is the exact leak this path exists to close. It costs two small
858/// control writes on top of the content write, and they are sequential for
859/// the same reason.
860pub(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
884/// Stages a payload this runtime forwards under a session that owns it.
885///
886/// The streaming twin of [`stage_owned_bytes`]: the bytes are hashed on
887/// their way to the store rather than held, and everything about ownership
888/// is identical.
889pub(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        // The identity is 128 fresh random bits and this session has made no
901        // earlier attempt, so an occupied key is corruption rather than a
902        // replay, and it fails loudly.
903        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
921/// Opens the session that will own a content object this runtime is about to
922/// write.
923///
924/// It is a service-proxied session because that is what it is: the bytes pass
925/// through this process on the way to the store. The upload id is never
926/// handed out, so the record has exactly one later reader — garbage
927/// collection, which learns from it that the object has an owner and what
928/// became of it.
929async fn open_owned_staging_session<S: ObjectStore + ?Sized>(
930    store: &S,
931    catalog: &VerifiedNamespaceCatalogEntry,
932    context: &MutationContext,
933) -> Result<OwnedStagingSession> {
934    // No availability check, unlike the sessions a remote peer opens. This
935    // caller holds a catalog read off the namespace's own head, and the
936    // publication it is staging for is the admission decision: a namespace
937    // deleted in between refuses there, and a collection pass on a terminal
938    // namespace reaches nothing, so it reclaims this session and the object
939    // it holds like any other completed content nobody references.
940    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
949/// Completes the session an in-process staging write just filled.
950///
951/// Nothing is verified here and nothing needs to be: this runtime wrote the
952/// object and hashed the payload doing it, which is the same evidence a
953/// service-proxied completion accepts from its own staged reference.
954///
955/// A failure before this point leaves an open session whose lease passes and
956/// whose sweep deletes both record and object, so no error path aborts: this
957/// session is unreachable by anyone else, the only way the transition fails
958/// is a store that is not answering, and an abort call is the least likely
959/// thing to get through it. Expiry costs a wait; a second failed write costs
960/// the wait anyway.
961async 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
980/// Aborts an upload session, then cleans up what it was writing.
981///
982/// The durable transition comes first and the provider work strictly after
983/// it, so a crash in between leaves an object that the next garbage
984/// collection pass reclaims from the aborted record — never an object
985/// deleted out from under a session that is still open. Repeating an abort
986/// is a success that reports the first abort's stamp.
987pub(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                    // Completion is final in the other direction: the
1018                    // content may already be published, so an abort cannot
1019                    // quietly succeed over it.
1020                    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/// The provider state one terminated session owned.
1044///
1045/// It travels out of the compare-and-swap that made the session terminal so
1046/// cleanup runs strictly after the durable transition — the ordering that
1047/// makes a crash in between cost a repeat rather than a lost object.
1048#[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    /// Releases everything the session left behind, provider upload first so
1070    /// the object it might still assemble cannot outlive the deletion.
1071    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
1089/// Reads one session, minting a fresh receipt when it is completed.
1090///
1091/// This read is the reason a lost commit response is cheap: the completed
1092/// session is durable, so the receipt it hands back is as good as the one
1093/// the completion returned, and the bytes never move again.
1094pub(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/// What a completed upload hands back: the wire response, the in-process
1138/// admission a same-process publication uses, and the receipt a remote one
1139/// carries back.
1140#[derive(Debug, Clone, PartialEq, Eq)]
1141pub struct CompletedUpload {
1142    /// Wire response for the completion or its idempotent replay.
1143    pub response: CompleteUploadResponse,
1144    /// Admission for a publication in this process, which needs no token.
1145    pub prepared: PreparedContent,
1146    /// Receipt for a publication elsewhere, or `None` once the session has
1147    /// stopped minting them.
1148    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
1180/// Mints a receipt only while the completed session is still inside its
1181/// receipt window.
1182///
1183/// The window is what makes content reclamation decidable: past it no new
1184/// receipt exists, so no new metadata reference to this content can appear
1185/// (`limits::CONTENT_RECLAMATION_GRACE_MS`).
1186fn 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
1202/// Answers a completion against a session that has already reached a
1203/// terminal state: a replay of the same content succeeds idempotently,
1204/// anything else is the terminal error for that state.
1205fn 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
1239/// What a completion attempt established about the session's content.
1240enum CompletionOutcome {
1241    /// The object at the session's key is the object it promised.
1242    Verified(ContentRef),
1243    /// The session can never produce the content it promised. The caller
1244    /// makes the session terminal and reports the reason.
1245    Unusable(String),
1246}
1247
1248/// What a completion will do, resolved from the session's transport and the
1249/// request together — no provider call, no durable write.
1250///
1251/// Every arm carries what proving it needs, taken from whichever side knew
1252/// it. That is why the step below has nothing left to look up and no case
1253/// it cannot handle.
1254enum CompletionPlan<'a> {
1255    /// A proxied session, which wrote and checked its own bytes: what it
1256    /// recorded staging is the evidence.
1257    Proxied {
1258        requested: ContentRef,
1259        staged: Option<&'a ContentRef>,
1260    },
1261    /// A direct-put session, whose bytes went past this server: the object
1262    /// that came to rest has to be read back against the promise.
1263    DirectPut {
1264        requested: ContentRef,
1265        promised: &'a ContentRef,
1266    },
1267    /// A direct-multipart session: the provider still has to assemble the
1268    /// object from the parts the client uploaded, and then be proven right.
1269    DirectMultipart {
1270        requested: ContentRef,
1271        provider_upload_id: &'a str,
1272        parts: &'a [CompletedUploadPart],
1273    },
1274}
1275
1276impl CompletionPlan<'_> {
1277    /// The reference this completion is about.
1278    fn requested(&self) -> &ContentRef {
1279        match self {
1280            Self::Proxied { requested, .. }
1281            | Self::DirectPut { requested, .. }
1282            | Self::DirectMultipart { requested, .. } => requested,
1283        }
1284    }
1285}
1286
1287/// Matches a completion request against the session it is completing.
1288///
1289/// Which side names the content depends on which side knew it first. A
1290/// proxied or `direct_put` session was handed a reference before any byte
1291/// moved, so the request names it back. A `direct_multipart` session was
1292/// never told one, so the request carries the claim instead and the
1293/// reference is assembled here, over the identity the session has held
1294/// since it opened. Either way the client cannot choose the identity.
1295///
1296/// This is the one thing decoding the request cannot settle: which shape is
1297/// right depends on the durable record, which only this server can read.
1298fn 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
1345/// Establishes the content reference a completion may freeze.
1346///
1347/// A proxied session already wrote and checked its bytes, so the staged
1348/// reference is the answer. A direct session's bytes bypassed the server,
1349/// so this is where the server learns what actually landed: it verifies
1350/// rather than trusts, because provider enforcement is not uniform across
1351/// the family we support and a random object id says nothing about its
1352/// contents. One checksum-bearing HEAD settles size and bytes together
1353/// without a download.
1354async 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                    // The id is random and still open, so the object nothing
1384                    // can name is safe to remove and would otherwise leak.
1385                    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
1412/// Asks the provider to assemble the parts a client uploaded, then proves
1413/// what it assembled.
1414///
1415/// The read-back is the load-bearing check, not a formality: AWS S3 treats
1416/// the whole-object checksum as a precondition and refuses a wrong one, but
1417/// Cloudflare R2 accepts it, assembles the object anyway, and reports the
1418/// true checksum instead. One provider enforces, one only witnesses, so
1419/// LoonFS witnesses for itself on both.
1420///
1421/// A completion whose response was lost reconciles here too. Replaying the
1422/// provider's completion is useless — S3 answers success with no checksum,
1423/// R2 answers `NoSuchUpload` while the object sits there correct — so a
1424/// consumed upload is not read as failure. The object at the key is the
1425/// evidence, and it answers the same way on both providers.
1426async 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        // Either the provider assembled the object on this call, or it had
1446        // already consumed the upload. Both questions are answered by the
1447        // same read of the object, so neither needs its own path.
1448        Ok(MultipartCompletion::Assembled | MultipartCompletion::UnknownUpload) => {}
1449        Err(err) => {
1450            // The provider refused to assemble. On AWS S3 that includes a
1451            // whole-object checksum that does not match the parts, which is
1452            // a wrong upload and not a transient one, so this is where the
1453            // session stops.
1454            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    /// One store with a namespace and one open, staged session in it.
1485    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    /// An aborted session is logically absent: it will never select content,
1537    /// which is the same thing the eventual physical deletion says. A
1538    /// completion arriving afterwards must not resurrect it — and must not
1539    /// touch the object the abort already cleaned up.
1540    #[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    /// Completion is terminal in the other direction. An abort cannot
1584    /// quietly succeed over it, because by then the content may already be
1585    /// published and deleting it would break a live reference.
1586    #[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    /// Aborting twice is a success that reports the abort that stands, so a
1621    /// client retrying a lost response learns the same thing both times.
1622    #[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    /// Bytes may only be staged into the one live state.
1654    #[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    /// A receipt exists for exactly one state. An open session has nothing
1700    /// durable to attest yet, and an aborted one never will.
1701    #[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        // A second session, aborted, to check the other terminal state.
1740        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    /// Re-minting is what makes a lost publish response cheap, and its
1774    /// window is what makes content reclamation decidable: the session hands
1775    /// out fresh receipts for as long as its content is protected, then
1776    /// stops.
1777    #[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        // Long after the first receipt would have expired, the durable
1797        // session still answers with a usable one.
1798        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        // The same rule governs a very late idempotent completion replay.
1822        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}