use std::{
collections::HashSet, future::Future, num::NonZeroU16, sync::Arc, time::Duration,
};
use async_stream::try_stream;
use backon::{BackoffBuilder as _, ExponentialBuilder, Retryable as _};
use futures::{Stream, StreamExt as _, TryStreamExt as _, stream};
use tokio::pin;
use tracing::{info, instrument, warn};
use crate::{
StorageError,
asset_manager::AssetManager,
config::RepoUpdateRetryConfig,
format::{
IcechunkFormatError, IcechunkResult, SnapshotId,
format_constants::SpecVersionBin,
repo_info::{RepoAvailability, RepoInfo},
snapshot::{Snapshot, SnapshotInfo},
},
refs::{RefError, RefResult, list_refs},
repository::{RepositoryError, RepositoryErrorKind, RepositoryResult},
};
use icechunk_types::{ICResultExt as _, error::ICResultCtxExt as _};
#[derive(Debug, thiserror::Error)]
pub enum GCError {
#[error("ref error {0}")]
Ref(#[from] RefError),
#[error("repository error {0}")]
Repository(#[from] RepositoryError),
#[error("format error {0}")]
FormatError(#[from] IcechunkFormatError),
#[error("storage error {0}")]
StorageError(#[from] StorageError),
#[error("too many consecutive delete failures under {prefix}: {last_error}")]
DeletesFailing { prefix: String, last_error: String },
}
pub type GCResult<A> = Result<A, GCError>;
pub(crate) async fn ensure_repo_writable(
asset_manager: &AssetManager,
op_name: &str,
) -> GCResult<()> {
if !asset_manager.can_write_to_storage().await? {
return Err(RepositoryErrorKind::ReadonlyStorage(format!("Cannot {op_name}")))
.capture()
.map_err(GCError::Repository);
}
if asset_manager.spec_version() >= SpecVersionBin::V2 {
let (repo_info, _) = asset_manager.fetch_repo_info().await?;
if repo_info.status()?.availability != RepoAvailability::Online {
return Err(RepositoryErrorKind::ReadonlyRepository(format!(
"Cannot {op_name}"
)))
.capture()
.map_err(GCError::Repository);
}
}
Ok(())
}
const FD_HEADROOM: u64 = 64;
#[cfg(unix)]
fn nofile_soft_limit() -> Option<u64> {
let (soft, _hard) = rlimit::Resource::NOFILE.get().ok()?;
(soft != rlimit::INFINITY).then_some(soft)
}
#[cfg(not(unix))]
fn nofile_soft_limit() -> Option<u64> {
None
}
pub(crate) fn walk_peak_requests(
max_concurrent_manifest_fetches: NonZeroU16,
max_snapshots_in_memory: NonZeroU16,
) -> u64 {
max_concurrent_manifest_fetches.get() as u64 + max_snapshots_in_memory.get() as u64
}
pub(crate) fn warn_on_low_fd_limit(peak_requests: u64, op_name: &str) {
let Some(soft) = nofile_soft_limit() else { return };
let needed = peak_requests + FD_HEADROOM;
if soft < needed {
warn!(
soft_limit = soft,
needed,
"{op_name} peaks at {peak_requests} concurrent requests, but this \
process can only open {soft} files and will likely fail with \
\"too many open files\". Raise the limit (ulimit -S -n {needed}) or \
lower the concurrency settings."
);
}
}
pub(crate) async fn retry_on_repo_info_update<T, Fut>(
retries: Option<&RepoUpdateRetryConfig>,
op_name: &'static str,
op: impl FnMut() -> Fut,
) -> GCResult<T>
where
Fut: Future<Output = GCResult<T>>,
{
let default_retry_config = RepoUpdateRetryConfig::default();
let retry_config = retries.unwrap_or(&default_retry_config).retries();
let backoff = ExponentialBuilder::new()
.with_min_delay(Duration::from_millis(retry_config.initial_backoff_ms() as u64))
.with_max_delay(Duration::from_millis(retry_config.max_backoff_ms() as u64))
.with_max_times(retry_config.max_tries().get() as usize)
.with_jitter()
.build();
op.retry(backoff)
.sleep(tokio::time::sleep)
.when(|e| {
matches!(
e,
GCError::Repository(RepositoryError {
kind: RepositoryErrorKind::RepoInfoUpdated,
..
})
)
})
.notify(move |_, _| {
info!(
"Repo info object was updated while {op_name} was running, retrying with backoff..."
);
})
.await
}
pub(crate) struct AbortOnDrop<T>(pub(crate) tokio::task::JoinHandle<T>);
impl<T> AbortOnDrop<T> {
pub(crate) async fn abort_and_wait(&mut self) {
self.0.abort();
let _ = (&mut self.0).await;
}
}
impl<T> Drop for AbortOnDrop<T> {
fn drop(&mut self) {
self.0.abort();
}
}
pub mod deleter;
pub mod expiration;
pub mod gc;
pub mod manifests;
pub mod sharded_set;
pub mod stats;
pub mod walker;
#[instrument(skip_all)]
pub fn all_roots_v2<'a>(
repo_info: &'a RepoInfo,
extra_roots: &'a HashSet<SnapshotId>,
) -> RepositoryResult<impl Iterator<Item = RepositoryResult<SnapshotId>> + 'a> {
let res = repo_info
.tag_names()
.inject()?
.map(|tag| repo_info.resolve_tag(tag))
.chain(repo_info.branch_names().inject()?.map(|br| repo_info.resolve_branch(br)))
.chain(extra_roots.iter().cloned().map(Ok))
.map(|r| r.inject());
Ok(res)
}
#[instrument(skip_all)]
pub fn reachable_snapshots_v2(
repo_info: &RepoInfo,
extra_roots: &HashSet<SnapshotId>,
) -> RepositoryResult<HashSet<SnapshotId>> {
let mut seen: HashSet<SnapshotId> = HashSet::new();
for pointed_snap_id in all_roots_v2(repo_info, extra_roots)? {
let pointed_snap_id = pointed_snap_id?;
if !seen.contains(&pointed_snap_id) {
for snap_info in repo_info.ancestry(&pointed_snap_id).inject()? {
if !seen.insert(snap_info.inject()?.id) {
break;
}
}
}
}
Ok(seen)
}
#[instrument(skip_all)]
pub fn pointed_snapshots_v2(
asset_manager: Arc<AssetManager>,
repo_info: &RepoInfo,
extra_roots: &HashSet<SnapshotId>,
max_concurrent_fetches: NonZeroU16,
) -> RepositoryResult<impl Stream<Item = RepositoryResult<Arc<Snapshot>>> + use<>> {
let ids = reachable_snapshots_v2(repo_info, extra_roots)?;
let res = stream::iter(ids)
.map(move |id| {
let asset_manager = Arc::clone(&asset_manager);
async move { asset_manager.fetch_snapshot(&id).await }
})
.buffer_unordered(max_concurrent_fetches.get() as usize);
Ok(res)
}
#[instrument(skip_all)]
pub async fn pointed_snapshots_v1<'a>(
asset_manager: Arc<AssetManager>,
extra_roots: &'a HashSet<SnapshotId>,
) -> RepositoryResult<impl Stream<Item = RepositoryResult<Arc<Snapshot>>> + 'a> {
let mut seen: HashSet<SnapshotId> = HashSet::new();
let res = try_stream! {
let roots = all_roots_v1(Arc::clone(&asset_manager), extra_roots)
.await.inject()?
.map(|r| r.inject());
pin!(roots);
while let Some(pointed_snap_id) = roots.try_next().await? {
if ! seen.contains(&pointed_snap_id) {
#[expect(deprecated)]
let parents = Arc::clone(&asset_manager).snapshot_ancestry_v1(&pointed_snap_id).await?;
for await parent in parents {
let parent = parent?;
let snap_id = parent.id();
if seen.insert(snap_id) {
yield parent
} else {
break
}
}
}
}
};
Ok(res)
}
pub async fn all_roots_v1<'a>(
asset_manager: Arc<AssetManager>,
extra_roots: &'a HashSet<SnapshotId>,
) -> RefResult<impl Stream<Item = RefResult<SnapshotId>> + 'a> {
let all_refs =
list_refs(asset_manager.storage().as_ref(), &asset_manager.storage_context())
.await?;
let roots = stream::iter(all_refs)
.then(move |r| {
let asset_manager = Arc::clone(&asset_manager);
async move {
r.fetch(
asset_manager.storage().as_ref(),
&asset_manager.storage_context(),
)
.await
.map(|ref_data| ref_data.snapshot)
}
})
.chain(stream::iter(extra_roots.iter().cloned()).map(Ok));
Ok(roots)
}
pub(crate) fn reparent_and_prune(
repo_info: &RepoInfo,
edited: &SnapshotInfo,
is_boundary: impl Fn(&SnapshotInfo) -> bool,
) -> IcechunkResult<(Option<SnapshotId>, Vec<SnapshotId>)> {
let mut new_parent = None;
let mut collapsed = Vec::new();
for ancestor in repo_info.ancestry(&edited.id)?.skip(1) {
let ancestor = ancestor?;
if is_boundary(&ancestor) {
new_parent = Some(ancestor.id);
break;
}
collapsed.push((ancestor.id, ancestor.pruned_ancestor_tx_logs));
}
let mut pruned = Vec::new();
for (id, ancestor_pruned) in collapsed.into_iter().rev() {
pruned.extend(ancestor_pruned);
pruned.push(id);
}
pruned.extend(edited.pruned_ancestor_tx_logs.iter().cloned());
Ok((new_parent, pruned))
}
pub async fn pointed_snapshots<'a>(
asset_manager: Arc<AssetManager>,
repo_info: Option<Arc<RepoInfo>>,
extra_roots: &'a HashSet<SnapshotId>,
max_concurrent_fetches: NonZeroU16,
) -> RepositoryResult<impl Stream<Item = RepositoryResult<Arc<Snapshot>>> + 'a> {
match asset_manager.spec_version() {
SpecVersionBin::V1 => {
Ok(pointed_snapshots_v1(asset_manager, extra_roots).await?.left_stream())
}
SpecVersionBin::V2 => {
let repo_info = match repo_info {
Some(repo_info) => repo_info,
None => asset_manager.fetch_repo_info().await?.0,
};
Ok(pointed_snapshots_v2(
asset_manager,
repo_info.as_ref(),
extra_roots,
max_concurrent_fetches,
)?
.right_stream())
}
}
}
#[cfg(test)]
mod tests {
use futures::TryStreamExt as _;
use std::{collections::HashSet, num::NonZeroU16, sync::Arc};
use bytes::Bytes;
use crate::{
Repository, Storage,
format::{Path, SNAPSHOTS_FILE_PATH, format_constants::SpecVersionBin},
new_in_memory_storage,
ops::pointed_snapshots,
test_utils::{logging_asset_manager, repo_with_converging_refs},
};
#[tokio::test]
async fn test_pointed_snapshots_duplicate() -> Result<(), Box<dyn std::error::Error>>
{
let storage = new_in_memory_storage().await?;
let repo = Repository::create(Arc::clone(&storage)).execute().await?;
let mut session = repo.writable_session("main").await?;
session.add_group(Path::root(), Bytes::new()).await?;
let snap = session.commit("commit").max_concurrent_nodes(8).execute().await?;
repo.create_tag("tag1", &snap).await?;
let mut session = repo.writable_session("main").await?;
session.add_group("/foo".try_into().unwrap(), Bytes::new()).await?;
let snap = session.commit("commit").max_concurrent_nodes(8).execute().await?;
repo.create_tag("tag2", &snap).await?;
let all_snaps = pointed_snapshots(
Arc::clone(repo.asset_manager()),
None,
&HashSet::new(),
NonZeroU16::new(10).unwrap(),
)
.await?
.try_collect::<Vec<_>>()
.await?;
assert_eq!(all_snaps.len(), 3);
Ok(())
}
#[tokio::test]
async fn test_pointed_snapshots_v2_fetches_each_snapshot_once()
-> Result<(), Box<dyn std::error::Error>> {
let backend: Arc<dyn Storage + Send + Sync> = new_in_memory_storage().await?;
let repo = repo_with_converging_refs(&backend).await?;
let (logging, asset_manager) = logging_asset_manager(
&backend,
repo.storage_settings().clone(),
SpecVersionBin::V2,
);
let all_snaps = pointed_snapshots(
asset_manager,
None,
&HashSet::new(),
NonZeroU16::new(10).unwrap(),
)
.await?
.try_collect::<Vec<_>>()
.await?;
assert_eq!(all_snaps.len(), 6);
let snapshot_reads = logging
.fetch_operations()
.into_iter()
.filter(|(_, path)| path.starts_with(SNAPSHOTS_FILE_PATH))
.count();
assert_eq!(snapshot_reads, 6);
Ok(())
}
}