use std::io::Result;
use std::time::SystemTime;
use std::str::FromStr;
use std::collections::HashMap;
use bytes::Bytes;
use ulid::Ulid;
use uuid::Uuid;
use base64::{engine, alphabet, Engine as _};
use hyperfile::staging::config::StagingConfig;
#[allow(async_fn_in_trait)]
pub trait DirStaging {
async fn list_scatter_inodes(&self) -> Result<Vec<DirScatterInode>>;
async fn list_scatter_inodes_for_name(&self, filename: &str) -> Result<Vec<DirScatterInode>>;
async fn collect_scatter_inodes(&self, v_scatters: Vec<DirScatterInode>) -> Result<Vec<(DirScatterInode, Bytes)>>;
async fn remove_scatter_inodes(&self, v: Vec<String>) -> Result<()>;
fn to_dir_staging_config(config: &StagingConfig) -> StagingConfig;
async fn emit_scatter_event(&self, filename: &str, child_uuid: &Uuid, buf: &[u8], op: DirScatterInodeOp) -> Result<()>;
fn scatter_inodes_path(&self) -> String;
fn compact_lease_path(&self) -> String;
async fn acquire_compact_lease(&self, ttl_ms: u64) -> Result<CompactLeaseGuard>;
async fn release_compact_lease(&self, guard: CompactLeaseGuard) -> Result<()>;
}
pub const DEFAULT_COMPACT_LEASE_TTL_MS: u64 = 60_000;
pub const DEFAULT_COMPACT_LEASE_FILE: &str = "_compact.lease";
#[repr(C, align(8))]
#[derive(Debug, Clone, Copy)]
pub struct TombstoneHeader {
pub deleted_at_unix_ms: i64,
pub retention_until_unix_ms: i64,
}
impl TombstoneHeader {
pub const SIZE: usize = std::mem::size_of::<Self>();
}
pub(crate) fn build_predelete_body(is_dir: bool, retention_until_unix_ms: i64) -> Vec<u8> {
let mut body = Vec::with_capacity(9);
body.push(is_dir as u8);
body.extend_from_slice(&retention_until_unix_ms.to_le_bytes());
body
}
pub(crate) fn parse_predelete_body(buf: &[u8]) -> (bool, i64) {
let is_dir = buf.first().is_some_and(|b| *b != 0);
let retention = match buf.get(1..9) {
Some(b) => i64::from_le_bytes(b.try_into().unwrap()),
None => 0,
};
(is_dir, retention)
}
pub(crate) fn build_tombstone_body(
deleted_at_unix_ms: i64,
retention_until_unix_ms: i64,
inode_raw_bytes: &[u8],
) -> Vec<u8> {
let header = TombstoneHeader { deleted_at_unix_ms, retention_until_unix_ms };
let header_bytes = unsafe {
std::slice::from_raw_parts(
(&header as *const TombstoneHeader) as *const u8,
TombstoneHeader::SIZE,
)
};
let mut body = Vec::with_capacity(TombstoneHeader::SIZE + inode_raw_bytes.len());
body.extend_from_slice(header_bytes);
body.extend_from_slice(inode_raw_bytes);
body
}
pub(crate) fn parse_tombstone_body(buf: &[u8]) -> Result<(TombstoneHeader, &[u8])> {
if buf.len() < TombstoneHeader::SIZE {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData,
format!("tombstone body too small: {} < {} bytes",
buf.len(), TombstoneHeader::SIZE)));
}
let (header_bytes, rest) = buf.split_at(TombstoneHeader::SIZE);
let header = unsafe { std::ptr::read_unaligned(header_bytes.as_ptr() as *const TombstoneHeader) };
Ok((header, rest))
}
#[derive(Debug, Clone)]
pub struct CompactLeaseGuard {
pub holder_id: String,
pub etag: String,
pub lease_key: String,
pub expires_at_unix_ms: i64,
}
pub(crate) fn format_lease_body(holder: &str, expires_at_unix_ms: i64) -> String {
format!("holder={}\nexpires_at_unix_ms={}\n", holder, expires_at_unix_ms)
}
pub(crate) fn parse_lease_body(buf: &[u8]) -> Result<(String, i64)> {
let s = std::str::from_utf8(buf)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData,
format!("compact lease body not UTF-8: {}", e)))?;
let mut holder: Option<String> = None;
let mut expires_at: Option<i64> = None;
for line in s.lines() {
if let Some(v) = line.strip_prefix("holder=") {
holder = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("expires_at_unix_ms=") {
expires_at = v.parse::<i64>().ok();
}
}
let holder = holder.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData,
"compact lease body missing 'holder='"))?;
let expires_at = expires_at.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData,
"compact lease body missing 'expires_at_unix_ms='"))?;
Ok((holder, expires_at))
}
pub(crate) fn unix_now_ms() -> i64 {
clock::now_ms()
}
pub mod ondisk;
pub mod file;
pub mod s3;
pub mod hyper;
pub mod fs;
pub mod interceptor;
pub mod layout;
pub mod lock;
pub mod clock;
#[cfg(feature = "fault-injection")]
pub mod fault;
pub use interceptor::ScatterFirstInterceptor;
pub use file::CompactStats;
pub use fs::{GcStats, Outcome, TXN_INTENT_STALE_MS};
pub use layout::{HyperDirLayout, ROOT_DIR_UUID, KeyKind};
pub use lock::{LockKind, Conflict, SetLkOutcome, DEFAULT_LOCK_TTL_MS};
pub use clock::{S3ClockInterceptor, s3_clock_skew_ms, CLOCK_TRUST_LOCAL_MS};
pub const DEFAULT_DIR_INODE_SCATTER_FOLDER: &str = "!";
pub const DEFAULT_DIR_INODE_SUFFIX: &str = ".inode";
pub const DEFAULT_DIR_TOMBSTONE_SUFFIX: &str = ".tombstone";
#[derive(Clone, Debug)]
#[repr(u8)]
pub enum DirScatterInodeOp {
Create = 1,
Update = 2,
PreDelete = 3,
Delete = 4,
Unknown = 255,
}
impl DirScatterInodeOp {
pub fn from_u8(n: u8) -> Self {
match n {
1 => Self::Create,
2 => Self::Update,
3 => Self::PreDelete,
4 => Self::Delete,
_ => Self::Unknown,
}
}
}
#[derive(Clone, Debug)]
pub struct DirScatterInode {
pub key: String,
pub op: DirScatterInodeOp,
pub ulid: Ulid,
pub uuid: Uuid,
pub filename: String,
pub last_modified: SystemTime,
}
impl DirScatterInode {
pub fn path_decode(scatter_inode: &str, last_modified: SystemTime) -> Self {
let components: Vec<&str> = scatter_inode.split(DEFAULT_DIR_INODE_SCATTER_FOLDER).collect();
assert!(components.len() == 2);
let key = scatter_inode.to_owned();
let stem = components[1]
.trim_start_matches('/')
.strip_suffix(DEFAULT_DIR_INODE_SUFFIX)
.or_else(|| components[1].trim_start_matches('/').strip_suffix(DEFAULT_DIR_TOMBSTONE_SUFFIX))
.expect("scatter object missing .inode/.tombstone suffix");
let (b64name, rest) = stem.split_once('/').expect("scatter object missing name segment");
let parts: Vec<&str> = rest.split('_').collect();
let ulid = Ulid::from_string(parts[0]).expect("failed to decode ulid from event path");
let alphabet = alphabet::Alphabet::new("*-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").unwrap();
let crazy_config = engine::GeneralPurposeConfig::new()
.with_decode_padding_mode(engine::DecodePaddingMode::RequireNone);
let crazy_engine = engine::GeneralPurpose::new(&alphabet, crazy_config);
let decoded = crazy_engine.decode(b64name).expect("failed to decode filename from event path");
let filename = String::from_utf8(decoded).expect("failed to get back string of filename");
let uuid = Uuid::parse_str(parts[1]).expect("failed to decode uuid from event path");
let op_u8 = u8::from_str(parts[2]).expect("failed to decode DirScatterInodeOp from event path ");
let op = DirScatterInodeOp::from_u8(op_u8);
Self { key, op, ulid, uuid, filename, last_modified }
}
pub fn path_encode(dir_staging_path: &str, filename: &str, uuid: &Uuid, op: u8) -> String {
assert!(!dir_staging_path.ends_with("/"));
let ulid = Ulid::new().to_string();
let encoded_filename = Self::encode_filename(filename);
let suffix = if op == DirScatterInodeOp::Delete as u8 {
DEFAULT_DIR_TOMBSTONE_SUFFIX
} else {
DEFAULT_DIR_INODE_SUFFIX
};
format!("{dir_staging_path}/{DEFAULT_DIR_INODE_SCATTER_FOLDER}/{encoded_filename}/{ulid}_{uuid}_{op}{suffix}")
}
pub fn encode_filename(filename: &str) -> String {
let alphabet = alphabet::Alphabet::new("*-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").unwrap();
let crazy_config = engine::GeneralPurposeConfig::new()
.with_encode_padding(false);
engine::GeneralPurpose::new(&alphabet, crazy_config).encode(filename)
}
pub fn filter_last_view(scatters: Vec<Self>) -> Vec<Self> {
let mut map: HashMap<String, Vec<Self>> = HashMap::new();
for s in scatters.into_iter() {
if let Some(v) = map.get_mut(&s.filename) {
v.push(s);
} else {
let mut v = Vec::new();
let key = s.filename.to_owned();
v.push(s);
map.insert(key, v);
}
}
for (_, v) in map.iter_mut() {
v.sort_by(|a, b| {
a.last_modified.cmp(&b.last_modified)
.then_with(|| a.ulid.cmp(&b.ulid))
.then_with(|| a.key.cmp(&b.key))
});
}
let mut last_view: Vec<Self> = Vec::new();
for (_, mut v) in map.into_iter() {
let last = v.pop().expect("unable to get last scatter from map");
last_view.push(last);
}
last_view
}
fn order_cmp(a: &Self, b: &Self) -> std::cmp::Ordering {
a.last_modified.cmp(&b.last_modified)
.then_with(|| a.ulid.cmp(&b.ulid))
.then_with(|| a.key.cmp(&b.key))
}
pub fn resolve_name_view(scatters: Vec<Self>) -> NameView {
let mut latest: HashMap<Uuid, Self> = HashMap::new();
for s in scatters.into_iter() {
match latest.get(&s.uuid) {
Some(cur) if Self::order_cmp(cur, &s) != std::cmp::Ordering::Less => {}, _ => { latest.insert(s.uuid, s); },
}
}
let mut live: Option<Self> = None;
let mut dead: Vec<Self> = Vec::new();
let mut superseded: Vec<Self> = Vec::new();
for (_uuid, s) in latest.into_iter() {
match s.op {
DirScatterInodeOp::Create | DirScatterInodeOp::Update => {
match live.take() {
None => live = Some(s),
Some(cur) => {
if Self::order_cmp(&cur, &s) != std::cmp::Ordering::Less {
superseded.push(s);
live = Some(cur);
} else {
superseded.push(cur);
live = Some(s);
}
},
}
},
DirScatterInodeOp::Delete | DirScatterInodeOp::PreDelete => dead.push(s),
DirScatterInodeOp::Unknown => {},
}
}
NameView { live, dead, superseded }
}
pub fn resolve_dir_view(scatters: Vec<Self>) -> HashMap<String, NameView> {
let mut by_name: HashMap<String, Vec<Self>> = HashMap::new();
for s in scatters.into_iter() {
by_name.entry(s.filename.clone()).or_default().push(s);
}
by_name.into_iter().map(|(n, v)| (n, Self::resolve_name_view(v))).collect()
}
}
#[derive(Debug)]
pub struct NameView {
pub live: Option<DirScatterInode>,
pub dead: Vec<DirScatterInode>,
pub superseded: Vec<DirScatterInode>,
}
#[cfg(test)]
mod scatter_key_tests {
use super::*;
use std::time::SystemTime;
fn roundtrip(name: &str, op: DirScatterInodeOp) {
let uuid = Uuid::new_v4();
let op_u8 = op.clone() as u8;
let key = DirScatterInode::path_encode("DIR/parent-uuid", name, &uuid, op_u8);
let decoded = DirScatterInode::path_decode(&key, SystemTime::UNIX_EPOCH);
assert_eq!(decoded.filename, name);
assert_eq!(decoded.uuid, uuid);
assert_eq!(decoded.op as u8, op_u8);
}
#[test]
fn inode_suffix_for_non_delete() {
let key = DirScatterInode::path_encode(
"DIR/p", "foo", &Uuid::nil(), DirScatterInodeOp::Create as u8);
assert!(key.ends_with(DEFAULT_DIR_INODE_SUFFIX));
assert!(!key.ends_with(DEFAULT_DIR_TOMBSTONE_SUFFIX));
}
#[test]
fn tombstone_suffix_for_delete() {
let key = DirScatterInode::path_encode(
"DIR/p", "foo", &Uuid::nil(), DirScatterInodeOp::Delete as u8);
assert!(key.ends_with(DEFAULT_DIR_TOMBSTONE_SUFFIX));
}
#[test]
fn roundtrip_ops_and_names() {
roundtrip("hello.txt", DirScatterInodeOp::Create);
roundtrip("with space", DirScatterInodeOp::Update);
roundtrip("a/weird\\name", DirScatterInodeOp::Delete);
roundtrip("unicode-\u{6587}\u{4ef6}", DirScatterInodeOp::Create);
}
}