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`.