kvbm_engine/object/s3/lock.rs
1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! S3-based distributed lock manager implementation.
5//!
6//! This module provides [`S3LockManager`], an implementation of [`ObjectLockManager`]
7//! that uses S3 conditional PUT operations for atomic lock acquisition.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use anyhow::Result;
13use futures::future::BoxFuture;
14
15use super::S3ObjectBlockClient;
16use crate::SequenceHash;
17use crate::object::{LockFileContent, ObjectLockManager};
18
19/// S3-based implementation of [`ObjectLockManager`].
20///
21/// Uses conditional PUT (If-None-Match: *) for atomic lock acquisition.
22/// Lock files contain instance_id and deadline; stale locks (past deadline)
23/// can be overwritten.
24///
25/// # Lock File Format
26///
27/// Lock files are stored at `{hash}.lock` as JSON:
28/// ```json
29/// {
30/// "instance_id": "uuid-of-leader-instance",
31/// "acquired_at": "2025-12-14T10:30:00Z",
32/// "deadline": "2025-12-14T10:35:00Z"
33/// }
34/// ```
35///
36/// # Meta File Format
37///
38/// Meta files are stored at `{hash}.meta` as empty objects (presence-only).
39pub struct S3LockManager {
40 client: Arc<S3ObjectBlockClient>,
41 instance_id: String,
42 lock_timeout: Duration,
43}
44
45impl S3LockManager {
46 /// Default lock timeout: 300 seconds (5 minutes).
47 pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(300);
48
49 /// Create a new S3 lock manager.
50 ///
51 /// # Arguments
52 /// * `client` - S3 client for object operations
53 /// * `instance_id` - Unique identifier for this instance (e.g., UUID)
54 pub fn new(client: Arc<S3ObjectBlockClient>, instance_id: String) -> Self {
55 Self {
56 client,
57 instance_id,
58 lock_timeout: Self::DEFAULT_LOCK_TIMEOUT,
59 }
60 }
61
62 /// Create with a custom lock timeout.
63 pub fn with_timeout(mut self, timeout: Duration) -> Self {
64 self.lock_timeout = timeout;
65 self
66 }
67
68 /// Format the lock key for a given hash.
69 fn lock_key(&self, hash: &SequenceHash) -> String {
70 format!("{}.lock", hash)
71 }
72
73 /// Format the meta key for a given hash.
74 fn meta_key(&self, hash: &SequenceHash) -> String {
75 format!("{}.meta", hash)
76 }
77
78 /// Create lock file content with current timestamp.
79 fn create_lock_content(&self) -> LockFileContent {
80 let now = chrono::Utc::now();
81 let deadline = now + chrono::Duration::from_std(self.lock_timeout).unwrap_or_default();
82 LockFileContent {
83 instance_id: self.instance_id.clone(),
84 acquired_at: now.to_rfc3339(),
85 deadline: deadline.to_rfc3339(),
86 }
87 }
88
89 /// Check if a lock's deadline has been breached.
90 fn is_lock_expired(lock: &LockFileContent) -> bool {
91 if let Ok(deadline) = chrono::DateTime::parse_from_rfc3339(&lock.deadline) {
92 let now = chrono::Utc::now();
93 now > deadline.with_timezone(&chrono::Utc)
94 } else {
95 // If we can't parse the deadline, consider it expired
96 true
97 }
98 }
99}
100
101impl ObjectLockManager for S3LockManager {
102 fn has_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>> {
103 let client = self.client.clone();
104 let meta_key = self.meta_key(&hash);
105
106 Box::pin(async move { client.has_object(&meta_key).await })
107 }
108
109 fn try_acquire_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>> {
110 let client = self.client.clone();
111 let lock_key = self.lock_key(&hash);
112 let lock_content = self.create_lock_content();
113 let our_instance_id = self.instance_id.clone();
114
115 Box::pin(async move {
116 // Serialize lock content
117 let lock_data = serde_json::to_vec(&lock_content)
118 .map_err(|e| anyhow::anyhow!("failed to serialize lock content: {}", e))?;
119
120 // Try conditional PUT (If-None-Match: *)
121 match client
122 .put_if_not_exists(&lock_key, bytes::Bytes::from(lock_data.clone()))
123 .await
124 {
125 Ok(true) => {
126 // Successfully acquired lock
127 tracing::debug!(lock_key = %lock_key, "Acquired lock");
128 Ok(true)
129 }
130 Ok(false) => {
131 // Lock exists, read it with ETag for CAS-style takeover
132 tracing::debug!(lock_key = %lock_key, "Lock exists, checking deadline");
133 match client.get_object_with_etag(&lock_key).await? {
134 Some((existing_data, etag)) => {
135 match serde_json::from_slice::<LockFileContent>(&existing_data) {
136 Ok(existing_lock) => {
137 // Check if we own the lock
138 if existing_lock.instance_id == our_instance_id {
139 tracing::debug!(lock_key = %lock_key, "We own this lock");
140 return Ok(true);
141 }
142
143 // Check if the lock is expired
144 if Self::is_lock_expired(&existing_lock) {
145 tracing::debug!(
146 lock_key = %lock_key,
147 old_instance = %existing_lock.instance_id,
148 deadline = %existing_lock.deadline,
149 "Lock expired, attempting atomic takeover"
150 );
151 // Atomically overwrite the expired lock using ETag
152 if let Some(etag) = etag {
153 let won = client
154 .put_object_if_match(
155 &lock_key,
156 bytes::Bytes::from(lock_data),
157 &etag,
158 )
159 .await?;
160 if !won {
161 tracing::debug!(
162 lock_key = %lock_key,
163 "Lost race for expired lock takeover"
164 );
165 }
166 Ok(won)
167 } else {
168 // No ETag available, fall back to unconditional put
169 tracing::warn!(
170 lock_key = %lock_key,
171 "No ETag on expired lock, falling back to unconditional overwrite"
172 );
173 client
174 .put_object(
175 &lock_key,
176 bytes::Bytes::from(lock_data),
177 )
178 .await?;
179 Ok(true)
180 }
181 } else {
182 tracing::debug!(
183 lock_key = %lock_key,
184 owner = %existing_lock.instance_id,
185 deadline = %existing_lock.deadline,
186 "Lock held by another instance"
187 );
188 Ok(false)
189 }
190 }
191 Err(e) => {
192 // Malformed lock file, attempt atomic overwrite
193 tracing::warn!(
194 lock_key = %lock_key,
195 error = %e,
196 "Malformed lock file, attempting atomic overwrite"
197 );
198 if let Some(etag) = etag {
199 let won = client
200 .put_object_if_match(
201 &lock_key,
202 bytes::Bytes::from(lock_data),
203 &etag,
204 )
205 .await?;
206 if !won {
207 tracing::debug!(
208 lock_key = %lock_key,
209 "Lost race for malformed lock takeover"
210 );
211 }
212 Ok(won)
213 } else {
214 tracing::warn!(
215 lock_key = %lock_key,
216 "No ETag on malformed lock, falling back to unconditional overwrite"
217 );
218 client
219 .put_object(&lock_key, bytes::Bytes::from(lock_data))
220 .await?;
221 Ok(true)
222 }
223 }
224 }
225 }
226 None => {
227 // Lock was deleted between checks, try to acquire again
228 tracing::debug!(lock_key = %lock_key, "Lock disappeared, retrying");
229 match client
230 .put_if_not_exists(&lock_key, bytes::Bytes::from(lock_data))
231 .await
232 {
233 Ok(created) => Ok(created),
234 Err(e) => Err(e),
235 }
236 }
237 }
238 }
239 Err(e) => Err(e),
240 }
241 })
242 }
243
244 fn create_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>> {
245 let client = self.client.clone();
246 let meta_key = self.meta_key(&hash);
247
248 Box::pin(async move {
249 // Create empty meta file to mark block as offloaded
250 client.put_object(&meta_key, bytes::Bytes::new()).await?;
251 tracing::debug!(meta_key = %meta_key, "Created meta file");
252 Ok(())
253 })
254 }
255
256 fn release_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>> {
257 let client = self.client.clone();
258 let lock_key = self.lock_key(&hash);
259
260 Box::pin(async move {
261 client.delete_object(&lock_key).await?;
262 tracing::debug!(lock_key = %lock_key, "Released lock");
263 Ok(())
264 })
265 }
266}
267
268#[cfg(all(test, feature = "testing-s3"))]
269mod s3_integration {
270 use super::*;
271 use crate::object::s3::client::s3_integration::create_test_client;
272
273 #[tokio::test]
274 async fn test_lock_expired_takeover_is_atomic() {
275 let client = Arc::new(create_test_client("test-lock-atomic").await);
276 let hash = SequenceHash::new(0xDEAD_BEEF_u64, None, 0);
277
278 // Create a lock manager with an already-expired timeout (1ms)
279 let manager_a = S3LockManager::new(client.clone(), "instance-a".into())
280 .with_timeout(Duration::from_millis(1));
281
282 // Acquire the lock with instance A (it will expire almost immediately)
283 let acquired = manager_a.try_acquire_lock(hash).await.unwrap();
284 assert!(acquired, "instance A should acquire lock");
285
286 // Wait for the lock to expire
287 tokio::time::sleep(Duration::from_millis(50)).await;
288
289 // Now race two instances trying to take over the expired lock
290 let client_b = client.clone();
291 let client_c = client.clone();
292
293 let manager_b =
294 S3LockManager::new(client_b, "instance-b".into()).with_timeout(Duration::from_secs(60));
295 let manager_c =
296 S3LockManager::new(client_c, "instance-c".into()).with_timeout(Duration::from_secs(60));
297
298 let (result_b, result_c) = tokio::join!(
299 manager_b.try_acquire_lock(hash),
300 manager_c.try_acquire_lock(hash),
301 );
302
303 let won_b = result_b.unwrap();
304 let won_c = result_c.unwrap();
305
306 // At most one should win. Both could fail if timing is unlucky (B wins the
307 // conditional put, then C sees B's non-expired lock). The key invariant is
308 // that they can't BOTH win.
309 assert!(
310 !(won_b && won_c),
311 "both instances won the lock — race condition!"
312 );
313
314 // Cleanup
315 if won_b {
316 manager_b.release_lock(hash).await.unwrap();
317 } else if won_c {
318 manager_c.release_lock(hash).await.unwrap();
319 } else {
320 // Neither won, clean up the expired lock
321 client.delete_object(&format!("{}.lock", hash)).await.ok();
322 }
323 }
324}