use crate::context::MutationContext;
use crate::control_update::{
read_upload_session_state, update_upload_session, UploadSessionUpdate,
};
use crate::engine::{
BeginDirectMultipartUploadTargetResponse, BeginDirectPutUploadTargetResponse,
DirectMultipartUploadTarget, DirectPutUploadTarget, MultipartPartTarget, MultipartPartTargets,
};
use crate::error::MetadataProjectionLoadError;
use crate::error::{CoreError, Result};
use crate::limits::{
COMPLETED_UPLOAD_RECEIPT_WINDOW_MS, CONTENTION_RETRY_LIMIT, MAX_MULTIPART_PARTS,
MAX_MULTIPART_PART_BYTES, MAX_SIGNED_PARTS_PER_REQUEST, MIN_MULTIPART_PART_BYTES,
UPLOAD_SESSION_LEASE_MS,
};
use crate::namespace::catalog::{load_namespace_content_store_id, VerifiedNamespaceCatalogEntry};
use crate::namespace::control::load_namespace_head_control;
use crate::storage::content::{
abort_unpublished_multipart_upload, delete_unpublished_content_object,
identify_streamed_payload, stage_bytes_under_content_id, stage_streamed_under_content_id,
verify_durable_content_checksum,
};
use crate::storage::content_admission::{
CompletedUploadReceipt, ContentAdmission, PreparedContent,
};
use bytes::Bytes;
use loonfs_api::v0::{
AbortUploadResponse, BeginUploadRequest, BeginUploadResponse, CompleteUploadRequest,
CompleteUploadResponse, CompletedUploadPart, DirectMultipartContentClaim,
DirectMultipartUploadOptions, DirectPutContentClaim, UploadContentResponse, UploadMode,
UploadPartChecksumClaim, UploadSessionStatus, UploadStatusResponse,
};
use loonfs_api::wire::control::{
encode_control_object, ControlObjectKind, NamespaceState, UploadSessionEnvelope,
UploadSessionLifecycle, UploadSessionState, UploadSessionTransport,
};
use loonfs_api::{
ChecksumAlgorithm, ContentId, ContentRef, ContentRefKind, ContentStoreId, NamespaceId,
StorageChecksum, UploadId,
};
use loonfs_objectstore::keys::{content_blob, upload_session};
use loonfs_objectstore::{
ByteStream, MultipartCompletion, MultipartPart, ObjectStore, PROVIDER_MULTIPART_PART_BYTES,
};
use std::num::NonZeroU64;
pub(crate) async fn begin_upload<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
request: BeginUploadRequest,
context: &MutationContext,
) -> Result<BeginUploadResponse> {
ensure_upload_namespace_available(store, namespace_id).await?;
if !matches!(request, BeginUploadRequest::ServiceProxied {}) {
return Err(CoreError::InvalidUploadContent(format!(
"{} requires a presigned URL issuer",
upload_mode_name(request.mode())
)));
}
let upload_id = create_upload_session(
store,
namespace_id,
NewUploadSession::service_proxied(),
context,
)
.await?;
Ok(BeginUploadResponse {
namespace_id: namespace_id.clone(),
upload_id,
mode: UploadMode::ServiceProxied,
direct_put: None,
direct_multipart: None,
})
}
fn upload_mode_name(mode: UploadMode) -> &'static str {
match mode {
UploadMode::ServiceProxied => "service_proxied",
UploadMode::DirectPut => "direct_put",
UploadMode::DirectMultipart => "direct_multipart",
}
}
pub(crate) async fn begin_direct_put_upload_target<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
claim: DirectPutContentClaim,
context: &MutationContext,
) -> Result<BeginDirectPutUploadTargetResponse> {
ensure_upload_namespace_available(store, namespace_id).await?;
let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
let content_id = ContentId::generate();
let content_ref = direct_put_content_ref(content_id.clone(), &claim)?;
let object_key = content_blob(content_store_id.as_str(), &content_id);
let upload_id = create_upload_session(
store,
namespace_id,
NewUploadSession::direct_put(content_ref.clone()),
context,
)
.await?;
Ok(BeginDirectPutUploadTargetResponse {
namespace_id: namespace_id.clone(),
upload_id,
target: DirectPutUploadTarget {
content_ref,
object_key,
},
})
}
pub(crate) async fn begin_direct_multipart_upload_target<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
options: DirectMultipartUploadOptions,
context: &MutationContext,
) -> Result<BeginDirectMultipartUploadTargetResponse> {
ensure_upload_namespace_available(store, namespace_id).await?;
let part_size_bytes = multipart_part_size(options.part_size_bytes)?;
let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
let content_id = ContentId::generate();
let object_key = content_blob(content_store_id.as_str(), &content_id);
let provider_upload_id = store
.create_multipart_upload(&object_key)
.await
.map_err(|err| CoreError::store(&object_key, &err))?;
let session = NewUploadSession::direct_multipart(
content_id.clone(),
&provider_upload_id,
part_size_bytes,
);
let upload_id = match create_upload_session(store, namespace_id, session, context).await {
Ok(upload_id) => upload_id,
Err(error) => {
abort_unpublished_multipart_upload(
store,
&content_store_id,
&content_id,
&provider_upload_id,
)
.await;
return Err(error);
}
};
Ok(BeginDirectMultipartUploadTargetResponse {
namespace_id: namespace_id.clone(),
upload_id,
target: DirectMultipartUploadTarget {
object_key,
part_size_bytes: part_size_bytes.get(),
},
})
}
fn multipart_part_size(requested: Option<u64>) -> Result<NonZeroU64> {
let part_size_bytes = requested.unwrap_or(PROVIDER_MULTIPART_PART_BYTES);
NonZeroU64::new(part_size_bytes)
.filter(|size| (MIN_MULTIPART_PART_BYTES..=MAX_MULTIPART_PART_BYTES).contains(&size.get()))
.ok_or_else(|| {
CoreError::InvalidUploadContent(format!(
"part_size_bytes must be between {MIN_MULTIPART_PART_BYTES} and \
{MAX_MULTIPART_PART_BYTES} bytes"
))
})
}
pub(crate) async fn direct_multipart_part_targets<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
upload_id: &UploadId,
requested: &[UploadPartChecksumClaim],
) -> Result<MultipartPartTargets> {
if requested.is_empty() {
return Err(CoreError::InvalidUploadContent(
"a part-signing request names at least one part".to_owned(),
));
}
if requested.len() > MAX_SIGNED_PARTS_PER_REQUEST {
return Err(CoreError::InvalidUploadContent(format!(
"a part-signing request names at most {MAX_SIGNED_PARTS_PER_REQUEST} parts"
)));
}
let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
let session = read_upload_session_state(store, namespace_id, upload_id).await?;
if let Some(error) = terminal_session_error(&session.state, upload_id.clone()) {
return Err(error);
}
let provider_upload_id = multipart_session_upload(&session)?;
let mut parts = Vec::with_capacity(requested.len());
for claim in requested {
if claim.part_number == 0 || claim.part_number > MAX_MULTIPART_PARTS {
return Err(CoreError::InvalidUploadContent(format!(
"part {} is outside the provider's 1..={MAX_MULTIPART_PARTS} part range",
claim.part_number
)));
}
parts.push(MultipartPartTarget {
part_number: claim.part_number,
checksum: crc64nvme_claim(&claim.crc64nvme)?,
});
}
Ok(MultipartPartTargets {
object_key: content_blob(content_store_id.as_str(), &session.content_id),
provider_upload_id: provider_upload_id.to_owned(),
parts,
})
}
fn multipart_session_upload(session: &UploadSessionState) -> Result<&str> {
match &session.transport {
UploadSessionTransport::DirectMultipart {
provider_upload_id, ..
} => Ok(provider_upload_id),
UploadSessionTransport::ServiceProxied {} | UploadSessionTransport::DirectPut { .. } => {
Err(CoreError::InvalidUploadContent(
"this upload session is not a direct_multipart upload".to_owned(),
))
}
}
}
fn direct_multipart_content_ref(
content_id: ContentId,
claim: &DirectMultipartContentClaim,
) -> Result<ContentRef> {
let content_ref = ContentRef {
kind: ContentRefKind::BlobV1,
content_id,
size_bytes: claim.size_bytes,
storage_checksum: crc64nvme_claim(&claim.crc64nvme)?,
whole_file_sha256: None,
};
content_ref
.validate()
.map_err(|err| CoreError::InvalidUploadContent(err.to_string()))?;
Ok(content_ref)
}
fn crc64nvme_claim(value: &str) -> Result<StorageChecksum> {
let checksum = StorageChecksum {
algorithm: ChecksumAlgorithm::Crc64nvme,
value: value.to_owned(),
};
let width = ChecksumAlgorithm::Crc64nvme.value_bytes() * 2;
if checksum.value.len() != width
|| !checksum
.value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(CoreError::InvalidUploadContent(format!(
"crc64nvme must be {width} lowercase hex characters"
)));
}
Ok(checksum)
}
fn multipart_parts(parts: &[CompletedUploadPart]) -> Result<Vec<MultipartPart>> {
let mut previous = 0;
parts
.iter()
.map(|part| {
if part.part_number <= previous {
return Err(CoreError::InvalidUploadContent(
"completion lists each part once, in ascending part order".to_owned(),
));
}
previous = part.part_number;
if part.etag.trim().is_empty() {
return Err(CoreError::InvalidUploadContent(format!(
"part {} carries no etag",
part.part_number
)));
}
Ok(MultipartPart {
part_number: part.part_number,
etag: part.etag.clone(),
checksum: crc64nvme_claim(&part.crc64nvme)?,
})
})
.collect()
}
fn direct_put_content_ref(
content_id: ContentId,
claim: &DirectPutContentClaim,
) -> Result<ContentRef> {
let storage_checksum = StorageChecksum {
algorithm: ChecksumAlgorithm::Sha256,
value: claim.sha256.clone(),
};
let content_ref = ContentRef {
kind: ContentRefKind::BlobV1,
content_id,
size_bytes: claim.size_bytes,
whole_file_sha256: Some(storage_checksum.value.clone()),
storage_checksum,
};
content_ref
.validate()
.map_err(|err| CoreError::InvalidUploadContent(err.to_string()))?;
Ok(content_ref)
}
struct NewUploadSession {
content_id: ContentId,
transport: UploadSessionTransport,
}
impl NewUploadSession {
fn service_proxied() -> Self {
Self {
content_id: ContentId::generate(),
transport: UploadSessionTransport::ServiceProxied {},
}
}
fn direct_put(content_ref: ContentRef) -> Self {
Self {
content_id: content_ref.content_id.clone(),
transport: UploadSessionTransport::DirectPut {
promised_content: content_ref,
},
}
}
fn direct_multipart(
content_id: ContentId,
provider_upload_id: &str,
part_size_bytes: NonZeroU64,
) -> Self {
Self {
content_id,
transport: UploadSessionTransport::DirectMultipart {
provider_upload_id: provider_upload_id.to_owned(),
part_size_bytes,
},
}
}
}
async fn create_upload_session<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
session: NewUploadSession,
context: &MutationContext,
) -> Result<UploadId> {
let upload_id = UploadId::generate();
let state = UploadSessionState {
namespace_id: namespace_id.clone(),
upload_id: upload_id.clone(),
content_id: session.content_id,
created_at_ms: context.now_ms,
transport: session.transport,
state: UploadSessionLifecycle::Open {
expires_at_ms: context.now_ms.saturating_add(UPLOAD_SESSION_LEASE_MS),
staged_content: None,
},
};
let envelope = UploadSessionEnvelope::from_state(ControlObjectKind::UploadSession, state)
.map_err(|err| {
CoreError::Internal(format!("failed to build upload session envelope: {err}"))
})?;
let encoded = encode_control_object(&envelope).map_err(|err| {
CoreError::Internal(format!("failed to encode upload session envelope: {err}"))
})?;
let object_key = upload_session(namespace_id.as_str(), upload_id.as_str());
store
.put_if_absent(&object_key, Bytes::from(encoded))
.await
.map_err(|err| CoreError::store(&object_key, &err))?;
Ok(upload_id)
}
async fn ensure_upload_namespace_available<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
) -> Result<()> {
let head = load_namespace_head_control(store, namespace_id)
.await
.map_err(|error| {
CoreError::MetadataProjection(MetadataProjectionLoadError::LoadHead(error))
})?
.state;
if head.state == NamespaceState::Deleted {
return Err(CoreError::NamespaceDeleted {
namespace_id: namespace_id.clone(),
});
}
Ok(())
}
fn terminal_session_error(
state: &UploadSessionLifecycle,
upload_id: UploadId,
) -> Option<CoreError> {
match state {
UploadSessionLifecycle::Open { .. } => None,
UploadSessionLifecycle::Completed { .. } => {
Some(CoreError::UploadAlreadyCompleted { upload_id })
}
UploadSessionLifecycle::Aborted { .. } => Some(CoreError::UploadNotFound { upload_id }),
}
}
fn open_staging_slot<'a>(
state: &'a mut UploadSessionLifecycle,
upload_id: &UploadId,
) -> Result<&'a mut Option<ContentRef>> {
match state {
UploadSessionLifecycle::Open { staged_content, .. } => Ok(staged_content),
UploadSessionLifecycle::Completed { .. } => Err(CoreError::UploadAlreadyCompleted {
upload_id: upload_id.clone(),
}),
UploadSessionLifecycle::Aborted { .. } => Err(CoreError::UploadNotFound {
upload_id: upload_id.clone(),
}),
}
}
fn staged_content(state: &UploadSessionLifecycle) -> Option<&ContentRef> {
match state {
UploadSessionLifecycle::Open { staged_content, .. } => staged_content.as_ref(),
UploadSessionLifecycle::Completed { .. } | UploadSessionLifecycle::Aborted { .. } => None,
}
}
fn transport_name(transport: &UploadSessionTransport) -> &'static str {
match transport {
UploadSessionTransport::ServiceProxied {} => "service_proxied",
UploadSessionTransport::DirectPut { .. } => "direct_put",
UploadSessionTransport::DirectMultipart { .. } => "direct_multipart",
}
}
pub(crate) async fn upload_content<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
upload_id: &UploadId,
bytes: &[u8],
) -> Result<UploadContentResponse> {
let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
update_upload_session(
store,
namespace_id,
upload_id,
CONTENTION_RETRY_LIMIT,
|mut state| {
let content_store_id = content_store_id.clone();
let namespace_id = namespace_id.clone();
let upload_id = upload_id.to_owned();
async move {
if let Some(error) = terminal_session_error(&state.state, upload_id.clone()) {
return Err(error);
}
if !matches!(state.transport, UploadSessionTransport::ServiceProxied {}) {
return Err(CoreError::InvalidUploadContent(format!(
"{} sessions must be completed after using the presigned URLs",
transport_name(&state.transport)
)));
}
let content_ref = ContentRef::blob_v1(state.content_id.clone(), bytes);
if let Some(existing) = staged_content(&state.state) {
if existing == &content_ref {
return Ok(UploadSessionUpdate::Noop(UploadContentResponse {
namespace_id,
upload_id,
content_ref,
}));
}
return Err(CoreError::UploadContentConflict { upload_id });
}
let stored = stage_bytes_under_content_id(
store,
content_store_id,
state.content_id.clone(),
bytes,
)
.await?;
*open_staging_slot(&mut state.state, &upload_id)? =
Some(stored.content_ref.clone());
Ok(UploadSessionUpdate::Replace {
next: Box::new(state),
outcome: UploadContentResponse {
namespace_id,
upload_id,
content_ref: stored.content_ref,
},
})
}
},
)
.await
}
pub(crate) async fn upload_streamed_content<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
upload_id: &UploadId,
body: ByteStream,
) -> Result<UploadContentResponse> {
let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
if let Some(error) = terminal_session_error(&loaded.state, upload_id.clone()) {
return Err(error);
}
if !matches!(loaded.transport, UploadSessionTransport::ServiceProxied {}) {
return Err(CoreError::InvalidUploadContent(format!(
"{} sessions must be completed after using the presigned URLs",
transport_name(&loaded.transport)
)));
}
if let Some(staged) = staged_content(&loaded.state) {
let content_ref = identify_streamed_payload(loaded.content_id.clone(), body).await?;
if staged != &content_ref {
return Err(CoreError::UploadContentConflict {
upload_id: upload_id.clone(),
});
}
return Ok(UploadContentResponse {
namespace_id: namespace_id.clone(),
upload_id: upload_id.clone(),
content_ref,
});
}
let staged =
stage_streamed_under_content_id(store, content_store_id, loaded.content_id.clone(), body)
.await?;
update_upload_session(
store,
namespace_id,
upload_id,
CONTENTION_RETRY_LIMIT,
|mut state| {
let namespace_id = namespace_id.clone();
let upload_id = upload_id.to_owned();
let content_ref = staged.content_ref.clone();
let already_present = staged.already_present;
async move {
if let Some(error) = terminal_session_error(&state.state, upload_id.clone()) {
return Err(error);
}
let response = UploadContentResponse {
namespace_id,
upload_id: upload_id.clone(),
content_ref: content_ref.clone(),
};
match staged_content(&state.state) {
Some(existing) if existing == &content_ref => {
Ok(UploadSessionUpdate::Noop(response))
}
Some(_) => Err(CoreError::UploadContentConflict { upload_id }),
None if already_present => Err(CoreError::UploadContentConflict { upload_id }),
None => {
*open_staging_slot(&mut state.state, &upload_id)? = Some(content_ref);
Ok(UploadSessionUpdate::Replace {
next: Box::new(state),
outcome: response,
})
}
}
}
},
)
.await
}
pub(crate) async fn complete_upload<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
request: &CompleteUploadRequest,
context: &MutationContext,
) -> Result<CompletedUpload> {
let now_ms = context.now_ms;
let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
if matches!(loaded.state, UploadSessionLifecycle::Aborted { .. }) {
return Err(CoreError::UploadNotFound {
upload_id: upload_id.clone(),
});
}
let plan = completion_plan(&loaded, request)?;
if let Some(completed) = completed_outcome(
&loaded.state,
namespace_id,
content_store_id,
upload_id,
Some(plan.requested()),
now_ms,
)? {
return Ok(completed);
}
let verified = match completion_outcome(store, content_store_id, plan).await? {
CompletionOutcome::Verified(content_ref) => content_ref,
CompletionOutcome::Unusable(reason) => {
if let Err(error) =
abort_upload(store, namespace_id, content_store_id, upload_id, context).await
{
tracing::warn!(
namespace_id = %namespace_id,
upload_id = %upload_id,
error = %error,
"failed to abandon an upload session whose completion did not verify"
);
}
return Err(CoreError::InvalidUploadContent(reason));
}
};
freeze_completed_session(
store,
namespace_id,
content_store_id,
upload_id,
&verified,
now_ms,
)
.await
}
async fn freeze_completed_session<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
verified: &ContentRef,
now_ms: u64,
) -> Result<CompletedUpload> {
update_upload_session(
store,
namespace_id,
upload_id,
CONTENTION_RETRY_LIMIT,
|mut state| {
let namespace_id = namespace_id.clone();
let content_store_id = content_store_id.clone();
let upload_id = upload_id.to_owned();
let verified = verified.clone();
async move {
if let Some(completed) = completed_outcome(
&state.state,
&namespace_id,
&content_store_id,
&upload_id,
Some(&verified),
now_ms,
)? {
return Ok(UploadSessionUpdate::Noop(completed));
}
state.state = UploadSessionLifecycle::Completed {
completed_at_ms: now_ms,
content_ref: verified.clone(),
};
let outcome = completed_upload(
&namespace_id,
&content_store_id,
&upload_id,
&verified,
now_ms,
now_ms,
);
Ok(UploadSessionUpdate::Replace {
next: Box::new(state),
outcome,
})
}
},
)
.await
}
struct OwnedStagingSession {
upload_id: UploadId,
content_id: ContentId,
}
pub(crate) async fn stage_owned_bytes<S: ObjectStore + ?Sized>(
store: &S,
catalog: &VerifiedNamespaceCatalogEntry,
bytes: &[u8],
context: &MutationContext,
) -> Result<PreparedContent> {
let session = open_owned_staging_session(store, catalog, context).await?;
let stored = stage_bytes_under_content_id(
store,
catalog.content_store_id().clone(),
session.content_id,
bytes,
)
.await?;
complete_owned_staging(
store,
catalog,
&session.upload_id,
stored.content_ref,
context,
)
.await
}
pub(crate) async fn stage_owned_stream<S: ObjectStore + ?Sized>(
store: &S,
catalog: &VerifiedNamespaceCatalogEntry,
body: ByteStream,
context: &MutationContext,
) -> Result<PreparedContent> {
let session = open_owned_staging_session(store, catalog, context).await?;
let content_store_id = catalog.content_store_id().clone();
let staged =
stage_streamed_under_content_id(store, content_store_id, session.content_id, body).await?;
if staged.already_present {
return Err(CoreError::Internal(format!(
"content object `{}` already holds bytes under a freshly minted identity",
content_blob(
catalog.content_store_id().as_str(),
&staged.content_ref.content_id
)
)));
}
complete_owned_staging(
store,
catalog,
&session.upload_id,
staged.content_ref,
context,
)
.await
}
async fn open_owned_staging_session<S: ObjectStore + ?Sized>(
store: &S,
catalog: &VerifiedNamespaceCatalogEntry,
context: &MutationContext,
) -> Result<OwnedStagingSession> {
let session = NewUploadSession::service_proxied();
let content_id = session.content_id.clone();
let upload_id = create_upload_session(store, catalog.namespace_id(), session, context).await?;
Ok(OwnedStagingSession {
upload_id,
content_id,
})
}
async fn complete_owned_staging<S: ObjectStore + ?Sized>(
store: &S,
catalog: &VerifiedNamespaceCatalogEntry,
upload_id: &UploadId,
content_ref: ContentRef,
context: &MutationContext,
) -> Result<PreparedContent> {
Ok(freeze_completed_session(
store,
catalog.namespace_id(),
catalog.content_store_id(),
upload_id,
&content_ref,
context.now_ms,
)
.await?
.prepared)
}
pub(crate) async fn abort_upload<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
context: &MutationContext,
) -> Result<AbortUploadResponse> {
let now_ms = context.now_ms;
let (response, abandoned) = update_upload_session(
store,
namespace_id,
upload_id,
CONTENTION_RETRY_LIMIT,
|mut state| {
let namespace_id = namespace_id.clone();
let upload_id = upload_id.to_owned();
async move {
let aborted = |aborted_at_ms| AbortUploadResponse {
namespace_id: namespace_id.clone(),
upload_id: upload_id.clone(),
aborted_at_ms,
};
match state.state {
UploadSessionLifecycle::Aborted { aborted_at_ms } => {
let abandoned = AbandonedUpload::of(&state);
Ok(UploadSessionUpdate::Noop((
aborted(aborted_at_ms),
abandoned,
)))
}
UploadSessionLifecycle::Completed { .. } => {
Err(CoreError::UploadAlreadyCompleted { upload_id })
}
UploadSessionLifecycle::Open { .. } => {
let abandoned = AbandonedUpload::of(&state);
state.state = UploadSessionLifecycle::Aborted {
aborted_at_ms: now_ms,
};
Ok(UploadSessionUpdate::Replace {
next: Box::new(state),
outcome: (aborted(now_ms), abandoned),
})
}
}
}
},
)
.await?;
abandoned.release(store, content_store_id).await;
Ok(response)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AbandonedUpload {
content_id: ContentId,
provider_multipart_upload_id: Option<String>,
}
impl AbandonedUpload {
pub(crate) fn of(state: &UploadSessionState) -> Self {
let provider_multipart_upload_id = match &state.transport {
UploadSessionTransport::DirectMultipart {
provider_upload_id, ..
} => Some(provider_upload_id.clone()),
UploadSessionTransport::ServiceProxied {}
| UploadSessionTransport::DirectPut { .. } => None,
};
Self {
content_id: state.content_id.clone(),
provider_multipart_upload_id,
}
}
pub(crate) async fn release<S: ObjectStore + ?Sized>(
&self,
store: &S,
content_store_id: &ContentStoreId,
) {
if let Some(provider_upload_id) = &self.provider_multipart_upload_id {
abort_unpublished_multipart_upload(
store,
content_store_id,
&self.content_id,
provider_upload_id,
)
.await;
}
delete_unpublished_content_object(store, content_store_id, &self.content_id).await;
}
}
pub(crate) async fn read_upload_status<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
now_ms: u64,
) -> Result<(UploadStatusResponse, Option<CompletedUploadReceipt>)> {
let loaded = read_upload_session_state(store, namespace_id, upload_id).await?;
let (status, receipt) = match loaded.state {
UploadSessionLifecycle::Open { expires_at_ms, .. } => {
(UploadSessionStatus::Open { expires_at_ms }, None)
}
UploadSessionLifecycle::Aborted { aborted_at_ms } => {
(UploadSessionStatus::Aborted { aborted_at_ms }, None)
}
UploadSessionLifecycle::Completed {
completed_at_ms,
content_ref,
} => (
UploadSessionStatus::Completed {
completed_at_ms,
content_ref: content_ref.clone(),
validated_content_token: None,
},
receipt_within_window(
namespace_id,
content_store_id,
&content_ref,
completed_at_ms,
now_ms,
),
),
};
Ok((
UploadStatusResponse {
namespace_id: namespace_id.clone(),
upload_id: upload_id.clone(),
status,
},
receipt,
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletedUpload {
pub response: CompleteUploadResponse,
pub prepared: PreparedContent,
pub receipt: Option<CompletedUploadReceipt>,
}
fn completed_upload(
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
content_ref: &ContentRef,
completed_at_ms: u64,
now_ms: u64,
) -> CompletedUpload {
CompletedUpload {
response: CompleteUploadResponse {
namespace_id: namespace_id.clone(),
upload_id: upload_id.clone(),
content_ref: content_ref.clone(),
validated_content_token: None,
},
prepared: PreparedContent::from_admission(ContentAdmission::for_durable_content_write(
content_store_id.clone(),
content_ref.clone(),
)),
receipt: receipt_within_window(
namespace_id,
content_store_id,
content_ref,
completed_at_ms,
now_ms,
),
}
}
fn receipt_within_window(
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
content_ref: &ContentRef,
completed_at_ms: u64,
now_ms: u64,
) -> Option<CompletedUploadReceipt> {
(now_ms.saturating_sub(completed_at_ms) < COMPLETED_UPLOAD_RECEIPT_WINDOW_MS).then(|| {
CompletedUploadReceipt::for_completed_session(
namespace_id.clone(),
content_store_id.clone(),
content_ref.clone(),
)
})
}
fn completed_outcome(
state: &UploadSessionLifecycle,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
expected: Option<&ContentRef>,
now_ms: u64,
) -> Result<Option<CompletedUpload>> {
match state {
UploadSessionLifecycle::Open { .. } => Ok(None),
UploadSessionLifecycle::Aborted { .. } => Err(CoreError::UploadNotFound {
upload_id: upload_id.clone(),
}),
UploadSessionLifecycle::Completed {
completed_at_ms,
content_ref,
} => {
if expected.is_some_and(|expected| expected != content_ref) {
return Err(CoreError::UploadAlreadyCompleted {
upload_id: upload_id.clone(),
});
}
Ok(Some(completed_upload(
namespace_id,
content_store_id,
upload_id,
content_ref,
*completed_at_ms,
now_ms,
)))
}
}
}
enum CompletionOutcome {
Verified(ContentRef),
Unusable(String),
}
enum CompletionPlan<'a> {
Proxied {
requested: ContentRef,
staged: Option<&'a ContentRef>,
},
DirectPut {
requested: ContentRef,
promised: &'a ContentRef,
},
DirectMultipart {
requested: ContentRef,
provider_upload_id: &'a str,
parts: &'a [CompletedUploadPart],
},
}
impl CompletionPlan<'_> {
fn requested(&self) -> &ContentRef {
match self {
Self::Proxied { requested, .. }
| Self::DirectPut { requested, .. }
| Self::DirectMultipart { requested, .. } => requested,
}
}
}
fn completion_plan<'a>(
session: &'a UploadSessionState,
request: &'a CompleteUploadRequest,
) -> Result<CompletionPlan<'a>> {
match (&session.transport, request) {
(
UploadSessionTransport::ServiceProxied {},
CompleteUploadRequest::ContentRef { content_ref },
) => Ok(CompletionPlan::Proxied {
requested: content_ref.clone(),
staged: staged_content(&session.state),
}),
(
UploadSessionTransport::DirectPut { promised_content },
CompleteUploadRequest::ContentRef { content_ref },
) => Ok(CompletionPlan::DirectPut {
requested: content_ref.clone(),
promised: promised_content,
}),
(
UploadSessionTransport::DirectMultipart {
provider_upload_id, ..
},
CompleteUploadRequest::Multipart { multipart, parts },
) => Ok(CompletionPlan::DirectMultipart {
requested: direct_multipart_content_ref(session.content_id.clone(), multipart)?,
provider_upload_id,
parts,
}),
(
UploadSessionTransport::ServiceProxied {} | UploadSessionTransport::DirectPut { .. },
CompleteUploadRequest::Multipart { .. },
) => Err(CoreError::InvalidUploadContent(format!(
"{} completion carries no multipart claim",
transport_name(&session.transport)
))),
(
UploadSessionTransport::DirectMultipart { .. },
CompleteUploadRequest::ContentRef { .. },
) => Err(CoreError::InvalidUploadContent(
"direct_multipart completion names no content ref: the server owns the identity \
and reports it back"
.to_owned(),
)),
}
}
async fn completion_outcome<S: ObjectStore + ?Sized>(
store: &S,
content_store_id: &ContentStoreId,
plan: CompletionPlan<'_>,
) -> Result<CompletionOutcome> {
match plan {
CompletionPlan::Proxied { requested, staged } => {
let staged = staged.ok_or_else(|| {
CoreError::InvalidUploadContent("upload content has not been staged".to_owned())
})?;
if staged != &requested {
return Err(CoreError::InvalidUploadContent(
"completed content ref does not match staged content".to_owned(),
));
}
Ok(CompletionOutcome::Verified(staged.clone()))
}
CompletionPlan::DirectPut {
requested,
promised,
} => {
if promised != &requested {
return Err(CoreError::InvalidUploadContent(
"completed content ref does not match the direct_put target".to_owned(),
));
}
match verify_durable_content_checksum(store, content_store_id, promised).await {
Ok(()) => Ok(CompletionOutcome::Verified(promised.clone())),
Err(err) => {
delete_unpublished_content_object(
store,
content_store_id,
&requested.content_id,
)
.await;
Err(CoreError::InvalidUploadContent(err.to_string()))
}
}
}
CompletionPlan::DirectMultipart {
requested,
provider_upload_id,
parts,
} => {
assemble_multipart_upload(
store,
content_store_id,
provider_upload_id,
parts,
&requested,
)
.await
}
}
}
async fn assemble_multipart_upload<S: ObjectStore + ?Sized>(
store: &S,
content_store_id: &ContentStoreId,
provider_upload_id: &str,
parts: &[CompletedUploadPart],
expected: &ContentRef,
) -> Result<CompletionOutcome> {
let parts = multipart_parts(parts)?;
let object_key = content_blob(content_store_id.as_str(), &expected.content_id);
match store
.complete_multipart_upload(
&object_key,
provider_upload_id,
&parts,
&expected.storage_checksum,
)
.await
{
Ok(MultipartCompletion::Assembled | MultipartCompletion::UnknownUpload) => {}
Err(err) => {
return Ok(CompletionOutcome::Unusable(format!(
"multipart completion failed: {}",
err.message()
)));
}
}
match verify_durable_content_checksum(store, content_store_id, expected).await {
Ok(()) => Ok(CompletionOutcome::Verified(expected.clone())),
Err(err) => Ok(CompletionOutcome::Unusable(err.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::namespace::bootstrap::bootstrap_namespace;
use loonfs_api::v0::BeginUploadRequest;
use loonfs_objectstore::local_fs_store::LocalFsStore;
use tempfile::tempdir;
const BYTES: &[u8] = b"terminal states\n";
fn context(now_ms: u64) -> MutationContext {
MutationContext {
writer_id: "upload-test".to_owned(),
now_ms,
}
}
async fn staged_session(
store: &LocalFsStore,
context: &MutationContext,
) -> (NamespaceId, ContentStoreId, UploadId, ContentRef, String) {
let namespace_id = NamespaceId::parse("demo").expect("namespace id");
bootstrap_namespace(store, &namespace_id, context, false)
.await
.expect("bootstrap");
let begin = begin_upload(
store,
&namespace_id,
BeginUploadRequest::ServiceProxied {},
context,
)
.await
.expect("begin upload");
let staged = upload_content(store, &namespace_id, &begin.upload_id, BYTES)
.await
.expect("stage upload");
let content_store_id = load_namespace_content_store_id(store, &namespace_id)
.await
.expect("content store id");
let content_key = content_blob(content_store_id.as_str(), &staged.content_ref.content_id);
(
namespace_id,
content_store_id,
begin.upload_id,
staged.content_ref,
content_key,
)
}
async fn complete(
store: &LocalFsStore,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
content_ref: &ContentRef,
context: &MutationContext,
) -> Result<CompletedUpload> {
complete_upload(
store,
namespace_id,
content_store_id,
upload_id,
&CompleteUploadRequest::for_content_ref(content_ref.clone()),
context,
)
.await
}
#[tokio::test]
async fn a_completion_after_an_abort_fails_terminally_and_touches_nothing() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, content_ref, content_key) =
staged_session(&store, &setup).await;
abort_upload(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&context(2_000),
)
.await
.expect("abort");
assert!(store.head(&content_key).await.expect("head").is_none());
let error = complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(3_000),
)
.await
.expect_err("an aborted session cannot complete");
assert!(matches!(error, CoreError::UploadNotFound { .. }));
let state = read_upload_session_state(&store, &namespace_id, &upload_id)
.await
.expect("session still readable");
assert!(matches!(
state.state,
UploadSessionLifecycle::Aborted {
aborted_at_ms: 2_000
}
));
assert!(store.head(&content_key).await.expect("head").is_none());
}
#[tokio::test]
async fn an_abort_after_completion_is_refused_and_keeps_the_content() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, content_ref, content_key) =
staged_session(&store, &setup).await;
complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(2_000),
)
.await
.expect("complete");
let error = abort_upload(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&context(3_000),
)
.await
.expect_err("a completed session cannot be aborted");
assert!(matches!(error, CoreError::UploadAlreadyCompleted { .. }));
assert!(
store.head(&content_key).await.expect("head").is_some(),
"a refused abort must not clean up published-able content"
);
}
#[tokio::test]
async fn a_repeated_abort_reports_the_first_stamp() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, _content_ref, _content_key) =
staged_session(&store, &setup).await;
let first = abort_upload(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&context(2_000),
)
.await
.expect("first abort");
let second = abort_upload(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&context(9_000),
)
.await
.expect("repeated abort");
assert_eq!(first.aborted_at_ms, 2_000);
assert_eq!(second, first);
}
#[tokio::test]
async fn staging_into_a_terminal_session_is_refused() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
staged_session(&store, &setup).await;
complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(2_000),
)
.await
.expect("complete");
let error = upload_content(&store, &namespace_id, &upload_id, BYTES)
.await
.expect_err("a completed session takes no more bytes");
assert!(matches!(error, CoreError::UploadAlreadyCompleted { .. }));
let aborted = begin_upload(
&store,
&namespace_id,
BeginUploadRequest::ServiceProxied {},
&setup,
)
.await
.expect("begin a second upload");
abort_upload(
&store,
&namespace_id,
&content_store_id,
&aborted.upload_id,
&context(3_000),
)
.await
.expect("abort");
let error = upload_content(&store, &namespace_id, &aborted.upload_id, BYTES)
.await
.expect_err("an aborted session takes no more bytes");
assert!(matches!(error, CoreError::UploadNotFound { .. }));
}
#[tokio::test]
async fn only_a_completed_session_mints_a_receipt() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
staged_session(&store, &setup).await;
let (open, receipt) =
read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, 1_500)
.await
.expect("status of an open session");
assert!(matches!(open.status, UploadSessionStatus::Open { .. }));
assert!(receipt.is_none(), "an open session attests nothing");
complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(2_000),
)
.await
.expect("complete");
let (completed, receipt) =
read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, 2_500)
.await
.expect("status of a completed session");
assert!(matches!(
completed.status,
UploadSessionStatus::Completed { .. }
));
assert_eq!(
receipt.expect("a completed session mints").content_ref(),
&content_ref
);
let begin = begin_upload(
&store,
&namespace_id,
BeginUploadRequest::ServiceProxied {},
&setup,
)
.await
.expect("begin second upload");
abort_upload(
&store,
&namespace_id,
&content_store_id,
&begin.upload_id,
&context(3_000),
)
.await
.expect("abort");
let (aborted, receipt) = read_upload_status(
&store,
&namespace_id,
&content_store_id,
&begin.upload_id,
3_500,
)
.await
.expect("status of an aborted session");
assert!(matches!(
aborted.status,
UploadSessionStatus::Aborted { .. }
));
assert!(receipt.is_none(), "an aborted session attests nothing");
}
#[tokio::test]
async fn a_completed_session_re_mints_until_its_receipt_window_closes() {
let temp_dir = tempdir().expect("tempdir");
let store = LocalFsStore::new(temp_dir.path()).expect("store");
let setup = context(1_000);
let (namespace_id, content_store_id, upload_id, content_ref, _content_key) =
staged_session(&store, &setup).await;
let completed_at_ms = 2_000;
complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(completed_at_ms),
)
.await
.expect("complete");
let much_later = completed_at_ms + COMPLETED_UPLOAD_RECEIPT_WINDOW_MS - 1;
let (_, receipt) = read_upload_status(
&store,
&namespace_id,
&content_store_id,
&upload_id,
much_later,
)
.await
.expect("status inside the receipt window");
assert_eq!(receipt.expect("still minting").content_ref(), &content_ref);
let past = completed_at_ms + COMPLETED_UPLOAD_RECEIPT_WINDOW_MS;
let (status, receipt) =
read_upload_status(&store, &namespace_id, &content_store_id, &upload_id, past)
.await
.expect("status past the receipt window");
assert!(matches!(status, UploadStatusResponse { .. }));
assert!(
receipt.is_none(),
"past the window no receipt exists, which is what lets content GC decide"
);
let replay = complete(
&store,
&namespace_id,
&content_store_id,
&upload_id,
&content_ref,
&context(past),
)
.await
.expect("replay still succeeds");
assert_eq!(replay.response.content_ref, content_ref);
assert!(replay.receipt.is_none());
}
}