test_printer_file_cache.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """One download of a printer file serves the plate picker (TO-C2).
  2. The plate picker asks for the plate list and then one thumbnail per plate,
  3. and each request downloaded the whole 3MF from the printer again: ten plates
  4. of a 60 MB file on an A1 meant eleven full downloads, all at once. Requests for
  5. the same file now share one download, and its bytes serve the requests that
  6. follow for a minute.
  7. """
  8. import asyncio
  9. import pytest
  10. from backend.app.services import printer_file_cache as cache_mod
  11. from backend.app.services.printer_file_cache import PrinterFileCache
  12. pytestmark = pytest.mark.asyncio
  13. class _Printer:
  14. """Counts downloads; each one can be held open until released."""
  15. def __init__(self, data: bytes = b"3mf-bytes") -> None:
  16. self.data = data
  17. self.calls = 0
  18. self.release = asyncio.Event()
  19. self.release.set()
  20. async def fetch(self) -> bytes:
  21. self.calls += 1
  22. await self.release.wait()
  23. return self.data
  24. async def test_requests_at_the_same_time_share_one_download():
  25. cache, printer = PrinterFileCache(), _Printer()
  26. printer.release.clear()
  27. tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(10)]
  28. await asyncio.sleep(0)
  29. printer.release.set()
  30. assert await asyncio.gather(*tasks) == [b"3mf-bytes"] * 10
  31. assert printer.calls == 1
  32. async def test_a_request_soon_after_is_served_from_memory():
  33. cache, printer = PrinterFileCache(), _Printer()
  34. await cache.get(1, "/cache/a.3mf", printer.fetch)
  35. await cache.get(1, "/cache/a.3mf", printer.fetch)
  36. assert printer.calls == 1
  37. async def test_it_is_downloaded_again_once_the_minute_is_up(monkeypatch):
  38. now = [1000.0]
  39. monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
  40. cache, printer = PrinterFileCache(), _Printer()
  41. await cache.get(1, "/cache/a.3mf", printer.fetch)
  42. now[0] += cache_mod.CACHE_SECONDS + 1
  43. await cache.get(1, "/cache/a.3mf", printer.fetch)
  44. assert printer.calls == 2
  45. async def test_files_and_printers_are_kept_apart():
  46. cache, printer = PrinterFileCache(), _Printer()
  47. await cache.get(1, "/cache/a.3mf", printer.fetch)
  48. await cache.get(1, "/cache/b.3mf", printer.fetch)
  49. await cache.get(2, "/cache/a.3mf", printer.fetch)
  50. assert printer.calls == 3
  51. async def test_a_failure_is_not_kept():
  52. cache, printer = PrinterFileCache(), _Printer()
  53. attempts = []
  54. async def failing():
  55. attempts.append(1)
  56. raise RuntimeError("printer busy")
  57. with pytest.raises(RuntimeError):
  58. await cache.get(1, "/cache/a.3mf", failing)
  59. assert await cache.get(1, "/cache/a.3mf", printer.fetch) == b"3mf-bytes"
  60. assert len(attempts) == 1 and printer.calls == 1
  61. async def test_a_waiter_that_leaves_does_not_end_the_download_for_the_others():
  62. cache, printer = PrinterFileCache(), _Printer()
  63. printer.release.clear()
  64. first = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
  65. second = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
  66. await asyncio.sleep(0)
  67. first.cancel()
  68. printer.release.set()
  69. assert await second == b"3mf-bytes"
  70. assert printer.calls == 1
  71. async def test_a_deleted_file_is_forgotten():
  72. cache, printer = PrinterFileCache(), _Printer()
  73. await cache.get(1, "/cache/a.3mf", printer.fetch)
  74. cache.forget(1, "/cache/a.3mf")
  75. await cache.get(1, "/cache/a.3mf", printer.fetch)
  76. assert printer.calls == 2
  77. async def test_a_deleted_printer_is_forgotten():
  78. cache, printer = PrinterFileCache(), _Printer()
  79. await cache.get(1, "/cache/a.3mf", printer.fetch)
  80. await cache.get(2, "/cache/a.3mf", printer.fetch)
  81. cache.forget_printer(1)
  82. await cache.get(1, "/cache/a.3mf", printer.fetch)
  83. await cache.get(2, "/cache/a.3mf", printer.fetch)
  84. assert printer.calls == 3
  85. async def test_a_file_too_big_to_keep_is_still_shared_while_it_downloads(monkeypatch):
  86. monkeypatch.setattr(cache_mod, "MAX_ENTRY_BYTES", 4)
  87. cache, printer = PrinterFileCache(), _Printer(b"too-big")
  88. printer.release.clear()
  89. tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(3)]
  90. await asyncio.sleep(0)
  91. printer.release.set()
  92. await asyncio.gather(*tasks)
  93. await cache.get(1, "/cache/a.3mf", printer.fetch)
  94. assert printer.calls == 2
  95. async def test_the_oldest_files_make_room(monkeypatch):
  96. monkeypatch.setattr(cache_mod, "MAX_TOTAL_BYTES", 20)
  97. cache = PrinterFileCache()
  98. printer = _Printer(b"x" * 8)
  99. await cache.get(1, "/a", printer.fetch)
  100. await cache.get(1, "/b", printer.fetch)
  101. await cache.get(1, "/a", printer.fetch) # used again: /b is now the oldest
  102. await cache.get(1, "/c", printer.fetch) # 24 bytes: /b goes
  103. assert printer.calls == 3
  104. await cache.get(1, "/a", printer.fetch)
  105. assert printer.calls == 3
  106. await cache.get(1, "/b", printer.fetch)
  107. assert printer.calls == 4
  108. async def test_a_file_deleted_while_downloading_is_not_kept():
  109. cache, printer = PrinterFileCache(), _Printer()
  110. printer.release.clear()
  111. old = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
  112. await asyncio.sleep(0)
  113. cache.forget(1, "/cache/a.3mf")
  114. printer.release.set()
  115. await old
  116. await cache.get(1, "/cache/a.3mf", printer.fetch)
  117. assert printer.calls == 2
  118. async def test_expired_files_free_their_memory(monkeypatch):
  119. """Not only when the same file is asked for again: two large previews
  120. must not stay resident until something pushes them out."""
  121. now = [1000.0]
  122. monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
  123. cache = PrinterFileCache()
  124. await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
  125. await cache.get(1, "/b", _Printer(b"y" * 10).fetch)
  126. now[0] += cache_mod.CACHE_SECONDS + 1
  127. await cache.get(1, "/c", _Printer(b"z" * 3).fetch)
  128. assert list(cache._entries) == [(1, "/c")]
  129. assert cache._total == 3
  130. async def test_a_failure_reaches_every_request_waiting_on_it():
  131. cache, printer = PrinterFileCache(), _Printer()
  132. printer.release.clear()
  133. async def failing():
  134. await printer.release.wait()
  135. raise RuntimeError("printer busy")
  136. tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", failing)) for _ in range(3)]
  137. await asyncio.sleep(0)
  138. printer.release.set()
  139. results = await asyncio.gather(*tasks, return_exceptions=True)
  140. assert all(isinstance(r, RuntimeError) for r in results)
  141. assert cache._total == 0
  142. async def test_a_printer_deleted_during_a_download_keeps_nothing():
  143. cache, printer = PrinterFileCache(), _Printer()
  144. printer.release.clear()
  145. task = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
  146. await asyncio.sleep(0)
  147. cache.forget_printer(1)
  148. printer.release.set()
  149. await task
  150. assert cache._entries == {} and cache._total == 0
  151. async def test_files_that_expire_during_a_download_are_freed_when_it_lands(monkeypatch):
  152. now = [1000.0]
  153. monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
  154. cache = PrinterFileCache()
  155. await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
  156. slow = _Printer(b"z" * 3)
  157. slow.release.clear()
  158. task = asyncio.create_task(cache.get(1, "/c", slow.fetch))
  159. await asyncio.sleep(0)
  160. now[0] += cache_mod.CACHE_SECONDS + 1
  161. slow.release.set()
  162. await task
  163. assert list(cache._entries) == [(1, "/c")]
  164. assert cache._total == 3