test_expected_print_rollback.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. """A dispatch that never sends the print command must leave no expectation.
  2. ``register_expected_print`` has to run *before* the MQTT command, because the
  3. printer can report the print before the send returns. So any path that
  4. registers and then fails to send leaves Bambuddy expecting a print that will
  5. never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
  6. False, or an exception in between — a PostgreSQL connection failure mid-dispatch
  7. is the case that surfaced this (#2702 follow-up).
  8. The two-hour TTL sweep does eventually evict such an entry, but two hours is far
  9. longer than it takes someone to react to a failed dispatch by pressing print
  10. again. That reprint would be folded into the *old* archive and inherit its
  11. ``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
  12. """
  13. from __future__ import annotations
  14. import pytest
  15. @pytest.fixture
  16. def expected_print_tables():
  17. """The module-level registries, emptied around each test."""
  18. from backend.app import main
  19. names = (
  20. "_expected_prints",
  21. "_expected_print_creators",
  22. "_expected_print_registered_at",
  23. "_print_ams_mappings",
  24. "_print_plate_ids",
  25. "_print_cost_center_ids",
  26. "_active_prints",
  27. )
  28. saved = {n: dict(getattr(main, n)) for n in names}
  29. for n in names:
  30. getattr(main, n).clear()
  31. yield main
  32. for n in names:
  33. getattr(main, n).clear()
  34. getattr(main, n).update(saved[n])
  35. # ---------------------------------------------------------------------------
  36. # unregister_expected_print is the exact inverse of register_expected_print
  37. # ---------------------------------------------------------------------------
  38. def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
  39. """The strongest form: register then unregister is a round trip to empty."""
  40. main = expected_print_tables
  41. main.register_expected_print(
  42. 1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1, cost_center_id=5
  43. )
  44. assert main._expected_prints, "nothing registered — the test proves nothing"
  45. main.unregister_expected_print(1, "widget.3mf", 298)
  46. assert main._expected_prints == {}
  47. assert main._expected_print_creators == {}
  48. assert main._expected_print_registered_at == {}
  49. assert main._print_ams_mappings == {}
  50. assert main._print_plate_ids == {}
  51. assert main._print_cost_center_ids == {}
  52. def test_unregister_clears_the_filename_variants_too(expected_print_tables):
  53. """Registration stores the name three ways; a partial undo still matches."""
  54. main = expected_print_tables
  55. main.register_expected_print(1, "widget.3mf", 298)
  56. main.unregister_expected_print(1, "widget.3mf", 298)
  57. for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
  58. assert key not in main._expected_prints, f"{key} survived"
  59. def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
  60. main = expected_print_tables
  61. main.register_expected_print(1, "widget.3mf", 298)
  62. main.register_expected_print(2, "widget.3mf", 299)
  63. main.unregister_expected_print(1, "widget.3mf", 298)
  64. assert main._expected_prints[(2, "widget.3mf")] == 299
  65. def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
  66. expected_print_tables,
  67. ):
  68. """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
  69. ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
  70. by file. Two files can be registered against one archive, so dropping them
  71. on the first unregister would strip usage-tracking data from a print that is
  72. still expected.
  73. """
  74. main = expected_print_tables
  75. main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
  76. main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
  77. main.unregister_expected_print(1, "plate1.3mf", 298)
  78. assert main._print_ams_mappings.get(298) == [3]
  79. assert 298 in main._print_plate_ids
  80. def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
  81. """Runs from a ``finally``, so it must tolerate having nothing to do."""
  82. main = expected_print_tables
  83. main.unregister_expected_print(99, "never-registered.3mf", 1234)
  84. assert main._expected_prints == {}
  85. # ---------------------------------------------------------------------------
  86. # The scheduler's rollback hook
  87. # ---------------------------------------------------------------------------
  88. def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
  89. main = expected_print_tables
  90. from backend.app.services.print_scheduler import PrintScheduler
  91. sched = PrintScheduler()
  92. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  93. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  94. sched._rollback_unconfirmed_expected_print(597)
  95. assert main._expected_prints == {}
  96. assert sched._unconfirmed_expected_print == {}
  97. def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
  98. """A sent print's expectation must survive — the callback needs it."""
  99. main = expected_print_tables
  100. from backend.app.services.print_scheduler import PrintScheduler
  101. sched = PrintScheduler()
  102. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  103. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  104. # What `_start_print` does once start_print() returns True.
  105. sched._unconfirmed_expected_print.pop(597, None)
  106. sched._rollback_unconfirmed_expected_print(597)
  107. assert main._expected_prints[(1, "widget.3mf")] == 298
  108. assert main._print_ams_mappings[298] == [3, 6]
  109. def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
  110. """It runs in the ``finally`` of dispatch, usually with an exception already
  111. propagating — it must not replace it with one of its own."""
  112. from backend.app.services.print_scheduler import PrintScheduler
  113. sched = PrintScheduler()
  114. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  115. monkeypatch.setattr(
  116. expected_print_tables,
  117. "unregister_expected_print",
  118. lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
  119. )
  120. sched._rollback_unconfirmed_expected_print(597) # must not raise
  121. assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
  122. @pytest.mark.asyncio
  123. @pytest.mark.unit
  124. async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
  125. """End to end through `_dispatch_one`, on the reported failure.
  126. A database error inside `_start_print` must leave no expectation behind, must
  127. still release the claim, and must not be swallowed — the background-task
  128. runner logs it, and hiding it here would turn a loud failure into a silent
  129. one.
  130. """
  131. from unittest.mock import AsyncMock, MagicMock, patch
  132. main = expected_print_tables
  133. from backend.app.services.print_scheduler import PrintScheduler
  134. sched = PrintScheduler()
  135. async def fake_start_print(db, item):
  136. # What `_start_print` does before the point the real one died.
  137. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  138. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  139. raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
  140. db = MagicMock()
  141. db.get = AsyncMock(return_value=MagicMock(id=597))
  142. ctx = MagicMock()
  143. ctx.__aenter__ = AsyncMock(return_value=db)
  144. ctx.__aexit__ = AsyncMock(return_value=False)
  145. with (
  146. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  147. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  148. patch.object(sched, "_start_print", side_effect=fake_start_print),
  149. patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
  150. pytest.raises(RuntimeError),
  151. ):
  152. await sched._dispatch_one(597)
  153. assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
  154. assert main._print_ams_mappings == {}
  155. assert main._print_plate_ids == {}
  156. assert sched._unconfirmed_expected_print == {}
  157. clear.assert_awaited_once_with(db, 597)
  158. @pytest.mark.asyncio
  159. @pytest.mark.unit
  160. async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
  161. """The mirror image: a confirmed send must survive dispatch teardown, or the
  162. print-complete callback would create a duplicate archive."""
  163. from unittest.mock import AsyncMock, MagicMock, patch
  164. main = expected_print_tables
  165. from backend.app.services.print_scheduler import PrintScheduler
  166. sched = PrintScheduler()
  167. async def fake_start_print(db, item):
  168. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  169. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  170. sched._unconfirmed_expected_print.pop(597, None) # start_print() returned True
  171. db = MagicMock()
  172. db.get = AsyncMock(return_value=MagicMock(id=597))
  173. ctx = MagicMock()
  174. ctx.__aenter__ = AsyncMock(return_value=db)
  175. ctx.__aexit__ = AsyncMock(return_value=False)
  176. with (
  177. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  178. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  179. patch.object(sched, "_start_print", side_effect=fake_start_print),
  180. patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
  181. ):
  182. await sched._dispatch_one(597)
  183. assert main._expected_prints[(1, "widget.3mf")] == 298
  184. assert main._print_ams_mappings[298] == [3, 6]
  185. def test_rollback_entries_are_per_item(expected_print_tables):
  186. """Two dispatches in flight must not roll back each other's registration."""
  187. main = expected_print_tables
  188. from backend.app.services.print_scheduler import PrintScheduler
  189. sched = PrintScheduler()
  190. main.register_expected_print(1, "a.3mf", 1)
  191. main.register_expected_print(2, "b.3mf", 2)
  192. sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
  193. sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
  194. sched._rollback_unconfirmed_expected_print(10)
  195. assert (1, "a.3mf") not in main._expected_prints
  196. assert main._expected_prints[(2, "b.3mf")] == 2
  197. # ---------------------------------------------------------------------------
  198. # The archive-keyed tables follow the newest dispatch of the archive
  199. # ---------------------------------------------------------------------------
  200. def test_a_fresh_dispatch_without_a_plate_drops_the_previous_plate(expected_print_tables):
  201. """EP-6: plate 2 was sent but never started; the whole file is queued next.
  202. Its start and completion must not read plate 2 for usage and cost."""
  203. main = expected_print_tables
  204. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3], plate_id=2, cost_center_id=5)
  205. main.register_expected_print(1, "widget.3mf", 298)
  206. assert 298 not in main._print_plate_ids
  207. assert 298 not in main._print_ams_mappings
  208. assert 298 not in main._print_cost_center_ids
  209. def test_but_not_while_a_plate_of_the_archive_is_printing(expected_print_tables):
  210. """Plate 1 runs; dropping its entries would leave its completion without them."""
  211. main = expected_print_tables
  212. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3], plate_id=1)
  213. main._active_prints[(1, "widget.3mf")] = 298
  214. main.register_expected_print(2, "widget.3mf", 298)
  215. assert main._print_plate_ids.get(298) == 1
  216. assert main._print_ams_mappings.get(298) == [3]
  217. def test_a_failed_send_leaves_a_running_plate_s_entries(expected_print_tables):
  218. """EP-4: plate 1 runs on printer 1, plate 2's send to printer 2 fails."""
  219. main = expected_print_tables
  220. main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1, cost_center_id=5)
  221. # Plate 1 started: its expectation became an active print
  222. for key in [k for k in main._expected_prints if k[0] == 1]:
  223. del main._expected_prints[key]
  224. main._active_prints[(1, "plate1.3mf")] = 298
  225. main.register_expected_print(2, "plate2.3mf", 298, ams_mapping=[3], plate_id=1, cost_center_id=5)
  226. main.unregister_expected_print(2, "plate2.3mf", 298)
  227. assert main._print_ams_mappings.get(298) == [3]
  228. assert main._print_plate_ids.get(298) == 1
  229. assert main._print_cost_center_ids.get(298) == 5