test_expected_print_rollback.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. )
  26. saved = {n: dict(getattr(main, n)) for n in names}
  27. for n in names:
  28. getattr(main, n).clear()
  29. yield main
  30. for n in names:
  31. getattr(main, n).clear()
  32. getattr(main, n).update(saved[n])
  33. # ---------------------------------------------------------------------------
  34. # unregister_expected_print is the exact inverse of register_expected_print
  35. # ---------------------------------------------------------------------------
  36. def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
  37. """The strongest form: register then unregister is a round trip to empty."""
  38. main = expected_print_tables
  39. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1)
  40. assert main._expected_prints, "nothing registered — the test proves nothing"
  41. main.unregister_expected_print(1, "widget.3mf", 298)
  42. assert main._expected_prints == {}
  43. assert main._expected_print_creators == {}
  44. assert main._expected_print_registered_at == {}
  45. assert main._print_ams_mappings == {}
  46. assert main._print_plate_ids == {}
  47. def test_unregister_clears_the_filename_variants_too(expected_print_tables):
  48. """Registration stores the name three ways; a partial undo still matches."""
  49. main = expected_print_tables
  50. main.register_expected_print(1, "widget.3mf", 298)
  51. main.unregister_expected_print(1, "widget.3mf", 298)
  52. for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
  53. assert key not in main._expected_prints, f"{key} survived"
  54. def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
  55. main = expected_print_tables
  56. main.register_expected_print(1, "widget.3mf", 298)
  57. main.register_expected_print(2, "widget.3mf", 299)
  58. main.unregister_expected_print(1, "widget.3mf", 298)
  59. assert main._expected_prints[(2, "widget.3mf")] == 299
  60. def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
  61. expected_print_tables,
  62. ):
  63. """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
  64. ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
  65. by file. Two files can be registered against one archive, so dropping them
  66. on the first unregister would strip usage-tracking data from a print that is
  67. still expected.
  68. """
  69. main = expected_print_tables
  70. main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
  71. main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
  72. main.unregister_expected_print(1, "plate1.3mf", 298)
  73. assert main._print_ams_mappings.get(298) == [3]
  74. assert 298 in main._print_plate_ids
  75. def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
  76. """Runs from a ``finally``, so it must tolerate having nothing to do."""
  77. main = expected_print_tables
  78. main.unregister_expected_print(99, "never-registered.3mf", 1234)
  79. assert main._expected_prints == {}
  80. # ---------------------------------------------------------------------------
  81. # The scheduler's rollback hook
  82. # ---------------------------------------------------------------------------
  83. def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
  84. main = expected_print_tables
  85. from backend.app.services.print_scheduler import PrintScheduler
  86. sched = PrintScheduler()
  87. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  88. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  89. sched._rollback_unconfirmed_expected_print(597)
  90. assert main._expected_prints == {}
  91. assert sched._unconfirmed_expected_print == {}
  92. def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
  93. """A sent print's expectation must survive — the callback needs it."""
  94. main = expected_print_tables
  95. from backend.app.services.print_scheduler import PrintScheduler
  96. sched = PrintScheduler()
  97. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  98. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  99. # What `_start_print` does once start_print() returns True.
  100. sched._unconfirmed_expected_print.pop(597, None)
  101. sched._rollback_unconfirmed_expected_print(597)
  102. assert main._expected_prints[(1, "widget.3mf")] == 298
  103. assert main._print_ams_mappings[298] == [3, 6]
  104. def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
  105. """It runs in the ``finally`` of dispatch, usually with an exception already
  106. propagating — it must not replace it with one of its own."""
  107. from backend.app.services.print_scheduler import PrintScheduler
  108. sched = PrintScheduler()
  109. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  110. monkeypatch.setattr(
  111. expected_print_tables,
  112. "unregister_expected_print",
  113. lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
  114. )
  115. sched._rollback_unconfirmed_expected_print(597) # must not raise
  116. assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
  117. @pytest.mark.asyncio
  118. @pytest.mark.unit
  119. async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
  120. """End to end through `_dispatch_one`, on the reported failure.
  121. A database error inside `_start_print` must leave no expectation behind, must
  122. still release the claim, and must not be swallowed — the background-task
  123. runner logs it, and hiding it here would turn a loud failure into a silent
  124. one.
  125. """
  126. from unittest.mock import AsyncMock, MagicMock, patch
  127. main = expected_print_tables
  128. from backend.app.services.print_scheduler import PrintScheduler
  129. sched = PrintScheduler()
  130. async def fake_start_print(db, item):
  131. # What `_start_print` does before the point the real one died.
  132. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  133. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  134. raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
  135. db = MagicMock()
  136. db.get = AsyncMock(return_value=MagicMock(id=597))
  137. ctx = MagicMock()
  138. ctx.__aenter__ = AsyncMock(return_value=db)
  139. ctx.__aexit__ = AsyncMock(return_value=False)
  140. with (
  141. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  142. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  143. patch.object(sched, "_start_print", side_effect=fake_start_print),
  144. patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
  145. pytest.raises(RuntimeError),
  146. ):
  147. await sched._dispatch_one(597)
  148. assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
  149. assert main._print_ams_mappings == {}
  150. assert main._print_plate_ids == {}
  151. assert sched._unconfirmed_expected_print == {}
  152. clear.assert_awaited_once_with(db, 597)
  153. @pytest.mark.asyncio
  154. @pytest.mark.unit
  155. async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
  156. """The mirror image: a confirmed send must survive dispatch teardown, or the
  157. print-complete callback would create a duplicate archive."""
  158. from unittest.mock import AsyncMock, MagicMock, patch
  159. main = expected_print_tables
  160. from backend.app.services.print_scheduler import PrintScheduler
  161. sched = PrintScheduler()
  162. async def fake_start_print(db, item):
  163. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  164. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  165. sched._unconfirmed_expected_print.pop(597, None) # start_print() returned True
  166. db = MagicMock()
  167. db.get = AsyncMock(return_value=MagicMock(id=597))
  168. ctx = MagicMock()
  169. ctx.__aenter__ = AsyncMock(return_value=db)
  170. ctx.__aexit__ = AsyncMock(return_value=False)
  171. with (
  172. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  173. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  174. patch.object(sched, "_start_print", side_effect=fake_start_print),
  175. patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
  176. ):
  177. await sched._dispatch_one(597)
  178. assert main._expected_prints[(1, "widget.3mf")] == 298
  179. assert main._print_ams_mappings[298] == [3, 6]
  180. def test_rollback_entries_are_per_item(expected_print_tables):
  181. """Two dispatches in flight must not roll back each other's registration."""
  182. main = expected_print_tables
  183. from backend.app.services.print_scheduler import PrintScheduler
  184. sched = PrintScheduler()
  185. main.register_expected_print(1, "a.3mf", 1)
  186. main.register_expected_print(2, "b.3mf", 2)
  187. sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
  188. sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
  189. sched._rollback_unconfirmed_expected_print(10)
  190. assert (1, "a.3mf") not in main._expected_prints
  191. assert main._expected_prints[(2, "b.3mf")] == 2