| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- """A short-lived, shared copy of printer files for the file browser's previews.
- 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 meant eleven full downloads, all opened at once over FTP. Requests
- for the same file now share one download, and its bytes serve the requests
- that follow for ``CACHE_SECONDS``.
- Kept in memory, bounded in total (least recently used goes first) and per file:
- a file too big to keep is still shared by the requests waiting on it. A failed
- download is never kept. Entries go when the file is deleted through Bambuddy
- and when the printer is deleted. A file replaced on the printer by other means
- can be served from the old copy for at most ``CACHE_SECONDS``.
- """
- from __future__ import annotations
- import asyncio
- import time
- from collections import OrderedDict
- from collections.abc import Awaitable, Callable
- # Long enough for the plate picker's burst of requests and a look at the
- # plates, short enough that a file replaced on the printer is soon re-read.
- CACHE_SECONDS = 60.0
- # Modest, for the 1-2 GB appliance: held only while someone is previewing.
- MAX_TOTAL_BYTES = 128 * 1024 * 1024
- MAX_ENTRY_BYTES = 64 * 1024 * 1024
- Key = tuple[int, str]
- class PrinterFileCache:
- def __init__(self) -> None:
- self._entries: OrderedDict[Key, tuple[float, bytes]] = OrderedDict()
- self._total = 0
- self._in_flight: dict[Key, asyncio.Task[bytes]] = {}
- # Bumped by forget(): a download that started before the file was
- # deleted must not put the old bytes back when it finishes.
- self._generation: dict[Key, int] = {}
- async def get(self, printer_id: int, path: str, fetch: Callable[[], Awaitable[bytes]]) -> bytes:
- """The file's bytes: kept, already downloading, or fetched now.
- ``fetch`` raises on failure; every request waiting on that download
- sees the same exception, and nothing is kept.
- """
- key = (printer_id, path)
- self._sweep()
- entry = self._entries.get(key)
- if entry is not None:
- self._entries.move_to_end(key)
- return entry[1]
- task = self._in_flight.get(key)
- if task is None:
- task = asyncio.ensure_future(self._fetch(key, fetch, self._generation.get(key, 0)))
- self._in_flight[key] = task
- # Shielded: a request that leaves (its client closed the page) must not
- # end the download the others are waiting on.
- return await asyncio.shield(task)
- async def _fetch(self, key: Key, fetch: Callable[[], Awaitable[bytes]], generation: int) -> bytes:
- # ``generation`` is read when the download is created, not when this
- # first runs: a forget() in between must still win.
- try:
- data = await fetch()
- finally:
- if self._in_flight.get(key) is asyncio.current_task():
- self._in_flight.pop(key, None)
- if self._generation.get(key, 0) == generation:
- self._keep(key, data)
- return data
- def _keep(self, key: Key, data: bytes) -> None:
- size = len(data)
- if size > MAX_ENTRY_BYTES or size > MAX_TOTAL_BYTES:
- return
- self._drop(key)
- self._sweep()
- while self._entries and self._total + size > MAX_TOTAL_BYTES:
- oldest = next(iter(self._entries))
- self._drop(oldest)
- self._entries[key] = (time.monotonic() + CACHE_SECONDS, data)
- self._total += size
- def _sweep(self) -> None:
- """Free every entry whose minute is up, not only the one asked for."""
- now = time.monotonic()
- for key in [key for key, (expires, _data) in self._entries.items() if expires <= now]:
- self._drop(key)
- def _drop(self, key: Key) -> None:
- entry = self._entries.pop(key, None)
- if entry is not None:
- self._total -= len(entry[1])
- def forget(self, printer_id: int, path: str) -> None:
- """Drop a file that changed on the printer (deleted through Bambuddy)."""
- self._forget_key((printer_id, path))
- def forget_printer(self, printer_id: int) -> None:
- """Drop everything kept for a deleted printer."""
- for key in {key for key in [*self._entries, *self._in_flight] if key[0] == printer_id}:
- self._forget_key(key)
- def clear(self) -> None:
- """Drop everything (tests)."""
- self._entries.clear()
- self._total = 0
- self._in_flight.clear()
- self._generation.clear()
- def _forget_key(self, key: Key) -> None:
- self._drop(key)
- # A download still running finishes for its waiters but is not kept,
- # and the next request starts a new one.
- self._in_flight.pop(key, None)
- self._generation[key] = self._generation.get(key, 0) + 1
- printer_file_cache = PrinterFileCache()
|