use std::{
collections::{BTreeSet, HashMap, VecDeque},
io,
path::{Component, Path},
sync::{Arc, Mutex},
time::{Duration, Instant, SystemTime},
};
use object_store::{
GetOptions, GetRange, ObjectMeta, ObjectStore, ObjectStoreExt, aws::AmazonS3Builder,
path::Path as OsPath,
};
use crate::{
BoxFuture,
fs::{Dirent, DirentKind, FileSystem, Stat},
lock::lock,
};
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct S3Config {
pub bucket: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub endpoint: Option<String>,
pub key_prefix: Option<String>,
}
impl std::fmt::Debug for S3Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3Config")
.field("bucket", &self.bucket)
.field("region", &self.region)
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"[redacted]")
.field("endpoint", &self.endpoint)
.field("key_prefix", &self.key_prefix)
.finish()
}
}
pub struct S3Fs {
store: Arc<dyn ObjectStore>,
prefix: String,
listings: Mutex<Listings>,
windows: Mutex<Windows>,
}
impl S3Fs {
pub fn new(cfg: &S3Config) -> io::Result<Self> {
let mut builder = AmazonS3Builder::new()
.with_bucket_name(&cfg.bucket)
.with_region(&cfg.region)
.with_access_key_id(&cfg.access_key_id)
.with_secret_access_key(&cfg.secret_access_key);
if let Some(endpoint) = &cfg.endpoint {
builder = builder.with_endpoint(endpoint).with_allow_http(true);
}
let store = builder.build().map_err(to_io_error)?;
Ok(Self::with_store(
Arc::new(store),
cfg.key_prefix.clone().unwrap_or_default(),
))
}
fn with_store(store: Arc<dyn ObjectStore>, prefix: String) -> Self {
S3Fs {
prefix: prefix.trim_matches('/').to_string(),
store,
listings: Mutex::default(),
windows: Mutex::default(),
}
}
pub async fn check_reachable(&self) -> io::Result<()> {
self.list(Path::new("")).await?;
Ok(())
}
fn key(&self, path: &Path) -> io::Result<String> {
let mut parts: Vec<&str> = Vec::new();
if !self.prefix.is_empty() {
parts.extend(self.prefix.split('/').filter(|s| !s.is_empty()));
}
for comp in path.components() {
match comp {
Component::RootDir | Component::CurDir => {}
Component::Normal(name) => parts.push(
name.to_str()
.ok_or(io::Error::from(io::ErrorKind::InvalidFilename))?,
),
Component::ParentDir | Component::Prefix(_) => {
return Err(io::ErrorKind::InvalidFilename.into());
}
}
}
Ok(parts.join("/"))
}
}
fn os_path(key: &str) -> io::Result<OsPath> {
OsPath::parse(key).map_err(|_| io::ErrorKind::InvalidFilename.into())
}
fn to_io_error(err: object_store::Error) -> io::Error {
use object_store::Error;
let kind = match &err {
Error::NotFound { .. } => io::ErrorKind::NotFound,
Error::AlreadyExists { .. } => io::ErrorKind::AlreadyExists,
Error::InvalidPath { .. } => io::ErrorKind::InvalidFilename,
Error::PermissionDenied { .. } => io::ErrorKind::PermissionDenied,
Error::Unauthenticated { .. } => io::ErrorKind::PermissionDenied,
Error::UnknownConfigurationKey { .. } => io::ErrorKind::InvalidInput,
Error::NotSupported { .. } => io::ErrorKind::Unsupported,
Error::NotImplemented { .. } => io::ErrorKind::Unsupported,
_ => io::ErrorKind::Other,
};
io::Error::new(kind, err)
}
const READAHEAD_CHUNK: u64 = 8 << 20;
const MAX_CACHED_KEYS: usize = 8;
const MAX_CACHED_BYTES: u64 = 64 << 20;
struct ReadCache {
size: u64,
etag: Option<String>,
mtime: SystemTime,
window: Option<(u64, Vec<u8>)>,
last_end: Option<u64>,
}
impl ReadCache {
fn fresh(meta: &ObjectMeta) -> Self {
ReadCache {
size: meta.size,
etag: meta.e_tag.clone(),
mtime: meta.last_modified.into(),
window: None,
last_end: None,
}
}
fn describes(&self, meta: &ObjectMeta) -> bool {
match (&self.etag, &meta.e_tag) {
(Some(held), Some(fresh)) => held == fresh,
_ => self.size == meta.size && self.mtime == SystemTime::from(meta.last_modified),
}
}
}
const LISTING_TTL: Duration = Duration::from_secs(30);
const MAX_CACHED_LISTINGS: usize = 256;
#[derive(Default)]
struct Listings {
by_prefix: HashMap<String, (Instant, Arc<Vec<Dirent>>)>,
order: VecDeque<String>,
}
impl Listings {
fn get(&self, prefix: &str) -> Option<Arc<Vec<Dirent>>> {
let (at, listed) = self.by_prefix.get(prefix)?;
(at.elapsed() < LISTING_TTL).then(|| listed.clone())
}
fn admit(&mut self, prefix: String, listed: Arc<Vec<Dirent>>) {
if self
.by_prefix
.insert(prefix.clone(), (Instant::now(), listed))
.is_none()
{
self.order.push_back(prefix);
}
while self.order.len() > MAX_CACHED_LISTINGS {
if let Some(oldest) = self.order.pop_front() {
self.by_prefix.remove(&oldest);
}
}
}
fn clear(&mut self) {
self.by_prefix.clear();
self.order.clear();
}
}
#[derive(Default)]
struct Windows {
by_key: HashMap<String, ReadCache>,
order: VecDeque<String>,
}
impl Windows {
fn admit(&mut self, key: String, entry: ReadCache) {
if self.by_key.insert(key.clone(), entry).is_none() {
self.order.push_back(key);
}
if self.order.len() > MAX_CACHED_KEYS
&& let Some(oldest) = self.order.pop_front()
{
self.by_key.remove(&oldest);
}
}
fn store_window(&mut self, key: &str, at: u64, data: Vec<u8>) {
let Some(cache) = self.by_key.get_mut(key) else {
return;
};
cache.window = Some((at, data));
while self.held_bytes() > MAX_CACHED_BYTES {
let Some(oldest) = self
.order
.iter()
.find(|held| {
held.as_str() != key
&& self.by_key.get(*held).is_some_and(|c| c.window.is_some())
})
.cloned()
else {
return;
};
if let Some(cache) = self.by_key.get_mut(&oldest) {
cache.window = None;
}
}
}
fn held_bytes(&self) -> u64 {
self.by_key
.values()
.filter_map(|c| c.window.as_ref())
.map(|(_, data)| data.len() as u64)
.sum()
}
fn forget(&mut self, key: &str) {
if self.by_key.remove(key).is_some() {
self.order.retain(|held| held != key);
}
}
}
fn file_stat(meta: &ObjectMeta) -> Stat {
let mut stat = Stat::new(DirentKind::File, meta.size);
stat.mtime = Some(meta.last_modified.into());
stat.etag = meta.e_tag.clone();
stat.version = meta.version.clone();
stat
}
fn dir_stat() -> Stat {
Stat::new(DirentKind::Dir, 0)
}
enum Entry {
File(ObjectMeta),
Dir,
}
fn children_from(listed: object_store::Result<object_store::ListResult>) -> io::Result<bool> {
let listed = listed.map_err(to_io_error)?;
Ok(!listed.common_prefixes.is_empty() || !listed.objects.is_empty())
}
impl S3Fs {
async fn classify(&self, path: &Path) -> io::Result<Entry> {
let key = self.key(path)?;
if key.is_empty() {
return Ok(Entry::Dir);
}
let found = self.classify_key(&key).await;
if let Ok(found) = &found {
self.revalidate(&key, found);
}
found
}
async fn classify_key(&self, key: &str) -> io::Result<Entry> {
let os = os_path(key)?;
match self.store.head(&os).await {
Ok(meta) if meta.size > 0 => Ok(Entry::File(meta)),
Ok(meta) => {
if self.has_children(&os).await? {
Ok(Entry::Dir)
} else {
Ok(Entry::File(meta))
}
}
Err(object_store::Error::NotFound { .. }) => {
if self.has_children(&os).await? {
Ok(Entry::Dir)
} else {
Err(io::ErrorKind::NotFound.into())
}
}
Err(err) => Err(to_io_error(err)),
}
}
async fn has_children(&self, prefix: &OsPath) -> io::Result<bool> {
children_from(self.store.list_with_delimiter(Some(prefix)).await)
}
fn resolve_collision(listed: object_store::ListResult, marker: &str) -> Vec<Dirent> {
let mut dirs: BTreeSet<String> = listed
.common_prefixes
.iter()
.filter_map(|prefix| prefix.filename().map(str::to_owned))
.collect();
let mut files: Vec<(String, ObjectMeta)> = Vec::new();
for meta in listed.objects {
if meta.location.as_ref() == marker {
continue;
}
let Some(name) = meta.location.filename().map(str::to_owned) else {
continue;
};
if dirs.contains(&name) {
if meta.size == 0 {
continue;
}
dirs.remove(&name);
}
files.push((name, meta));
}
let mut out: Vec<Dirent> = dirs
.into_iter()
.map(|name| Dirent::new(name, DirentKind::Dir))
.chain(
files
.into_iter()
.map(|(name, meta)| Dirent::with_stat(name, file_stat(&meta))),
)
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
}
impl FileSystem for S3Fs {
fn stat<'a>(&'a self, path: &'a Path) -> BoxFuture<'a, io::Result<Stat>> {
Box::pin(async move {
Ok(match self.classify(path).await? {
Entry::File(meta) => file_stat(&meta),
Entry::Dir => dir_stat(),
})
})
}
fn list<'a>(&'a self, path: &'a Path) -> BoxFuture<'a, io::Result<Vec<Dirent>>> {
Box::pin(async move {
let key = self.key(path)?;
if let Some(listed) = lock(&self.listings).get(&key) {
return Ok((*listed).clone());
}
let prefix = if key.is_empty() {
None
} else {
Some(os_path(&key)?)
};
let listed = self
.store
.list_with_delimiter(prefix.as_ref())
.await
.map_err(to_io_error)?;
let entries = Arc::new(Self::resolve_collision(
listed,
prefix.as_ref().map(|p| p.as_ref()).unwrap_or(""),
));
lock(&self.listings).admit(key, entries.clone());
Ok((*entries).clone())
})
}
fn forget<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
lock(&self.listings).clear();
*lock(&self.windows) = Windows::default();
})
}
fn read_at<'a>(
&'a self,
path: &'a Path,
buf: &'a mut [u8],
offset: u64,
) -> BoxFuture<'a, io::Result<usize>> {
Box::pin(async move {
let key = self.key(path)?;
let (location, size) = self.located(path, &key).await?;
if buf.is_empty() || offset >= size {
return Ok(0);
}
let want = (buf.len() as u64).min(size - offset) as usize;
let buf = &mut buf[..want];
let mut filled = 0usize;
while filled < want {
let at = offset + filled as u64;
let (from_cache, sequential) = {
let windows = lock(&self.windows);
match windows.by_key.get(&key) {
Some(cache) => (
from_window(&cache.window, at, &mut buf[filled..]),
cache.last_end == Some(at),
),
None => (0, false),
}
};
if from_cache > 0 {
filled += from_cache;
continue;
}
let span = if sequential {
READAHEAD_CHUNK
} else {
(want - filled) as u64
};
let data = self.fetch(&location, at, (at + span).min(size)).await?;
if data.is_empty() {
break;
}
let n = data.len().min(want - filled);
buf[filled..filled + n].copy_from_slice(&data[..n]);
filled += n;
lock(&self.windows).store_window(&key, at, data);
}
if filled > 0 {
if let Some(cache) = lock(&self.windows).by_key.get_mut(&key) {
cache.last_end = Some(offset + filled as u64);
}
}
Ok(filled)
})
}
}
impl S3Fs {
async fn located(&self, path: &Path, key: &str) -> io::Result<(OsPath, u64)> {
if let Some(cache) = lock(&self.windows).by_key.get(key) {
return Ok((os_path(key)?, cache.size));
}
let meta = match self.classify(path).await? {
Entry::Dir => return Err(io::ErrorKind::IsADirectory.into()),
Entry::File(meta) => meta,
};
lock(&self.windows).admit(key.to_string(), ReadCache::fresh(&meta));
Ok((meta.location, meta.size))
}
fn revalidate(&self, key: &str, fresh: &Entry) {
let mut windows = lock(&self.windows);
let stale = match (windows.by_key.get(key), fresh) {
(None, _) => false,
(Some(cache), Entry::File(meta)) => !cache.describes(meta),
(Some(_), Entry::Dir) => true,
};
if stale {
windows.forget(key);
}
}
async fn fetch(&self, location: &OsPath, at: u64, end: u64) -> io::Result<Vec<u8>> {
let options = GetOptions {
range: Some(GetRange::Bounded(at..end)),
..Default::default()
};
let got = self
.store
.get_opts(location, options)
.await
.map_err(to_io_error)?;
let bytes = got.bytes().await.map_err(to_io_error)?;
Ok(bytes.to_vec())
}
}
fn from_window(window: &Option<(u64, Vec<u8>)>, at: u64, out: &mut [u8]) -> usize {
let Some((start, data)) = window else {
return 0;
};
if at < *start || at >= *start + data.len() as u64 {
return 0;
}
let from = (at - *start) as usize;
let n = (data.len() - from).min(out.len());
out[..n].copy_from_slice(&data[from..from + n]);
n
}
#[cfg(test)]
mod tests {
use object_store::{PutPayload, memory::InMemory};
use super::*;
fn store() -> (Arc<InMemory>, S3Fs) {
let store = Arc::new(InMemory::new());
(store.clone(), S3Fs::with_store(store, String::new()))
}
async fn put(store: &InMemory, key: &str, body: &[u8]) {
store
.put(&os_path(key).unwrap(), PutPayload::from(body.to_vec()))
.await
.unwrap();
}
async fn read(fs: &S3Fs, key: &str, len: usize) -> Vec<u8> {
let mut buf = vec![0u8; len];
let n = fs.read_at(Path::new(key), &mut buf, 0).await.unwrap();
buf.truncate(n);
buf
}
#[tokio::test]
async fn a_listing_is_answered_from_the_last_one() {
let (store, fs) = store();
put(&store, "a.txt", b"a").await;
put(&store, "b.txt", b"b").await;
assert_eq!(fs.list(Path::new("/")).await.unwrap().len(), 2);
store.delete(&os_path("b.txt").unwrap()).await.unwrap();
assert_eq!(
fs.list(Path::new("/")).await.unwrap().len(),
2,
"the second listing is the first one, which is the whole point"
);
fs.forget().await;
assert_eq!(fs.list(Path::new("/")).await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_read_still_asks_after_a_listing_was_kept() {
let (store, fs) = store();
put(&store, "a.txt", b"one").await;
fs.list(Path::new("/")).await.unwrap();
put(&store, "a.txt", b"two").await;
assert_eq!(fs.stat(Path::new("a.txt")).await.unwrap().size, 3);
assert_eq!(read(&fs, "a.txt", 3).await, b"two");
}
fn held(mib: usize) -> ReadCache {
ReadCache {
size: (mib << 20) as u64,
etag: None,
mtime: SystemTime::UNIX_EPOCH,
window: None,
last_end: None,
}
}
#[test]
fn what_is_held_stays_inside_the_budget() {
let mut windows = Windows::default();
let mib = 1 << 20;
for key in ["a", "b", "c"] {
windows.admit(key.to_string(), held(32));
windows.store_window(key, 0, vec![0u8; 32 * mib]);
}
assert!(windows.held_bytes() <= MAX_CACHED_BYTES);
assert!(
windows.by_key["a"].window.is_none(),
"the oldest gave up its body"
);
assert!(
windows.by_key["c"].window.is_some(),
"the one just read is kept"
);
assert_eq!(windows.by_key["a"].size, (32 * mib) as u64);
}
#[test]
fn a_body_larger_than_the_budget_is_still_what_the_read_is_served_from() {
let mut windows = Windows::default();
windows.admit("small".to_string(), held(1));
windows.store_window("small", 0, vec![0u8; 1 << 20]);
windows.admit("huge".to_string(), held(96));
windows.store_window("huge", 0, vec![0u8; 96 << 20]);
assert!(windows.by_key["huge"].window.is_some());
assert!(
windows.by_key["small"].window.is_none(),
"everything else gave way"
);
}
#[tokio::test]
async fn a_file_read_twice_is_fetched_once() {
let (store, fs) = store();
put(&store, "doc.pdf", b"the whole document").await;
assert_eq!(read(&fs, "doc.pdf", 18).await, b"the whole document");
store.delete(&os_path("doc.pdf").unwrap()).await.unwrap();
assert_eq!(read(&fs, "doc.pdf", 18).await, b"the whole document");
}
#[test]
fn listings_are_given_up_oldest_first() {
let mut kept = Listings::default();
for i in 0..MAX_CACHED_LISTINGS + 2 {
kept.admit(format!("dir-{i}"), Arc::new(vec![]));
}
assert!(kept.get("dir-0").is_none(), "the first one listed went");
assert!(
kept.get(&format!("dir-{}", MAX_CACHED_LISTINGS + 1))
.is_some()
);
assert_eq!(kept.by_prefix.len(), MAX_CACHED_LISTINGS);
let before = kept.order.len();
kept.admit(format!("dir-{}", MAX_CACHED_LISTINGS + 1), Arc::new(vec![]));
assert_eq!(kept.order.len(), before);
kept.clear();
assert!(
kept.get(&format!("dir-{}", MAX_CACHED_LISTINGS + 1))
.is_none()
);
}
#[tokio::test]
async fn a_grown_object_is_read_whole_once_a_stat_has_seen_it() {
let (store, fs) = store();
put(&store, "a.txt", b"aaaa").await;
assert_eq!(read(&fs, "a.txt", 8).await, b"aaaa");
put(&store, "a.txt", b"bbbbbbbb").await;
assert_eq!(read(&fs, "a.txt", 8).await, b"aaaa");
assert_eq!(fs.stat(Path::new("a.txt")).await.unwrap().size, 8);
assert_eq!(read(&fs, "a.txt", 8).await, b"bbbbbbbb");
}
#[tokio::test]
async fn a_replacement_of_the_same_length_is_caught_by_the_etag() {
let (store, fs) = store();
put(&store, "a.txt", b"aaaa").await;
assert_eq!(read(&fs, "a.txt", 4).await, b"aaaa");
put(&store, "a.txt", b"cccc").await;
fs.stat(Path::new("a.txt")).await.unwrap();
assert_eq!(read(&fs, "a.txt", 4).await, b"cccc");
}
#[tokio::test]
async fn a_key_that_becomes_a_prefix_is_no_longer_readable() {
let (store, fs) = store();
put(&store, "a.txt", b"aaaa").await;
assert_eq!(read(&fs, "a.txt", 4).await, b"aaaa");
put(&store, "a.txt/inner", b"x").await;
put(&store, "a.txt", b"").await;
assert_eq!(
fs.stat(Path::new("a.txt")).await.unwrap().kind,
DirentKind::Dir
);
let err = fs
.read_at(Path::new("a.txt"), &mut [0u8; 4], 0)
.await
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::IsADirectory);
}
#[tokio::test]
async fn a_failed_head_leaves_the_entry_alone() {
let (store, fs) = store();
put(&store, "a.txt", b"aaaa").await;
assert_eq!(read(&fs, "a.txt", 4).await, b"aaaa");
store.delete(&os_path("a.txt").unwrap()).await.unwrap();
assert_eq!(
fs.stat(Path::new("a.txt")).await.unwrap_err().kind(),
io::ErrorKind::NotFound
);
assert!(lock(&fs.windows).by_key.contains_key("a.txt"));
}
#[tokio::test]
async fn a_re_read_key_holds_one_slot() {
let (store, fs) = store();
put(&store, "a.txt", b"aaaa").await;
read(&fs, "a.txt", 4).await;
lock(&fs.windows).forget("a.txt");
read(&fs, "a.txt", 4).await;
let windows = lock(&fs.windows);
assert_eq!(windows.order.len(), windows.by_key.len());
}
}