use std::io::{Result, Error, ErrorKind};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::SdkBody;
use crate::s3::s3_error_kind;
use uuid::Uuid;
use crate::hyper::HyperDir;
use crate::layout::HyperDirLayout;
use crate::unix_now_ms;
const LOCK_OBJECT: &str = "_lock";
pub const DEFAULT_LOCK_TTL_MS: u64 = 60_000;
const LOCK_OCC_RETRIES: usize = 8;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LockKind {
Read,
Write,
Unlock,
}
impl LockKind {
fn as_char(self) -> char {
match self {
LockKind::Read => 'R',
LockKind::Write => 'W',
LockKind::Unlock => 'U',
}
}
fn from_char(c: char) -> Option<Self> {
match c {
'R' => Some(LockKind::Read),
'W' => Some(LockKind::Write),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Conflict {
pub start: u64,
pub end: u64,
pub kind: LockKind,
pub pid: u32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SetLkOutcome {
Granted,
Conflict(Conflict),
}
#[derive(Clone, Debug)]
struct Entry {
owner: String,
kind: LockKind, start: u64,
end: u64, pid: u32,
expire_ms: i64,
}
#[derive(Default, Debug)]
pub struct LockTable {
entries: Vec<Entry>,
}
fn overlaps(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool {
a_start <= b_end && b_start <= a_end
}
impl LockTable {
fn purge_expired(&mut self, now_ms: i64) {
self.entries.retain(|e| e.expire_ms > now_ms);
}
pub fn test(&self, owner: &str, kind: LockKind, start: u64, end: u64, now_ms: i64) -> Option<Conflict> {
let want_write = kind == LockKind::Write;
for e in &self.entries {
if e.expire_ms <= now_ms || e.owner == owner {
continue;
}
if overlaps(start, end, e.start, e.end) && (want_write || e.kind == LockKind::Write) {
return Some(Conflict { start: e.start, end: e.end, kind: e.kind, pid: e.pid });
}
}
None
}
fn remove_range(&mut self, owner: &str, start: u64, end: u64) {
let mut out = Vec::with_capacity(self.entries.len());
for e in std::mem::take(&mut self.entries) {
if e.owner != owner || !overlaps(start, end, e.start, e.end) {
out.push(e);
continue;
}
if e.start < start {
out.push(Entry { end: start - 1, ..e.clone() }); }
if e.end > end {
out.push(Entry { start: end + 1, ..e.clone() }); }
}
self.entries = out;
}
#[allow(clippy::too_many_arguments)]
pub fn set(&mut self, owner: &str, kind: LockKind, start: u64, end: u64, pid: u32, expire_ms: i64, now_ms: i64)
-> std::result::Result<(), Conflict>
{
self.purge_expired(now_ms);
if kind == LockKind::Unlock {
self.remove_range(owner, start, end);
return Ok(());
}
if let Some(c) = self.test(owner, kind, start, end, now_ms) {
return Err(c);
}
self.remove_range(owner, start, end);
self.entries.push(Entry { owner: owner.to_string(), kind, start, end, pid, expire_ms });
Ok(())
}
pub fn release_owner(&mut self, owner: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| e.owner != owner);
self.entries.len() != before
}
pub fn renew(&mut self, owner: &str, expire_ms: i64) -> bool {
let mut any = false;
for e in &mut self.entries {
if e.owner == owner {
e.expire_ms = expire_ms;
any = true;
}
}
any
}
pub fn encode(&self) -> Vec<u8> {
let mut s = String::from("v1\n");
for e in &self.entries {
s.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\n",
e.owner, e.kind.as_char(), e.start, e.end, e.pid, e.expire_ms
));
}
s.into_bytes()
}
pub fn decode(buf: &[u8]) -> Self {
let mut t = LockTable::default();
let Ok(s) = std::str::from_utf8(buf) else { return t; };
for line in s.lines().skip(1) {
let f: Vec<&str> = line.split('\t').collect();
if f.len() != 6 {
continue;
}
let (Some(kind), Ok(start), Ok(end), Ok(pid), Ok(expire_ms)) = (
f[1].chars().next().and_then(LockKind::from_char),
f[2].parse::<u64>(),
f[3].parse::<u64>(),
f[4].parse::<u32>(),
f[5].parse::<i64>(),
) else {
continue;
};
t.entries.push(Entry { owner: f[0].to_string(), kind, start, end, pid, expire_ms });
}
t
}
}
fn lock_key(layout: &HyperDirLayout, uuid: &Uuid, is_dir: bool) -> String {
let base = if is_dir { layout.dir_key(uuid) } else { layout.file_key(uuid) };
format!("{}/{}", base, LOCK_OBJECT)
}
async fn load_table(client: &Client, bucket: &str, key: &str) -> Result<(LockTable, Option<String>)> {
match client.get_object().bucket(bucket).key(key).send().await {
Ok(res) => {
let etag = res.e_tag.clone().map(|t| t.replace('"', ""));
let bytes = res.body.collect().await
.map_err(|e| Error::other(format!("collect _lock s3://{}/{}: {}", bucket, key, e)))?
.into_bytes();
Ok((LockTable::decode(&bytes), etag))
},
Err(e) if e.as_service_error().is_some_and(|s| s.is_no_such_key()) => Ok((LockTable::default(), None)),
Err(e) => Err(Error::new(s3_error_kind(&e), format!("GetObject _lock s3://{}/{}: {}", bucket, key, e))),
}
}
enum PutOutcome {
Ok,
Retry, }
async fn cas_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, etag: Option<&str>) -> Result<PutOutcome> {
let b = client.put_object().bucket(bucket).key(key).body(SdkBody::from(body).into());
let b = match etag {
Some(t) => b.if_match(t),
None => b.if_none_match('*'),
};
match b.send().await {
Ok(_) => Ok(PutOutcome::Ok),
Err(sdk_err) => {
if sdk_err.raw_response().map(|r| matches!(r.status().as_u16(), 412 | 409)).unwrap_or(false) {
Ok(PutOutcome::Retry)
} else {
Err(Error::new(s3_error_kind(&sdk_err), format!("PutObject _lock s3://{}/{}: {}", bucket, key, sdk_err)))
}
},
}
}
impl HyperDir<'_> {
#[allow(clippy::too_many_arguments)]
pub async fn fs_getlk(
client: &Client, layout: &HyperDirLayout, bucket: &str, uuid: &Uuid, is_dir: bool,
owner: &str, kind: LockKind, start: u64, end: u64,
) -> Result<Option<Conflict>> {
let key = lock_key(layout, uuid, is_dir);
let (table, _) = load_table(client, bucket, &key).await?;
Ok(table.test(owner, kind, start, end, unix_now_ms()))
}
#[allow(clippy::too_many_arguments)]
pub async fn fs_setlk(
client: &Client, layout: &HyperDirLayout, bucket: &str, uuid: &Uuid, is_dir: bool,
owner: &str, kind: LockKind, start: u64, end: u64, pid: u32, ttl_ms: u64,
) -> Result<SetLkOutcome> {
let key = lock_key(layout, uuid, is_dir);
for _ in 0..LOCK_OCC_RETRIES {
let (mut table, etag) = load_table(client, bucket, &key).await?;
if kind == LockKind::Unlock && etag.is_none() {
return Ok(SetLkOutcome::Granted);
}
let now = unix_now_ms();
let expire = now.saturating_add(ttl_ms as i64);
if let Err(c) = table.set(owner, kind, start, end, pid, expire, now) {
return Ok(SetLkOutcome::Conflict(c));
}
match cas_put(client, bucket, &key, table.encode(), etag.as_deref()).await? {
PutOutcome::Ok => return Ok(SetLkOutcome::Granted),
PutOutcome::Retry => continue,
}
}
Err(Error::new(ErrorKind::ResourceBusy, "fs_setlk: too many OCC conflicts"))
}
pub async fn fs_unlock_owner(
client: &Client, layout: &HyperDirLayout, bucket: &str, uuid: &Uuid, is_dir: bool, owner: &str,
) -> Result<()> {
let key = lock_key(layout, uuid, is_dir);
for _ in 0..LOCK_OCC_RETRIES {
let (mut table, etag) = load_table(client, bucket, &key).await?;
let Some(etag) = etag else { return Ok(()); }; if !table.release_owner(owner) {
return Ok(()); }
match cas_put(client, bucket, &key, table.encode(), Some(&etag)).await? {
PutOutcome::Ok => return Ok(()),
PutOutcome::Retry => continue,
}
}
Err(Error::new(ErrorKind::ResourceBusy, "fs_unlock_owner: too many OCC conflicts"))
}
pub async fn fs_lock_renew(
client: &Client, layout: &HyperDirLayout, bucket: &str, uuid: &Uuid, is_dir: bool,
owner: &str, ttl_ms: u64,
) -> Result<()> {
let key = lock_key(layout, uuid, is_dir);
for _ in 0..LOCK_OCC_RETRIES {
let (mut table, etag) = load_table(client, bucket, &key).await?;
let Some(etag) = etag else { return Ok(()); };
let expire = unix_now_ms().saturating_add(ttl_ms as i64);
if !table.renew(owner, expire) {
return Ok(());
}
match cas_put(client, bucket, &key, table.encode(), Some(&etag)).await? {
PutOutcome::Ok => return Ok(()),
PutOutcome::Retry => continue,
}
}
Err(Error::new(ErrorKind::ResourceBusy, "fs_lock_renew: too many OCC conflicts"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const FUTURE: i64 = i64::MAX;
const NOW: i64 = 1_000;
fn set(t: &mut LockTable, owner: &str, kind: LockKind, s: u64, e: u64) -> std::result::Result<(), Conflict> {
t.set(owner, kind, s, e, 0, FUTURE, NOW)
}
#[test]
fn read_read_compatible_write_excludes() {
let mut t = LockTable::default();
set(&mut t, "a", LockKind::Read, 0, 10).unwrap();
set(&mut t, "b", LockKind::Read, 5, 20).unwrap();
let c = set(&mut t, "c", LockKind::Write, 8, 9).unwrap_err();
assert_eq!(c.kind, LockKind::Read);
set(&mut t, "c", LockKind::Write, 100, 200).unwrap();
}
#[test]
fn write_write_overlap_conflicts_disjoint_ok() {
let mut t = LockTable::default();
set(&mut t, "a", LockKind::Write, 0, 10).unwrap();
assert!(set(&mut t, "b", LockKind::Write, 10, 10).is_err()); set(&mut t, "b", LockKind::Write, 11, 20).unwrap(); }
#[test]
fn same_owner_replaces_and_upgrades() {
let mut t = LockTable::default();
set(&mut t, "a", LockKind::Read, 0, 10).unwrap();
set(&mut t, "a", LockKind::Write, 0, 10).unwrap();
assert!(set(&mut t, "b", LockKind::Write, 0, 0).is_err());
}
#[test]
fn unlock_splits_middle() {
let mut t = LockTable::default();
set(&mut t, "a", LockKind::Write, 0, 100).unwrap();
set(&mut t, "a", LockKind::Unlock, 40, 60).unwrap();
assert!(set(&mut t, "b", LockKind::Write, 50, 50).is_ok());
assert!(set(&mut t, "c", LockKind::Write, 0, 0).is_err());
assert!(set(&mut t, "d", LockKind::Write, 100, 100).is_err());
}
#[test]
fn release_owner_and_renew() {
let mut t = LockTable::default();
set(&mut t, "a", LockKind::Write, 0, 10).unwrap();
set(&mut t, "a", LockKind::Write, 20, 30).unwrap();
assert!(t.renew("a", FUTURE));
assert!(!t.renew("nobody", FUTURE));
assert!(t.release_owner("a"));
assert!(t.entries.is_empty());
assert!(set(&mut t, "b", LockKind::Write, 0, 30).is_ok());
}
#[test]
fn expired_locks_are_ignored() {
let mut t = LockTable::default();
t.set("a", LockKind::Write, 0, 10, 0, NOW - 1, NOW).unwrap();
t.set("b", LockKind::Write, 0, 10, 0, FUTURE, NOW).unwrap();
assert!(t.test("c", LockKind::Read, 5, 5, NOW).is_some());
}
#[test]
fn encode_decode_roundtrip() {
let mut t = LockTable::default();
set(&mut t, "client-1:42", LockKind::Read, 0, 10).unwrap();
set(&mut t, "client-2:7", LockKind::Write, 100, u64::MAX).unwrap();
let back = LockTable::decode(&t.encode());
assert!(back.test("x", LockKind::Read, 0, 10, NOW).is_none());
assert!(back.test("x", LockKind::Write, 0, 0, NOW).is_some());
assert!(back.test("x", LockKind::Read, 200, 200, NOW).is_some());
}
#[test]
fn decode_tolerates_garbage() {
assert!(LockTable::decode(b"").entries.is_empty());
assert!(LockTable::decode(b"v1\ngarbage line\n\tbad\t\t\t\t").entries.is_empty());
}
}