Build and Push Docker Container / build-and-push (push) Successful in 2m37s
File cache: - Add my_modules/filecache with metadata, blob, validator and store layers - Cache convex.get_file via AsyncCache, TTL clamped to the file expiry - Skip caching for missing, incomplete or already expired records - Add a process-local, size-bounded LRU for immutable storage blobs - Build the cache stack from FILE_CACHE_* env vars in build_file_cache - Attach the cache to the app in my_modules/app/setup.py - Expose app-bound helpers in filecache/access.py for routes Serving: - Send strong ETag, Last-Modified and Cache-Control on served files - Answer If-None-Match and If-Modified-Since with 304 from metadata only - Avoid pulling the blob from Convex on a revalidation hit - Add a short Cloudflare-CDN-Cache-Control edge window for deleted files - Record cache_status in the wide event on a 304 - Invalidate cached metadata after file update and delete in both routes Text viewer: - Add my_modules/textview to render text/plain and text/markdown as HTML - Linkify http(s) URLs and trim trailing sentence punctuation - Resolve links pointing at this instance to the referenced file - Inline image previews, mark missing and expired targets - Cap resolution at MAX_LOOKUPS backend lookups per document - Serve the viewer only for real browser navigations, never for curl or CLI - Keep raw bytes for ?raw=1, downloads and Accept headers without text/html - Add templates/side/views/webpage/files/text_view.htm with a raw link Other: - Add tests for cache layers, serving, text rendering and the route - Bump multidict to 7.2.1 in uv.lock - Update the my_helpers submodule pointer
71 lines
2.0 KiB
Python
71 lines
2.0 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import OrderedDict
|
|
|
|
class BlobCache:
|
|
"""Process-local, size-bounded LRU for file bytes.
|
|
|
|
Deliberately not AsyncCache: that layer JSON-encodes values (binary would need
|
|
base64) and its memory backend evicts only on read, so blobs could grow without
|
|
bound. Entries are immutable storage blobs, so a stale read is impossible.
|
|
"""
|
|
|
|
def __init__(self, max_total_bytes: int, max_item_bytes: int, ttl: int = 300):
|
|
self._max_total = max(0, int(max_total_bytes))
|
|
self._max_item = max(0, int(max_item_bytes))
|
|
self._ttl = max(1, int(ttl))
|
|
self._entries: OrderedDict[str, tuple[bytes, float]] = OrderedDict()
|
|
self._total = 0
|
|
self.hits = 0
|
|
self.misses = 0
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._max_total > 0 and self._max_item > 0
|
|
|
|
@property
|
|
def current_bytes(self) -> int:
|
|
return self._total
|
|
|
|
def _drop(self, key: str) -> None:
|
|
payload = self._entries.pop(key, None)
|
|
if payload is not None:
|
|
self._total -= len(payload[0])
|
|
|
|
def get(self, key: str, now: float | None = None) -> bytes | None:
|
|
entry = self._entries.get(key)
|
|
if entry is None:
|
|
self.misses += 1
|
|
return None
|
|
|
|
data, expires_at = entry
|
|
if (now if now is not None else time.monotonic()) >= expires_at:
|
|
self._drop(key)
|
|
self.misses += 1
|
|
return None
|
|
|
|
self._entries.move_to_end(key)
|
|
self.hits += 1
|
|
return data
|
|
|
|
def set(self, key: str, data: bytes, now: float | None = None) -> bool:
|
|
if not self.enabled or len(data) > self._max_item:
|
|
return False
|
|
|
|
self._drop(key)
|
|
self._entries[key] = (data, (now if now is not None else time.monotonic()) + self._ttl)
|
|
self._total += len(data)
|
|
|
|
while self._total > self._max_total and self._entries:
|
|
oldest = next(iter(self._entries))
|
|
self._drop(oldest)
|
|
return True
|
|
|
|
def invalidate(self, key: str) -> None:
|
|
self._drop(key)
|
|
|
|
def clear(self) -> None:
|
|
self._entries.clear()
|
|
self._total = 0
|