spoolman_slot_sync.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """Writing a Spoolman AMS sync's results to the local slot table.
  2. Shared by the automatic pass in ``on_ams_change`` and the Sync buttons in
  3. Settings. A sync reads the slot table, then awaits Spoolman for every tray, so
  4. by the time it writes, a slot may hold a spool the user assigned meanwhile.
  5. Writing every synced slot back, the unchanged ones included, overwrote that
  6. assignment with what the sync had read (SSA-3). Only a slot whose spool the
  7. sync changed is written, and only while it still holds what the sync read.
  8. What it did change is announced, its grace hold dropped, as a hand assignment
  9. does; the Sync buttons did neither (SSA-2).
  10. """
  11. import logging
  12. from collections.abc import Iterable, Mapping
  13. from typing import cast
  14. from sqlalchemy import delete, text, update
  15. from sqlalchemy.engine import CursorResult
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.core.websocket import ws_manager
  18. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  19. from backend.app.services import slot_unlink_grace, spool_swaps
  20. logger = logging.getLogger(__name__)
  21. Slot = tuple[int, int, int] # (printer_id, ams_id, tray_id)
  22. async def write_slot_results(
  23. db: AsyncSession,
  24. read: Mapping[Slot, int] | None,
  25. synced: Iterable[tuple[int, int, int, int]],
  26. emptied: Iterable[Slot],
  27. ) -> list[Slot]:
  28. """Write the slots the sync changed and commit; returns them.
  29. ``read`` is the slot table as the sync read it, ``synced`` the
  30. (printer_id, ams_id, tray_id, spoolman_spool_id) it found in the slots, and
  31. ``emptied`` the slots it found empty. Raises on a database error, as the
  32. callers report it.
  33. ``read`` None: the table could not be read, so there is nothing to compare
  34. against, and every slot is written as it was before the compare existed. A
  35. swap and a removal must not be dropped for want of a snapshot.
  36. """
  37. synced = list(synced) # read again by _release_elsewhere
  38. if read is None:
  39. return await _write_unconditionally(db, synced, emptied)
  40. changed: list[Slot] = []
  41. assigned: list[Slot] = []
  42. for printer_id, ams_id, tray_id, spool_id in synced:
  43. slot = (printer_id, ams_id, tray_id)
  44. before = read.get(slot)
  45. if before == spool_id:
  46. continue
  47. where = (
  48. SpoolmanSlotAssignment.printer_id == printer_id,
  49. SpoolmanSlotAssignment.ams_id == ams_id,
  50. SpoolmanSlotAssignment.tray_id == tray_id,
  51. )
  52. if before is None:
  53. statement = text(
  54. "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
  55. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  56. " ON CONFLICT(printer_id, ams_id, tray_id) DO NOTHING"
  57. ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
  58. else:
  59. statement = (
  60. update(SpoolmanSlotAssignment)
  61. .where(*where, SpoolmanSlotAssignment.spoolman_spool_id == before)
  62. .values(spoolman_spool_id=spool_id)
  63. .execution_options(synchronize_session=False)
  64. )
  65. if cast(CursorResult, await db.execute(statement)).rowcount:
  66. changed.append(slot)
  67. assigned.append(slot)
  68. await spool_swaps.record_assignment(db, printer_id, ams_id, tray_id, spool_id, spool_swaps.SPOOLMAN)
  69. for slot in emptied:
  70. # A slot the sync read without a spool matches no row below: there is
  71. # nothing of the sync's own to remove.
  72. before = read.get(slot)
  73. printer_id, ams_id, tray_id = slot
  74. result = cast(
  75. CursorResult,
  76. await db.execute(
  77. delete(SpoolmanSlotAssignment)
  78. .where(
  79. SpoolmanSlotAssignment.printer_id == printer_id,
  80. SpoolmanSlotAssignment.ams_id == ams_id,
  81. SpoolmanSlotAssignment.tray_id == tray_id,
  82. SpoolmanSlotAssignment.spoolman_spool_id == before,
  83. )
  84. .execution_options(synchronize_session=False)
  85. ),
  86. )
  87. if result.rowcount:
  88. changed.append(slot)
  89. await db.commit()
  90. await _announce(assigned, changed)
  91. await _release_elsewhere(synced, assigned)
  92. return changed
  93. async def _write_unconditionally(
  94. db: AsyncSession, synced: Iterable[tuple[int, int, int, int]], emptied: Iterable[Slot]
  95. ) -> list[Slot]:
  96. assigned: list[Slot] = []
  97. for printer_id, ams_id, tray_id, spool_id in synced:
  98. await db.execute(
  99. text(
  100. "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
  101. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  102. " ON CONFLICT(printer_id, ams_id, tray_id)"
  103. " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
  104. ).bindparams(printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spool_id=spool_id)
  105. )
  106. assigned.append((printer_id, ams_id, tray_id))
  107. await spool_swaps.record_assignment(db, printer_id, ams_id, tray_id, spool_id, spool_swaps.SPOOLMAN)
  108. removed: list[Slot] = []
  109. for printer_id, ams_id, tray_id in emptied:
  110. await db.execute(
  111. delete(SpoolmanSlotAssignment)
  112. .where(
  113. SpoolmanSlotAssignment.printer_id == printer_id,
  114. SpoolmanSlotAssignment.ams_id == ams_id,
  115. SpoolmanSlotAssignment.tray_id == tray_id,
  116. )
  117. .execution_options(synchronize_session=False)
  118. )
  119. removed.append((printer_id, ams_id, tray_id))
  120. await db.commit()
  121. changed = assigned + removed
  122. await _announce(assigned, changed)
  123. await _release_elsewhere(synced, assigned)
  124. return changed
  125. async def _release_elsewhere(synced: Iterable[tuple[int, int, int, int]], assigned: list[Slot]) -> None:
  126. """Take each spool this write put in a slot out of its other slots (#3427).
  127. Only where the slot reports a tag, which ``release_other_slots`` checks:
  128. the sync also fills slots from what they held before, which proves
  129. nothing about where the spool is. A failure leaves the assignment written.
  130. """
  131. from backend.app.services.spool_relocation import SPOOLMAN, release_other_slots
  132. written = set(assigned)
  133. for printer_id, ams_id, tray_id, spool_id in synced:
  134. slot = (printer_id, ams_id, tray_id)
  135. if slot not in written:
  136. continue
  137. try:
  138. await release_other_slots(SPOOLMAN, spool_id, slot)
  139. except Exception as e:
  140. logger.warning("Releasing Spoolman spool %s from its other slots failed: %s", spool_id, e, exc_info=True)
  141. async def _announce(assigned: list[Slot], changed: list[Slot]) -> None:
  142. for printer_id, ams_id, tray_id in assigned:
  143. slot_unlink_grace.forget_slot(printer_id, ams_id, tray_id)
  144. for printer_id, ams_id, tray_id in changed:
  145. await ws_manager.broadcast(
  146. {"type": "spool_assignment_changed", "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id}
  147. )