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
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""Per-user serialization via Postgres advisory locks.
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.
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
15from uuid import UUID
17from sqlalchemy import text
18from sqlalchemy.orm import Session
21def acquire_user_xact_lock(db: Session, user_id: UUID) -> None:
22 """Block until this transaction holds the per-user advisory lock.
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 )
36def acquire_deployment_xact_lock(db: Session, deployment_id: UUID) -> None:
37 """Per-deployment advisory lock — serialises lifecycle dispatch.
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.
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 )