zeph_memory/graph/
entity_lock.rs1use std::time::Duration;
16
17use tokio::time::sleep;
18use zeph_common::SessionId;
19use zeph_db::{ActiveDialect, DbPool, query, query_scalar, sql};
20
21use crate::error::MemoryError;
22
23const LOCK_TTL_SECS: i64 = 120;
25
26const MAX_RETRIES: u32 = 3;
28
29const BASE_BACKOFF_MS: u64 = 50;
31
32pub struct EntityLockManager {
34 pool: DbPool,
35 session_id: SessionId,
36}
37
38impl EntityLockManager {
39 #[must_use]
44 pub fn new(pool: DbPool, session_id: impl Into<SessionId>) -> Self {
45 Self {
46 pool,
47 session_id: session_id.into(),
48 }
49 }
50
51 pub async fn try_acquire(&self, entity_name: &str) -> Result<bool, MemoryError> {
64 for attempt in 0..=MAX_RETRIES {
65 match self.try_acquire_once(entity_name).await? {
66 true => return Ok(true),
67 false if attempt == MAX_RETRIES => return Ok(false),
68 false => {
69 let backoff_ms = BASE_BACKOFF_MS * (1u64 << attempt);
70 sleep(Duration::from_millis(backoff_ms)).await;
71 }
72 }
73 }
74 Ok(false)
75 }
76
77 async fn try_acquire_once(&self, entity_name: &str) -> Result<bool, MemoryError> {
78 let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
85 let expires_at_expr = if cfg!(feature = "postgres") {
89 format!("NOW() + INTERVAL '{LOCK_TTL_SECS} seconds'")
90 } else {
91 format!("datetime('now', '{LOCK_TTL_SECS} seconds')")
92 };
93 let raw = format!(
94 "INSERT INTO entity_advisory_locks (entity_name, session_id, acquired_at, expires_at)
95 VALUES (?, ?, {now}, {expires_at_expr})
96 ON CONFLICT(entity_name) DO UPDATE SET
97 session_id = excluded.session_id,
98 acquired_at = excluded.acquired_at,
99 expires_at = excluded.expires_at
100 WHERE
101 -- reclaim if expired
102 entity_advisory_locks.expires_at < {now}
103 OR
104 -- refresh if same session
105 entity_advisory_locks.session_id = excluded.session_id
106 RETURNING (session_id = ?) AS acquired"
107 );
108 let query_sql = zeph_db::rewrite_placeholders(&raw);
109 let acquired: bool = query_scalar(sqlx::AssertSqlSafe(query_sql))
110 .bind(entity_name)
111 .bind(self.session_id.as_str())
112 .bind(self.session_id.as_str())
113 .fetch_optional(self.pool())
114 .await?
115 .unwrap_or(false);
116
117 Ok(acquired)
118 }
119
120 pub async fn extend_lock(
131 &self,
132 entity_name: &str,
133 extra_secs: i64,
134 ) -> Result<bool, MemoryError> {
135 let expires_at_expr = if cfg!(feature = "postgres") {
140 "expires_at + INTERVAL '1 second' * ?"
141 } else {
142 "datetime(expires_at, ? || ' seconds')"
143 };
144 let raw = format!(
145 "UPDATE entity_advisory_locks
146 SET expires_at = {expires_at_expr}
147 WHERE entity_name = ? AND session_id = ?"
148 );
149 let query_sql = zeph_db::rewrite_placeholders(&raw);
150 let affected = query(sqlx::AssertSqlSafe(query_sql))
151 .bind(extra_secs)
152 .bind(entity_name)
153 .bind(self.session_id.as_str())
154 .execute(self.pool())
155 .await?
156 .rows_affected();
157
158 Ok(affected > 0)
159 }
160
161 pub async fn release(&self, entity_name: &str) -> Result<(), MemoryError> {
169 query(sql!(
170 "DELETE FROM entity_advisory_locks
171 WHERE entity_name = ? AND session_id = ?"
172 ))
173 .bind(entity_name)
174 .bind(self.session_id.as_str())
175 .execute(self.pool())
176 .await?;
177
178 Ok(())
179 }
180
181 pub async fn release_all(&self) -> Result<(), MemoryError> {
189 query(sql!(
190 "DELETE FROM entity_advisory_locks WHERE session_id = ?"
191 ))
192 .bind(self.session_id.as_str())
193 .execute(self.pool())
194 .await?;
195
196 Ok(())
197 }
198
199 fn pool(&self) -> &DbPool {
200 &self.pool
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::store::SqliteStore;
208
209 async fn make_lock_manager(session_id: &str) -> EntityLockManager {
210 let store = SqliteStore::with_pool_size(":memory:", 1)
211 .await
212 .expect("in-memory store");
213 EntityLockManager::new(store.pool().clone(), session_id)
214 }
215
216 async fn make_shared_managers(
217 session_a: &str,
218 session_b: &str,
219 ) -> (EntityLockManager, EntityLockManager) {
220 let store = SqliteStore::with_pool_size(":memory:", 2)
221 .await
222 .expect("in-memory store");
223 let pool = store.pool().clone();
224 (
225 EntityLockManager::new(pool.clone(), session_a),
226 EntityLockManager::new(pool, session_b),
227 )
228 }
229
230 #[tokio::test]
231 async fn try_acquire_succeeds_on_first_call() {
232 let mgr = make_lock_manager("session-a").await;
233 let acquired = mgr.try_acquire("entity::Foo").await.expect("try_acquire");
234 assert!(acquired);
235 }
236
237 #[tokio::test]
238 async fn try_acquire_same_session_refresh_succeeds() {
239 let mgr = make_lock_manager("session-a").await;
240 assert!(mgr.try_acquire("entity::Foo").await.expect("first"));
241 assert!(mgr.try_acquire("entity::Foo").await.expect("second"));
243 }
244
245 #[tokio::test]
246 async fn try_acquire_fails_when_held_by_different_session() {
247 let (a, b) = make_shared_managers("session-a", "session-b").await;
248 assert!(a.try_acquire("entity::Foo").await.expect("a acquires"));
249 let acquired = b.try_acquire("entity::Foo").await.expect("b tries");
254 assert!(
255 !acquired,
256 "session-b should not acquire a lock held by session-a"
257 );
258 }
259
260 #[tokio::test]
261 async fn expired_lock_is_reclaimed_by_new_session() {
262 let store = SqliteStore::with_pool_size(":memory:", 2)
263 .await
264 .expect("in-memory store");
265 let pool = store.pool().clone();
266 let b = EntityLockManager::new(pool.clone(), "session-b");
267 zeph_db::query(zeph_db::sql!(
269 "INSERT INTO entity_advisory_locks (entity_name, session_id, acquired_at, expires_at)
270 VALUES ('entity::Bar', 'session-a', datetime('now', '-200 seconds'), datetime('now', '-80 seconds'))"
271 ))
272 .execute(&pool)
273 .await
274 .expect("insert expired lock");
275
276 let acquired = b.try_acquire("entity::Bar").await.expect("try_acquire");
278 assert!(acquired, "session-b should reclaim an expired lock");
279 }
280
281 #[tokio::test]
282 async fn release_clears_the_lock() {
283 let (a, b) = make_shared_managers("session-a", "session-b").await;
284 a.try_acquire("entity::Baz").await.expect("acquire");
285 a.release("entity::Baz").await.expect("release");
286
287 let acquired = b.try_acquire("entity::Baz").await.expect("b reacquire");
289 assert!(acquired);
290 }
291
292 #[tokio::test]
293 async fn release_is_noop_for_wrong_session() {
294 let (a, b) = make_shared_managers("session-a", "session-b").await;
295 assert!(a.try_acquire("entity::Qux").await.expect("a acquires"));
296 b.release("entity::Qux").await.expect("release noop");
298 let acquired = b.try_acquire("entity::Qux").await.expect("b tries");
300 assert!(!acquired);
301 }
302
303 #[tokio::test]
304 async fn release_all_removes_all_session_locks() {
305 let mgr = make_lock_manager("session-a").await;
306 mgr.try_acquire("entity::One").await.expect("one");
307 mgr.try_acquire("entity::Two").await.expect("two");
308 mgr.release_all().await.expect("release_all");
309
310 assert!(mgr.try_acquire("entity::One").await.expect("re-one"));
312 assert!(mgr.try_acquire("entity::Two").await.expect("re-two"));
313 }
314
315 #[tokio::test]
316 async fn extend_lock_returns_true_for_owner() {
317 let mgr = make_lock_manager("session-a").await;
318 mgr.try_acquire("entity::Ext").await.expect("acquire");
319 let extended = mgr.extend_lock("entity::Ext", 60).await.expect("extend");
320 assert!(extended);
321 }
322
323 #[tokio::test]
324 async fn extend_lock_returns_false_for_non_owner() {
325 let (a, b) = make_shared_managers("session-a", "session-b").await;
326 a.try_acquire("entity::Ext2").await.expect("a acquires");
327 let extended = b.extend_lock("entity::Ext2", 60).await.expect("b extend");
328 assert!(
329 !extended,
330 "non-owner session should not be able to extend lock"
331 );
332 }
333}