Skip to main content

zeph_memory/graph/
entity_lock.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Advisory entity locking for multi-agent `GraphStore` coordination (#2478).
5//!
6//! `SQLite` does not provide row-level locks. This module implements a soft advisory
7//! locking pattern using a dedicated `entity_advisory_locks` table. Locks are
8//! automatically expired after 120 seconds (covers worst-case slow LLM calls).
9//!
10//! Expired locks are reclaimed on the next `try_acquire` call rather than via a
11//! cleanup sweep. When a lock is reclaimed by another session, the original holder's
12//! subsequent writes follow last-writer-wins semantics — acceptable for entity
13//! resolution where duplicate entities can be merged in a later consolidation sweep.
14
15use 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
23/// TTL for advisory locks in seconds. Must exceed the worst-case LLM call latency.
24const LOCK_TTL_SECS: i64 = 120;
25
26/// Maximum retry attempts when a lock is held by another session.
27const MAX_RETRIES: u32 = 3;
28
29/// Base backoff duration for lock acquisition retries.
30const BASE_BACKOFF_MS: u64 = 50;
31
32/// Advisory entity lock manager for a single session.
33pub struct EntityLockManager {
34    pool: DbPool,
35    session_id: SessionId,
36}
37
38impl EntityLockManager {
39    /// Create an `EntityLockManager` for the given session.
40    ///
41    /// Accepts anything convertible to [`SessionId`]: a `SessionId` directly,
42    /// a `&str`, or a `String`.
43    #[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    /// Try to acquire an advisory lock on `entity_name`.
52    ///
53    /// - If no lock exists: INSERT and return `true`.
54    /// - If the current session already holds the lock: UPDATE `expires_at`, return `true`.
55    /// - If another session holds a non-expired lock: retry with exponential backoff.
56    /// - After `MAX_RETRIES` failures: return `false` (caller proceeds without lock).
57    ///
58    /// Expired locks (past `expires_at`) are atomically reclaimed on the INSERT conflict.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error on database failures.
63    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        // INSERT OR IGNORE: succeeds if no row exists.
79        // Then UPDATE: refreshes the lock if held by this session OR if it has expired.
80        // A single round-trip via RETURNING id would be nicer but the expired-or-same-session
81        // condition requires a WHERE clause that INSERT OR IGNORE cannot express.
82        // We use a two-statement approach in a transaction for atomicity.
83
84        let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
85        // `LOCK_TTL_SECS` is a compile-time constant, so the "now + TTL" expiry expression is
86        // interpolated as a literal rather than bound — `datetime('now', 'N seconds')`
87        // (`SQLite`) has no equivalent syntax to `NOW() + INTERVAL 'N seconds'` (`PostgreSQL`).
88        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    /// Extend the TTL of a lock held by this session.
121    ///
122    /// Called before long operations (e.g., an LLM call inside entity resolution)
123    /// to prevent the lock from expiring while work is in progress.
124    ///
125    /// Returns `true` if the lock was extended (still held by this session).
126    ///
127    /// # Errors
128    ///
129    /// Returns an error on database failures.
130    pub async fn extend_lock(
131        &self,
132        entity_name: &str,
133        extra_secs: i64,
134    ) -> Result<bool, MemoryError> {
135        // Offsets the stored `expires_at` column value (not `'now'`), so this needs the same
136        // dialect split as `try_acquire_once`'s `expires_at_expr`: `SQLite`'s
137        // `datetime(expires_at, ? || ' seconds')` has no `PostgreSQL` equivalent — `expires_at`
138        // is `TIMESTAMPTZ` there and `datetime()` does not exist.
139        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    /// Release the lock on `entity_name` held by this session.
162    ///
163    /// No-op if the lock was already reclaimed by another session.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error on database failures.
168    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    /// Release all locks held by this session.
182    ///
183    /// Called on agent shutdown to avoid leaving locks until TTL expiry.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error on database failures.
188    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        // Same session — should refresh and return true immediately.
242        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        // Session B cannot acquire the same entity (will exhaust retries).
250        // We use try_acquire_once directly via a fresh lock on an entity no one holds first,
251        // then test contention by calling the public API.
252        // Since MAX_RETRIES=3 with backoff, this adds ~350ms per test. Acceptable.
253        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        // Insert an already-expired lock directly into the table.
268        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        // Session B should reclaim the expired lock.
277        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        // After release, a different session can immediately acquire (no retries needed).
288        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        // Session B releasing a lock it doesn't hold: should be a no-op.
297        b.release("entity::Qux").await.expect("release noop");
298        // Session A still holds the lock — B cannot acquire.
299        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        // Both locks removed — can re-acquire immediately.
311        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}