use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use futures::future::BoxFuture;
use super::S3ObjectBlockClient;
use crate::SequenceHash;
use crate::object::{LockFileContent, ObjectLockManager};
pub struct S3LockManager {
client: Arc<S3ObjectBlockClient>,
instance_id: String,
lock_timeout: Duration,
}
impl S3LockManager {
pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(300);
pub fn new(client: Arc<S3ObjectBlockClient>, instance_id: String) -> Self {
Self {
client,
instance_id,
lock_timeout: Self::DEFAULT_LOCK_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.lock_timeout = timeout;
self
}
fn lock_key(&self, hash: &SequenceHash) -> String {
format!("{}.lock", hash)
}
fn meta_key(&self, hash: &SequenceHash) -> String {
format!("{}.meta", hash)
}
fn create_lock_content(&self) -> LockFileContent {
let now = chrono::Utc::now();
let deadline = now + chrono::Duration::from_std(self.lock_timeout).unwrap_or_default();
LockFileContent {
instance_id: self.instance_id.clone(),
acquired_at: now.to_rfc3339(),
deadline: deadline.to_rfc3339(),
}
}
fn is_lock_expired(lock: &LockFileContent) -> bool {
if let Ok(deadline) = chrono::DateTime::parse_from_rfc3339(&lock.deadline) {
let now = chrono::Utc::now();
now > deadline.with_timezone(&chrono::Utc)
} else {
true
}
}
}
impl ObjectLockManager for S3LockManager {
fn has_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>> {
let client = self.client.clone();
let meta_key = self.meta_key(&hash);
Box::pin(async move { client.has_object(&meta_key).await })
}
fn try_acquire_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>> {
let client = self.client.clone();
let lock_key = self.lock_key(&hash);
let lock_content = self.create_lock_content();
let our_instance_id = self.instance_id.clone();
Box::pin(async move {
let lock_data = serde_json::to_vec(&lock_content)
.map_err(|e| anyhow::anyhow!("failed to serialize lock content: {}", e))?;
match client
.put_if_not_exists(&lock_key, bytes::Bytes::from(lock_data.clone()))
.await
{
Ok(true) => {
tracing::debug!(lock_key = %lock_key, "Acquired lock");
Ok(true)
}
Ok(false) => {
tracing::debug!(lock_key = %lock_key, "Lock exists, checking deadline");
match client.get_object_with_etag(&lock_key).await? {
Some((existing_data, etag)) => {
match serde_json::from_slice::<LockFileContent>(&existing_data) {
Ok(existing_lock) => {
if existing_lock.instance_id == our_instance_id {
tracing::debug!(lock_key = %lock_key, "We own this lock");
return Ok(true);
}
if Self::is_lock_expired(&existing_lock) {
tracing::debug!(
lock_key = %lock_key,
old_instance = %existing_lock.instance_id,
deadline = %existing_lock.deadline,
"Lock expired, attempting atomic takeover"
);
if let Some(etag) = etag {
let won = client
.put_object_if_match(
&lock_key,
bytes::Bytes::from(lock_data),
&etag,
)
.await?;
if !won {
tracing::debug!(
lock_key = %lock_key,
"Lost race for expired lock takeover"
);
}
Ok(won)
} else {
tracing::warn!(
lock_key = %lock_key,
"No ETag on expired lock, falling back to unconditional overwrite"
);
client
.put_object(
&lock_key,
bytes::Bytes::from(lock_data),
)
.await?;
Ok(true)
}
} else {
tracing::debug!(
lock_key = %lock_key,
owner = %existing_lock.instance_id,
deadline = %existing_lock.deadline,
"Lock held by another instance"
);
Ok(false)
}
}
Err(e) => {
tracing::warn!(
lock_key = %lock_key,
error = %e,
"Malformed lock file, attempting atomic overwrite"
);
if let Some(etag) = etag {
let won = client
.put_object_if_match(
&lock_key,
bytes::Bytes::from(lock_data),
&etag,
)
.await?;
if !won {
tracing::debug!(
lock_key = %lock_key,
"Lost race for malformed lock takeover"
);
}
Ok(won)
} else {
tracing::warn!(
lock_key = %lock_key,
"No ETag on malformed lock, falling back to unconditional overwrite"
);
client
.put_object(&lock_key, bytes::Bytes::from(lock_data))
.await?;
Ok(true)
}
}
}
}
None => {
tracing::debug!(lock_key = %lock_key, "Lock disappeared, retrying");
match client
.put_if_not_exists(&lock_key, bytes::Bytes::from(lock_data))
.await
{
Ok(created) => Ok(created),
Err(e) => Err(e),
}
}
}
}
Err(e) => Err(e),
}
})
}
fn create_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>> {
let client = self.client.clone();
let meta_key = self.meta_key(&hash);
Box::pin(async move {
client.put_object(&meta_key, bytes::Bytes::new()).await?;
tracing::debug!(meta_key = %meta_key, "Created meta file");
Ok(())
})
}
fn release_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>> {
let client = self.client.clone();
let lock_key = self.lock_key(&hash);
Box::pin(async move {
client.delete_object(&lock_key).await?;
tracing::debug!(lock_key = %lock_key, "Released lock");
Ok(())
})
}
}
#[cfg(all(test, feature = "testing-s3"))]
mod s3_integration {
use super::*;
use crate::object::s3::client::s3_integration::create_test_client;
#[tokio::test]
async fn test_lock_expired_takeover_is_atomic() {
let client = Arc::new(create_test_client("test-lock-atomic").await);
let hash = SequenceHash::new(0xDEAD_BEEF_u64, None, 0);
let manager_a = S3LockManager::new(client.clone(), "instance-a".into())
.with_timeout(Duration::from_millis(1));
let acquired = manager_a.try_acquire_lock(hash).await.unwrap();
assert!(acquired, "instance A should acquire lock");
tokio::time::sleep(Duration::from_millis(50)).await;
let client_b = client.clone();
let client_c = client.clone();
let manager_b =
S3LockManager::new(client_b, "instance-b".into()).with_timeout(Duration::from_secs(60));
let manager_c =
S3LockManager::new(client_c, "instance-c".into()).with_timeout(Duration::from_secs(60));
let (result_b, result_c) = tokio::join!(
manager_b.try_acquire_lock(hash),
manager_c.try_acquire_lock(hash),
);
let won_b = result_b.unwrap();
let won_c = result_c.unwrap();
assert!(
!(won_b && won_c),
"both instances won the lock — race condition!"
);
if won_b {
manager_b.release_lock(hash).await.unwrap();
} else if won_c {
manager_c.release_lock(hash).await.unwrap();
} else {
client.delete_object(&format!("{}.lock", hash)).await.ok();
}
}
}