use std::{
num::NonZeroU16,
sync::{
Arc, LazyLock,
atomic::{AtomicUsize, Ordering},
},
};
use async_stream::try_stream;
use futures::{
Stream, StreamExt as _, TryStreamExt as _, future::ready, stream::BoxStream,
};
use icechunk_types::{ICResultExt as _, error::ICResultCtxExt as _};
use tokio::{sync::mpsc, task::JoinSet};
use tracing::debug;
use crate::{
Storage,
format::{OBJECT_ID_ONE_CHAR_PREFIXES, OBJECT_ID_TWO_CHAR_PREFIXES},
repository::RepositoryResult,
storage::{self, AttributionLabels, ListInfo, StorageContext},
};
pub fn default_list_concurrency() -> NonZeroU16 {
let cores = std::thread::available_parallelism().map_or(4, |n| n.get());
let n = (cores * 8).clamp(32, 256);
NonZeroU16::new(n as u16).unwrap_or(NonZeroU16::MIN)
}
const LIST_PAGE_SIZE: usize = 1_000;
static PROBE_ID_PREFIX: LazyLock<&'static [String]> =
LazyLock::new(|| &OBJECT_ID_TWO_CHAR_PREFIXES[..1]);
pub(crate) async fn list_object_ids<'a, Id>(
storage: &'a Arc<dyn Storage + Send + Sync>,
ctx: &StorageContext<'_>,
prefix: &str,
concurrency: NonZeroU16,
) -> RepositoryResult<BoxStream<'a, RepositoryResult<ListInfo<Id>>>>
where
Id: for<'b> TryFrom<&'b str> + Send + std::fmt::Debug + 'static,
{
if !storage.lists_id_prefixes_natively() {
let stream = storage.list_objects(ctx, prefix).await.inject()?;
return Ok(translate_list_infos(stream.map(|r| r.inject())));
}
let probe = storage
.list_objects_with_id_prefixes(ctx, prefix, *PROBE_ID_PREFIX)
.await
.inject()?;
let probed = probe
.take(LIST_PAGE_SIZE)
.try_fold(0usize, |n, _| ready(Ok(n + 1)))
.await
.inject()?;
let prefixes: &'static [String] = if probed >= LIST_PAGE_SIZE {
OBJECT_ID_TWO_CHAR_PREFIXES.as_slice()
} else {
OBJECT_ID_ONE_CHAR_PREFIXES.as_slice()
};
debug!(
prefix,
probed,
fan_out = prefixes.len(),
concurrency = concurrency.get(),
"listing object ids"
);
let storage = Arc::clone(storage);
let settings = ctx.settings.clone();
let labels = ctx.attribution.labels.clone();
let prefix = prefix.to_string();
let workers_n = (concurrency.get() as usize).min(prefixes.len());
let (tx, rx) = mpsc::channel::<Vec<ListInfo<Id>>>(2 * workers_n);
let next = Arc::new(AtomicUsize::new(0));
let mut workers: JoinSet<RepositoryResult<()>> = JoinSet::new();
for _ in 0..workers_n {
workers.spawn(list_prefixes_worker(
Arc::clone(&storage),
settings.clone(),
labels.clone(),
prefix.clone(),
prefixes,
Arc::clone(&next),
tx.clone(),
));
}
drop(tx);
let stream = try_stream! {
let mut workers = workers;
let mut rx = rx;
loop {
let event = tokio::select! {
biased;
Some(joined) = workers.join_next() => ListEvent::Worker(joined),
page = rx.recv() => ListEvent::Page(page),
};
match event {
ListEvent::Worker(joined) => { joined.capture()??; }
ListEvent::Page(Some(page)) => {
for info in page {
yield info;
}
}
ListEvent::Page(None) => break,
}
}
while let Some(joined) = workers.join_next().await {
joined.capture()??;
}
};
Ok(stream.boxed())
}
pub(crate) fn convert_list_item<Id>(item: &ListInfo<String>) -> Option<ListInfo<Id>>
where
Id: for<'b> TryFrom<&'b str>,
{
let id = Id::try_from(item.id.as_str()).ok()?;
let created_at = item.created_at;
Some(ListInfo { created_at, id, size_bytes: item.size_bytes })
}
pub(crate) fn translate_list_infos<'a, Id>(
s: impl Stream<Item = RepositoryResult<ListInfo<String>>> + Send + 'a,
) -> BoxStream<'a, RepositoryResult<ListInfo<Id>>>
where
Id: for<'b> TryFrom<&'b str> + Send + std::fmt::Debug + 'a,
{
s.try_filter_map(|info| async move {
let converted = convert_list_item(&info);
if converted.is_none() {
tracing::error!(list_info = ?info, "Error processing list item metadata");
}
Ok(converted)
})
.boxed()
}
enum ListEvent<T> {
Worker(Result<RepositoryResult<()>, tokio::task::JoinError>),
Page(Option<T>),
}
async fn list_prefixes_worker<Id>(
storage: Arc<dyn Storage + Send + Sync>,
settings: storage::Settings,
labels: AttributionLabels,
prefix: String,
prefixes: &'static [String],
next: Arc<AtomicUsize>,
tx: mpsc::Sender<Vec<ListInfo<Id>>>,
) -> RepositoryResult<()>
where
Id: for<'b> TryFrom<&'b str> + Send + std::fmt::Debug + 'static,
{
let ctx = StorageContext::without_node(&settings, &labels);
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(id_prefix) = prefixes.get(i..=i) else { return Ok(()) };
let stream = storage
.list_objects_with_id_prefixes(&ctx, &prefix, id_prefix)
.await
.inject()?;
let mut pages = stream.ready_chunks(LIST_PAGE_SIZE);
while let Some(items) = pages.next().await {
let mut page = Vec::with_capacity(items.len());
for item in items {
let item = item.inject()?;
match convert_list_item(&item) {
Some(info) => page.push(info),
None => {
tracing::error!(list_info = ?item, "Error processing list item metadata");
}
}
}
if tx.send(page).await.is_err() {
return Ok(());
}
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use bytes::Bytes;
use icechunk_macros::tokio_test;
use super::*;
use crate::{
Repository,
asset_manager::AssetManager,
format::{ChunkId, format_constants::SpecVersionBin},
storage::{
latency::LatencyStorage, logging::LoggingStorage, new_in_memory_storage,
},
};
#[cfg(not(feature = "shuttle"))]
async fn repo_with_chunks_under_first_prefix(
backend: &Arc<dyn Storage + Send + Sync>,
n: usize,
two_char: bool,
) -> Arc<AssetManager> {
let repo = Repository::create(Arc::clone(backend)).execute().await.unwrap();
let am = Arc::clone(repo.asset_manager());
for _ in 0..n {
let mut id = ChunkId::random();
if two_char {
id.0[0] = 0;
id.0[1] &= 0x3F;
} else {
id.0[0] &= 0x07;
}
am.write_chunk("a", &[0], id, Bytes::new()).await.unwrap();
}
am
}
#[cfg(not(feature = "shuttle"))]
fn list_calls(logging: &LoggingStorage) -> usize {
logging
.fetch_operations()
.into_iter()
.filter(|(op, _)| op == "list_objects_with_id_prefixes")
.count()
}
#[tokio_test]
async fn small_repo_lists_with_32_prefixes() {
let backend: Arc<dyn Storage + Send + Sync> =
new_in_memory_storage().await.unwrap();
let am = repo_with_chunks_under_first_prefix(&backend, 50, false).await;
let (logging, lam) = crate::test_utils::fanning_out_logging_asset_manager(
&backend,
am.storage_settings().clone(),
SpecVersionBin::V2,
);
let ids: Vec<ChunkId> = lam
.list_chunks_with_concurrency(NonZeroU16::new(8).unwrap())
.await
.unwrap()
.map_ok(|i| i.id)
.try_collect()
.await
.unwrap();
assert_eq!(ids.len(), 50);
assert_eq!(list_calls(&logging), 1 + 32, "probe plus one call per prefix");
}
#[tokio_test]
async fn full_probe_page_lists_with_1024_prefixes() {
let backend: Arc<dyn Storage + Send + Sync> =
new_in_memory_storage().await.unwrap();
let am = repo_with_chunks_under_first_prefix(&backend, 1000, true).await;
let (logging, lam) = crate::test_utils::fanning_out_logging_asset_manager(
&backend,
am.storage_settings().clone(),
SpecVersionBin::V2,
);
let ids: Vec<ChunkId> = lam
.list_chunks_with_concurrency(NonZeroU16::new(64).unwrap())
.await
.unwrap()
.map_ok(|i| i.id)
.try_collect()
.await
.unwrap();
assert_eq!(ids.len(), 1000);
assert_eq!(list_calls(&logging), 1 + 1024);
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), 1000);
}
#[tokio_test]
async fn concurrency_does_not_change_the_result() {
let backend: Arc<dyn Storage + Send + Sync> =
new_in_memory_storage().await.unwrap();
let written = repo_with_chunks_under_first_prefix(&backend, 300, false).await;
let (_logging, am) = crate::test_utils::fanning_out_logging_asset_manager(
&backend,
written.storage_settings().clone(),
SpecVersionBin::V2,
);
let one: std::collections::HashSet<ChunkId> = am
.list_chunks_with_concurrency(NonZeroU16::MIN)
.await
.unwrap()
.map_ok(|i| i.id)
.try_collect()
.await
.unwrap();
let many: std::collections::HashSet<ChunkId> = am
.list_chunks_with_concurrency(NonZeroU16::new(64).unwrap())
.await
.unwrap()
.map_ok(|i| i.id)
.try_collect()
.await
.unwrap();
assert_eq!(one.len(), 300);
assert_eq!(one, many);
let default_: std::collections::HashSet<ChunkId> =
am.list_chunks().await.unwrap().map_ok(|i| i.id).try_collect().await.unwrap();
assert_eq!(default_, many);
}
#[tokio_test]
async fn dropping_the_stream_stops_the_workers() {
let backend: Arc<dyn Storage + Send + Sync> =
new_in_memory_storage().await.unwrap();
let written = repo_with_chunks_under_first_prefix(&backend, 1000, false).await;
let slow: Arc<dyn Storage + Send + Sync> =
Arc::new(LatencyStorage::new(Arc::clone(&backend), 0, 50));
let (logging, am) = crate::test_utils::fanning_out_logging_asset_manager(
&slow,
written.storage_settings().clone(),
SpecVersionBin::V2,
);
let idle = Arc::strong_count(&logging);
let mut stream =
am.list_chunks_with_concurrency(NonZeroU16::new(16).unwrap()).await.unwrap();
let first = stream.try_next().await.unwrap();
assert!(first.is_some());
assert!(
Arc::strong_count(&logging) > idle,
"workers should be holding storage clones while listing"
);
drop(stream);
tokio::time::timeout(Duration::from_secs(5), async {
while Arc::strong_count(&logging) != idle {
tokio::task::yield_now().await;
}
})
.await
.expect("aborted workers should drop their storage clones");
let n = am
.list_chunks()
.await
.unwrap()
.try_fold(0usize, |n, _| async move { Ok(n + 1) })
.await
.unwrap();
assert_eq!(n, 1000);
}
#[tokio_test]
async fn backend_without_native_prefixes_lists_once() {
let backend: Arc<dyn Storage + Send + Sync> =
new_in_memory_storage().await.unwrap();
let written = repo_with_chunks_under_first_prefix(&backend, 50, false).await;
assert!(
!backend.lists_id_prefixes_natively(),
"the in-memory backend filters, it does not list prefixes server side"
);
let (logging, am) = crate::test_utils::logging_asset_manager(
&backend,
written.storage_settings().clone(),
SpecVersionBin::V2,
);
let ids: Vec<ChunkId> = am
.list_chunks_with_concurrency(NonZeroU16::new(64).unwrap())
.await
.unwrap()
.map_ok(|i| i.id)
.try_collect()
.await
.unwrap();
assert_eq!(ids.len(), 50);
let ops = logging.fetch_operations();
let plain = ops.iter().filter(|(op, _)| op == "list_objects").count();
assert_eq!(plain, 1, "exactly one full listing");
assert_eq!(list_calls(&logging), 0, "no prefixed listing, not even a probe");
}
#[test]
fn probe_prefix_is_the_smallest_two_char_prefix() {
assert_eq!(*PROBE_ID_PREFIX, ["00".to_string()].as_slice());
assert_eq!(
PROBE_ID_PREFIX[0],
*OBJECT_ID_TWO_CHAR_PREFIXES.iter().min().expect("non-empty table")
);
assert!(OBJECT_ID_TWO_CHAR_PREFIXES.is_sorted());
assert_eq!(
OBJECT_ID_TWO_CHAR_PREFIXES.len(),
OBJECT_ID_ONE_CHAR_PREFIXES.len().pow(2)
);
}
#[test]
fn default_list_concurrency_is_clamped() {
let n = default_list_concurrency().get();
assert!((32..=256).contains(&n));
}
}