Render result cache (doc_hash + page + DPI)

This commit is contained in:
saqib mir
2026-07-09 10:26:11 +05:30
parent 10775936de
commit f667db1f75
8 changed files with 379 additions and 9 deletions
+11
View File
@@ -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
View File
@@ -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():
+3
View File
@@ -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:
+20
View File
@@ -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,
}
+99 -7
View File
@@ -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}")
+222
View File
@@ -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()
+2
View File
@@ -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,