Skip to main content

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}