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        // v7.38.2 (R1) — candidates come from the SAME index-seek
345        // machinery the executor uses. The 10:40 tpcc profile put this
346        // pre-pass at 64% of the serving thread: every FOR UPDATE
347        // walked EVERY visible row through the interpreted evaluator
348        // while the execution right after it seeks. A seek result is a
349        // candidate superset, so the predicate is still re-checked per
350        // candidate — same answers, thousands fewer rows touched.
351        let alias_or_name = alias.as_deref().unwrap_or(&tname);
352        let seeked: Option<alloc::vec::Vec<usize>> = stmt.where_.as_ref().and_then(|pred| {
353            crate::index_access::try_index_seek_positions(pred, &cols, table, alias_or_name, &snap)
354        });
355        // v7.38.2 (R1, red-first pin) — lock by the row's REAL RowId.
356        // This walk used to lock `RowId(position)`, but RowIds are
357        // dense 1-based while positions are 0-based (and diverge
358        // arbitrarily after churn), so `SELECT … FOR UPDATE` locked
359        // the WRONG row: it never conflicted with a DML on the same
360        // row (which locks via `table.rowids()`), and spuriously
361        // blocked writers of a neighbour.
362        let mut picked: alloc::vec::Vec<(
363            usize,
364            spg_storage::row_header::RowId,
365            spg_storage::Row<'static>,
366        )> = alloc::vec::Vec::new();
367        let mut push_if_match =
368            |idx: usize, row: &spg_storage::Row<'static>| -> Result<(), crate::EngineError> {
369                if let Some(pred) = &stmt.where_ {
370                    let keep = crate::eval::eval_expr(pred, row, &ctx)
371                        .map_err(crate::EngineError::Eval)?;
372                    if !matches!(keep, spg_storage::Value::Bool(true)) {
373                        return Ok(());
374                    }
375                }
376                let Some(rid) = table.rowids().get(idx).copied() else {
377                    return Ok(());
378                };
379                picked.push((idx, rid, row.clone()));
380                Ok(())
381            };
382        if let Some(positions) = seeked {
383            for idx in positions {
384                if let Some(row) = table.rows().get(idx) {
385                    push_if_match(idx, row)?;
386                }
387            }
388        } else {
389            for (idx, row) in table.scan_visible(&snap) {
390                push_if_match(idx, row)?;
391            }
392        }
393        if !stmt.order_by.is_empty() {
394            let descs: alloc::vec::Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
395            let mut tagged: alloc::vec::Vec<(
396                alloc::vec::Vec<crate::orderby::OrderKey>,
397                usize,
398                spg_storage::row_header::RowId,
399            )> = alloc::vec::Vec::with_capacity(picked.len());
400            for (idx, rid, row) in &picked {
401                tagged.push((
402                    crate::orderby::build_order_keys(&stmt.order_by, row, &ctx)?,
403                    *idx,
404                    *rid,
405                ));
406            }
407            tagged.sort_by(|a, b| crate::orderby::cmp_multi_key(&a.0, &b.0, &descs));
408            picked = tagged
409                .into_iter()
410                .map(|(_, i, rid)| (i, rid, spg_storage::Row::new(alloc::vec::Vec::new())))
411                .collect();
412        }
413        // Walk in result order, locking until the query's window is full.
414        let offset = stmt.offset_literal().unwrap_or(0) as usize;
415        let limit = stmt.limit_literal().map(|n| n as usize);
416        let want = limit.map(|n| n.saturating_add(offset));
417        let mut skipped: alloc::collections::BTreeSet<usize> = alloc::collections::BTreeSet::new();
418        let mut taken = 0usize;
419        for (idx, rid, _) in &picked {
420            if want.is_some_and(|w| taken >= w) {
421                break;
422            }
423            let outcome = self.acquire_row_lock(rel, *rid, mode, version, policy);
424            match outcome {
425                LockOutcome::Granted => taken += 1,
426                LockOutcome::Skip => {
427                    skipped.insert(*idx);
428                }
429                LockOutcome::NotAvailable => {
430                    return Err(crate::EngineError::Unsupported(alloc::format!(
431                        "could not obtain lock on row in relation \"{tname}\""
432                    )));
433                }
434                // The caller (the server) drops the engine lock and
435                // retries; blocking here would stop every connection,
436                // including the one whose COMMIT frees this row.
437                LockOutcome::WouldBlock { .. } => {
438                    return Err(crate::EngineError::LockWouldBlock);
439                }
440                // v7.39 (round 300) — the detector NAMES a victim, and
441                // only the victim dies. PG breaks a cycle by aborting
442                // one transaction so the other can proceed; erroring on
443                // both sides kills work that was never at fault. A
444                // non-victim keeps waiting — the victim's rollback
445                // releases the row it needs.
446                LockOutcome::Deadlock { victim } if victim == version => {
447                    return Err(crate::EngineError::LockDeadlock);
448                }
449                LockOutcome::Deadlock { .. } => {
450                    return Err(crate::EngineError::LockWouldBlock);
451                }
452            }
453        }
454        self.lock_skip_rows = Some((tname, skipped));
455        Ok(())
456    }
457
458    /// 7.38.1 S2.1 (MATRIX #20) — row locks for UPDATE / DELETE
459    /// targets, the write-side twin of the FOR UPDATE walk above.
460    /// PG's READ COMMITTED serialises same-row writers through tuple
461    /// locks: the first updater holds, later ones wait. The engine
462    /// never parks (that would wedge every connection, including the
463    /// holder's COMMIT) — `WouldBlock` maps to
464    /// [`crate::EngineError::LockWouldBlock`] and the SERVER retries
465    /// outside the engine guard, exactly the round-299 shape.
466    ///
467    /// Outcome mapping mirrors the SELECT walk: a named deadlock
468    /// victim dies with 40P01, a non-victim keeps waiting.
469    pub(crate) fn lock_dml_rows(
470        &mut self,
471        rel: RelId,
472        rids: &[spg_storage::row_header::RowId],
473        mode: LockMode,
474        version: u64,
475    ) -> Result<(), crate::EngineError> {
476        for rid in rids {
477            match self
478                .locks
479                .acquire(rel, *rid, mode, version, WaitPolicy::Wait)
480            {
481                LockOutcome::Granted => {}
482                LockOutcome::WouldBlock { .. } => {
483                    return Err(crate::EngineError::LockWouldBlock);
484                }
485                LockOutcome::Deadlock { victim } if victim == version => {
486                    return Err(crate::EngineError::LockDeadlock);
487                }
488                LockOutcome::Deadlock { .. } => {
489                    return Err(crate::EngineError::LockWouldBlock);
490                }
491                // Wait policy never yields Skip / NotAvailable.
492                LockOutcome::Skip | LockOutcome::NotAvailable => {
493                    return Err(crate::EngineError::LockWouldBlock);
494                }
495            }
496        }
497        Ok(())
498    }
499}
500
501/// How PG spells the clause in diagnostics.
502const fn lock_verb(s: spg_sql::ast::LockStrength) -> &'static str {
503    use spg_sql::ast::LockStrength as LS;
504    match s {
505        LS::Update => "FOR UPDATE",
506        LS::NoKeyUpdate => "FOR NO KEY UPDATE",
507        LS::Share => "FOR SHARE",
508        LS::KeyShare => "FOR KEY SHARE",
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    const R: RelId = RelId(1);
517    fn row(n: u64) -> RowId {
518        RowId(n)
519    }
520
521    #[test]
522    fn conflict_matrix_matches_pg() {
523        use LockMode::{Exclusive, KeyShare, NoKeyUpdate, Share};
524        // Row = held, Col = requested. true = conflict (blocks).
525        assert!(!KeyShare.conflicts_with(KeyShare));
526        assert!(!KeyShare.conflicts_with(Share));
527        assert!(!KeyShare.conflicts_with(NoKeyUpdate));
528        assert!(KeyShare.conflicts_with(Exclusive));
529
530        assert!(!Share.conflicts_with(KeyShare));
531        assert!(!Share.conflicts_with(Share));
532        assert!(Share.conflicts_with(NoKeyUpdate));
533        assert!(Share.conflicts_with(Exclusive));
534
535        assert!(!NoKeyUpdate.conflicts_with(KeyShare)); // load-bearing
536        assert!(NoKeyUpdate.conflicts_with(Share));
537        assert!(NoKeyUpdate.conflicts_with(NoKeyUpdate));
538        assert!(NoKeyUpdate.conflicts_with(Exclusive));
539
540        assert!(Exclusive.conflicts_with(KeyShare));
541        assert!(Exclusive.conflicts_with(Share));
542        assert!(Exclusive.conflicts_with(NoKeyUpdate));
543        assert!(Exclusive.conflicts_with(Exclusive));
544    }
545
546    #[test]
547    fn compatible_locks_both_granted() {
548        let mut t = LockTable::new();
549        // FK check (KeyShare) + non-key UPDATE (NoKeyUpdate) on the same
550        // row both succeed — the concurrency win we match.
551        assert_eq!(
552            t.acquire(R, row(1), LockMode::KeyShare, 10, WaitPolicy::Wait),
553            LockOutcome::Granted
554        );
555        assert_eq!(
556            t.acquire(R, row(1), LockMode::NoKeyUpdate, 20, WaitPolicy::Wait),
557            LockOutcome::Granted
558        );
559    }
560
561    #[test]
562    fn exclusive_blocks_and_nowait_skiplocked_report() {
563        let mut t = LockTable::new();
564        assert_eq!(
565            t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
566            LockOutcome::Granted
567        );
568        // A conflicting Wait parks.
569        match t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait) {
570            LockOutcome::WouldBlock { on } => assert_eq!(on, alloc::vec![10]),
571            other => panic!("expected WouldBlock, got {other:?}"),
572        }
573        // NoWait / SkipLocked report immediately instead.
574        assert_eq!(
575            t.acquire(R, row(1), LockMode::Exclusive, 30, WaitPolicy::NoWait),
576            LockOutcome::NotAvailable
577        );
578        assert_eq!(
579            t.acquire(R, row(1), LockMode::Exclusive, 40, WaitPolicy::SkipLocked),
580            LockOutcome::Skip
581        );
582    }
583
584    #[test]
585    fn release_lets_a_waiter_in() {
586        let mut t = LockTable::new();
587        t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait);
588        t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait);
589        t.release_all(10);
590        assert_eq!(
591            t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait),
592            LockOutcome::Granted
593        );
594        assert_eq!(t.locked_row_count(), 1);
595        t.release_all(20);
596        assert_eq!(t.locked_row_count(), 0);
597    }
598
599    #[test]
600    fn deadlock_cycle_aborts_youngest() {
601        let mut t = LockTable::new();
602        // tx10 holds row1, tx20 holds row2.
603        t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait);
604        t.acquire(R, row(2), LockMode::Exclusive, 20, WaitPolicy::Wait);
605        // tx10 waits for row2 (held by 20): edge 10 -> 20.
606        assert!(matches!(
607            t.acquire(R, row(2), LockMode::Exclusive, 10, WaitPolicy::Wait),
608            LockOutcome::WouldBlock { .. }
609        ));
610        // tx20 waits for row1 (held by 10): edge 20 -> 10 closes the
611        // cycle → abort the youngest (20).
612        assert_eq!(
613            t.acquire(R, row(1), LockMode::Exclusive, 20, WaitPolicy::Wait),
614            LockOutcome::Deadlock { victim: 20 }
615        );
616    }
617
618    #[test]
619    fn relock_same_version_is_idempotent() {
620        let mut t = LockTable::new();
621        assert_eq!(
622            t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
623            LockOutcome::Granted
624        );
625        // Same version re-locking the same row never blocks on itself.
626        assert_eq!(
627            t.acquire(R, row(1), LockMode::Exclusive, 10, WaitPolicy::Wait),
628            LockOutcome::Granted
629        );
630    }
631}