|
|
@@ -0,0 +1,234 @@
|
|
|
+"""One download of a printer file serves the plate picker (TO-C2).
|
|
|
+
|
|
|
+The plate picker asks for the plate list and then one thumbnail per plate,
|
|
|
+and each request downloaded the whole 3MF from the printer again: ten plates
|
|
|
+of a 60 MB file on an A1 meant eleven full downloads, all at once. Requests for
|
|
|
+the same file now share one download, and its bytes serve the requests that
|
|
|
+follow for a minute.
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services import printer_file_cache as cache_mod
|
|
|
+from backend.app.services.printer_file_cache import PrinterFileCache
|
|
|
+
|
|
|
+pytestmark = pytest.mark.asyncio
|
|
|
+
|
|
|
+
|
|
|
+class _Printer:
|
|
|
+ """Counts downloads; each one can be held open until released."""
|
|
|
+
|
|
|
+ def __init__(self, data: bytes = b"3mf-bytes") -> None:
|
|
|
+ self.data = data
|
|
|
+ self.calls = 0
|
|
|
+ self.release = asyncio.Event()
|
|
|
+ self.release.set()
|
|
|
+
|
|
|
+ async def fetch(self) -> bytes:
|
|
|
+ self.calls += 1
|
|
|
+ await self.release.wait()
|
|
|
+ return self.data
|
|
|
+
|
|
|
+
|
|
|
+async def test_requests_at_the_same_time_share_one_download():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(10)]
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ printer.release.set()
|
|
|
+
|
|
|
+ assert await asyncio.gather(*tasks) == [b"3mf-bytes"] * 10
|
|
|
+ assert printer.calls == 1
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_request_soon_after_is_served_from_memory():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 1
|
|
|
+
|
|
|
+
|
|
|
+async def test_it_is_downloaded_again_once_the_minute_is_up(monkeypatch):
|
|
|
+ now = [1000.0]
|
|
|
+ monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ now[0] += cache_mod.CACHE_SECONDS + 1
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 2
|
|
|
+
|
|
|
+
|
|
|
+async def test_files_and_printers_are_kept_apart():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ await cache.get(1, "/cache/b.3mf", printer.fetch)
|
|
|
+ await cache.get(2, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 3
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_failure_is_not_kept():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ attempts = []
|
|
|
+
|
|
|
+ async def failing():
|
|
|
+ attempts.append(1)
|
|
|
+ raise RuntimeError("printer busy")
|
|
|
+
|
|
|
+ with pytest.raises(RuntimeError):
|
|
|
+ await cache.get(1, "/cache/a.3mf", failing)
|
|
|
+ assert await cache.get(1, "/cache/a.3mf", printer.fetch) == b"3mf-bytes"
|
|
|
+ assert len(attempts) == 1 and printer.calls == 1
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_waiter_that_leaves_does_not_end_the_download_for_the_others():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ first = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
|
|
|
+ second = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ first.cancel()
|
|
|
+ printer.release.set()
|
|
|
+
|
|
|
+ assert await second == b"3mf-bytes"
|
|
|
+ assert printer.calls == 1
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_deleted_file_is_forgotten():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ cache.forget(1, "/cache/a.3mf")
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 2
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_deleted_printer_is_forgotten():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ await cache.get(2, "/cache/a.3mf", printer.fetch)
|
|
|
+ cache.forget_printer(1)
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+ await cache.get(2, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 3
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_file_too_big_to_keep_is_still_shared_while_it_downloads(monkeypatch):
|
|
|
+ monkeypatch.setattr(cache_mod, "MAX_ENTRY_BYTES", 4)
|
|
|
+ cache, printer = PrinterFileCache(), _Printer(b"too-big")
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch)) for _ in range(3)]
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ printer.release.set()
|
|
|
+ await asyncio.gather(*tasks)
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 2
|
|
|
+
|
|
|
+
|
|
|
+async def test_the_oldest_files_make_room(monkeypatch):
|
|
|
+ monkeypatch.setattr(cache_mod, "MAX_TOTAL_BYTES", 20)
|
|
|
+ cache = PrinterFileCache()
|
|
|
+ printer = _Printer(b"x" * 8)
|
|
|
+
|
|
|
+ await cache.get(1, "/a", printer.fetch)
|
|
|
+ await cache.get(1, "/b", printer.fetch)
|
|
|
+ await cache.get(1, "/a", printer.fetch) # used again: /b is now the oldest
|
|
|
+ await cache.get(1, "/c", printer.fetch) # 24 bytes: /b goes
|
|
|
+ assert printer.calls == 3
|
|
|
+
|
|
|
+ await cache.get(1, "/a", printer.fetch)
|
|
|
+ assert printer.calls == 3
|
|
|
+ await cache.get(1, "/b", printer.fetch)
|
|
|
+ assert printer.calls == 4
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_file_deleted_while_downloading_is_not_kept():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ old = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ cache.forget(1, "/cache/a.3mf")
|
|
|
+ printer.release.set()
|
|
|
+ await old
|
|
|
+ await cache.get(1, "/cache/a.3mf", printer.fetch)
|
|
|
+
|
|
|
+ assert printer.calls == 2
|
|
|
+
|
|
|
+
|
|
|
+async def test_expired_files_free_their_memory(monkeypatch):
|
|
|
+ """Not only when the same file is asked for again: two large previews
|
|
|
+ must not stay resident until something pushes them out."""
|
|
|
+ now = [1000.0]
|
|
|
+ monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
|
|
|
+ cache = PrinterFileCache()
|
|
|
+
|
|
|
+ await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
|
|
|
+ await cache.get(1, "/b", _Printer(b"y" * 10).fetch)
|
|
|
+ now[0] += cache_mod.CACHE_SECONDS + 1
|
|
|
+ await cache.get(1, "/c", _Printer(b"z" * 3).fetch)
|
|
|
+
|
|
|
+ assert list(cache._entries) == [(1, "/c")]
|
|
|
+ assert cache._total == 3
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_failure_reaches_every_request_waiting_on_it():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ async def failing():
|
|
|
+ await printer.release.wait()
|
|
|
+ raise RuntimeError("printer busy")
|
|
|
+
|
|
|
+ tasks = [asyncio.create_task(cache.get(1, "/cache/a.3mf", failing)) for _ in range(3)]
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ printer.release.set()
|
|
|
+ results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
+
|
|
|
+ assert all(isinstance(r, RuntimeError) for r in results)
|
|
|
+ assert cache._total == 0
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_printer_deleted_during_a_download_keeps_nothing():
|
|
|
+ cache, printer = PrinterFileCache(), _Printer()
|
|
|
+ printer.release.clear()
|
|
|
+
|
|
|
+ task = asyncio.create_task(cache.get(1, "/cache/a.3mf", printer.fetch))
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ cache.forget_printer(1)
|
|
|
+ printer.release.set()
|
|
|
+ await task
|
|
|
+
|
|
|
+ assert cache._entries == {} and cache._total == 0
|
|
|
+
|
|
|
+
|
|
|
+async def test_files_that_expire_during_a_download_are_freed_when_it_lands(monkeypatch):
|
|
|
+ now = [1000.0]
|
|
|
+ monkeypatch.setattr(cache_mod.time, "monotonic", lambda: now[0])
|
|
|
+ cache = PrinterFileCache()
|
|
|
+ await cache.get(1, "/a", _Printer(b"x" * 10).fetch)
|
|
|
+ slow = _Printer(b"z" * 3)
|
|
|
+ slow.release.clear()
|
|
|
+
|
|
|
+ task = asyncio.create_task(cache.get(1, "/c", slow.fetch))
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ now[0] += cache_mod.CACHE_SECONDS + 1
|
|
|
+ slow.release.set()
|
|
|
+ await task
|
|
|
+
|
|
|
+ assert list(cache._entries) == [(1, "/c")]
|
|
|
+ assert cache._total == 3
|