Coverage for app/crud/locks.py: 100.00%

8 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1"""Per-user serialization via Postgres advisory locks. 

2 

3Used to make credential mutation and deployment dispatch atomic from the 

4caller's perspective: while a user is creating a deployment, their 

5credential row cannot be flipped underneath them, and vice versa. 

6 

7The lock is *transaction-scoped* (`pg_advisory_xact_lock`) — it is 

8released automatically on COMMIT or ROLLBACK. Callers therefore must 

9hold a single transaction across the protected region; do NOT call this 

10and then call CRUD functions that auto-commit, because each commit would 

11release the lock prematurely. 

12""" 

13from __future__ import annotations 

14 

15from uuid import UUID 

16 

17from sqlalchemy import text 

18from sqlalchemy.orm import Session 

19 

20 

21def acquire_user_xact_lock(db: Session, user_id: UUID) -> None: 

22 """Block until this transaction holds the per-user advisory lock. 

23 

24 `hashtext()` collapses the UUID string into the int8 key Postgres 

25 advisory locks expect. Collisions across users are possible in 

26 principle (32-bit hash space), but harmless: the worst outcome is a 

27 short serialization between two users who happen to hash to the same 

28 bucket. No correctness loss. 

29 """ 

30 db.execute( 

31 text("SELECT pg_advisory_xact_lock(hashtext(:uid))"), 

32 {"uid": str(user_id)}, 

33 ) 

34 

35 

36def acquire_deployment_xact_lock(db: Session, deployment_id: UUID) -> None: 

37 """Per-deployment advisory lock — serialises lifecycle dispatch. 

38 

39 Used by every endpoint that prepares a new lifecycle task 

40 (destroy / pause / resume) so two concurrent requests on the 

41 *same* deployment can never both pass the 

42 ``ensure_action_allowed`` matrix check, both call 

43 ``prepare_task_in_tx``, and one ends up surfacing a raw 

44 ``IntegrityError`` from the partial unique index instead of a 

45 clean 409. 

46 

47 Uses a separate keyspace from ``acquire_user_xact_lock`` — 

48 ``pg_advisory_xact_lock(int4, int4)`` takes two ints; we pin the 

49 first to ``1`` for the deployment namespace and hash the UUID 

50 into the second slot. Same caveat about 32-bit hash collisions 

51 applies (worst case: two unrelated deployments share a bucket 

52 and serialize, no correctness loss). 

53 """ 

54 db.execute( 

55 text("SELECT pg_advisory_xact_lock(1, hashtext(:did))"), 

56 {"did": str(deployment_id)}, 

57 )