Skip to main content

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}