use std::{fmt, ops::Range, sync::Arc, time::SystemTime};
use async_trait::async_trait;
use bytes::Bytes;
use thiserror::Error;
pub mod azure;
pub(crate) mod counting;
pub mod gcs;
pub mod local_fs;
pub(crate) mod options;
mod retry;
pub mod s3;
pub use azure::AzureStorageProvider;
pub use gcs::GcsStorageProvider;
pub use local_fs::LocalFsStorageProvider;
pub(crate) use options::StorageOptions;
pub use s3::S3StorageProvider;
use crate::runtime_metrics::io::UsageMeter;
#[derive(Debug, Clone)]
pub struct ObjectMeta {
pub size: u64,
pub etag: Option<String>,
pub last_modified: SystemTime,
}
#[derive(Debug, Error)]
pub enum StorageError {
#[error("not found: {uri}")]
NotFound { uri: String },
#[error("precondition failed: {uri}")]
PreconditionFailed { uri: String },
#[error("transient error after retry: {uri} — {source}")]
TransientExhausted {
uri: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("permanent error: {uri} — {source}")]
Permanent {
uri: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
pub mod io_counters {
use std::{
sync::{Mutex, OnceLock},
time::Instant,
};
use crate::runtime_metrics::io::{UsageMeter, UsageSnapshot, io_is_background};
pub fn take() -> (u64, u64, u64, u64) {
UsageMeter::process_default().take_gets()
}
pub fn snapshot() -> UsageSnapshot {
UsageMeter::process_default().snapshot()
}
#[derive(Clone)]
pub struct FetchSpan {
pub op: &'static str,
pub uri: String,
pub off: u64,
pub len: u64,
pub start_us: u64,
pub end_us: u64,
pub background: bool,
}
static TIMELINE_ON: OnceLock<bool> = OnceLock::new();
static PHASE_TRACE_ON: OnceLock<bool> = OnceLock::new();
static EPOCH: Mutex<Option<Instant>> = Mutex::new(None);
static SPANS: Mutex<Vec<FetchSpan>> = Mutex::new(Vec::new());
pub fn timeline_enabled() -> bool {
*TIMELINE_ON.get_or_init(|| crate::config::global().diagnostics.io_timeline)
}
pub fn phase_enabled() -> bool {
*PHASE_TRACE_ON.get_or_init(|| {
timeline_enabled() || std::env::var_os("INFINO_TRACE_VECTOR_WARM_PHASES").is_some()
})
}
pub fn timeline_start() -> Option<Instant> {
if timeline_enabled() {
Some(Instant::now())
} else {
None
}
}
pub fn phase_start() -> Option<Instant> {
if phase_enabled() {
Some(Instant::now())
} else {
None
}
}
pub fn timeline_record(
op: &'static str,
uri: &str,
off: u64,
len: u64,
start: Option<Instant>,
) {
let Some(start) = start else { return };
let epoch = {
let mut e = match EPOCH.lock() {
Ok(e) => e,
Err(_) => return,
};
*e.get_or_insert(start)
};
let to_us = |t: Instant| t.saturating_duration_since(epoch).as_micros() as u64;
if let Ok(mut spans) = SPANS.lock() {
spans.push(FetchSpan {
op,
uri: uri.to_string(),
off,
len,
start_us: to_us(start),
end_us: to_us(Instant::now()),
background: io_is_background(),
});
}
}
pub fn timeline_reset() {
if let Ok(mut spans) = SPANS.lock() {
spans.clear();
}
if let Ok(mut e) = EPOCH.lock() {
*e = None;
}
}
pub fn timeline_take() -> Vec<FetchSpan> {
let mut out = SPANS
.lock()
.map(|mut s| std::mem::take(&mut *s))
.unwrap_or_default();
out.sort_by_key(|s| s.start_us);
out
}
static PHASES: Mutex<Vec<(&'static str, u64)>> = Mutex::new(Vec::new());
pub fn phase_record(name: &'static str, micros: u64) {
if !phase_enabled() {
return;
}
if let Ok(mut p) = PHASES.lock() {
p.push((name, micros));
}
}
pub fn phase_timed<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
if !phase_enabled() {
return f();
}
let t = Instant::now();
let out = f();
phase_record(name, t.elapsed().as_micros() as u64);
out
}
pub async fn phase_timed_async<T>(name: &'static str, fut: impl Future<Output = T>) -> T {
if !phase_enabled() {
return fut.await;
}
let t = Instant::now();
let out = fut.await;
phase_record(name, t.elapsed().as_micros() as u64);
out
}
pub fn phase_reset() {
if let Ok(mut p) = PHASES.lock() {
p.clear();
}
}
pub fn phase_take() -> Vec<(&'static str, u64)> {
PHASES
.lock()
.map(|mut p| std::mem::take(&mut *p))
.unwrap_or_default()
}
pub fn phase_take_summed() -> Vec<(&'static str, u64)> {
let phases = phase_take();
let mut by_name: std::collections::BTreeMap<&'static str, u64> =
std::collections::BTreeMap::new();
for (name, us) in phases {
*by_name.entry(name).or_default() += us;
}
by_name.into_iter().collect()
}
}
#[async_trait]
pub trait StorageProvider: Send + Sync + fmt::Debug {
async fn head(&self, uri: &str) -> Result<ObjectMeta, StorageError>;
async fn get(&self, uri: &str) -> Result<(Bytes, ObjectMeta), StorageError>;
async fn get_if_none_match(
&self,
uri: &str,
etag: &str,
) -> Result<Option<(Bytes, ObjectMeta)>, StorageError> {
let (bytes, meta) = self.get(uri).await?;
if meta.etag.as_deref() == Some(etag) {
return Ok(None);
}
Ok(Some((bytes, meta)))
}
async fn get_range(&self, uri: &str, range: Range<u64>) -> Result<Bytes, StorageError>;
async fn tail(&self, uri: &str, len: u64) -> Result<(Bytes, u64), StorageError> {
let meta = self.head(uri).await?;
let len = len.min(meta.size);
if len == 0 {
return Ok((Bytes::new(), meta.size));
}
let start = meta.size - len;
let bytes = self.get_range(uri, start..meta.size).await?;
Ok((bytes, meta.size))
}
async fn put_atomic(&self, uri: &str, bytes: Bytes) -> Result<Option<String>, StorageError>;
async fn put_if_match(
&self,
uri: &str,
bytes: Bytes,
expected_etag: Option<&str>,
) -> Result<Option<String>, StorageError>;
async fn put_multipart(
&self,
uri: &str,
) -> Result<Box<dyn object_store::MultipartUpload>, StorageError>;
async fn delete(&self, uri: &str) -> Result<(), StorageError>;
async fn list_with_prefix_metadata(
&self,
_prefix: &str,
) -> Result<Vec<(String, ObjectMeta)>, StorageError> {
Ok(Vec::new())
}
async fn list_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
Ok(self
.list_with_prefix_metadata(prefix)
.await?
.into_iter()
.map(|(key, _)| key)
.collect())
}
fn object_store_handle(
&self,
_uri: &str,
) -> Option<(Arc<dyn object_store::ObjectStore>, object_store::path::Path)> {
None
}
fn usage_meter(&self) -> Arc<UsageMeter> {
UsageMeter::process_default()
}
}
pub(crate) fn logical_list_key(provider_prefix: &str, location: &str) -> String {
if provider_prefix.is_empty() {
return location.to_owned();
}
let prefix = format!("{provider_prefix}/");
location
.strip_prefix(&prefix)
.expect("listed object remains under provider prefix")
.to_owned()
}
#[derive(Debug)]
pub struct PrefixedStorageProvider {
inner: Arc<dyn StorageProvider>,
sub_prefix: String,
}
impl PrefixedStorageProvider {
pub fn new(inner: Arc<dyn StorageProvider>, sub_prefix: impl Into<String>) -> Self {
let mut sub = sub_prefix.into();
if !sub.is_empty() && !sub.ends_with('/') {
sub.push('/');
}
Self {
inner,
sub_prefix: sub,
}
}
fn prefixed(&self, uri: &str) -> String {
format!("{}{}", self.sub_prefix, uri)
}
}
#[async_trait::async_trait]
impl StorageProvider for PrefixedStorageProvider {
async fn head(&self, uri: &str) -> Result<ObjectMeta, StorageError> {
self.inner.head(&self.prefixed(uri)).await
}
async fn get(&self, uri: &str) -> Result<(bytes::Bytes, ObjectMeta), StorageError> {
self.inner.get(&self.prefixed(uri)).await
}
async fn get_if_none_match(
&self,
uri: &str,
etag: &str,
) -> Result<Option<(bytes::Bytes, ObjectMeta)>, StorageError> {
self.inner
.get_if_none_match(&self.prefixed(uri), etag)
.await
}
async fn get_range(
&self,
uri: &str,
range: std::ops::Range<u64>,
) -> Result<bytes::Bytes, StorageError> {
self.inner.get_range(&self.prefixed(uri), range).await
}
async fn tail(&self, uri: &str, len: u64) -> Result<(bytes::Bytes, u64), StorageError> {
self.inner.tail(&self.prefixed(uri), len).await
}
async fn put_atomic(
&self,
uri: &str,
bytes: bytes::Bytes,
) -> Result<Option<String>, StorageError> {
self.inner.put_atomic(&self.prefixed(uri), bytes).await
}
async fn put_if_match(
&self,
uri: &str,
bytes: bytes::Bytes,
expected_etag: Option<&str>,
) -> Result<Option<String>, StorageError> {
self.inner
.put_if_match(&self.prefixed(uri), bytes, expected_etag)
.await
}
async fn put_multipart(
&self,
uri: &str,
) -> Result<Box<dyn object_store::MultipartUpload>, StorageError> {
self.inner.put_multipart(&self.prefixed(uri)).await
}
async fn delete(&self, uri: &str) -> Result<(), StorageError> {
self.inner.delete(&self.prefixed(uri)).await
}
async fn list_with_prefix(&self, prefix: &str) -> Result<Vec<String>, StorageError> {
let full = self.prefixed(prefix);
let results = self.inner.list_with_prefix(&full).await?;
let strip_len = self.sub_prefix.len();
Ok(results
.into_iter()
.map(|s| s[strip_len..].to_owned())
.collect())
}
async fn list_with_prefix_metadata(
&self,
prefix: &str,
) -> Result<Vec<(String, ObjectMeta)>, StorageError> {
let full = self.prefixed(prefix);
let results = self.inner.list_with_prefix_metadata(&full).await?;
let strip_len = self.sub_prefix.len();
Ok(results
.into_iter()
.map(|(key, meta)| (key[strip_len..].to_owned(), meta))
.collect())
}
fn object_store_handle(
&self,
uri: &str,
) -> Option<(Arc<dyn object_store::ObjectStore>, object_store::path::Path)> {
self.inner.object_store_handle(&self.prefixed(uri))
}
fn usage_meter(&self) -> Arc<UsageMeter> {
self.inner.usage_meter()
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, error::Error, ops::Range, sync::Mutex};
use async_trait::async_trait;
use bytes::Bytes;
use super::*;
const MOCK_ETAG: &str = "mock-etag";
#[test]
fn logical_list_key_strips_exact_provider_root() {
assert_eq!(
logical_list_key("table/root", "table/root/data/segment.parquet"),
"data/segment.parquet"
);
assert_eq!(
logical_list_key("", "table/root/data/segment.parquet"),
"table/root/data/segment.parquet"
);
}
#[derive(Debug, Default)]
struct InMemoryMock {
objects: Mutex<HashMap<String, Bytes>>,
}
impl InMemoryMock {
fn with(uri: &str, bytes: &[u8]) -> Self {
let mock = Self::default();
mock.objects
.lock()
.expect("lock")
.insert(uri.into(), Bytes::copy_from_slice(bytes));
mock
}
}
fn not_found(uri: &str) -> StorageError {
StorageError::NotFound { uri: uri.into() }
}
#[async_trait]
impl StorageProvider for InMemoryMock {
async fn head(&self, uri: &str) -> Result<ObjectMeta, StorageError> {
let map = self.objects.lock().expect("lock");
match map.get(uri) {
Some(b) => Ok(ObjectMeta {
size: b.len() as u64,
etag: Some(MOCK_ETAG.into()),
last_modified: SystemTime::UNIX_EPOCH,
}),
None => Err(not_found(uri)),
}
}
async fn get(&self, uri: &str) -> Result<(Bytes, ObjectMeta), StorageError> {
let map = self.objects.lock().expect("lock");
match map.get(uri) {
Some(b) => Ok((
b.clone(),
ObjectMeta {
size: b.len() as u64,
etag: Some(MOCK_ETAG.into()),
last_modified: SystemTime::UNIX_EPOCH,
},
)),
None => Err(not_found(uri)),
}
}
async fn get_range(&self, uri: &str, range: Range<u64>) -> Result<Bytes, StorageError> {
let map = self.objects.lock().expect("lock");
match map.get(uri) {
Some(b) => Ok(b.slice(range.start as usize..range.end as usize)),
None => Err(not_found(uri)),
}
}
async fn put_atomic(
&self,
uri: &str,
bytes: Bytes,
) -> Result<Option<String>, StorageError> {
let mut map = self.objects.lock().expect("lock");
if map.contains_key(uri) {
return Err(StorageError::PreconditionFailed { uri: uri.into() });
}
map.insert(uri.into(), bytes);
Ok(Some(MOCK_ETAG.into()))
}
async fn put_if_match(
&self,
uri: &str,
bytes: Bytes,
_expected_etag: Option<&str>,
) -> Result<Option<String>, StorageError> {
self.objects.lock().expect("lock").insert(uri.into(), bytes);
Ok(Some(MOCK_ETAG.into()))
}
async fn put_multipart(
&self,
uri: &str,
) -> Result<Box<dyn object_store::MultipartUpload>, StorageError> {
let boxed: Box<dyn Error + Send + Sync> = "multipart unsupported".into();
Err(StorageError::Permanent {
uri: uri.into(),
source: boxed,
})
}
async fn delete(&self, uri: &str) -> Result<(), StorageError> {
self.objects.lock().expect("lock").remove(uri);
Ok(())
}
fn usage_meter(&self) -> Arc<UsageMeter> {
UsageMeter::process_default()
}
}
#[tokio::test]
async fn default_tail_returns_trailing_bytes_and_size() {
let mock = InMemoryMock::with("k", b"abcdefgh");
let (bytes, size) = mock.tail("k", 3).await.expect("tail");
assert_eq!(size, 8);
assert_eq!(&bytes[..], b"fgh");
}
#[tokio::test]
async fn default_tail_clamps_len_to_object_size() {
let mock = InMemoryMock::with("k", b"abc");
let (bytes, size) = mock.tail("k", 100).await.expect("tail over-long");
assert_eq!(size, 3);
assert_eq!(&bytes[..], b"abc", "len clamps to the whole object");
}
#[tokio::test]
async fn default_tail_zero_len_returns_empty_with_size() {
let mock = InMemoryMock::with("k", b"abc");
let (bytes, size) = mock.tail("k", 0).await.expect("tail zero");
assert_eq!(size, 3);
assert!(bytes.is_empty(), "zero-len tail still discloses size");
}
#[tokio::test]
async fn default_tail_propagates_not_found() {
let mock = InMemoryMock::default();
assert!(matches!(
mock.tail("missing", 4).await,
Err(StorageError::NotFound { .. })
));
}
#[tokio::test]
async fn default_list_with_prefix_is_empty() {
let mock = InMemoryMock::with("a/b", b"x");
assert!(
mock.list_with_prefix("a/").await.expect("list").is_empty(),
"the default list never enumerates objects",
);
}
#[test]
fn default_object_store_handle_is_none() {
let mock = InMemoryMock::default();
assert!(mock.object_store_handle("k").is_none());
}
#[tokio::test]
async fn mock_byte_ops_round_trip() {
let mock = InMemoryMock::default();
assert_eq!(
mock.put_atomic("k", Bytes::from_static(b"hello"))
.await
.expect("put_atomic"),
Some(MOCK_ETAG.to_string()),
);
assert!(matches!(
mock.put_atomic("k", Bytes::from_static(b"x")).await,
Err(StorageError::PreconditionFailed { .. })
));
assert_eq!(mock.head("k").await.expect("head").size, 5);
let (bytes, _) = mock.get("k").await.expect("get");
assert_eq!(&bytes[..], b"hello");
assert_eq!(&mock.get_range("k", 1..3).await.expect("range")[..], b"el");
mock.put_if_match("k", Bytes::from_static(b"world!"), Some(MOCK_ETAG))
.await
.expect("put_if_match");
assert_eq!(mock.head("k").await.expect("head2").size, 6);
mock.delete("k").await.expect("delete");
mock.delete("k").await.expect("delete idempotent");
assert!(matches!(
mock.get("k").await,
Err(StorageError::NotFound { .. })
));
assert!(matches!(
mock.head("missing").await,
Err(StorageError::NotFound { .. })
));
assert!(matches!(
mock.get_range("missing", 0..1).await,
Err(StorageError::NotFound { .. })
));
}
#[tokio::test]
async fn default_get_if_none_match_reports_modified_state() {
let mock = InMemoryMock::with("k", b"payload");
assert!(
mock.get_if_none_match("k", MOCK_ETAG)
.await
.expect("conditional get")
.is_none(),
"a matching etag means not-modified",
);
let (bytes, meta) = mock
.get_if_none_match("k", "stale-etag")
.await
.expect("conditional get")
.expect("a mismatched etag returns the body");
assert_eq!(&bytes[..], b"payload");
assert_eq!(meta.etag.as_deref(), Some(MOCK_ETAG));
}
#[tokio::test]
async fn mock_put_multipart_surfaces_permanent_error() {
let mock = InMemoryMock::default();
assert!(matches!(
mock.put_multipart("k").await,
Err(StorageError::Permanent { .. })
));
}
#[test]
fn storage_error_display_covers_every_variant() {
let cases: [(StorageError, &str); 4] = [
(StorageError::NotFound { uri: "u".into() }, "not found"),
(
StorageError::PreconditionFailed { uri: "u".into() },
"precondition failed",
),
(
StorageError::TransientExhausted {
uri: "u".into(),
source: "boom".into(),
},
"transient",
),
(
StorageError::Permanent {
uri: "u".into(),
source: "boom".into(),
},
"permanent",
),
];
for (err, needle) in cases {
assert!(
err.to_string().contains(needle),
"{err:?} display should contain {needle:?}",
);
}
}
#[test]
fn object_meta_is_clone_and_debug() {
let meta = ObjectMeta {
size: 7,
etag: Some("e".into()),
last_modified: SystemTime::UNIX_EPOCH,
};
let cloned = meta.clone();
assert_eq!(cloned.size, 7);
assert_eq!(cloned.etag.as_deref(), Some("e"));
assert!(format!("{meta:?}").contains("ObjectMeta"));
}
#[test]
fn usage_meter_record_and_snapshot_are_monotonic() {
let meter = UsageMeter::new();
let before = meter.snapshot();
meter.record_get("seg/x", None, 100);
meter.record_head();
meter.record_put(50);
meter.record_list();
meter.record_delete();
let delta = meter.snapshot().since(&before);
assert_eq!(delta.get_count, 1);
assert_eq!(delta.get_bytes, 100);
assert_eq!(delta.head_count, 1);
assert_eq!(delta.put_count, 1);
assert_eq!(delta.put_bytes, 50);
assert_eq!(delta.list_count, 1);
assert_eq!(delta.delete_count, 1);
}
#[tokio::test]
async fn prefixed_provider_tags_hidden_gets() {
use object_store::ObjectStoreExt;
use crate::storage::LocalFsStorageProvider;
let dir = tempfile::TempDir::new().expect("tempdir");
let meter = UsageMeter::new();
let inner = Arc::new(
LocalFsStorageProvider::new_with_meter(dir.path(), Arc::clone(&meter))
.expect("localfs"),
);
let prefixed = PrefixedStorageProvider::new(inner, "_infino_test_vector_index/");
prefixed
.put_atomic("seg/x.bin", Bytes::from_static(b"0123456789"))
.await
.expect("put");
let before = meter.snapshot();
let (got, _) = prefixed.get("seg/x.bin").await.expect("get");
assert_eq!(got.as_ref(), b"0123456789");
let delta = meter.snapshot().since(&before);
assert_eq!(delta.get_count, 1);
assert_eq!(delta.hidden_get_count(), 1);
assert_eq!(delta.hidden_get_bytes(), 10);
let before = meter.snapshot();
let _ = prefixed.get_range("seg/x.bin", 0..4).await.expect("range");
let delta = meter.snapshot().since(&before);
assert_eq!(delta.get_count, 1);
assert_eq!(delta.hidden_get_count(), 1);
assert_eq!(delta.hidden_get_bytes(), 4);
let before = meter.snapshot();
let (tail, size) = prefixed.tail("seg/x.bin", 3).await.expect("tail");
assert_eq!(size, 10);
assert_eq!(tail.as_ref(), b"789");
let delta = meter.snapshot().since(&before);
assert!(delta.get_count >= 1);
assert_eq!(delta.hidden_get_count(), 1);
assert_eq!(delta.hidden_get_bytes(), 3);
let before = meter.snapshot();
let (store, path) = prefixed.object_store_handle("seg/x.bin").expect("handle");
let _ = store.get(&path).await.expect("os get");
let delta = meter.snapshot().since(&before);
assert_eq!(delta.get_count, 1);
assert_eq!(delta.hidden_get_count(), 1);
assert_eq!(delta.hidden_get_bytes(), 10);
}
}