test_assignment_routes_take_the_slot_lock.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. """Spool assignments made by hand wait for the printer's assignment lock (SA-5).
  2. The AMS change handler writes a printer's slot assignments under a per-printer
  3. lock, so two of its updates can't both find a slot free and both insert. The
  4. assign, unassign and create-from-slot routes wrote the same rows without it: a
  5. manual assign racing an AMS update (or a double-click) on PostgreSQL failed
  6. with a 500 on the unique (printer, AMS, tray) constraint, or on a row the other
  7. writer had just deleted.
  8. """
  9. import asyncio
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from sqlalchemy import select
  14. from backend.app.models.spool import Spool
  15. from backend.app.models.spool_assignment import SpoolAssignment
  16. from backend.app.services.ams_assignment_lock import ams_assignment_locks, get_ams_assignment_lock
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. @pytest.fixture(autouse=True)
  19. def _fresh_locks():
  20. """A contended lock binds to its test's event loop; printer ids repeat."""
  21. ams_assignment_locks.clear()
  22. yield
  23. ams_assignment_locks.clear()
  24. TRAY = {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "tag_uid": "A1B2C3D4E5F60708", "state": 11}
  25. async def _waits_for_the_lock(printer_id: int, request) -> object:
  26. """Run *request* while the lock is held: it must not finish until released."""
  27. lock = get_ams_assignment_lock(printer_id)
  28. await lock.acquire()
  29. try:
  30. task = asyncio.ensure_future(request)
  31. await asyncio.sleep(0.3)
  32. assert not task.done(), "the route wrote the slot without waiting for the lock"
  33. finally:
  34. lock.release()
  35. return await asyncio.wait_for(task, 10)
  36. @pytest.fixture
  37. def printer_status():
  38. status = SimpleNamespace(raw_data={"ams": [{"id": 0, "tray": [TRAY]}]}, state="IDLE", connected=True)
  39. with (
  40. patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
  41. patch("backend.app.services.printer_manager.printer_manager.get_client", MagicMock(return_value=None)),
  42. patch("backend.app.api.routes.inventory.ws_manager") as ws,
  43. ):
  44. ws.broadcast = AsyncMock()
  45. yield
  46. async def _spool(db_session) -> Spool:
  47. spool = Spool(material="PLA", label_weight=1000, weight_used=0)
  48. db_session.add(spool)
  49. await db_session.commit()
  50. return spool
  51. async def test_assigning(async_client, db_session, printer_factory, printer_status):
  52. printer = await printer_factory()
  53. spool = await _spool(db_session)
  54. response = await _waits_for_the_lock(
  55. printer.id,
  56. async_client.post(
  57. "/api/v1/inventory/assignments",
  58. json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
  59. ),
  60. )
  61. assert response.status_code == 200, response.text
  62. async def test_unassigning(async_client, db_session, printer_factory, printer_status):
  63. printer = await printer_factory()
  64. spool = await _spool(db_session)
  65. db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
  66. await db_session.commit()
  67. response = await _waits_for_the_lock(
  68. printer.id, async_client.delete(f"/api/v1/inventory/assignments/{printer.id}/0/0")
  69. )
  70. assert response.status_code == 200, response.text
  71. assert (await db_session.execute(select(SpoolAssignment))).scalars().first() is None
  72. async def test_creating_a_spool_from_a_slot(async_client, db_session, printer_factory, printer_status):
  73. printer = await printer_factory()
  74. response = await _waits_for_the_lock(
  75. printer.id,
  76. async_client.post(
  77. "/api/v1/inventory/spools/from-slot", json={"printer_id": printer.id, "ams_id": 0, "tray_id": 0}
  78. ),
  79. )
  80. assert response.status_code == 200, response.text