Skip to main content

reddb_server/runtime/
locking.rs

1//! Intent-lock hierarchy adapter — `Resource` naming + `LockerGuard` RAII.
2//!
3//! Thin layer over `crate::runtime::lock_manager::LockManager`
4//! that gives the runtime dispatch paths a typed API:
5//!
6//! - Read dispatch: `(Global, IS) → (Collection, IS)`
7//! - Write dispatch: `(Global, IX) → (Collection, IX)`
8//! - DDL dispatch: `(Global, IX) → (Catalog, X) → (Collection, X)`
9//!
10//! The adapter owns:
11//!
12//! 1. A `Resource` enum that maps the two hierarchy levels to the
13//!    byte-key format `LockManager` expects (no string collisions,
14//!    cheap encoding).
15//! 2. A `LockerGuard` that records each `(resource, mode)` pair in
16//!    acquisition order and releases them on drop. Releases run in
17//!    reverse order so the global lock is the last to go.
18//! 3. A tiny monotonic `TxnId` allocator keyed by the current
19//!    connection id — enough for deadlock detection to distinguish
20//!    concurrent acquirers.
21//!
22//! The adapter does **not** implement ordered-acquire enforcement
23//! via phantom types yet (TODO for P1.T4); callers currently discipline
24//! themselves by always going `Global → Collection`.
25
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::Arc;
28
29use crate::runtime::lock_manager::{LockManager, LockMode, LockResult, TxnId};
30
31/// Hierarchical resources the runtime locks on. The byte-key encoding
32/// prefixes each level with a discriminator so `Collection("global")`
33/// can never collide with the true global-scope `Resource::Global`.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum Resource {
36    Global,
37    /// Shared catalog state every DDL rewrites (system collections,
38    /// physical metadata, full-store persist). DDL holds it exclusively
39    /// between `Global` and `Collection`, which serialises DDL (#2376).
40    Catalog,
41    Collection(String),
42}
43
44impl Resource {
45    /// Deterministic byte-key for the underlying `LockManager`.
46    pub fn key(&self) -> Vec<u8> {
47        match self {
48            Resource::Global => b"G/".to_vec(),
49            Resource::Catalog => b"K/".to_vec(),
50            Resource::Collection(name) => {
51                let mut out = Vec::with_capacity(2 + name.len());
52                out.extend_from_slice(b"C/");
53                out.extend_from_slice(name.as_bytes());
54                out
55            }
56        }
57    }
58}
59
60/// Outcome of a `try_acquire` — `Granted` / `Upgraded` fall through to
61/// the guard, the rest bubble up as an error the caller can log or
62/// retry.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum AcquireError {
65    Deadlock(Vec<TxnId>),
66    Timeout,
67    LockLimitExceeded,
68    /// Requested mode isn't compatible with a mode this guard already
69    /// holds on the same resource and can't be upgraded there.
70    IncompatibleEscalation {
71        resource: Resource,
72        held: LockMode,
73        requested: LockMode,
74    },
75}
76
77impl std::fmt::Display for AcquireError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Deadlock(cycle) => write!(f, "deadlock detected (cycle: {cycle:?})"),
81            Self::Timeout => f.write_str("lock acquire timed out"),
82            Self::LockLimitExceeded => f.write_str("per-txn lock limit exceeded"),
83            Self::IncompatibleEscalation {
84                resource,
85                held,
86                requested,
87            } => write!(
88                f,
89                "cannot escalate lock on {resource:?}: held={held:?} requested={requested:?}"
90            ),
91        }
92    }
93}
94
95impl std::error::Error for AcquireError {}
96
97/// Monotonic per-process TxnId allocator. Deadlock detection needs
98/// acquirer uniqueness, not connection identity, so a plain counter
99/// suffices. `0` is reserved by the underlying lock manager for "not
100/// a real txn", so allocation starts at `1`.
101static NEXT_TXN_ID: AtomicU64 = AtomicU64::new(1);
102
103pub fn fresh_txn_id() -> TxnId {
104    NEXT_TXN_ID.fetch_add(1, Ordering::Relaxed)
105}
106
107/// Acquisition record tracked by the guard so drop can release in
108/// reverse order.
109#[derive(Debug, Clone)]
110struct Held {
111    resource: Resource,
112    mode: LockMode,
113}
114
115/// RAII guard over a set of acquired locks. Drop releases every
116/// acquired resource in reverse-acquire order, so the common
117/// `Global → Collection` path releases `Collection` first then
118/// `Global`.
119pub struct LockerGuard {
120    manager: Arc<LockManager>,
121    txn_id: TxnId,
122    held: Vec<Held>,
123}
124
125impl LockerGuard {
126    /// Start a new guard bound to the given manager. No locks are
127    /// acquired yet — callers chain `acquire` calls.
128    pub fn new(manager: Arc<LockManager>) -> Self {
129        Self {
130            manager,
131            txn_id: fresh_txn_id(),
132            held: Vec::with_capacity(2),
133        }
134    }
135
136    /// Acquire a lock on `resource` with `mode`. Records the
137    /// acquisition so drop can reverse it. Rejects illegal upgrades
138    /// (already holding Exclusive, requesting Shared) with
139    /// `IncompatibleEscalation` so bugs in the dispatch layer don't
140    /// silently downgrade.
141    pub fn acquire(&mut self, resource: Resource, mode: LockMode) -> Result<(), AcquireError> {
142        // If we already hold this resource, only allow legal upgrades.
143        if let Some(existing) = self.held.iter().find(|h| h.resource == resource) {
144            let already = existing.mode;
145            if already == mode {
146                return Ok(());
147            }
148            if !already.can_upgrade_to(&mode) {
149                return Err(AcquireError::IncompatibleEscalation {
150                    resource,
151                    held: already,
152                    requested: mode,
153                });
154            }
155        }
156
157        let key = resource.key();
158        match self.manager.acquire(self.txn_id, &key, mode) {
159            LockResult::Granted | LockResult::Upgraded | LockResult::Waiting => {
160                // `Waiting` shouldn't surface under the blocking
161                // `acquire()` — treat it defensively as granted.
162                self.held.push(Held { resource, mode });
163                Ok(())
164            }
165            LockResult::Deadlock(cycle) => Err(AcquireError::Deadlock(cycle)),
166            LockResult::Timeout => Err(AcquireError::Timeout),
167            LockResult::LockLimitExceeded => Err(AcquireError::LockLimitExceeded),
168            // `AlreadyHeld` only fires when our own txn already had
169            // the lock — equivalent to a no-op acquire from the
170            // caller's POV. `TxnNotFound` shouldn't reach this layer
171            // (release-before-acquire bug); treat both as success.
172            LockResult::AlreadyHeld | LockResult::TxnNotFound => {
173                self.held.push(Held { resource, mode });
174                Ok(())
175            }
176        }
177    }
178
179    /// Number of currently-held resources. Useful for lock-stats
180    /// assertions in tests.
181    pub fn held_count(&self) -> usize {
182        self.held.len()
183    }
184
185    /// The txn id this guard uses for underlying `LockManager`
186    /// acquisitions. Exposed for lock-stats inspection in tests.
187    pub fn txn_id(&self) -> TxnId {
188        self.txn_id
189    }
190}
191
192impl Drop for LockerGuard {
193    fn drop(&mut self) {
194        // Release in reverse acquire order. The per-resource release
195        // is already robust against the lock not existing (manager
196        // returns false), so we can just drain.
197        while let Some(Held { resource, .. }) = self.held.pop() {
198            let key = resource.key();
199            self.manager.release(self.txn_id, &key);
200        }
201        // Belt-and-suspenders — clear any residue so deadlock
202        // detection's wait-graph doesn't leak this txn.
203        self.manager.release_all(self.txn_id);
204    }
205}
206
207// Unit-level tests live in `tests/unit_locking.rs` because the
208// project's lib-test target has pre-existing unrelated compile
209// errors that would block `cargo test --lib`.