ams_assignment_lock.py 2.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. """Per-printer lock for writes to a printer's spool assignments (AMS slots).
  2. on_ams_change serialises its spool-assignment side (auto-unlink stale +
  3. auto-assign new) when MQTT bursts deliver multiple AMS updates for the same
  4. printer in quick succession (~30 ms apart, observed in the wild on H2D + dual
  5. AMS).
  6. Without this serialisation, two concurrent on_ams_change callbacks each read
  7. "no assignment for (printer, ams, tray)", each call auto_assign_spool, and
  8. the second commit hits
  9. IntegrityError: duplicate key value violates unique constraint
  10. "spool_assignment_printer_id_ams_id_tray_id_key"
  11. SQLite's WAL serial-write semantics had been silently swallowing the race
  12. until optional Postgres support landed (asyncpg allows true concurrent
  13. transactions and surfaces the constraint violation).
  14. The assign, unassign and create-from-slot routes write the same rows and take
  15. the same lock (SA-5), before their first write: on SQLite a session that
  16. already holds the write lock would otherwise wait here on an AMS update that
  17. waits for that write lock.
  18. Scope is intentionally narrow: only the DB-mutating blocks are inside the lock.
  19. The Spoolman sync block in on_ams_change stays concurrent because it's
  20. network-bound and idempotent.
  21. A move of a spool (#1574) also takes a per-spool lock, before any printer lock;
  22. see get_spool_move_lock.
  23. """
  24. import asyncio
  25. ams_assignment_locks: dict[int, asyncio.Lock] = {}
  26. spool_move_locks: dict[tuple[str, int], asyncio.Lock] = {}
  27. def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
  28. """Return the per-printer assignment lock, creating it on first use."""
  29. lock = ams_assignment_locks.get(printer_id)
  30. if lock is None:
  31. lock = asyncio.Lock()
  32. ams_assignment_locks[printer_id] = lock
  33. return lock
  34. def get_spool_move_lock(inventory: str, spool_id: int) -> asyncio.Lock:
  35. """Return the lock moves of one spool take turns on, creating it on first use.
  36. A move takes the spool out of every slot it holds, and which slots those are
  37. is only known once read. Two moves of one spool at once each read them
  38. before the other committed, and both assigned it: the printer locks keep
  39. them apart only where they share a printer, and on PostgreSQL a DELETE
  40. doesn't see the row another transaction commits after the DELETE began.
  41. ``inventory`` ("inventory" or "spoolman") keeps the two id spaces apart.
  42. Taken before any printer lock and before the move's first write.
  43. """
  44. key = (inventory, spool_id)
  45. lock = spool_move_locks.get(key)
  46. if lock is None:
  47. lock = asyncio.Lock()
  48. spool_move_locks[key] = lock
  49. return lock