printer_file_cache.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """A short-lived, shared copy of printer files for the file browser's previews.
  2. The plate picker asks for the plate list and then one thumbnail per plate, and
  3. each request downloaded the whole 3MF from the printer again: ten plates of a
  4. 60 MB file meant eleven full downloads, all opened at once over FTP. Requests
  5. for the same file now share one download, and its bytes serve the requests
  6. that follow for ``CACHE_SECONDS``.
  7. Kept in memory, bounded in total (least recently used goes first) and per file:
  8. a file too big to keep is still shared by the requests waiting on it. A failed
  9. download is never kept. Entries go when the file is deleted through Bambuddy
  10. and when the printer is deleted. A file replaced on the printer by other means
  11. can be served from the old copy for at most ``CACHE_SECONDS``.
  12. """
  13. from __future__ import annotations
  14. import asyncio
  15. import time
  16. from collections import OrderedDict
  17. from collections.abc import Awaitable, Callable
  18. # Long enough for the plate picker's burst of requests and a look at the
  19. # plates, short enough that a file replaced on the printer is soon re-read.
  20. CACHE_SECONDS = 60.0
  21. # Modest, for the 1-2 GB appliance: held only while someone is previewing.
  22. MAX_TOTAL_BYTES = 128 * 1024 * 1024
  23. MAX_ENTRY_BYTES = 64 * 1024 * 1024
  24. Key = tuple[int, str]
  25. class PrinterFileCache:
  26. def __init__(self) -> None:
  27. self._entries: OrderedDict[Key, tuple[float, bytes]] = OrderedDict()
  28. self._total = 0
  29. self._in_flight: dict[Key, asyncio.Task[bytes]] = {}
  30. # Bumped by forget(): a download that started before the file was
  31. # deleted must not put the old bytes back when it finishes.
  32. self._generation: dict[Key, int] = {}
  33. async def get(self, printer_id: int, path: str, fetch: Callable[[], Awaitable[bytes]]) -> bytes:
  34. """The file's bytes: kept, already downloading, or fetched now.
  35. ``fetch`` raises on failure; every request waiting on that download
  36. sees the same exception, and nothing is kept.
  37. """
  38. key = (printer_id, path)
  39. self._sweep()
  40. entry = self._entries.get(key)
  41. if entry is not None:
  42. self._entries.move_to_end(key)
  43. return entry[1]
  44. task = self._in_flight.get(key)
  45. if task is None:
  46. task = asyncio.ensure_future(self._fetch(key, fetch, self._generation.get(key, 0)))
  47. self._in_flight[key] = task
  48. # Shielded: a request that leaves (its client closed the page) must not
  49. # end the download the others are waiting on.
  50. return await asyncio.shield(task)
  51. async def _fetch(self, key: Key, fetch: Callable[[], Awaitable[bytes]], generation: int) -> bytes:
  52. # ``generation`` is read when the download is created, not when this
  53. # first runs: a forget() in between must still win.
  54. try:
  55. data = await fetch()
  56. finally:
  57. if self._in_flight.get(key) is asyncio.current_task():
  58. self._in_flight.pop(key, None)
  59. if self._generation.get(key, 0) == generation:
  60. self._keep(key, data)
  61. return data
  62. def _keep(self, key: Key, data: bytes) -> None:
  63. size = len(data)
  64. if size > MAX_ENTRY_BYTES or size > MAX_TOTAL_BYTES:
  65. return
  66. self._drop(key)
  67. self._sweep()
  68. while self._entries and self._total + size > MAX_TOTAL_BYTES:
  69. oldest = next(iter(self._entries))
  70. self._drop(oldest)
  71. self._entries[key] = (time.monotonic() + CACHE_SECONDS, data)
  72. self._total += size
  73. def _sweep(self) -> None:
  74. """Free every entry whose minute is up, not only the one asked for."""
  75. now = time.monotonic()
  76. for key in [key for key, (expires, _data) in self._entries.items() if expires <= now]:
  77. self._drop(key)
  78. def _drop(self, key: Key) -> None:
  79. entry = self._entries.pop(key, None)
  80. if entry is not None:
  81. self._total -= len(entry[1])
  82. def forget(self, printer_id: int, path: str) -> None:
  83. """Drop a file that changed on the printer (deleted through Bambuddy)."""
  84. self._forget_key((printer_id, path))
  85. def forget_printer(self, printer_id: int) -> None:
  86. """Drop everything kept for a deleted printer."""
  87. for key in {key for key in [*self._entries, *self._in_flight] if key[0] == printer_id}:
  88. self._forget_key(key)
  89. def clear(self) -> None:
  90. """Drop everything (tests)."""
  91. self._entries.clear()
  92. self._total = 0
  93. self._in_flight.clear()
  94. self._generation.clear()
  95. def _forget_key(self, key: Key) -> None:
  96. self._drop(key)
  97. # A download still running finishes for its waiters but is not kept,
  98. # and the next request starts a new one.
  99. self._in_flight.pop(key, None)
  100. self._generation[key] = self._generation.get(key, 0) + 1
  101. printer_file_cache = PrinterFileCache()