use super::budget::PassBudget;
use super::live_set::LiveSet;
use crate::checkpoint::load_verified_manifest_tables;
use crate::context::MutationContext;
use crate::control_update::{
read_upload_session_state, try_update_upload_session, UploadSessionCas, UploadSessionUpdate,
};
use crate::error::{CoreError, Result};
use crate::limits::CONTENT_RECLAMATION_GRACE_MS;
use crate::protocol::AbandonedUpload;
use crate::storage::content::delete_unpublished_content_object;
use loonfs_api::wire::control::{UploadSessionLifecycle, UploadSessionState};
use loonfs_api::wire::manifest::{lookup_keys, MetadataRow, MetadataTableFamily};
use loonfs_api::wire::sst_blocks::string_prefix_upper_bound;
use loonfs_api::wire::wal::{decode_wal_segment_envelope_zstd, WalDelta};
use loonfs_api::{ContentId, ContentStoreId, NamespaceId, UploadId};
use loonfs_objectstore::ObjectStore;
use std::collections::BTreeSet;
const REVISION_SCAN_WAVE_ROWS: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UploadSessionSweep {
Retain {
reclaimable_at_ms: Option<u64>,
},
Delete {
reclaimed_content: bool,
},
ContentReclamationDeferred,
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn sweep_upload_session<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
grace_window_ms: u64,
references: &mut ContentReferences<'_>,
budget: &mut PassBudget,
context: &MutationContext,
) -> Result<UploadSessionSweep> {
let state = match read_upload_session_state(store, namespace_id, upload_id).await {
Ok(state) => state,
Err(CoreError::UploadNotFound { .. }) => return Ok(retain_undated()),
Err(error) => return Err(error),
};
match state.state {
UploadSessionLifecycle::Open { expires_at_ms, .. } => {
abort_expired_session(
store,
namespace_id,
content_store_id,
upload_id,
expires_at_ms,
grace_window_ms,
context,
)
.await
}
UploadSessionLifecycle::Aborted { aborted_at_ms } => {
if context.now_ms.saturating_sub(aborted_at_ms) < grace_window_ms {
return Ok(retain_until(aborted_at_ms.saturating_add(grace_window_ms)));
}
AbandonedUpload::of(&state)
.release(store, content_store_id)
.await;
Ok(UploadSessionSweep::Delete {
reclaimed_content: false,
})
}
UploadSessionLifecycle::Completed {
completed_at_ms,
content_ref,
} => {
if context.now_ms.saturating_sub(completed_at_ms) < CONTENT_RECLAMATION_GRACE_MS {
return Ok(retain_until(
completed_at_ms.saturating_add(CONTENT_RECLAMATION_GRACE_MS),
));
}
match references
.lookup(store, namespace_id, &content_ref.content_id, budget)
.await?
{
ContentReference::Unknown => Ok(retain_undated()),
ContentReference::Deferred => Ok(UploadSessionSweep::ContentReclamationDeferred),
ContentReference::Referenced => Ok(UploadSessionSweep::Delete {
reclaimed_content: false,
}),
ContentReference::Absent => {
delete_unpublished_content_object(
store,
content_store_id,
&content_ref.content_id,
)
.await;
Ok(UploadSessionSweep::Delete {
reclaimed_content: true,
})
}
}
}
}
}
fn retain_until(at_ms: u64) -> UploadSessionSweep {
UploadSessionSweep::Retain {
reclaimable_at_ms: Some(at_ms),
}
}
fn retain_undated() -> UploadSessionSweep {
UploadSessionSweep::Retain {
reclaimable_at_ms: None,
}
}
async fn abort_expired_session<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
content_store_id: &ContentStoreId,
upload_id: &UploadId,
expires_at_ms: u64,
grace_window_ms: u64,
context: &MutationContext,
) -> Result<UploadSessionSweep> {
if context.now_ms.saturating_sub(expires_at_ms) < grace_window_ms {
return Ok(retain_until(expires_at_ms.saturating_add(grace_window_ms)));
}
let aborted = try_update_upload_session(
store,
namespace_id,
upload_id,
|mut state: UploadSessionState, _metadata| async move {
if !matches!(state.state, UploadSessionLifecycle::Open { .. }) {
return Ok(UploadSessionUpdate::Noop(None));
}
let abandoned = AbandonedUpload::of(&state);
state.state = UploadSessionLifecycle::Aborted {
aborted_at_ms: context.now_ms,
};
Ok(UploadSessionUpdate::Replace {
next: Box::new(state),
outcome: Some(abandoned),
})
},
)
.await;
match aborted {
Ok(UploadSessionCas::Applied(Some(abandoned))) => {
abandoned.release(store, content_store_id).await;
Ok(retain_until(context.now_ms.saturating_add(grace_window_ms)))
}
Ok(UploadSessionCas::Applied(None)) => Ok(retain_undated()),
Ok(UploadSessionCas::Conflict) => {
tracing::debug!(
namespace_id = %namespace_id,
upload_id = %upload_id,
"upload-session abort lost its inspected etag; retaining"
);
Ok(retain_undated())
}
Err(CoreError::UploadNotFound { .. }) => Ok(retain_undated()),
Err(error) => Err(error),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContentReference {
Referenced,
Absent,
Unknown,
Deferred,
}
enum CollectedReferences {
NotYet,
Unavailable,
Deferred,
Referenced(BTreeSet<ContentId>),
}
enum ScanOutcome {
Complete(BTreeSet<ContentId>),
Unavailable,
BudgetExhausted,
}
pub(super) struct ContentReferences<'a> {
live: &'a LiveSet,
collected: CollectedReferences,
}
impl<'a> ContentReferences<'a> {
pub(super) fn over(live: &'a LiveSet) -> Self {
Self {
live,
collected: CollectedReferences::NotYet,
}
}
async fn lookup<S: ObjectStore + ?Sized>(
&mut self,
store: &S,
namespace_id: &NamespaceId,
content_id: &ContentId,
budget: &mut PassBudget,
) -> Result<ContentReference> {
if matches!(self.collected, CollectedReferences::NotYet) {
if self.live.degraded {
self.collected = CollectedReferences::Unavailable;
} else {
match collect_referenced_content(store, namespace_id, self.live, budget).await? {
ScanOutcome::Complete(referenced) => {
self.collected = CollectedReferences::Referenced(referenced);
}
ScanOutcome::Unavailable => {
self.collected = CollectedReferences::Unavailable;
}
ScanOutcome::BudgetExhausted => {
self.collected = CollectedReferences::Deferred;
}
}
}
}
Ok(match &self.collected {
CollectedReferences::NotYet | CollectedReferences::Unavailable => {
ContentReference::Unknown
}
CollectedReferences::Deferred => ContentReference::Deferred,
CollectedReferences::Referenced(referenced) => {
if referenced.contains(content_id) {
ContentReference::Referenced
} else {
ContentReference::Absent
}
}
})
}
}
async fn collect_referenced_content<S: ObjectStore + ?Sized>(
store: &S,
namespace_id: &NamespaceId,
live: &LiveSet,
budget: &mut PassBudget,
) -> Result<ScanOutcome> {
let mut referenced = BTreeSet::new();
for manifest_object_id in &live.manifests {
if !budget.try_charge() {
return Ok(ScanOutcome::BudgetExhausted);
}
let Ok(tables) =
load_verified_manifest_tables(store, namespace_id, manifest_object_id).await
else {
return Ok(ScanOutcome::Unavailable);
};
let mut lower_bound = lookup_keys::REVISION_ROW_PREFIX.to_owned();
let upper_bound = string_prefix_upper_bound(lookup_keys::REVISION_ROW_PREFIX);
loop {
if !budget.try_charge() {
return Ok(ScanOutcome::BudgetExhausted);
}
let Ok(rows) = tables
.scan_range_page_with_keys(
MetadataTableFamily::Revisions,
&lower_bound,
upper_bound.as_deref(),
REVISION_SCAN_WAVE_ROWS,
)
.await
else {
return Ok(ScanOutcome::Unavailable);
};
let exhausted = rows.len() < REVISION_SCAN_WAVE_ROWS;
match rows.last() {
Some((row_key, _)) => lower_bound = format!("{row_key}\0"),
None => break,
}
for (_, row) in rows {
if let MetadataRow::Revision { content_ref, .. } = row {
referenced.insert(content_ref.content_id);
}
}
if exhausted {
break;
}
}
}
for segment_key in &live.wal_segments {
if !budget.try_charge() {
return Ok(ScanOutcome::BudgetExhausted);
}
let Some(bytes) = store
.get(segment_key, None)
.await
.map_err(|error| CoreError::store(segment_key, &error))?
else {
return Ok(ScanOutcome::Unavailable);
};
let Ok(envelope) = decode_wal_segment_envelope_zstd(&bytes) else {
return Ok(ScanOutcome::Unavailable);
};
for record in &envelope.payload.records {
for delta in &record.deltas {
if let WalDelta::AppendFileRevision { content_ref, .. } = &delta.delta {
referenced.insert(content_ref.content_id.clone());
}
}
}
}
Ok(ScanOutcome::Complete(referenced))
}