spg_engine/locks.rs
1//! v7.37.15 (Phase C.4) — row-level lock table, the four PG tuple-lock
2//! modes, and wait-for deadlock detection.
3//!
4//! ## Why this exists
5//!
6//! Phase C's in-place write path (C.3) lets concurrent transactions
7//! update / delete different rows without blocking. But two writers
8//! that touch the SAME row must serialise, and a `SELECT ... FOR
9//! UPDATE` must be able to reserve rows ahead of the write. This
10//! module is the lock table that arbitrates: it keys locks on the
11//! stable `(RelId, RowId)` identity (Phase C.1) so a held lock keeps
12//! naming the same row across concurrent compaction.
13//!
14//! ## Additive at this commit
15//!
16//! Pure `no_std` logic with no consumer yet: the write path (C.3/C.4)
17//! calls `acquire` / `release_all`, and the host (`spg-server`,
18//! thread-per-connection) parks a waiting thread behind a `Parker`
19//! informed by [`LockOutcome::WouldBlock`]. The engine core only
20//! records the wait-for edges and runs the cycle detector — keeping it
21//! `no_std` (no thread primitives leak into the core). Under today's
22//! single external engine lock the table is mutated serially; the
23//! sharded lock-free version is Phase C.5.
24//!
25//! ## The four modes and their conflicts
26//!
27//! Mirrors PG's tuple-lock strengths (weakest → strongest):
28//! `KeyShare < Share < NoKeyUpdate < Exclusive`. The load-bearing
29//! compatibility is `KeyShare ∥ NoKeyUpdate`: an FK existence check
30//! (`FOR KEY SHARE`) runs concurrently with a non-key `UPDATE`
31//! (`NoKeyUpdate`) on the same parent row — a real concurrency win we
32//! match, not a coincidence.
33
34extern crate alloc;
35
36use alloc::collections::{BTreeMap, BTreeSet};
37use alloc::vec::Vec;
38
39use spg_storage::row_header::{RelId, RowId};
40
41/// A PG tuple-lock strength. `FOR KEY SHARE` / `FOR SHARE` / `FOR NO
42/// KEY UPDATE` / `FOR UPDATE`, plus the implicit modes a write takes:
43/// a key-touching UPDATE or any DELETE takes `Exclusive`; a non-key
44/// UPDATE takes `NoKeyUpdate`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum LockMode {
47 KeyShare,
48 Share,
49 NoKeyUpdate,
50 Exclusive,
51}
52
53impl LockMode {
54 /// PG tuple-lock conflict matrix. `held.conflicts_with(requested)`
55 /// is true iff a currently-held lock in mode `self` blocks a new
56 /// request in mode `requested`.
57 ///
58 /// ```text
59 /// held \ req KeyShare Share NoKeyUpd Excl
60 /// KeyShare ok ok ok X
61 /// Share ok ok X X
62 /// NoKeyUpdate ok X X X
63 /// Exclusive X X X X
64 /// ```
65 #[must_use]
66 pub fn conflicts_with(self, requested: LockMode) -> bool {
67 use LockMode::{Exclusive, KeyShare, NoKeyUpdate, Share};
68 match self {
69 KeyShare => matches!(requested, Exclusive),
70 Share => matches!(requested, NoKeyUpdate | Exclusive),
71 NoKeyUpdate => !matches!(requested, KeyShare),
72 Exclusive => true,
73 }
74 }
75}
76
77/// What a caller wants to happen when the lock it requests is not
78/// immediately available. Mirrors PG's `LockWaitPolicy`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum WaitPolicy {
81 /// Block until the lock is granted (the default DML behaviour and
82 /// bare `FOR UPDATE`).
83 Wait,
84 /// `FOR UPDATE NOWAIT` — fail immediately rather than block.
85 NoWait,
86 /// `FOR UPDATE SKIP LOCKED` — skip this row rather than block.
87 SkipLocked,
88}
89
90/// The result of an [`LockTable::acquire`] attempt.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum LockOutcome {
93 /// The lock is held by the requesting version.
94 Granted,
95 /// The request conflicts and the policy is `Wait`; the caller
96 /// should park until one of `on` releases. The wait-for edges are
97 /// already recorded, and no cycle was found.
98 WouldBlock { on: Vec<u64> },
99 /// `SkipLocked` policy and the row is locked — skip it.
100 Skip,
101 /// `NoWait` policy and the row is locked — fail the statement.
102 NotAvailable,
103 /// Granting the wait would close a wait-for cycle; the caller must
104 /// abort transaction `victim` (the youngest in the cycle) with a
105 /// deadlock error rather than park.
106 Deadlock { victim: u64 },
107}
108
109#[derive(Debug, Default, Clone)]
110struct LockEntry {
111 /// `(version, mode)` pairs currently holding this row.
112 holders: Vec<(u64, LockMode)>,
113 /// Versions parked waiting for this row (in FIFO order).
114 waiters: Vec<u64>,
115}
116
117/// The row-lock table. Keyed on stable `(RelId, RowId)`; carries the
118/// wait-for graph used for deadlock detection.
119///
120/// `Clone` to match the engine's other transient concurrency state
121/// (`active_writer_versions`) that rides on `Engine`; the shared
122/// (non-cloned) lock manager is Phase C.5, when the single engine lock
123/// is split.
124#[derive(Debug, Default, Clone)]
125pub struct LockTable {
126 entries: BTreeMap<(RelId, RowId), LockEntry>,
127 /// `waiter → versions it is blocked on`. Rebuilt as waits are
128 /// added / released; the deadlock detector walks it.
129 wait_for: BTreeMap<u64, BTreeSet<u64>>,
130}
131
132impl LockTable {
133 #[must_use]
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 /// Try to acquire `mode` on `(rel, row)` for transaction
139 /// `version`. Re-locking a row this version already holds upgrades
140 /// in place (idempotent for equal-or-weaker modes).
141 pub fn acquire(
142 &mut self,
143 rel: RelId,
144 row: RowId,
145 mode: LockMode,
146 version: u64,
147 policy: WaitPolicy,
148 ) -> LockOutcome {
149 let entry = self.entries.entry((rel, row)).or_default();
150
151 // Collect the distinct conflicting holders (never conflict with
152 // your own held lock — self-conflict would deadlock trivially).
153 let mut blockers: Vec<u64> = Vec::new();
154 for &(hv, hmode) in &entry.holders {
155 if hv != version && hmode.conflicts_with(mode) && !blockers.contains(&hv) {
156 blockers.push(hv);
157 }
158 }
159
160 if blockers.is_empty() {
161 // Grant: record the holder if not already present.
162 if !entry
163 .holders
164 .iter()
165 .any(|&(hv, hm)| hv == version && hm == mode)
166 {
167 entry.holders.push((version, mode));
168 }
169 // A previously-parked waiter that now gets in drops its
170 // wait edges.
171 entry.waiters.retain(|&w| w != version);
172 self.wait_for.remove(&version);
173 return LockOutcome::Granted;
174 }
175
176 match policy {
177 WaitPolicy::NoWait => LockOutcome::NotAvailable,
178 WaitPolicy::SkipLocked => LockOutcome::Skip,
179 WaitPolicy::Wait => {
180 if !entry.waiters.contains(&version) {
181 entry.waiters.push(version);
182 }
183 let edges = self.wait_for.entry(version).or_default();
184 for &b in &blockers {
185 edges.insert(b);
186 }
187 // Deadlock check: does following wait-for edges from
188 // `version` return to `version`?
189 if let Some(cycle) = self.find_cycle(version) {
190 // Abort the youngest (highest version) in the cycle.
191 let victim = cycle.into_iter().max().unwrap_or(version);
192 return LockOutcome::Deadlock { victim };
193 }
194 LockOutcome::WouldBlock { on: blockers }
195 }
196 }
197 }
198
199 /// Release every lock + wait held by `version` (transaction end:
200 /// commit or abort). Removes it from all entries and the wait-for
201 /// graph, and drops now-empty entries.
202 pub fn release_all(&mut self, version: u64) {
203 self.entries.retain(|_, e| {
204 e.holders.retain(|&(hv, _)| hv != version);
205 e.waiters.retain(|&w| w != version);
206 !(e.holders.is_empty() && e.waiters.is_empty())
207 });
208 self.wait_for.remove(&version);
209 for edges in self.wait_for.values_mut() {
210 edges.remove(&version);
211 }
212 }
213
214 /// Number of rows with at least one holder or waiter. For
215 /// `pg_locks` enumeration (Phase C.4) and tests.
216 #[must_use]
217 pub fn locked_row_count(&self) -> usize {
218 self.entries.len()
219 }
220
221 /// DFS over `wait_for` from `start`; returns the set of versions on
222 /// a cycle through `start`, or `None` if the wait graph is acyclic
223 /// from here. Bounded by the number of active waiters.
224 fn find_cycle(&self, start: u64) -> Option<BTreeSet<u64>> {
225 let mut stack: Vec<u64> = Vec::new();
226 let mut on_path: BTreeSet<u64> = BTreeSet::new();
227 let mut visited: BTreeSet<u64> = BTreeSet::new();
228 stack.push(start);
229 // Iterative DFS tracking the current path so we can detect a
230 // return to `start`.
231 self.dfs_cycle(start, start, &mut on_path, &mut visited, &mut stack)
232 }
233
234 fn dfs_cycle(
235 &self,
236 start: u64,
237 node: u64,
238 on_path: &mut BTreeSet<u64>,
239 visited: &mut BTreeSet<u64>,
240 path: &mut Vec<u64>,
241 ) -> Option<BTreeSet<u64>> {
242 on_path.insert(node);
243 visited.insert(node);
244 if let Some(edges) = self.wait_for.get(&node) {
245 for &next in edges {
246 if next == start {
247 // Closed a cycle back to the origin.
248 let mut cyc: BTreeSet<u64> = on_path.iter().copied().collect();
249 cyc.insert(start);
250 return Some(cyc);
251 }
252 if !on_path.contains(&next) {
253 path.push(next);
254 if let Some(c) = self.dfs_cycle(start, next, on_path, visited, path) {
255 return Some(c);
256 }
257 path.pop();
258 }
259 }
260 }
261 on_path.remove(&node);
262 None
263 }
264}
265
266/// v7.39 (round 295, E3 Phase 1b) — the locking pre-pass.
267///
268/// Runs under `&mut self` from the write dispatch, BEFORE the ordinary
269/// SELECT. It reproduces the query's row choice — scan, WHERE, ORDER BY
270/// — then walks the ordered rows taking locks until OFFSET+LIMIT is
271/// satisfied, and stops.
272///
273/// Respecting LIMIT here is the whole point. PG locks only the rows it
274/// RETURNS; a pre-pass that locked every matching row would be
275/// observably wrong — another session's `SKIP LOCKED` would skip rows
276/// this query locked but never returned. (RFC §5.6 shortcut B.)
277///
278/// What it leaves behind is the set of rows it SKIPPED because someone
279/// else holds them. The ordinary SELECT that follows excludes those and
280/// therefore lands on exactly the rows this pass locked.
281impl crate::Engine {
282 pub(crate) fn run_locking_prepass(
283 &mut self,
284 stmt: &spg_sql::ast::SelectStatement,
285 ) -> Result<(), crate::EngineError> {
286 use spg_sql::ast::{LockStrength as LS, LockWait as LW};
287 let Some(lock) = &stmt.locking else {
288 return Ok(());
289 };
290 // Only a plain single-table SELECT carries a row identity all
291 // the way here. PG allows joins and CTEs too; refusing them is
292 // a recorded gap, not a silent one.
293 let Some(from) = &stmt.from else {
294 return Ok(()); // `SELECT 1 FOR UPDATE` — no rows to lock.
295 };
296 let derived = from.primary.lateral_subquery.is_some()
297 || from.primary.unnest_expr.is_some()
298 || from.primary.generate_series_args.is_some()
299 || from.primary.table_fn_call.is_some();
300 if !from.joins.is_empty() || derived {
301 // PG locks the base rows of a join or a derived table; SPG
302 // cannot yet name which relation each result row came from,
303 // so no lock is taken here.
304 //
305 // Refusing the query outright would be a capability
306 // regression on SQL PG accepts. Taking no lock SILENTLY is
307 // what this whole epic exists to kill. So the gap is
308 // announced: the client is told, in the channel PG uses for
309 // exactly this kind of "I did something you should know
310 // about", and the statement proceeds.
311 self.notice(alloc::format!(
312 "{} over a join or subquery is accepted but NOT enforced by SPG yet; \
313 rows are returned unlocked",
314 lock_verb(lock.strength)
315 ));
316 return Ok(());
317 }
318 let tname = from.primary.name.clone();
319 let Some(table) = self.active_catalog().get(&tname) else {
320 return Ok(()); // a missing relation is the SELECT's error to raise
321 };
322 let mode = match lock.strength {
323 LS::KeyShare => LockMode::KeyShare,
324 LS::Share => LockMode::Share,
325 LS::NoKeyUpdate => LockMode::NoKeyUpdate,
326 LS::Update => LockMode::Exclusive,
327 };
328 let policy = match lock.policy {
329 LW::Wait => WaitPolicy::Wait,
330 LW::NoWait => WaitPolicy::NoWait,
331 LW::SkipLocked => WaitPolicy::SkipLocked,
332 };
333 let version = self
334 .current_tx
335 .and_then(|tx| self.tx_writer_versions.get(&tx).copied())
336 .unwrap_or(0);
337 let rel = table.rel_id();
338 // Reproduce the row choice: visible rows, WHERE, ORDER BY.
339 let snap = self.current_snapshot();
340 let cols = table.schema().columns.clone();
341 let alias = from.primary.alias.clone();
342 let ctx = crate::eval::EvalContext::new(&cols, alias.as_deref())
343 .with_catalog(self.active_catalog());
344 let mut picked: alloc::vec::Vec<(usize, spg_storage::Row<'static>)> =
345 alloc::vec::Vec::new();
346 for (idx, row) in table.scan_visible(&snap) {
347 if let Some(pred) = &stmt.where_ {
348 let keep =
349 crate::eval::eval_expr(pred, row, &ctx).map_err(crate::EngineError::Eval)?;
350 if !matches!(keep, spg_storage::Value::Bool(true)) {
351 continue;
352 }
353 }
354 picked.push((idx, row.clone()));
355 }
356 if !stmt.order_by.is_empty() {
357 let descs: alloc::vec::Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
358 let mut tagged: alloc::vec::Vec<(alloc::vec::Vec<crate::orderby::OrderKey>, usize)> =
359 alloc::vec::Vec::with_capacity(picked.len());
360 for (idx, row) in &picked {
361 tagged.push((
362 crate::orderby::build_order_keys(&stmt.order_by, row, &ctx)?,
363 *idx,
364 ));
365 }
366 tagged.sort_by(|a, b| crate::orderby::cmp_multi_key(&a.0, &b.0, &descs));
367 let order: alloc::vec::Vec<usize> = tagged.into_iter().map(|(_, i)| i).collect();
368 picked = order
369 .into_iter()
370 .map(|i| (i, spg_storage::Row::new(alloc::vec::Vec::new())))
371 .collect();
372 }
373 // Walk in result order, locking until the query's window is full.
374 let offset = stmt.offset_literal().unwrap_or(0) as usize;
375 let limit = stmt.limit_literal().map(|n| n as usize);
376 let want = limit.map(|n| n.saturating_add(offset));
377 let mut skipped: alloc::collections::BTreeSet<usize> = alloc::collections::BTreeSet::new();
378 let mut taken = 0usize;
379 for (idx, _) in &picked {
380 if want.is_some_and(|w| taken >= w) {
381 break;
382 }
383 let outcome = self.acquire_row_lock(
384 rel,
385 spg_storage::row_header::RowId(*idx as u64),
386 mode,
387 version,
388 policy,
389 );
390 match outcome {
391 LockOutcome::Granted => taken += 1,
392 LockOutcome::Skip => {
393 skipped.insert(*idx);
394 }
395 LockOutcome::NotAvailable => {
396 return Err(crate::EngineError::Unsupported(alloc::format!(
397 "could not obtain lock on row in relation \"{tname}\""
398 )));
399 }
400 // The caller (the server) drops the engine lock and
401 // retries; blocking here would stop every connection,
402 // including the one whose COMMIT frees this row.
403 LockOutcome::WouldBlock { .. } => {
404 return Err(crate::EngineError::LockWouldBlock);
405 }
406 // v7.39 (round 300) — the detector NAMES a victim, and
407 // only the victim dies. PG breaks a cycle by aborting
408 // one transaction so the other can proceed; erroring on
409 // both sides kills work that was never at fault. A
410 // non-victim keeps waiting — the victim's rollback
411 // releases the row it needs.
412 LockOutcome::Deadlock { victim } if victim == version => {
413 return Err(crate::EngineError::LockDeadlock);
414 }
415 LockOutcome::Deadlock { .. } => {
416 return Err(crate::EngineError::LockWouldBlock);
417 }
418 }
419 }
420 self.lock_skip_rows = Some((tname, skipped));
421 Ok(())
422 }
423}
424
425/// How PG spells the clause in diagnostics.
426const fn lock_verb(s: spg_sql::ast::LockStrength) -> &'static str {
427 use spg_sql::ast::LockStrength as LS;
428 match s {
429 LS::Update => "FOR UPDATE",
430 LS::NoKeyUpdate => "FOR NO KEY UPDATE",
431 LS::Share => "FOR SHARE",
432 LS::KeyShare => "FOR KEY SHARE",
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 const R: RelId = RelId(1);
441 fn row(n: u64) -> RowId {
442 RowId(n)
443 }
444
445 #[test]
446 fn conflict_matrix_matches_pg() {
447 use LockMode::{Exclusive, KeyShare, NoKeyUpdate, Share};
448 // Row = held, Col = requested. true = conflict (blocks).
449 assert!(!KeyShare.conflicts_with(KeyShare));
450 assert!(!KeyShare.conflicts_with(Share));
451 assert!(!KeyShare.conflicts_with(NoKeyUpdate));
452 assert!(KeyShare.conflicts_with(Exclusive));
453
454 assert!(!Share.conflicts_with(KeyShare));
455 assert!(!Share.conflicts_with(Share));
456 assert!(Share.conflicts_with(NoKeyUpdate));
457 assert!(Share.conflicts_with(Exclusive));
458
459 assert!(!NoKeyUpdate.conflicts_with(KeyShare)); // load-bearing
460 assert!(NoKeyUpdate.conflicts_with(Share));
461 assert!(NoKeyUpdate.conflicts_with(NoKeyUpdate));
462 assert!(NoKeyUpdate.conflicts_with(Exclusive));
463
464 assert!(Exclusive.conflicts_with(KeyShare));
465 assert!(Exclusive.conflicts_with(Share));
466 assert!(Exclusive.conflicts_with(NoKeyUpdate));
467 assert!(Exclusive.conflicts_with(Exclusive));
468 }
469
470 #[test]
471 fn compatible_locks_both_granted() {
472 let mut t = LockTable::new();
473 // FK check (KeyShare) + non-key UPDATE (NoKeyUpdate) on the same
474 // row both succeed — the concurrency win we match.
475 assert_eq!(
476 t.acquire(R, row(1), LockMode::KeyShare, 10, WaitPolicy::Wait),
477 LockOutcome::Granted
478 );
479 assert_eq!(
480 t.acquire(R, row(1), LockMode::NoKeyUpdate, 20, WaitPolicy::Wait),
481 LockOutcome::Granted
482 );
483 }
484
485 #[test]
486 fn exclusive_blocks_and_nowait_skiplocked_report() {
487 let mut t = LockTable::new();
488 assert_eq!(
489 t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
490 LockOutcome::Granted
491 );
492 // A conflicting Wait parks.
493 match t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait) {
494 LockOutcome::WouldBlock { on } => assert_eq!(on, alloc::vec![10]),
495 other => panic!("expected WouldBlock, got {other:?}"),
496 }
497 // NoWait / SkipLocked report immediately instead.
498 assert_eq!(
499 t.acquire(R, row(1), LockMode::Exclusive, 30, WaitPolicy::NoWait),
500 LockOutcome::NotAvailable
501 );
502 assert_eq!(
503 t.acquire(R, row(1), LockMode::Exclusive, 40, WaitPolicy::SkipLocked),
504 LockOutcome::Skip
505 );
506 }
507
508 #[test]
509 fn release_lets_a_waiter_in() {
510 let mut t = LockTable::new();
511 t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait);
512 t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait);
513 t.release_all(10);
514 assert_eq!(
515 t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait),
516 LockOutcome::Granted
517 );
518 assert_eq!(t.locked_row_count(), 1);
519 t.release_all(20);
520 assert_eq!(t.locked_row_count(), 0);
521 }
522
523 #[test]
524 fn deadlock_cycle_aborts_youngest() {
525 let mut t = LockTable::new();
526 // tx10 holds row1, tx20 holds row2.
527 t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait);
528 t.acquire(R, row(2), LockMode::Exclusive, 20, WaitPolicy::Wait);
529 // tx10 waits for row2 (held by 20): edge 10 -> 20.
530 assert!(matches!(
531 t.acquire(R, row(2), LockMode::Exclusive, 10, WaitPolicy::Wait),
532 LockOutcome::WouldBlock { .. }
533 ));
534 // tx20 waits for row1 (held by 10): edge 20 -> 10 closes the
535 // cycle → abort the youngest (20).
536 assert_eq!(
537 t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait),
538 LockOutcome::Deadlock { victim: 20 }
539 );
540 }
541
542 #[test]
543 fn relock_same_version_is_idempotent() {
544 let mut t = LockTable::new();
545 assert_eq!(
546 t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
547 LockOutcome::Granted
548 );
549 // Same version re-locking the same row never blocks on itself.
550 assert_eq!(
551 t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
552 LockOutcome::Granted
553 );
554 }
555}