feat(files): cache served files and add HTML text viewer
Build and Push Docker Container / build-and-push (push) Successful in 2m37s
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
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from .validators import remaining_seconds
|
||||
|
||||
class FileMetadataCache:
|
||||
"""Cache-aside layer for convex.get_file, shared across workers via AsyncCache.
|
||||
|
||||
Only complete, non-expired records are cached. Misses and expired markers stay
|
||||
uncached so a newly uploaded or just-expired file is never masked.
|
||||
"""
|
||||
|
||||
def __init__(self, cache, ttl: int = 60, prefix: str = 'nanoshare:filemeta:'):
|
||||
self._cache = cache
|
||||
self._ttl = max(1, int(ttl))
|
||||
self._prefix = prefix
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
|
||||
def _key(self, file_id: str) -> str:
|
||||
return f'{self._prefix}{file_id}'
|
||||
|
||||
def _effective_ttl(self, file_data: dict) -> int:
|
||||
"""Never hold a record past the moment it expires."""
|
||||
remaining = remaining_seconds(file_data.get('expires_at'))
|
||||
if remaining is None:
|
||||
return self._ttl
|
||||
return min(self._ttl, remaining)
|
||||
|
||||
def _is_cacheable(self, file_data) -> bool:
|
||||
if not isinstance(file_data, dict) or not file_data:
|
||||
return False
|
||||
if file_data.get('expired'):
|
||||
return False
|
||||
return bool(file_data.get('db_image_url'))
|
||||
|
||||
async def get(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]) -> dict | None:
|
||||
cached = await self._cache.get(self._key(file_id))
|
||||
if isinstance(cached, dict) and cached:
|
||||
self.hits += 1
|
||||
return cached
|
||||
|
||||
self.misses += 1
|
||||
file_data = await loader()
|
||||
if self._is_cacheable(file_data):
|
||||
ttl = self._effective_ttl(file_data)
|
||||
if ttl > 0:
|
||||
await self._cache.set(self._key(file_id), file_data, ttl=ttl)
|
||||
return file_data
|
||||
|
||||
async def invalidate(self, file_id: str) -> None:
|
||||
await self._cache.delete(self._key(file_id))
|
||||
Reference in New Issue
Block a user