Render result cache (doc_hash + page + DPI)
This commit is contained in:
@@ -26,6 +26,17 @@ class Settings(BaseSettings):
|
||||
"Phase 0 default is False — routes that need the engine return 501."
|
||||
),
|
||||
)
|
||||
|
||||
render_cache_enabled: bool = Field(default=True)
|
||||
render_cache_max_entries: int = Field(default=256)
|
||||
render_cache_max_bytes: int = Field(
|
||||
default=536_870_912,
|
||||
description="Max total bytes for the tile render cache. Set to 0 to disable byte cap."
|
||||
)
|
||||
render_cache_max_entry_bytes: int = Field(
|
||||
default=8_388_608,
|
||||
description="Max bytes for a single cache entry. Oversized tiles are rendered but not cached."
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.routers import documents, edits, health, info, render
|
||||
from app.routers import documents, edits, health, info, internal, render
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -29,6 +29,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(render.compat_router)
|
||||
app.include_router(edits.router)
|
||||
app.include_router(edits.compat_router)
|
||||
app.include_router(internal.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
||||
@@ -400,6 +400,9 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
|
||||
from app.services.render_cache import tile_cache
|
||||
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
|
||||
|
||||
return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions}
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.services.render_cache import tile_cache
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
@router.get("/cache/stats")
|
||||
def cache_stats():
|
||||
stats = tile_cache.stats()
|
||||
return {
|
||||
"hits": stats.hits,
|
||||
"misses": stats.misses,
|
||||
"evictions": stats.evictions,
|
||||
"current_entries": stats.current_entries,
|
||||
"current_bytes": stats.current_bytes,
|
||||
"max_bytes": stats.max_bytes,
|
||||
"hit_rate": stats.hit_rate,
|
||||
"avg_lookup_ns": stats.avg_lookup_ns,
|
||||
"avg_render_time_ns": stats.avg_render_time_ns,
|
||||
}
|
||||
@@ -1,19 +1,30 @@
|
||||
import hashlib
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response, status
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request, Response, status
|
||||
|
||||
from app.schemas.font import FontInfoResponse
|
||||
from app.services import engine
|
||||
from app.services.font import font_info_to_response
|
||||
from app.services.render_cache import RENDERER_VERSION, RenderMode, TileCacheKey, tile_cache
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||
compat_router = APIRouter(tags=["render"])
|
||||
|
||||
def _etag(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()[:16]
|
||||
|
||||
|
||||
@router.get("/{page_index}/render")
|
||||
def render_page(
|
||||
document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96
|
||||
request: Request,
|
||||
document_id: str,
|
||||
page_index: Annotated[int, Path(ge=0)],
|
||||
dpi: int = 96,
|
||||
zoom: float = 1.0,
|
||||
rotation: int = 0,
|
||||
render_mode: RenderMode = RenderMode.NORMAL
|
||||
) -> Response:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
@@ -28,8 +39,46 @@ def render_page(
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
|
||||
key = TileCacheKey(
|
||||
doc_hash=doc_info.get("doc_hash", ""),
|
||||
renderer_version=RENDERER_VERSION,
|
||||
page=page_index,
|
||||
dpi=dpi,
|
||||
zoom=zoom,
|
||||
rotation=rotation,
|
||||
render_mode=render_mode,
|
||||
tile_x=0.0,
|
||||
tile_y=0.0,
|
||||
tile_w=round(page.width, 2),
|
||||
tile_h=round(page.height, 2)
|
||||
)
|
||||
|
||||
cached = tile_cache.get(key)
|
||||
if cached:
|
||||
etag_val = f'"{_etag(cached)}"'
|
||||
if request.headers.get("if-none-match") == etag_val:
|
||||
return Response(status_code=304)
|
||||
return Response(
|
||||
content=cached,
|
||||
media_type="image/png",
|
||||
headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"}
|
||||
)
|
||||
|
||||
import time
|
||||
start = time.perf_counter_ns()
|
||||
img = page.render(dpi)
|
||||
return Response(content=img.data, media_type="image/png")
|
||||
elapsed = time.perf_counter_ns() - start
|
||||
|
||||
tile_cache.put(key, img.data)
|
||||
tile_cache.record_render_time(elapsed)
|
||||
|
||||
etag_val = f'"{_etag(img.data)}"'
|
||||
return Response(
|
||||
content=img.data,
|
||||
media_type="image/png",
|
||||
headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"}
|
||||
)
|
||||
except IndexError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
|
||||
@@ -40,13 +89,17 @@ def render_page(
|
||||
|
||||
@router.get("/{page_index}/render-tile")
|
||||
def render_page_tile(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
page_index: Annotated[int, Path(ge=0)],
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
dpi: int = 96
|
||||
dpi: int = 96,
|
||||
zoom: float = 1.0,
|
||||
rotation: int = 0,
|
||||
render_mode: RenderMode = RenderMode.NORMAL
|
||||
) -> Response:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
@@ -61,13 +114,52 @@ def render_page_tile(
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
|
||||
key = TileCacheKey(
|
||||
doc_hash=doc_info.get("doc_hash", ""),
|
||||
renderer_version=RENDERER_VERSION,
|
||||
page=page_index,
|
||||
dpi=dpi,
|
||||
zoom=zoom,
|
||||
rotation=rotation,
|
||||
render_mode=render_mode,
|
||||
tile_x=round(x, 2),
|
||||
tile_y=round(y, 2),
|
||||
tile_w=round(width, 2),
|
||||
tile_h=round(height, 2)
|
||||
)
|
||||
|
||||
cached = tile_cache.get(key)
|
||||
if cached:
|
||||
etag_val = f'"{_etag(cached)}"'
|
||||
if request.headers.get("if-none-match") == etag_val:
|
||||
return Response(status_code=304)
|
||||
return Response(
|
||||
content=cached,
|
||||
media_type="image/png",
|
||||
headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"}
|
||||
)
|
||||
|
||||
import time
|
||||
start = time.perf_counter_ns()
|
||||
img_width, img_height, img_data = page.render_tile(dpi, x, y, width, height)
|
||||
import io
|
||||
from PIL import Image
|
||||
img = Image.frombytes("RGBA", (img_width, img_height), img_data)
|
||||
out_buf = io.BytesIO()
|
||||
img.save(out_buf, format="PNG")
|
||||
return Response(content=out_buf.getvalue(), media_type="image/png")
|
||||
png_bytes = out_buf.getvalue()
|
||||
elapsed = time.perf_counter_ns() - start
|
||||
|
||||
tile_cache.put(key, png_bytes)
|
||||
tile_cache.record_render_time(elapsed)
|
||||
|
||||
etag_val = f'"{_etag(png_bytes)}"'
|
||||
return Response(
|
||||
content=png_bytes,
|
||||
media_type="image/png",
|
||||
headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"}
|
||||
)
|
||||
except IndexError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
|
||||
@@ -110,10 +202,10 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
|
||||
@compat_router.get("/render/{document_id}")
|
||||
def render_page_compat(
|
||||
document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
|
||||
request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
|
||||
) -> Response:
|
||||
dpi = int(96 * zoom)
|
||||
return render_page(document_id, page, dpi)
|
||||
return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL)
|
||||
|
||||
|
||||
@router.get("/{page_index}")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
RENDERER_VERSION: int = 1
|
||||
|
||||
|
||||
class RenderMode(str, Enum):
|
||||
# Current
|
||||
NORMAL = "normal"
|
||||
PRINT = "print"
|
||||
GRAYSCALE = "grayscale"
|
||||
|
||||
# Future (reserved)
|
||||
ANNOTATION_ONLY = "annotation_only"
|
||||
EDIT_PREVIEW = "edit_preview"
|
||||
SELECTION_OVERLAY = "selection_overlay"
|
||||
PROOF = "proof"
|
||||
HIGH_QUALITY = "high_quality"
|
||||
DRAFT = "draft"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileCacheKey:
|
||||
doc_hash: str
|
||||
renderer_version: int
|
||||
page: int
|
||||
dpi: int
|
||||
zoom: float
|
||||
rotation: int
|
||||
render_mode: str
|
||||
tile_x: float
|
||||
tile_y: float
|
||||
tile_w: float
|
||||
tile_h: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheStats:
|
||||
hits: int = 0
|
||||
misses: int = 0
|
||||
evictions: int = 0
|
||||
current_entries: int = 0
|
||||
current_bytes: int = 0
|
||||
max_bytes: int = 0
|
||||
hit_rate: float = 0.0
|
||||
avg_lookup_ns: float = 0.0
|
||||
avg_render_time_ns: float = 0.0
|
||||
|
||||
|
||||
class _Node:
|
||||
__slots__ = ["key", "value", "size", "prev", "next"]
|
||||
|
||||
def __init__(self, key: TileCacheKey | None, value: bytes | None, size: int):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.size = size
|
||||
self.prev: _Node | None = None
|
||||
self.next: _Node | None = None
|
||||
|
||||
|
||||
class TileCache:
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.enabled = settings.render_cache_enabled
|
||||
self.max_entries = settings.render_cache_max_entries
|
||||
self.max_bytes = settings.render_cache_max_bytes
|
||||
self.max_entry_bytes = settings.render_cache_max_entry_bytes
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._cache: dict[TileCacheKey, _Node] = {}
|
||||
self._head = _Node(None, None, 0)
|
||||
self._tail = _Node(None, None, 0)
|
||||
self._head.next = self._tail
|
||||
self._tail.prev = self._head
|
||||
|
||||
self._current_bytes = 0
|
||||
|
||||
# Stats
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._evictions = 0
|
||||
self._total_lookup_ns = 0
|
||||
self._total_render_time_ns = 0
|
||||
self._render_time_count = 0
|
||||
|
||||
def _remove(self, node: _Node):
|
||||
p = node.prev
|
||||
n = node.next
|
||||
if p and n:
|
||||
p.next = n
|
||||
n.prev = p
|
||||
|
||||
def _add_to_front(self, node: _Node):
|
||||
first = self._head.next
|
||||
if first:
|
||||
self._head.next = node
|
||||
node.prev = self._head
|
||||
node.next = first
|
||||
first.prev = node
|
||||
|
||||
def _evict(self):
|
||||
last = self._tail.prev
|
||||
if last and last != self._head:
|
||||
self._remove(last)
|
||||
if last.key:
|
||||
del self._cache[last.key]
|
||||
self._current_bytes -= last.size
|
||||
self._evictions += 1
|
||||
|
||||
def get(self, key: TileCacheKey) -> bytes | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
start_time = time.perf_counter_ns()
|
||||
with self._lock:
|
||||
node = self._cache.get(key)
|
||||
if node:
|
||||
self._hits += 1
|
||||
self._remove(node)
|
||||
self._add_to_front(node)
|
||||
res = node.value
|
||||
else:
|
||||
self._misses += 1
|
||||
res = None
|
||||
|
||||
lookup_time = time.perf_counter_ns() - start_time
|
||||
self._total_lookup_ns += lookup_time
|
||||
|
||||
return res
|
||||
|
||||
def put(self, key: TileCacheKey, data: bytes) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
size = len(data)
|
||||
if size > self.max_entry_bytes:
|
||||
# Too large to cache
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
node = self._cache[key]
|
||||
self._current_bytes -= node.size
|
||||
node.value = data
|
||||
node.size = size
|
||||
self._current_bytes += size
|
||||
self._remove(node)
|
||||
self._add_to_front(node)
|
||||
else:
|
||||
new_node = _Node(key, data, size)
|
||||
self._cache[key] = new_node
|
||||
self._add_to_front(new_node)
|
||||
self._current_bytes += size
|
||||
|
||||
# Evict if over limits
|
||||
while len(self._cache) > self.max_entries or (self.max_bytes > 0 and self._current_bytes > self.max_bytes):
|
||||
self._evict()
|
||||
|
||||
def invalidate_doc(self, doc_hash: str) -> None:
|
||||
with self._lock:
|
||||
keys_to_remove = [k for k in self._cache.keys() if k.doc_hash == doc_hash]
|
||||
for k in keys_to_remove:
|
||||
node = self._cache[k]
|
||||
self._remove(node)
|
||||
self._current_bytes -= node.size
|
||||
del self._cache[k]
|
||||
|
||||
def invalidate_renderer_version(self, old_version: int) -> None:
|
||||
with self._lock:
|
||||
keys_to_remove = [k for k in self._cache.keys() if k.renderer_version == old_version]
|
||||
for k in keys_to_remove:
|
||||
node = self._cache[k]
|
||||
self._remove(node)
|
||||
self._current_bytes -= node.size
|
||||
del self._cache[k]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._head.next = self._tail
|
||||
self._tail.prev = self._head
|
||||
self._current_bytes = 0
|
||||
|
||||
def stats(self) -> CacheStats:
|
||||
with self._lock:
|
||||
hit_rate = 0.0
|
||||
total_reqs = self._hits + self._misses
|
||||
if total_reqs > 0:
|
||||
hit_rate = self._hits / total_reqs
|
||||
|
||||
avg_lookup = 0.0
|
||||
if total_reqs > 0:
|
||||
avg_lookup = self._total_lookup_ns / total_reqs
|
||||
|
||||
avg_render = 0.0
|
||||
if self._render_time_count > 0:
|
||||
avg_render = self._total_render_time_ns / self._render_time_count
|
||||
|
||||
return CacheStats(
|
||||
hits=self._hits,
|
||||
misses=self._misses,
|
||||
evictions=self._evictions,
|
||||
current_entries=len(self._cache),
|
||||
current_bytes=self._current_bytes,
|
||||
max_bytes=self.max_bytes,
|
||||
hit_rate=hit_rate,
|
||||
avg_lookup_ns=avg_lookup,
|
||||
avg_render_time_ns=avg_render,
|
||||
)
|
||||
|
||||
def record_render_time(self, elapsed_ns: int):
|
||||
with self._lock:
|
||||
self._total_render_time_ns += elapsed_ns
|
||||
self._render_time_count += 1
|
||||
|
||||
|
||||
tile_cache = TileCache()
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
@@ -57,6 +58,7 @@ class DocumentStore:
|
||||
info = {
|
||||
"id": doc_id,
|
||||
"filename": filename,
|
||||
"doc_hash": hashlib.sha256(bytes_data).hexdigest(),
|
||||
"sizeBytes": len(bytes_data),
|
||||
"totalPages": doc_instance.page_count,
|
||||
"pageWidth": page_width,
|
||||
|
||||
Reference in New Issue
Block a user