penpot/exporter
Andrey Antukh be3f7d39fd ♻️ Render as an injected service with validation at the edge
The renderer becomes a system service (:exporter/renderer) whose
instance is the render fn with its config closed over; the consumer
renders through that dependency and never touches the facade. render
takes the task map as is, and only render-fn validates, so a bad
batch is rejected at the single entry before anything is leased. The
worker names the renderer by ref, service keys follow the domain,
and main boots render workers on their own thread.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 21:51:53 +00:00
..
2026-09-29 09:07:34 +02:00
2026-10-06 15:12:15 +02:00
2026-10-06 15:12:15 +02:00
2020-08-05 11:19:34 +02:00

Exporter

Node process that renders shapes, frames and files to bitmap, SVG and PDF. It is a consumer: it polls the backend's :exporter job queue on Redis and drives each export through the backend's management API — claim, report the milestones, and settle (complete-job multipart with the artifact, or fail-job on error). There is no HTTP surface of its own and nothing is persisted on this side: the row of the job, its cancel button and its result all live in the backend.

How a job runs

  1. The backend dispatcher pushes [job-id, scheduled-at] (JSON) into penpot.worker.queue:<tenant>:exporter.
  2. One of the K pollers pops it and owns that connection for the whole run: the blocking pop is both the semaphore of the slot and the backpressure, so the process never runs more exports than pollers. K is PENPOT_EXPORTER_WORKER_CONCURRENCY (default 2, and the browser and wasm pools size themselves to the same figure).
  3. The poller claims the job over the management API and, on :run, mints a render session (create-job-session) through which every asset fetch happens as the owner of the job.
  4. The milestones are reported as beats (:preparing, :rendering, :packaging, with the objects and pages counters). A watchdog re-sends the last one every second, whatever the run is doing.
  5. It settles with complete-job (the artifact rides multipart, the session is closed in the same step) or fail-job. The settle never rejects: a worker that cannot talk leaves the row to the backend's lease GC.

Cancellation

A cancellation reaches the worker through the beats: a skip answer means the backend no longer listens (the row is terminal), so the runner stops between units of work; mid-Skia the watchdog terminates the leased render worker. The row was already cancelled by the backend's cancel-job.

Configuration

Variable Default Description
PENPOT_PUBLIC_URI — The frontend the assets render through
PENPOT_INTERNAL_URI public-uri Same network's frontend; the management API too
PENPOT_REDIS_URI redis://redis/0 The queue and the rest of redis
PENPOT_TENANT default Queue prefix (workspace name in devenv)
PENPOT_EXPORTER_SHARED_KEY — The shared key; derived from the secret otherwise
PENPOT_EXPORTER_JOB_TTL 3600 Lifetime of the temp files a job owns, in seconds
PENPOT_EXPORTER_WORKER_CONCURRENCY 2 K: pollers, running exports, pool sizes
PENPOT_WASM_WORKER_POOL_MIN 1 Render workers kept warm; clamped to the max
PENPOT_WASM_WORKER_IDLE_TIMEOUT 300 Silence before a worker is terminated, in seconds
PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE 134217728 Per-worker image cache budget, in bytes

Inspecting the backend

Nothing about a job is stored here: get-job in the backend answers the full row, and its app.debug/admin surfaces render the same records.