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}