Files
simple-nanoshare/my_modules/filecache/blobs.py
T
daniel156161 3db374b1ab
Build and Push Docker Container / build-and-push (push) Successful in 2m37s
feat(files): cache served files and add HTML text viewer
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
2026-10-25 18:54:46 +01:00

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