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
63 lines
2.1 KiB
Python
63 lines
2.1 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from .blobs import BlobCache
|
|
from .metadata import FileMetadataCache
|
|
|
|
def _env_int(name: str, default: int) -> int:
|
|
try:
|
|
return int(os.getenv(name, str(default)))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
class FileCache:
|
|
"""Facade over the metadata cache (shared) and the blob cache (process-local)."""
|
|
|
|
def __init__(self, metadata: FileMetadataCache, blobs: BlobCache, blob_max_age: int, edge_max_age: int):
|
|
self.metadata = metadata
|
|
self.blobs = blobs
|
|
self.blob_max_age = blob_max_age
|
|
self.edge_max_age = edge_max_age
|
|
|
|
async def get_metadata(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]):
|
|
return await self.metadata.get(file_id, loader)
|
|
|
|
async def get_blob(self, storage_key: str, loader: Callable[[], Awaitable[bytes]]) -> bytes:
|
|
cached = self.blobs.get(storage_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
data = await loader()
|
|
self.blobs.set(storage_key, data)
|
|
return data
|
|
|
|
async def invalidate(self, file_id: str, storage_key: str | None = None) -> None:
|
|
await self.metadata.invalidate(file_id)
|
|
if storage_key:
|
|
self.blobs.invalidate(storage_key)
|
|
|
|
def stats(self) -> dict:
|
|
return {
|
|
'metadata_hits': self.metadata.hits,
|
|
'metadata_misses': self.metadata.misses,
|
|
'blob_hits': self.blobs.hits,
|
|
'blob_misses': self.blobs.misses,
|
|
'blob_bytes': self.blobs.current_bytes,
|
|
}
|
|
|
|
def build_file_cache(cache) -> FileCache:
|
|
"""Build the cache stack from FILE_CACHE_* environment settings."""
|
|
return FileCache(
|
|
metadata=FileMetadataCache(cache, ttl=_env_int('FILE_CACHE_METADATA_TTL', 60)),
|
|
blobs=BlobCache(
|
|
max_total_bytes=_env_int('FILE_CACHE_BLOB_TOTAL_BYTES', 64 * 1024 * 1024),
|
|
max_item_bytes=_env_int('FILE_CACHE_BLOB_ITEM_BYTES', 2 * 1024 * 1024),
|
|
ttl=_env_int('FILE_CACHE_BLOB_TTL', 300),
|
|
),
|
|
blob_max_age=_env_int('FILE_CACHE_BROWSER_MAX_AGE', 86400),
|
|
# Cloudflare keeps serving deleted files, so the edge window stays short.
|
|
edge_max_age=_env_int('FILE_CACHE_EDGE_MAX_AGE', 1800),
|
|
)
|