The Python standalone render API normally starts the bundled Go executable for
each call. Applications that render repeatedly can instead own a fixed pool of
persistent renderer processes using RendererPool. The pool is started and
stopped by the application’s lifespan; no separate daemon or CLI setup is
required.
FastAPI lifespan integration #
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from pydantic import BaseModel
from markata import RendererPool
class Preview(BaseModel):
markdown: str
@asynccontextmanager
async def lifespan(app: FastAPI):
async with RendererPool(workers=2, max_pending=64, workspace='.') as pool:
app.state.renderer_pool = pool
yield
app = FastAPI(lifespan=lifespan)
@app.post("/preview")
async def preview(body: Preview, request: Request):
result = await request.app.state.renderer_pool.render(body.markdown)
return {"html": result.html, "features": result.features,
"assets": result.assets}
The pool starts all workers at startup, verifies the persistent protocol via a capability request, and maintains a queue of idle renderers. Each worker is one bundled Go process that handles sequential JSON-lines requests. Requests can run concurrently across workers. The Go protocol caches configured parser instances so subsequent requests avoid parser reinitialization too.
Set inline_assets=True to embed styles and scripts in the returned result
or serve result.assets separately, as with markata.render_async. Rendering
options unsafe, asset_base, and inline_assets can be supplied on individual
pool requests.
Lifecycle and limits #
workers=2starts two subprocesses per running application process. Withuvicorn --workers 4, that is eight Go subprocesses. Size the pool accordingly; it does not share processes across Uvicorn workers.max_pending=64limits the number of waiting requests in addition to the currently active worker slots. When saturated, callers receiveRenderErrorrather than growing an unbounded backlog.render(..., timeout=30)covers queue wait and rendering. A timed-out or cancelled in-flight request kills and replaces that worker so a late result cannot contaminate a later request.- Unexpected child exit or broken protocol also replaces the affected process. A failed replacement makes the pool report an error rather than hang forever.
- Exiting the lifespan terminates and reaps its child processes. Do not share the pool across Python event loops or fork a process after starting it.
- Requests retain the standalone renderer’s 200,000-byte source limit. The wire protocol has a 1 MiB request frame limit.
The pool requires a bundled Go executable supporting markata render --stdio.
The original render() and render_async() remain backward-compatible one-shot
APIs and do not implicitly start a pool.
An optional workspace='.' loads Markata’s site configuration once at startup,
matching the standalone Python renderer’s workspace behavior. Restart the pool
after changing configuration. This is not an HTML sanitizer; keep unsafe=False for
untrusted authored HTML and apply your application’s own safety policy.
Performance verification #
Measure median and p95 of warm pool calls versus render_async() on the same
machine, with at least 100 requests for each fixture. Also measure throughput
at concurrency 1, 2, and 4, resident memory per pool worker, initial startup
latency, and large Chart.js response transfer. The existing standalone Go
benchmarks are not end-to-end pool timings. Do not assume the same latency
until the Python-to-Go pipe and JSON costs are measured.