🎉 Implement export jobs to process export requests (#11296)

*  Add export job model, store and scheduler to exporter

*  Render wasm exports on pooled worker threads

*  Add export job REST API to exporter

*  Use export job API and allow cancelling wasm exports

* 🔧 Show export jobs in the internal debug panel

* 🔧 Pass flags and export job settings to the exporter container

* 📚 Document the exporter job API and its redis layout
This commit is contained in:
Elena Torró 2026-08-31 14:42:51 +02:00 committed by GitHub
parent ac5c88be66
commit 66b4a99ac3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2934 additions and 706 deletions

View File

@ -296,5 +296,60 @@ Debug Main Page
</form>
</fieldset>
</section>
</main>
<main class="dashboard wide">
<section class="widget wide">
<fieldset>
<legend>Export jobs:</legend>
<desc>
Export jobs as the exporter left them in redis. Records expire an hour
after the export settles, so this is a live view, not a history.
</desc>
<form method="get" action="/dbg">
<div class="row">
<input type="text" style="width:300px" name="job-id"
placeholder="filter by job id" value="{{export-job-filter}}" />
<input type="submit" value="Filter" />
<a href="/dbg">clear</a>
</div>
</form>
<div class="scroll-box">
<table>
<thead>
<tr>
<th>JOB ID</th>
<th>STATE</th>
<th>PROGRESS</th>
<th>CMD</th>
<th>BACKEND</th>
<th>NAME</th>
<th>CREATED</th>
<th>ENDED</th>
</tr>
</thead>
<tbody>
{% for job in export-jobs %}
<tr>
<td><tt>{{job.id}}</tt></td>
<td>{{job.state}}{% if job.interrupted %} (interrupted){% endif %}</td>
<td>{{job.done}} / {{job.total}}</td>
<td>{{job.cmd}}</td>
<td>{{job.backend}}</td>
<td>{{job.name}}</td>
<td>{{job.created-at}}</td>
<td>{{job.ended-at}}</td>
</tr>
{% empty %}
<tr><td colspan="8">No export jobs.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</fieldset>
</section>
</main>
{% endblock %}

View File

@ -143,6 +143,35 @@ nav > div:not(:last-child) {
height: fit-content;
}
/* A widget that holds a table rather than a form: full width, and tall
enough to be worth scrolling inside. */
.dashboard.wide {
margin-top: 0px;
}
.widget.wide {
max-width: none;
width: 100%;
}
.widget.wide .scroll-box {
max-height: 320px;
overflow-y: auto;
margin-top: 10px;
}
.widget.wide table {
width: 100%;
border-collapse: collapse;
}
.widget.wide th {
text-align: left;
position: sticky;
top: 0;
background: white;
}
.widget input[type=submit] {
outline: none;
border: 1px solid gray;

View File

@ -26,6 +26,7 @@
[app.db :as db]
[app.features.file-migrations :as feat.fmig]
[app.http.session :as session]
[app.redis :as rds]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files-create :refer [create-file]]
[app.rpc.commands.profile :as profile]
@ -53,11 +54,53 @@
;; INDEX
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private max-export-jobs 200)
(defn- scan-export-job-keys
"Note: no index for now, get them all and filter"
[conn pattern]
(loop [cursor "0"
found []]
(let [[cursor keys] (rds/scan conn cursor pattern max-export-jobs)
found (into found keys)]
(if (or (nil? cursor)
(= "0" cursor)
(>= (count found) max-export-jobs))
(into [] (take max-export-jobs) found)
(recur cursor found)))))
(defn- get-export-jobs
[cfg job-id]
(let [filtered? (not (str/empty-or-nil? job-id))
job-uuid (when filtered? (parse-uuid job-id))]
(if (and filtered? (nil? job-uuid))
[]
(try
(let [pattern (str "penpot.exporter." (cf/get :tenant) ".job." (or job-uuid "*"))]
(->> (rds/run! cfg (fn [{:keys [::rds/conn]}]
(->> (scan-export-job-keys conn pattern)
(mapv (fn [key] (rds/hget conn key "data"))))))
(keep (fn [blob]
(try
(t/decode-str blob)
(catch Throwable _ nil))))
(sort-by :created-at #(compare %2 %1))
;; The exporter stores instants as epoch millis.
(map (fn [{:keys [created-at ended-at] :as job}]
(-> job
(assoc :created-at (some-> created-at ct/inst (ct/format-inst :rfc1123)))
(assoc :ended-at (some-> ended-at ct/inst (ct/format-inst :rfc1123))))))
(vec)))
(catch Throwable cause
(l/warn :hint "unable to read export jobs" :cause cause)
[])))))
(defn index-handler
[cfg request]
(let [profile-id (::session/profile-id request)
offset (clock/get-offset profile-id)
profile (profile/get-profile cfg profile-id)]
profile (profile/get-profile cfg profile-id)
job-filter (some-> request :params :job-id str/trim)]
{::yres/status 200
::yres/headers {"content-type" "text/html"}
::yres/body (-> (io/resource "app/templates/debug.tmpl")
@ -69,6 +112,8 @@
(ct/format-duration offset)
"NO OFFSET")
:current-time (ct/format-inst (ct/now) :http)
:export-jobs (get-export-jobs cfg job-filter)
:export-job-filter job-filter
:supported-features cfeat/supported-features}))}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -288,6 +288,7 @@
::http.debug/routes
{::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::session/manager (ig/ref ::session/manager)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::sto/storage (ig/ref ::sto/storage)

View File

@ -29,6 +29,7 @@
io.lettuce.core.api.sync.RedisScriptingCommands
io.lettuce.core.codec.RedisCodec
io.lettuce.core.codec.StringCodec
io.lettuce.core.KeyScanCursor
io.lettuce.core.KeyValue
io.lettuce.core.pubsub.api.sync.RedisPubSubCommands
io.lettuce.core.pubsub.RedisPubSubListener
@ -40,6 +41,8 @@
io.lettuce.core.RedisURI
io.lettuce.core.resource.ClientResources
io.lettuce.core.resource.DefaultClientResources
io.lettuce.core.ScanArgs
io.lettuce.core.ScanCursor
io.lettuce.core.ScriptOutputType
io.lettuce.core.SetArgs
io.netty.channel.nio.NioEventLoopGroup
@ -71,6 +74,8 @@
(-blpop [_ timeout keys])
(-eval [_ script])
(-get [_ key])
(-scan [_ cursor pattern limit])
(-hget [_ key field])
(-set [_ key val args])
(-del [_ key-or-keys])
(-ping [_]))
@ -205,6 +210,20 @@
(assert (string? key) "key expected to be string")
(.get cmd ^String key))
(-scan [_ cursor pattern limit]
(let [args (-> (ScanArgs.)
(.match ^String pattern)
(.limit (long limit)))
result (.scan cmd
^ScanCursor (ScanCursor/of ^String cursor)
^ScanArgs args)]
(MapEntry/create
(.getCursor ^KeyScanCursor result)
(vec (.getKeys ^KeyScanCursor result)))))
(-hget [_ key field]
(.hget cmd ^String key ^String field))
(-set [_ key val args]
(.set cmd
^String key
@ -345,6 +364,26 @@
(l/err :hint "timeout on get redis key" :key key :cause cause)
nil)))
(defn scan
[conn cursor pattern limit]
(assert (string? cursor) "cursor must be string instance")
(assert (string? pattern) "pattern must be string instance")
(try
(-scan conn cursor pattern limit)
(catch RedisCommandTimeoutException cause
(l/err :hint "timeout on scan" :pattern pattern :cause cause)
nil)))
(defn hget
[conn key field]
(assert (string? key) "key must be string instance")
(assert (string? field) "field must be string instance")
(try
(-hget conn key field)
(catch RedisCommandTimeoutException cause
(l/err :hint "timeout on hget" :key key :cause cause)
nil)))
(defn set
([conn key val]
(set conn key val nil))

View File

@ -197,14 +197,24 @@ services:
- penpot
environment:
<< : [*penpot-secret-key, *penpot-public-uri]
<< : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
## Valkey (or previously Redis) is used for the websockets notifications.
## Valkey (or previously Redis) is used for the websockets notifications
## and for storing the state export jobs
PENPOT_REDIS_URI: redis://penpot-valkey/0
# PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4
# PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2
# PENPOT_EXPORTER_QUEUE_MAX: 64
# PENPOT_EXPORTER_JOB_TTL: 3600
# PENPOT_WASM_WORKER_POOL_MAX: 2
# PENPOT_WASM_WORKER_POOL_MIN: 1
# PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300
# PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728
penpot-postgres:
image: "postgres:15"
restart: always

99
exporter/README.md Normal file
View File

@ -0,0 +1,99 @@
# Exporter
Node service that renders shapes and files to bitmap, SVG and PDF. Wasm exports
are **jobs**: created over HTTP, admitted by a scheduler with bounded
concurrency, and persisted in Redis so their state can be queried and cancelled.
The legacy entry point, which is what the browser backend still goes through,
runs the export as soon as it is asked for, with no admission control.
## HTTP API
Mounted under `/api/export` (the router matches on the path *after* that prefix,
so it also works when the process is hit directly on `/`).
| Method | Path | Description |
|----------|-----------------|----------------------------------------------------|
| `POST` | `/` | Legacy command multiplex; runs unscheduled |
| `POST` | `/jobs` | Create an export job |
| `GET` | `/jobs/{id}` | Job record |
| `DELETE` | `/jobs/{id}` | Request cancellation |
Job states: `queued` -> `running` -> `ended` | `error` | `cancelled`. The last
three are terminal.
## Redis layout
Every key is namespaced with `penpot.exporter.` plus the tenant
(`PENPOT_TENANT`, `default` in code but set to the workspace name in devenv,
e.g. `devenv-ws0`).
```
penpot.exporter.{tenant}.job.{job-id} hash field: data (transit blob of the
whole record)
penpot.exporter.{tenant}.job-cancel pubsub payload: the job id, one line
```
There is no index: the keyspace is one self-expiring hash per job and nothing
else. Each hash carries the same TTL as the exported file
(`PENPOT_EXPORTER_JOB_TTL`, default 3600s), refreshed on every write and never
after the job settles.
## Inspecting Redis
Redis is not published on the host, so `redis-cli` from your machine gets
connection refused. Run it **inside the devenv container**, against the `valkey`
host on database 0:
```bash
redis-cli -h valkey -n 0
```
`redis-cli -u "$PENPOT_REDIS_URI"` does the same and follows whatever the env is
set to (`redis://valkey/0` in devenv).
Keys carry the tenant, which in devenv is the **workspace name**
(`$PENPOT_TENANT`, e.g. `devenv-ws0`), not `default`. From the prompt:
```
# every job record
KEYS penpot.exporter.devenv-ws0.job.*
# the whole record, transit-json in the `data` field
HGET penpot.exporter.devenv-ws0.job.<job-id> data
# seconds left before the record expires
TTL penpot.exporter.devenv-ws0.job.<job-id>
# watch cancellations as they are published (blocks the connection)
SUBSCRIBE penpot.exporter.devenv-ws0.job-cancel
# drop one record
DEL penpot.exporter.devenv-ws0.job.<job-id>
```
`KEYS` is fine here -- the keyspace is a handful of job hashes. On a real
deployment use `SCAN 0 MATCH penpot.exporter.<tenant>.job.* COUNT 100` instead.
Do not `FLUSHDB`: the backend shares this database.
The backend debug UI also renders these records: `/dbg` has an *Export jobs*
section, with a `?job-id=` filter.
## Configuration
| Variable | Default | Description |
|---------------------------------------|---------|--------------------------------------|
| `PENPOT_REDIS_URI` | `redis://redis/0` | Job store and cancel topic |
| `PENPOT_TENANT` | `default` | Key and topic prefix |
| `PENPOT_EXPORTER_JOB_TTL` | `3600` | Lifetime of a job record, in seconds |
| `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit |
| `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit |
| `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` |
| `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 |
| `PENPOT_WASM_WORKER_POOL_MIN` | `1` | 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 |
A headless job leases one render worker for its whole run, so it is admitted
only when a worker is free: `PENPOT_WASM_WORKER_POOL_MAX` is the real limit for
them, and `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` bounds the browser ones
alongside.

View File

@ -39,7 +39,7 @@
"check-fmt:clj": "cljfmt check --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel --lint src/ test/",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",
"test": "pnpm run build:test && PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY:-test-secret-key} node target/tests/test.js",
"test:quiet": "node ./scripts/test-quiet.js"
}
}

View File

@ -0,0 +1,98 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
The export commands take `:profile-id` from the request body, which was
harmless while it only picked a pub/sub topic. The job API can read and
cancel other people's work, so its ownership comes from the session: the
token goes to the backend's `get-profile` command, which answers with the
anonymous profile (`uuid/zero`) when it is not a valid session.
Results are memoized briefly, so a burst of export calls from one client is
one round trip rather than one per request."
(:require
["undici" :as http]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[promesa.core :as p]))
(def ^:private cache-ttl-ms 60000)
(defonce ^:private cache (atom {}))
(defn- put-in-cache
"Stores the resolution by session token and removes expired entries.
Without cleanup, long-lived exporters accumulate stale entries"
[cache token profile-id now]
(-> (into {} (remove (fn [[_ {:keys [expires-at]}]] (<= expires-at now))) cache)
(assoc token {:profile-id profile-id
:expires-at (+ now cache-ttl-ms)})))
(defn- rpc-uri
[]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join "api/rpc/command/get-profile")
(str)))
(defn- fetch-profile-id
[token]
(let [uri (rpc-uri)
headers #js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)}]
(->> (p/do (http/fetch uri #js {:method "POST" :headers headers :body (t/encode-str {})}))
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [body]
(some-> body t/decode-str :id)))
(p/merr (fn [cause]
(l/warn :hint "unable to resolve session profile" :uri uri :cause cause)
(p/resolved nil))))))
(defn resolve-profile-id
"Promise of the authenticated profile id, or nil for an anonymous or absent
session."
[token]
(if (nil? token)
(p/resolved nil)
(let [{:keys [profile-id expires-at]} (get @cache token)]
(if (and expires-at (> expires-at (js/Date.now)))
(p/resolved profile-id)
(->> (fetch-profile-id token)
(p/fmap (fn [profile-id]
(let [profile-id (when (and profile-id (not= uuid/zero profile-id)) profile-id)]
(swap! cache put-in-cache token profile-id (js/Date.now))
profile-id))))))))
(defn require-profile-id
"Like `resolve-profile-id`, but rejects anonymous callers."
[token]
(->> (resolve-profile-id token)
(p/mcat (fn [profile-id]
(if profile-id
(p/resolved profile-id)
(ex/raise :type :authentication
:code :authentication-required
:hint "no valid session for this request"))))))
(defn check-owner!
"Raises unless `profile-id` owns `job`."
[job profile-id]
(when (or (nil? job)
(not= (str (:profile-id job)) (str profile-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "job does not exist"))
job)

View File

@ -28,7 +28,15 @@
:http-server-port 6061
:http-server-host "0.0.0.0"
:tempdir "/tmp/penpot"
:redis-uri "redis://redis/0"})
:redis-uri "redis://redis/0"
:exporter-max-concurrent-jobs 4
:exporter-max-jobs-per-profile 2
:exporter-queue-max 64
:exporter-job-ttl 3600
:wasm-worker-pool-max 2
:wasm-worker-pool-min 1
:wasm-worker-idle-timeout 300
:wasm-worker-image-cache-size (* 128 1024 1024)})
(def ^:private schema:config
[:map {:title "config"}
@ -42,7 +50,15 @@
[:redis-uri {:optional true} :string]
[:tempdir {:optional true} :string]
[:browser-pool-max {:optional true} ::sm/int]
[:browser-pool-min {:optional true} ::sm/int]])
[:browser-pool-min {:optional true} ::sm/int]
[:exporter-max-concurrent-jobs {:optional true} ::sm/int]
[:exporter-max-jobs-per-profile {:optional true} ::sm/int]
[:exporter-queue-max {:optional true} ::sm/int]
[:exporter-job-ttl {:optional true} ::sm/int]
[:wasm-worker-pool-max {:optional true} ::sm/int]
[:wasm-worker-pool-min {:optional true} ::sm/int]
[:wasm-worker-idle-timeout {:optional true} ::sm/int]
[:wasm-worker-image-cache-size {:optional true} ::sm/int]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -7,51 +7,88 @@
(ns app.core
(:require
["node:process" :as proc]
["node:worker_threads" :as wt]
[app.browser :as bwr]
[app.common.logging :as l]
[app.config :as cf]
[app.http :as http]
[app.jobs :as jobs]
[app.jobs.utils :as job.utils]
[app.redis :as redis]
[app.wasm :as wasm]
[app.wasm.pool :as wasm.pool]
[app.wasm.worker :as wasm.worker]
[promesa.core :as p]))
(enable-console-print!)
(l/setup! {:app :info})
(defn start
"Render workers run this same bundle, so the thread decides what gets booted:
the http server and its pools, or one render worker."
[& _]
(l/info :msg "initializing"
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(when (contains? cf/flags :wasm-export)
(l/warn :msg "headless wasm export enabled (experimental)"
:hint (str "renders run in-process on a single shared wasm module, "
"one at a time; not recommended for busy instances")
:wasm-dir wasm/artifact-dir
:image-cache-mb wasm/image-cache-mb))
(p/do!
(bwr/init)
(redis/init)
(http/init)))
(if-not ^boolean wt/isMainThread
(wasm.worker/main)
(do
(l/info :msg "initializing"
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(when (contains? cf/flags :wasm-export)
(l/info :msg "headless wasm export enabled (experimental)"
:wasm-dir wasm/artifact-dir
:workers (cf/get :wasm-worker-pool-max)
:image-cache-size (cf/get :wasm-worker-image-cache-size)))
(p/do
(bwr/init)
(redis/init)
(jobs/init)
(job.utils/init)
(wasm.pool/init)
(http/init)))))
(def main start)
;; Draining a pool waits for every checked-out resource to come back, which an
;; export in flight can hold for as long as its own timeout. On a hot reload
;; that would block `start` from ever running again, leaving a drained pool that
;; fails every later job.
(def ^:private shutdown-step-timeout 3000)
(defn- shutdown-step
[label f]
(-> (p/race [(p/do (f))
(p/fmap (constantly ::timeout) (p/delay shutdown-step-timeout))])
(p/handle (fn [result cause]
(when (or (some? cause) (= ::timeout result))
(l/warn :hint "shutdown step did not finish cleanly"
:step label
:cause cause))
nil))))
(defn stop
[done]
;; an empty line for visual feedback of restart
(js/console.log "")
(l/info :msg "stopping")
(p/do!
(bwr/stop)
(redis/stop)
(http/stop)
(done)))
(if-not ^boolean wt/isMainThread
;; A render worker owns no server, pools or connections; nothing to unwind.
(done)
(do
(l/info :msg "stopping")
(p/do
(shutdown-step "browser-pool" bwr/stop)
(shutdown-step "wasm-worker-pool" wasm.pool/stop)
(shutdown-step "redis" redis/stop)
(shutdown-step "http" http/stop)
(done)))))
(.on proc/default "uncaughtException"
(fn [cause]
(js/console.error cause)))
(.on proc/default "SIGTERM" (fn [] (proc/exit 0)))
(.on proc/default "SIGINT" (fn [] (proc/exit 0)))
;; Signals are only delivered to the main thread, and `exit` in a worker would
;; take down that worker rather than the process.
(when ^boolean wt/isMainThread
(.on proc/default "SIGTERM" (fn [] (proc/exit 0)))
(.on proc/default "SIGINT" (fn [] (proc/exit 0))))

View File

@ -6,22 +6,44 @@
(ns app.handlers
(:require
[app.auth :as auth]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.spec :as us]
[app.handlers.export-frames :as export-frames]
[app.handlers.export-shapes :as export-shapes]
[app.handlers.export :as export]
[app.util.transit :as t]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]))
[promesa.core :as p]))
(l/set-level! :debug)
(def ^:private error-codes
#{:queue-full})
(defn on-error
[error exchange]
(let [{:keys [type code] :as data} (ex-data error)]
(cond
(and (= :validation type)
(contains? error-codes code))
(let [data {:type :validation
:code code
:hint (ex-message error)}]
(l/warn :hint "rejecting export request" :code code)
(-> exchange
(assoc :response/status 429)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"})))
(= :authentication type)
(let [data {:type :authentication
:code code
:hint (ex-message error)}]
(-> exchange
(assoc :response/status 401)
(assoc :response/body (t/encode data))
(assoc :response/headers {"content-type" "application/transit+json"})))
(or (= :validation type)
(= :assertion type))
(let [explain (us/pretty-explain data)
@ -62,27 +84,27 @@
(assoc :response/body (t/encode (d/without-nils data)))
(assoc :response/headers {"content-type" "application/transit+json"}))))))
(defmulti command-spec :cmd)
(s/def ::id ::us/string)
(s/def ::wait ::us/boolean)
(s/def ::cmd ::us/keyword)
(defmethod command-spec :export-shapes [_] ::export-shapes/params)
(defmethod command-spec :export-frames [_] ::export-frames/params)
(s/def ::params
(s/and (s/keys :req-un [::cmd]
:opt-un [::wait])
(s/multi-spec command-spec :cmd)))
(defn handler
[{:keys [:request/params] :as exchange}]
(let [{:keys [cmd] :as params} (us/conform ::params params)]
"The original `POST /api/export` entry point, and the one the browser backend
still goes through. The export runs as soon as it is asked for, and the
contract is unchanged: `:wait` answers with the finished resource, otherwise
with the resource handle while the work runs."
[{:keys [:request/params :request/auth-token] :as exchange}]
(let [{:keys [cmd wait] :as params} (export/conform-params params)]
(l/debug :hint "process-request" :cmd cmd)
(case cmd
:export-shapes (export-shapes/handler exchange params)
:export-frames (export-frames/handler exchange params)
(ex/raise :type :internal
:code :method-not-implemented
:hint (str/istr "method ~{cmd} not implemented")))))
(->> (auth/resolve-profile-id auth-token)
(p/mcat (fn [profile-id]
;; The session wins when there is one; the body value stays
;; the fallback so nothing that used to work stops working.
(export/export! auth-token (cond-> params
(some? profile-id)
(assoc :profile-id profile-id)))))
(p/mcat (fn [{:keys [resource pending]}]
(if wait
(p/fmap (fn [resource]
(assoc exchange :response/body resource))
pending)
(do
(p/merr (constantly nil) pending)
(p/resolved
(assoc exchange :response/body (dissoc resource :path))))))))))

View File

@ -0,0 +1,103 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.export
"Handle export jobs"
(:require
[app.common.spec :as us]
[app.handlers.export-frames :as export-frames]
[app.handlers.export-shapes :as export-shapes]
[app.jobs :as jobs]
[app.jobs.scheduler :as scheduler]
[app.jobs.utils :as job.utils]
[clojure.spec.alpha :as s]
[promesa.core :as p]))
;; --- PARAMS
(defmulti command-spec :cmd)
(s/def ::cmd ::us/keyword)
(s/def ::wait ::us/boolean)
(defmethod command-spec :export-shapes [_] ::export-shapes/params)
(defmethod command-spec :export-frames [_] ::export-frames/params)
(s/def ::params
(s/and (s/keys :req-un [::cmd]
:opt-un [::wait])
(s/multi-spec command-spec :cmd)))
(defn conform-params
[params]
(us/conform ::params params))
(defn- prepare
[cmd auth-token params]
(case cmd
:export-shapes (export-shapes/prepare auth-token params)
:export-frames (export-frames/prepare auth-token params)))
(defn- current
[job]
(or (jobs/lookup (:id job)) job))
(defn- run-and-track
[job run]
(->> (p/do (run job))
(p/mcat (fn [resource]
(->> (jobs/complete! (current job) resource)
(p/fmap (constantly resource)))))
(p/merr (fn [cause]
(if (jobs/cancelled? (:id job))
(p/rejected cause)
(->> (jobs/fail! (current job) cause)
(p/mcat (fn [_] (p/rejected cause)))))))))
(defn- run-now!
"Runs the job as soon as it is created, outside the scheduler."
[job]
(->> (p/do (jobs/start! job))
(p/mcat (fn [job] (p/do ((jobs/run-fn (:id job)) job))))
(p/fnly (fn [_ _]
(jobs/release! (:id job))
(job.utils/release! (:id job))))))
(defn- create!
[auth-token {:keys [cmd profile-id] :as params} start]
(let [{:keys [resource total headless run]} (prepare cmd auth-token params)]
(->> (jobs/create! {:profile-id profile-id
:cmd cmd
;; What the renderer will actually do, not what the
;; client asked for: `is-wasm` alone still renders in
;; the browser without the `wasm-export` flag, or for
;; svg, and the backend decides both the admission cap
;; and whether the client offers to cancel.
:backend (if headless "wasm" "browser")
:total total
:name (:name resource)
:resource-id (:id resource)}
(fn [job] (run-and-track job run)))
(p/fmap (fn [job]
(try
{:job job
:resource resource
:pending (start job)}
(catch :default cause
(jobs/fail! job cause)
(jobs/release! (:id job))
(throw cause))))))))
(defn create-job!
"Returns a promise of `{:job :resource :pending}`."
[auth-token params]
(create! auth-token params scheduler/submit!))
(defn export!
"Like `create-job!`, but the work starts right away: This is what
keeps browser exports behaving exactly as they did before there were jobs."
[auth-token params]
(create! auth-token params run-now!))

View File

@ -6,19 +6,17 @@
(ns app.handlers.export-frames
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.handlers.export-shapes :refer [prepare-exports]]
[app.handlers.export-shapes :refer [count-objects headless-exports? prepare-exports]]
[app.handlers.resources :as rsc]
[app.redis :as redis]
[app.jobs :as jobs]
[app.jobs.utils :as job.utils]
[app.renderer :as rd]
[app.util.shell :as sh]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[promesa.core :as p]))
(declare ^:private handle-export)
(declare ^:private create-pdf)
(declare ^:private join-pdf)
(declare ^:private move-file)
@ -38,85 +36,53 @@
(s/keys :req-un [::exports]
:opt-un [::name ::is-wasm]))
(defn handler
[{:keys [:request/auth-token] :as exchange} {:keys [exports] :as params}]
;; NOTE: we need to have the `:type` prop because the exports
;; datastructure preparation uses it for creating the groups.
(let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports)
(prepare-exports auth-token))]
(handle-export exchange (assoc params :exports exports))))
(defn handle-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id is-wasm] :as params}]
(let [topic (str profile-id)
file-id (-> exports first :file-id)
resource
(rsc/create :pdf (or name (-> exports first :name)))
on-progress
(fn [done]
(let [data {:type :export-update
:resource-id (:id resource)
:status "running"
:done done}]
(redis/pub! topic data)))
on-complete
(fn [resource]
(let [data {:type :export-update
:resource-id (:id resource)
:resource-uri (:uri resource)
:name (:name resource)
:filename (:filename resource)
:mtype (:mtype resource)
:status "ended"}]
(redis/pub! topic data)))
on-error
(fn [cause]
(l/error :hint "unexpected error on frames exportation" :cause cause)
(let [data {:type :export-update
:resource-id (:id resource)
:name (:name resource)
:filename (:filename resource)
:status "error"
:cause (ex-message cause)}]
(redis/pub! topic data)))
result-cache
(atom [])
(defn- run-export
[job auth-token resource {:keys [exports is-wasm file-id]}]
(let [rendered (atom [])
on-object
(fn [{:keys [path] :as object}]
(let [res (swap! result-cache conj path)]
(on-progress (count res))))
(fn [{:keys [path] :as _object}]
(job.utils/track! (:id job) path)
(jobs/progress! job (count (swap! rendered conj path))))
procs
(->> (seq exports)
(map #(rd/render (assoc % :is-wasm is-wasm) on-object)))]
exports
(map #(assoc % :is-wasm is-wasm :job-id (:id job)) exports)]
(->> (p/all procs)
(p/fmap (fn [] @result-cache))
(p/mcat (partial join-pdf file-id))
(job.utils/track! (:id job) (:path resource))
(->> (rd/with-scope exports
(fn [render]
(jobs/check-cancelled! job)
(->> exports
(map (fn [export] (render export on-object)))
(p/all))))
(p/fmap (fn [_] @rendered))
(p/mcat (partial join-pdf job file-id))
(p/mcat (partial move-file resource))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/mcat (fn [resource]
(->> (sh/stat (:path resource))
(p/fmap #(merge resource %)))))
(p/merr on-error)
(p/fnly (fn [resource cause]
(when-not cause
(on-complete resource)))))
(p/fmap (fn [resource] (dissoc resource :path))))))
(assoc exchange :response/body (dissoc resource :path))))
(defn prepare
[auth-token {:keys [exports name is-wasm] :as _params}]
(let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports)
(prepare-exports auth-token is-wasm))
resource (rsc/create :pdf (or name (-> exports first :name)))
file-id (-> exports first :file-id)]
{:resource resource
:total (count-objects exports)
:headless (headless-exports? exports is-wasm)
:run (fn [job] (run-export job auth-token resource
{:exports exports
:is-wasm is-wasm
:file-id file-id}))}))
(defn- join-pdf
[file-id paths]
[job file-id paths]
(p/let [prefix (str/concat "penpot.pdfunite." file-id ".")
path (sh/tempfile :prefix prefix :suffix ".pdf")]
path (job.utils/track! (:id job) (sh/tempfile :prefix prefix :suffix ".pdf"))]
(apply sh/run-cmd! "pdfunite" (conj (vec paths) path))
path))

View File

@ -7,10 +7,10 @@
(ns app.handlers.export-shapes
(:require
[app.common.data :as d]
[app.common.logging :as l]
[app.common.spec :as us]
[app.handlers.resources :as rsc]
[app.redis :as redis]
[app.jobs :as jobs]
[app.jobs.utils :as job.utils]
[app.renderer :as rd]
[app.util.mime :as mime]
[app.util.shell :as sh]
@ -18,9 +18,6 @@
[cuerdas.core :as str]
[promesa.core :as p]))
(declare ^:private handle-single-export)
(declare ^:private handle-multiple-export)
(declare ^:private assoc-file-name)
(declare prepare-exports)
;; Regex to clean namefiles
@ -50,87 +47,92 @@
(s/keys :req-un [::exports ::profile-id]
:opt-un [::wait ::name ::skip-children ::force-multiple ::is-wasm]))
(defn handler
[{:keys [:request/auth-token] :as exchange} {:keys [exports force-multiple] :as params}]
(let [exports (prepare-exports exports auth-token)]
(if (and (not force-multiple)
(= 1 (count exports))
(= 1 (count (-> exports first :objects))))
(handle-single-export exchange (-> params
(assoc :export (first exports))
(dissoc :exports)))
(handle-multiple-export exchange (assoc params :exports exports)))))
(defn count-objects
[exports]
(reduce + 0 (map (comp count :objects) exports)))
(defn- handle-single-export
[{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
(let [resource (rsc/create (:type export) (or name (:name export)))
export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))]
(defn- render!
[job export on-object]
(jobs/check-cancelled! job)
(rd/render (assoc export :job-id (:id job)) on-object))
(->> (rd/render export
(fn [{:keys [path] :as object}]
(sh/move! path (:path resource))))
(defn- scoped-renders
"Renders every export, the headless ones sharing a single worker."
[job exports on-object]
(rd/with-scope exports
(fn [render]
(jobs/check-cancelled! job)
(->> exports
(map (fn [export] (render export on-object)))
(p/all)))))
(defn- run-single
[job auth-token resource {:keys [export is-wasm skip-children]}]
(job.utils/track! (:id job) (:path resource))
(->> (render! job
(assoc export :skip-children skip-children :is-wasm (boolean is-wasm))
(fn [{:keys [path] :as _object}]
(job.utils/track! (:id job) path)
(sh/move! path (:path resource))))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource] (dissoc resource :path)))))
(defn- run-multiple
[job auth-token resource {:keys [exports is-wasm]}]
(let [failure (volatile! nil)
zip (rsc/create-zip :resource resource
:on-error (fn [cause] (vreset! failure cause))
:on-progress (fn [{:keys [done]}]
(jobs/progress! job done)))
append (fn [{:keys [filename path] :as _object}]
(job.utils/track! (:id job) path)
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))]
(job.utils/track! (:id job) (:path resource))
(->> (scoped-renders job
(map #(assoc % :is-wasm (boolean is-wasm) :job-id (:id job)) exports)
append)
(p/mcat (fn [_]
(if-let [cause @failure]
(p/rejected cause)
(rsc/close-zip zip))))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]
(dissoc resource :path)))
(p/fmap (fn [resource]
(assoc exchange :response/body resource)))
(p/merr (fn [cause]
(l/error :hint "unexpected error on single export"
:cause cause)
(p/rejected cause))))))
(p/fmap (fn [resource] (dissoc resource :path))))))
(defn- handle-multiple-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}]
(let [resource (rsc/create :zip (or name (-> exports first :name)))
total (count exports)
topic (str profile-id)
(defn headless-exports?
"Whether any of `exports` renders headless, and so whether the job leases a
render worker. Mirrors what `rd/with-scope` decides at run time."
[exports is-wasm]
(boolean (some #(rd/headless? {:is-wasm is-wasm :type (:type %)}) exports)))
on-progress (fn [{:keys [done]}]
(when-not wait
(let [data {:type :export-update
:resource-id (:id resource)
:status "running"
:total total
:done done}]
(redis/pub! topic data))))
(defn prepare
[auth-token {:keys [exports force-multiple name skip-children is-wasm] :as _params}]
(let [exports (prepare-exports exports auth-token is-wasm)
headless? (headless-exports? exports is-wasm)
single? (and (not force-multiple)
(= 1 (count exports))
(= 1 (count (-> exports first :objects))))]
(if single?
(let [export (first exports)
resource (rsc/create (:type export) (or name (:name export)))]
{:resource resource
:total 1
:headless headless?
:run (fn [job] (run-single job auth-token resource
{:export export
:is-wasm is-wasm
:skip-children skip-children}))})
on-error (fn [cause]
(l/error :hint "unexpected error on multiple export" :cause cause)
(if wait
(p/rejected cause)
(redis/pub! topic {:type :export-update
:resource-id (:id resource)
:status "error"
:cause (ex-message cause)})))
zip (rsc/create-zip :resource resource
:on-error on-error
:on-progress on-progress)
append (fn [{:keys [filename path] :as resource}]
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append)))
(p/all)
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/fmap (fn [resource]
(let [data {:type :export-update
:name (:name resource)
:filename (:filename resource)
:resource-id (:id resource)
:resource-uri (:uri resource)
:mtype (:mtype resource)
:status "ended"}]
(p/do (redis/pub! topic data)
(assoc exchange :response/body resource)))))
(p/merr on-error))]
(if wait
(p/then proc #(assoc exchange :response/body (dissoc % :path)))
(assoc exchange :response/body (dissoc resource :path)))))
(let [resource (rsc/create :zip (or name (-> exports first :name)))]
{:resource resource
:total (count-objects exports)
:headless headless?
:run (fn [job] (run-multiple job auth-token resource
{:exports exports :is-wasm is-wasm}))}))))
(defn- assoc-file-name
"A transducer that assocs a candidate filename and avoid duplicates"
@ -160,13 +162,18 @@
default-partition-size 50)
(defn prepare-exports
[exports token]
(letfn [(process-group [group]
(sequence (comp (partition-all default-partition-size)
(map process-partition))
group))
[exports token is-wasm]
(letfn [(process-group [[part1 :as group]]
;; The browser renders a partition as a single DOM page, so it is
;; chunked to bound that page. A wasm export is headless, so
;; it does not need to be chunked, and can be rendered as a single partition.
(if (rd/headless? {:is-wasm is-wasm :type (:type part1)})
[(build-render group)]
(sequence (comp (partition-all default-partition-size)
(map build-render))
group)))
(process-partition [[part1 :as part]]
(build-render [[part1 :as part]]
{:file-id (:file-id part1)
:page-id (:page-id part1)
:share-id (:share-id part1)

View File

@ -0,0 +1,60 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
Ownership always comes from the session (see `app.auth`), never from the
request body."
(:require
[app.auth :as auth]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.handlers.export :as export]
[app.jobs :as jobs]
[promesa.core :as p]))
(defn create
[{:keys [:request/auth-token :request/params] :as exchange}]
(->> (auth/require-profile-id auth-token)
(p/mcat (fn [profile-id]
(let [params (-> params
(assoc :profile-id profile-id)
(export/conform-params))]
(l/dbg :hint "create export job" :cmd (:cmd params) :profile-id (str profile-id))
(export/create-job! auth-token params))))
(p/fmap (fn [{:keys [job resource pending]}]
;; A failure is reported through the job record, so the
;; promise must not surface as an unhandled rejection.
(p/merr (constantly nil) pending)
(-> exchange
(assoc :response/body (-> (or (jobs/lookup (:id job)) job)
(assoc :filename (:filename resource))
(assoc :mtype (:mtype resource)))))))))
(defn fetch
[{:keys [:request/auth-token] :as exchange} job-id]
(->> (auth/require-profile-id auth-token)
(p/mcat (fn [profile-id]
(->> (jobs/fetch job-id)
(p/fmap #(auth/check-owner! % profile-id)))))
(p/fmap (fn [job]
(assoc exchange :response/body job)))))
(defn cancel
[{:keys [:request/auth-token] :as exchange} job-id]
(->> (auth/require-profile-id auth-token)
(p/mcat (fn [profile-id]
(->> (jobs/fetch job-id)
(p/fmap #(auth/check-owner! % profile-id)))))
(p/mcat (fn [job] (jobs/cancel! (:id job))))
(p/mcat (fn [_] (jobs/fetch job-id)))
(p/fmap (fn [job]
(if job
(assoc exchange :response/body job)
(ex/raise :type :not-found
:code :object-not-found
:hint "job does not exist"))))))

View File

@ -15,6 +15,7 @@
[app.common.transit :as t]
[app.config :as cf]
[app.handlers :as handlers]
[app.router :as router]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[promesa.core :as p]))
@ -94,7 +95,7 @@
size (js/Buffer.byteLength data "utf-8")]
(-> exchange
(assoc :response/body data)
(assoc :response/status 200)
(assoc :response/status (or status 200))
(update :response/headers assoc "content-type" "application/transit+json")
(update :response/headers assoc "content-length" size)))
@ -159,7 +160,7 @@
(defn init
[]
(let [handler (-> handlers/handler
(let [handler (-> (router/create handlers/handler)
(wrap-health)
(wrap-auth "auth-token")
(wrap-response-format)

284
exporter/src/app/jobs.cljs Normal file
View File

@ -0,0 +1,284 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs
"Export job model and lifecycle.
The record is persisted in redis (`app.jobs.store`); the runtime bits that
cannot be serialized -- cancel callbacks, the cancel signal shared with a
render worker, the throttling bookkeeping -- stay in this process, keyed by
job id.
Every state change also publishes the same `:export-update` message the
exporter has always published, so websocket clients keep working unchanged."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.jobs.store :as store]
[app.redis :as redis]
[promesa.core :as p]))
(l/set-level! :debug)
;; A large export reports progress per object; persisting each one would be
;; hundreds of writes for information nobody reads at that resolution.
(def ^:private progress-throttle-ms 250)
(def ^:private terminal-states #{"ended" "error" "cancelled"})
(defonce ^:private registry (atom {}))
(defn- now-ms
[]
(inst-ms (ct/now)))
(defn- runtime
[job-id]
(get @registry (str job-id)))
(defn lookup
"The live record of a job running in this process, or nil."
[job-id]
(:job (runtime job-id)))
(defn fetch
"The job record from the shared store."
[job-id]
(store/fetch job-id))
(defn- publish!
[{:keys [id profile-id resource-id state done total name filename mtype
resource-uri error]}]
(redis/pub! (redis/->tenant-key (str profile-id))
(d/without-nils
{:type :export-update
:job-id id
:resource-id resource-id
:status state
:done done
:total total
:name name
:filename filename
:mtype mtype
:resource-uri resource-uri
:cause error})))
(defn- store-job!
[job]
(swap! registry update (str (:id job)) assoc :job job)
job)
(defn create!
"Builds a queued job and persists it. `run-fn` is a 1-arg fn of the job that
performs the export and returns a promise; the caller decides when to run
it."
[{:keys [profile-id cmd backend total name resource-id]} run-fn]
(let [job {:id (uuid/next)
:profile-id profile-id
:cmd cmd
:backend backend
:state "queued"
:done 0
:total total
:name name
:resource-id resource-id
:created-at (now-ms)}]
(swap! registry assoc (str (:id job)) {:job job :run-fn run-fn :cancelled? false})
(->> (store/persist! job)
(p/fmap (constantly job)))))
(defn run-fn
[job-id]
(:run-fn (runtime job-id)))
(defn cancelled?
[job-id]
(boolean (:cancelled? (runtime job-id))))
(defn terminal?
[job]
(contains? terminal-states (:state job)))
(defn check-cancelled!
"Raises when the job has been cancelled. Called as each render's turn comes
up, so a cancellation stops the ones that have not started yet."
[job]
(when (cancelled? (:id job))
(ex/raise :type :internal
:code :job-cancelled
:hint "export job was cancelled")))
(defn cancel-signal
"Int32Array over a SharedArrayBuffer, readable from a worker thread: 0 while
the job is live, 1 once it has been cancelled. Nil once the job has been
released -- writing the signal back would leave an entry for a settled job in
the registry that nothing would ever remove."
[job-id]
(let [k (str job-id)]
(when-let [rt (get @registry k)]
(or (:cancel-signal rt)
(let [signal (js/Int32Array. (js/SharedArrayBuffer. 4))]
(swap! registry update k (fn [rt] (some-> rt (assoc :cancel-signal signal))))
(when (cancelled? job-id)
(js/Atomics.store signal 0 1))
signal)))))
(defn on-cancel
"Registers a callback used to abort the job's in-flight work (terminating a
render worker). A job fans out over several renders, so callbacks
accumulate. One registered for a job that already settled is dropped: keeping
it would revive that job's registry entry for good."
[job-id f]
(swap! registry update (str job-id)
(fn [rt] (some-> rt (update :cancel-fns (fnil conj []) f)))))
(defn release!
"Drops the runtime entry once the job settled. The persisted record stays
until its TTL."
[job-id]
(swap! registry dissoc (str job-id)))
(defn- live
"The job as the lifecycle last left it, or nil once it settled. Callers hold
the snapshot handed to them when their work started; writing that back would
resurrect a failed export as running and drop the error with it."
[job]
(when-let [current (:job (runtime (:id job)))]
(when-not (terminal? current)
current)))
(defn- persist-and-publish!
[job]
(store-job! job)
(publish! job)
(store/persist! job))
(defn transition!
"Moves the job on. The first terminal state wins: anything arriving after it
is dropped, so a late failure cannot overwrite a cancellation, nor a straggler
overwrite either."
[job data]
(if-let [job (live job)]
(persist-and-publish! (merge job data))
(p/resolved job)))
(defn start!
[job]
(transition! job {:state "running" :started-at (now-ms)}))
(defn progress!
"Reports `done` objects completed. Writes are throttled, so the caller need
not care how often it calls this."
[{:keys [id] :as job} done]
(if-let [job (live job)]
(let [k (str id)
now (now-ms)
last (:last-progress-ms (runtime id) 0)
job (assoc job :done done)
write? (>= (- now last) progress-throttle-ms)]
(store-job! job)
(if write?
(do
(swap! registry update k assoc :last-progress-ms now)
(persist-and-publish! job))
(p/resolved job)))
(p/resolved job)))
(defn complete!
[job {:keys [uri filename mtype size] :as _resource}]
(transition! job {:state "ended"
:ended-at (now-ms)
:done (:total job)
:resource-uri uri
:filename filename
:mtype mtype
:size size}))
(defn fail!
[job cause]
(l/error :hint "export job failed" :job-id (str (:id job)) :cause cause)
(transition! job {:state "error"
:ended-at (now-ms)
:error (ex-message cause)}))
(defn- cancel-local!
[job-id]
(let [k (str job-id)
rt (get @registry k)
job (:job rt)]
(if (or (nil? job) (terminal? job))
(p/resolved job)
(do
(swap! registry update k (fn [rt] (some-> rt (assoc :cancelled? true))))
(when-let [signal (:cancel-signal rt)]
(js/Atomics.store signal 0 1))
;; Recorded before the callbacks run, not after: one of them
;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a
;; released job is a no-op, so a queued job would keep claiming to be
;; queued in the store and never publish its `cancelled` update.
(let [result (transition! job {:state "cancelled" :ended-at (now-ms)})]
(doseq [f (:cancel-fns rt)]
(try
(f)
(catch :default cause
(l/warn :hint "error on job cancel callback" :job-id k :cause cause))))
result)))))
(defn cancel!
"Cancels a job. One this process does not own is broadcast over the cancel
topic, so whoever runs it acts on it. Idempotent."
[job-id]
(if (some? (runtime job-id))
(cancel-local! job-id)
(->> (fetch job-id)
(p/mcat (fn [job]
(cond
(nil? job) (p/resolved nil)
(terminal? job) (p/resolved job)
:else (p/do
(store/request-cancel! (:id job))
job)))))))
(defn- clean-abandoned!
"Marks every job left mid-flight by a previous process as cancelled.
A queue and its running jobs live in the memory of the process that owns
them, so nothing in flight when it died can be resumed; without this the
record would keep claiming to be running until its TTL expires.
NOTE: the store cannot tell whose jobs are whose, so with more than one
exporter behind a load balancer this would also cancel a sibling's running
jobs. Single-instance deployments only."
[]
(->> (store/fetch-all)
(p/mcat (fn [jobs]
(let [abandoned (remove terminal? jobs)]
(when (seq abandoned)
(l/warn :hint "cancelling jobs abandoned by a previous process"
:count (count abandoned)))
(->> abandoned
(map (fn [job]
(store/persist! (assoc job
:state "cancelled"
:interrupted true
:ended-at (now-ms)))))
(p/all)))))
(p/fmap (fn [result] (count result)))
(p/merr (fn [cause]
(l/warn :hint "unable to clean abandoned jobs" :cause cause)
(p/resolved 0)))))
(defn init
[]
(store/on-cancel-request
(fn [job-id]
(when (some? (runtime job-id))
(l/info :hint "remote cancel request" :job-id job-id)
(cancel-local! job-id))))
(clean-abandoned!))

View File

@ -0,0 +1,149 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.scheduler
"Admission control for export jobs.
Limits concurrent jobs and rejects work rather than allowing an unbounded backlog.
Queue order is FIFO, except jobs whose profile is already at its cap are skipped,
as are headless jobs once every render worker is busy."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.config :as cf]
[app.jobs :as jobs]
[app.jobs.utils :as job.utils]
[app.wasm.pool :as pool]
[promesa.core :as p]))
(l/set-level! :debug)
(defonce ^:private state
(atom {:running {} ;; job-id -> {:profile-id :headless?}
:queue []})) ;; vector of {:job :resolve :reject}
(defn- max-concurrent [] (cf/get :exporter-max-concurrent-jobs 4))
(defn- max-per-profile [] (cf/get :exporter-max-jobs-per-profile 2))
(defn- max-queued [] (cf/get :exporter-queue-max 64))
(defn- headless?
[job]
(= "wasm" (:backend job)))
(defn- running-for
[{:keys [running]} profile-id]
(count (filter #(= profile-id (:profile-id %)) (vals running))))
(defn- running-headless
[{:keys [running]}]
(count (filter :headless? (vals running))))
(defn- eligible?
[state job]
(and (< (count (:running state)) (max-concurrent))
(< (running-for state (:profile-id job)) (max-per-profile))
;; A headless job holds one render worker for its whole run, so admitting
;; more of them than there are workers would only move the wait inside
;; the pool, with the job already reporting itself as running.
(or (not (headless? job))
(< (running-headless state) (pool/capacity)))))
(declare ^:private pump!)
(defn- finish!
[job-id]
(swap! state update :running dissoc (str job-id))
(jobs/release! job-id)
(job.utils/release! job-id)
(pump!))
(defn- execute!
[{:keys [id profile-id] :as job}]
(swap! state update :running assoc (str id) {:profile-id profile-id
:headless? (headless? job)})
(if (jobs/cancelled? id)
(do (finish! id)
(p/resolved job))
(let [run-fn (jobs/run-fn id)]
(->> (p/do (jobs/start! job))
(p/mcat (fn [job] (p/do (run-fn job))))
(p/fnly (fn [_ _] (finish! id)))))))
(defn- drop-queued!
"Removes a queued job and settles its promise, freeing its queue slot on cancellation."
[job-id]
(let [entry (volatile! nil)]
(swap! state (fn [state]
(let [queue (:queue state)
idx (->> (map-indexed vector queue)
(some (fn [[idx entry]]
(when (= (str job-id) (str (-> entry :job :id)))
idx))))]
(if idx
(do (vreset! entry (nth queue idx))
(assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx)))))
state))))
(when-let [{:keys [resolve]} @entry]
(jobs/release! job-id)
(job.utils/release! job-id)
(resolve nil))))
(defn- take-eligible
"Pops the first queued entry that can run now, or nil."
[state]
(let [queue (:queue state)
idx (->> (map-indexed vector queue)
(some (fn [[idx entry]]
(when (eligible? state (:job entry))
idx))))]
(when idx
[(assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx))))
(nth queue idx)])))
(defn- pump!
[]
(loop []
(let [entry (volatile! nil)]
(swap! state (fn [state]
(if-let [[next-state next-entry] (take-eligible state)]
(do (vreset! entry next-entry) next-state)
(do (vreset! entry nil) state))))
(when-let [{:keys [job resolve reject]} @entry]
(-> (execute! job)
(p/then resolve)
(p/catch reject))
(recur)))))
(defn submit!
"Registers `job` for execution. Returns a promise of the job's result, which
resolves when the export actually finishes; callers that only need the handle
can ignore it. Raises when the exporter is saturated."
[job]
(let [resolve* (volatile! nil)
reject* (volatile! nil)
pending (p/create (fn [resolve reject]
(vreset! resolve* resolve)
(vreset! reject* reject)))]
(if (eligible? @state job)
(-> (execute! job)
(p/then @resolve*)
(p/catch @reject*))
(let [queued (volatile! false)]
(swap! state (fn [state]
(if (< (count (:queue state)) (max-queued))
(do (vreset! queued true)
(update state :queue conj {:job job
:resolve @resolve*
:reject @reject*}))
(do (vreset! queued false) state))))
(when-not @queued
(ex/raise :type :validation
:code :queue-full
:hint "too many queued export jobs"))
(jobs/on-cancel (:id job) (fn [] (drop-queued! (:id job))))
(l/dbg :hint "export job queued" :job-id (str (:id job)))))
pending))

View File

@ -0,0 +1,87 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.store
"Redis persistence for export jobs.
Stores each job as a single blob with a TTL matching the exported file,
so records expire with their files.
Reads use Redis. Cancellation requires the process running the job."
(:require
[app.common.logging :as l]
[app.common.transit :as t]
[app.config :as cf]
[app.redis :as redis]
[promesa.core :as p]))
(def ^:private cancel-topic (redis/->key "job-cancel"))
(defn- job-key
[job-id]
(redis/->key "job." job-id))
(defn- ttl
[]
(cf/get :exporter-job-ttl 3600))
(defn persist!
"Writes the job record and refreshes its TTL.
If the write fails, log it and continue. The export still runs
and publishes websocket updates, but the job can't be fetched
afterward (fetch returns nil, REST returns 404)."
[{:keys [id state] :as job}]
(let [jkey (job-key id)]
(->> (p/do
(redis/hset! jkey {:data (t/encode-str job)})
(redis/expire! jkey (ttl))
job)
(p/merr (fn [cause]
(if (= :redis-not-available (:code (ex-data cause)))
(l/warn :hint "job record not persisted, no redis connection"
:job-id (str id) :state state)
(l/error :hint "unable to persist job record"
:job-id (str id) :state state :cause cause))
(p/resolved job))))))
(defn fetch
"The job record, or nil when unknown or expired."
[job-id]
(->> (redis/hgetall (job-key job-id))
(p/fmap (fn [data]
(when-let [blob (get data "data")]
(try
(t/decode-str blob)
(catch :default cause
(l/warn :hint "unable to decode job record" :job-id (str job-id) :cause cause)
nil)))))))
(defn fetch-all
[]
(->> (redis/scan (redis/->key "job.*"))
(p/mcat (fn [keys]
(->> (map (fn [k]
(->> (redis/hgetall k)
(p/fmap (fn [data]
(when-let [blob (get data "data")]
(try
(t/decode-str blob)
(catch :default _ nil)))))))
keys)
(p/all))))
(p/fmap (fn [jobs] (vec (remove nil? jobs))))))
(defn request-cancel!
"Asks every exporter to cancel `job-id`. Only the one running it will act."
[job-id]
(redis/pub! cancel-topic (str job-id)))
(defn on-cancel-request
"Registers `handler` (fn of the job-id string) for cancel requests. Returns an
unsubscribe fn."
[handler]
(redis/sub! cancel-topic handler))

View File

@ -0,0 +1,86 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
Temp files used to be cleaned only by the per-file timer in `app.util.shell`,
an hour after creation and lost entirely on restart. Here each job owns the
paths it creates, so they are dropped as soon as it settles and whatever a
crash left behind is cleaned at boot."
(:require
["node:fs/promises" :as fsp]
["node:path" :as path]
[app.common.logging :as l]
[app.config :as cf]
[app.util.shell :as sh]
[cuerdas.core :as str]
[promesa.core :as p]))
(def ^:private managed-prefix "penpot.")
(defonce ^:private tracked (atom {}))
(defn track!
"Registers `path` as owned by `job-id`, so it is removed when the job settles."
[job-id path]
(when (and job-id path)
(swap! tracked update (str job-id) (fnil conj #{}) path))
path)
(defn- remove-path!
[path]
(->> (p/do (fsp/rm path #js {:recursive true :force true}))
(p/merr (fn [cause]
(l/warn :hint "unable to remove job temp file" :path path :cause cause)
(p/resolved nil)))))
(defn release!
"Removes every file the job owns. Called once the job reached a terminal
state and its result has already been uploaded, so nothing else reads them."
[job-id]
(let [k (str job-id)
paths (get @tracked k)]
(swap! tracked dissoc k)
(if (seq paths)
(->> (map remove-path! paths)
(p/all)
(p/fmap (fn [_]
(l/dbg :hint "released job temp files" :job-id k :count (count paths))
nil)))
(p/resolved nil))))
(defn- clean!
"Removes managed temp files older than the job TTL. They can only be leftovers
of a previous process: every live one belongs to a job of this process."
[]
(let [max-age (* 1000 (cf/get :exporter-job-ttl 3600))
now (js/Date.now)]
(->> (p/do (fsp/readdir sh/tmpdir))
(p/mcat (fn [entries]
(->> (filter #(str/starts-with? % managed-prefix) entries)
(map (fn [entry]
(let [fpath (path/join sh/tmpdir entry)]
(->> (p/do (fsp/stat fpath))
(p/mcat (fn [^js stat]
(if (> (- now (inst-ms (.-mtime stat))) max-age)
(->> (remove-path! fpath)
(p/fmap (constantly 1)))
(p/resolved 0))))
(p/merr (fn [_] (p/resolved 0)))))))
(p/all))))
(p/fmap (fn [results]
(let [removed (reduce + 0 results)]
(when (pos? removed)
(l/info :hint "removed orphaned export temp files" :count removed))
removed)))
(p/merr (fn [cause]
(l/warn :hint "temp file cleanup failed" :cause cause)
(p/resolved 0))))))
(defn init
[]
(clean!))

View File

@ -8,47 +8,170 @@
(:require
["ioredis" :as redis]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.config :as cf]))
[app.config :as cf]
[promesa.core :as p]))
(l/set-level! :trace)
(def client (atom nil))
;; A connection in subscriber mode rejects every other command, so the
;; subscriptions need a connection of their own.
(def ^:private subscriber (atom nil))
(def ^:private subscriptions (atom {}))
(defn- create-client
[uri]
[uri role]
(let [^js client (new redis/default uri)]
(.on client "connect"
(fn [] (l/info :hint "redis connection established" :uri uri)))
(fn [] (l/info :hint "redis connection established" :uri uri :role role)))
(.on client "error"
(fn [cause] (l/error :hint "error on redis connection" :cause cause)))
(fn [cause] (l/error :hint "error on redis connection" :role role :cause cause)))
(.on client "close"
(fn [] (l/warn :hint "connection closed")))
(fn [] (l/warn :hint "connection closed" :role role)))
(.on client "reconnect"
(fn [ms] (l/warn :hint "reconnecting to redis" :ms ms)))
(fn [ms] (l/warn :hint "reconnecting to redis" :role role :ms ms)))
(.on client "end"
(fn [] (l/warn :hint "client ended, no more connections will be attempted")))
(fn [] (l/warn :hint "client ended, no more connections will be attempted" :role role)))
client))
(defn- dispatch-message
[topic payload]
(doseq [handler (get @subscriptions topic)]
(try
(handler payload)
(catch :default cause
(l/error :hint "error on redis subscription handler" :topic topic :cause cause)))))
(defn init
[]
(swap! client (fn [prev]
(when prev (.disconnect ^js prev))
(create-client (cf/get :redis-uri)))))
(let [uri (cf/get :redis-uri)]
(swap! client (fn [prev]
(when prev (.disconnect ^js prev))
(create-client uri "commands")))
(swap! subscriber (fn [prev]
(when prev (.disconnect ^js prev))
(let [^js conn (create-client uri "subscriber")]
(.on conn "message" (fn [topic payload] (dispatch-message topic payload)))
;; Reinstate subscriptions after a reconnection.
(.on conn "connect"
(fn []
(doseq [topic (keys @subscriptions)]
(.subscribe conn topic))))
conn)))))
(defn stop
[]
(reset! subscriptions {})
(swap! subscriber (fn [conn]
(when conn (.quit ^js conn))
nil))
(swap! client (fn [client]
(when client (.quit ^js client))
nil)))
(def ^:private tenant (cf/get :tenant))
(defn ->tenant-key
"Namespaces `parts` under the tenant, the prefix the backend msgbus uses."
[& parts]
(dm/str tenant "." (apply str parts)))
(defn ->key
"Namespaces `parts` under the exporter, inside the tenant."
[& parts]
(dm/str "penpot.exporter." tenant "." (apply str parts)))
(defn pub!
"Publishes on `topic`, which must already be namespaced."
[topic payload]
(let [payload (if (map? payload) (t/encode-str payload) payload)
topic (dm/str tenant "." topic)]
(let [payload (if (map? payload) (t/encode-str payload) payload)]
(when-let [client @client]
(.publish ^js client topic payload))))
(defn sub!
"Subscribes `handler` (fn of the raw payload string) to `topic`, which must
already be namespaced. Returns a 0-arg fn that removes this handler."
[topic handler]
(swap! subscriptions update topic (fnil conj []) handler)
(when-let [conn @subscriber]
(.subscribe ^js conn topic))
(fn []
(swap! subscriptions update topic (fn [handlers] (vec (remove #(= % handler) handlers))))))
(defn- with-client
"Runs `f` against the command connection. Rejects when there is no connection
or the command fails: whether a failure is survivable depends on what the
caller was doing, and only the caller knows."
[f]
(if-let [client @client]
(p/do (f client))
(p/rejected (ex/error :type :internal
:code :redis-not-available
:hint "no redis connection"))))
(defn- with-client-lenient
"For reads, where an unreachable redis is reported as \"nothing there\"."
[f]
(->> (with-client f)
(p/merr (fn [cause]
(l/warn :hint "redis command failed" :cause cause)
(p/resolved nil)))))
(defn hset!
"Writes `data` (a map of string/keyword -> value) as a hash. Nil values are
dropped, since redis has no null."
[k data]
(let [obj (reduce-kv (fn [obj field value]
(if (some? value)
(doto obj (unchecked-set (name field) (str value)))
obj))
#js {}
data)]
(if (zero? (alength (js/Object.keys obj)))
(p/resolved nil)
(with-client (fn [^js client] (.hset client k obj))))))
(defn hgetall
"Returns the hash as a map of string keys, or nil when it does not exist."
[k]
(->> (with-client-lenient (fn [^js client] (.hgetall client k)))
(p/fmap (fn [result]
(when (and result (pos? (alength (js/Object.keys result))))
(persistent!
(reduce (fn [res field]
(assoc! res field (unchecked-get result field)))
(transient {})
(js/Object.keys result))))))))
(defn expire!
[k seconds]
(with-client (fn [^js client] (.expire client k seconds))))
(defn del!
[k]
(with-client (fn [^js client] (.del client k))))
(defn scan
"Every key matching `pattern`, walked in cursor batches so a large keyspace is
never blocked the way `KEYS` would block it.
Batches are accumulated in memory rather than consumed as a stream, which a
promise-returning fn cannot express. Fine for the job keyspace, but reading
redis wants a streaming or reactive interface before it is used for more."
[pattern]
(letfn [(step [cursor found]
(->> (with-client-lenient (fn [^js client] (.scan client cursor "MATCH" pattern "COUNT" 200)))
(p/mcat (fn [result]
(if (nil? result)
(p/resolved found)
(let [next-cursor (aget result 0)
found (into found (aget result 1))]
(if (= "0" next-cursor)
(p/resolved found)
(step next-cursor found))))))))]
(step "0" [])))

View File

@ -26,6 +26,7 @@
(s/def ::token ::us/string)
(s/def ::filename ::us/string)
(s/def ::is-wasm ::us/boolean)
(s/def ::job-id ::us/uuid)
(s/def ::object
(s/keys :req-un [::id ::name ::suffix ::filename]
@ -36,18 +37,22 @@
(s/def ::render-params
(s/keys :req-un [::file-id ::page-id ::scale ::token ::type ::objects]
:opt-un [::is-wasm]))
:opt-un [::is-wasm ::job-id]))
(defn headless?
"Whether `params` renders with render-wasm rather than a browser."
[{:keys [type is-wasm]}]
(and is-wasm (contains? cf/flags :wasm-export) (not= :svg type)))
(defn render
[{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params)
(us/verify fn? on-object)
(let [wasm-export? (contains? cf/flags :wasm-export)
headless? (and is-wasm wasm-export? (not= :svg type))]
(let [headless? (headless? params)]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-export wasm-export?
:wasm-export (contains? cf/flags :wasm-export)
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)
@ -58,3 +63,19 @@
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))
(defn with-scope
"Runs `f`, a fn of a render fn with the same signature as `render`. Exports
that render headless share one worker for the whole call instead of acquiring
one per render; the browser backend keeps rendering them in parallel."
[exports f]
(if (some headless? exports)
(rw/with-scope (:job-id (first exports))
(fn [render-leased]
(f (fn [params on-object]
(us/verify ::render-params params)
(us/verify fn? on-object)
(if (headless? params)
(render-leased params on-object)
(render params on-object))))))
(f render)))

View File

@ -5,447 +5,46 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.renderer.wasm
"Headless renderer backend: renders exports with the render-wasm Skia
pipeline in this Node process, with no browser and no WebGL.
"Main-thread side of the headless renderer.
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
images -> relayout text with the real fonts -> render each object.
One shared WASM design state, so requests are serialized one at a time.
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
browser path."
Renders run on pooled workers because Skia calls are synchronous and would block
the HTTP server and other exports. Each job keeps one worker for all its renders,
sharing its caches and pool slot."
(:require
["node:fs" :as fs]
["undici" :as http]
[app.common.data :as d]
[app.common.fonts :as cfnt]
;; Required for side effects: these register the transit read handlers and
;; deftype impls the `get-page` response is decoded into.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.types.shape.images :as images]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[app.jobs :as jobs]
[app.wasm.pool :as pool]
[promesa.core :as p]))
;; --- module lifecycle (one shared, lazily-initialized instance)
(defonce ^:private module* (atom nil))
(defn- ensure-module!
(defn- serializer
"Chains thunks so a job's renders run one at a time on its worker. A failure
is isolated: it doesn't break the chain for the next one."
[]
(or @module*
(reset! module* (wasm/init!))))
(let [queue (atom (p/resolved nil))]
(fn [thunk]
(let [result (p/handle @queue (fn [_ _] (thunk)))]
(reset! queue (p/handle result (fn [_ _] nil)))
result))))
;; --- serialized access to the shared module
;;
;; `handle-multiple-export` fans out partitions concurrently, but there is one
;; design state and one global mem buffer, so their serialize/render/alloc must
;; not interleave.
(defonce ^:private queue (atom (p/resolved nil)))
(defn- enqueue!
"Runs `thunk` (0-arg, returns a promise) only after all previously enqueued
work has settled. Returns `thunk`'s promise. A task's failure is isolated:
it doesn't break the chain for the next task."
[thunk]
(let [result (p/handle @queue (fn [_ _] (thunk)))]
(reset! queue (p/handle result (fn [_ _] nil)))
result))
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri),
;; in a deployment the exporter reaches the backend over the container network
(defn- internal-uri
"Absolute URI for `path` on the internal (backend) endpoint."
[path]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join path)
(str)))
(defn- error-detail
"Node's fetch reports every transport failure as a bare `TypeError: fetch
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
nested `cause` chain that the logger does not print. Flattens the chain into
one readable string."
[cause]
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
(take-while some?)
(take 5)
(map (fn [^js e]
(let [code (unchecked-get e "code")
msg (or (unchecked-get e "message") (str e))]
(if code (str code ": " msg) msg))))
(str/join " <- ")))
(defn- fetch!
"`undici/fetch` that fails with an ex-info carrying the target uri and the
unwrapped cause chain, so a failed request says what actually went wrong and
against which endpoint."
[uri opts]
(->> (p/do (http/fetch uri opts))
(p/merr (fn [cause]
(p/rejected (ex-info "http fetch failed"
{:uri uri :detail (error-detail cause)}
cause))))))
(defn- explain
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
[cause]
(or (:detail (ex-data cause))
(error-detail cause)))
(defn- rpc-headers
"Auth headers for backend RPC calls (management key + bearer)."
[token]
#js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " token)})
(defn- asset-headers
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
authentication types\")."
[token]
#js {"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)})
;; --- shape bundle fetch (backend RPC)
(defn- fetch-objects
"Fetches the exported roots and their children from the backend via the
`get-page` RPC (`:object-id`, as the browser render path does), using the
same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token objects]}]
(let [headers (rpc-headers token)
root-ids (into #{} (map :id) objects)
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
(seq root-ids) (assoc :object-id root-ids)
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-page")]
(l/dbg :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:roots (count root-ids))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
:uri uri
:status (.-status resp)
:body resp-body)
(p/rejected (ex-info "get-page failed"
{:status (.-status resp)
:body resp-body}))))))))
(p/fmap t/decode-str)
(p/fmap :objects))))
;; --- font resolution
;;
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
;; reports it. Custom (team) fonts resolve through the file's font variants,
;; google fonts through the shared `app.common.fonts` catalog; builtin
;; fonts through its bundled family + the frontend's static `/fonts/`.
(defn- fetch-font-variants
"Team (custom) font variants for the file, or nil — a failure here degrades
to fallback fonts, it does not fail the export."
[{:keys [file-id share-id token]}]
(let [headers (rpc-headers token)
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-font-variants")]
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [s] (when s (t/decode-str s))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: get-font-variants failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- fetch-ttf-bytes
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
here degrades to fallback fonts, it does not fail the export."
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
([uri opts]
(->> (fetch! uri opts)
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font fetch failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
;; variant (a gfont id+weight+style, a builtin file name).
(defonce ^:private font-bytes* (atom {}))
(defn- cached-ttf-bytes
[cache-key fetch-fn]
(if-let [bytes (get @font-bytes* cache-key)]
(p/resolved bytes)
(->> (fetch-fn)
(p/fmap (fn [buf]
(when buf (swap! font-bytes* assoc cache-key buf))
buf)))))
(defn- fetch-asset-bytes
[asset-id {:keys [token]}]
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
#js {:method "GET" :headers (asset-headers token)}))
(defn- fetch-gfont-bytes
[ttf-url]
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
(defn- fetch-builtin-font-bytes
[ttf-file]
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
variants first, matching uuid+weight+style then degrading to uuid+weight then
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
every builtin family to; google catalog otherwise."
[variants params]
(fn [{:keys [id weight style]}]
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
style-str (if (zero? style) "normal" "italic")
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)
(= (name (:font-style v)) style-str)))
variants)
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)))
variants)
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(cond
(:ttf-file-id variant)
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
:else
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
;; --- fallback fonts (emoji + per-script noto fonts)
;;
;; Emoji and non-latin scripts render through fallback families, not through
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
;; provisioning above never uploads them. Must run per request, since
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text. Deduped because
several languages map to one noto family and provisioning is concurrent —
otherwise they all miss the byte cache at once and refetch the same TTF."
[scene]
(let [texts (for [shape (vals scene)
:when (= :text (:type shape))
node (or (some->> (:content shape) (tree-seq :children :children)) [])
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some cfnt/contains-emoji? texts))
langs (reduce cfnt/collect-used-languages #{} texts)]
(distinct
(cond-> (cfnt/add-noto-fonts [] langs)
emoji? (cfnt/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
font-id-only key would serve the first downloaded variant for every other one."
[{:keys [font-id weight style]}]
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
(p/resolved nil)))
(defn- provision-fallback-fonts!
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
:weight weight
:style style
:emoji? (boolean is-emoji)
:fallback? (boolean is-fallback)}
buf)
(l/warn :hint "wasm render: fallback font unavailable"
:font-id font-id)))))
(p/resolved nil))))
(p/all)))
;; --- image resolution
;;
;; Image fills reference file-media ids; the encoded bytes go straight to
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
;; once per request rather than per rendered object.
(defn- fetch-file-media-bytes
"Downloads an image fill's encoded bytes by file-media id."
[media-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(do
(l/warn :hint "wasm render: image fetch non-200"
:media-id (str media-id)
:uri uri
:status (.-status resp))
(p/resolved nil)))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: image fetch failed"
:media-id (str media-id) :uri uri
:detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
(p/fmap (fn [buf]
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
(wasm/store-image! image-id buf))
(l/warn :hint "wasm render: image unavailable"
:media-id (str image-id))))))))
(p/all))))
(defn- relayout-text!
"Recomputes layout for every text shape, once the real fonts are provisioned
(serialize-time layout used the fallback)."
[scene]
(doseq [shape (vals scene)
:when (= :text (:type shape))]
(wasm/update-text-layout! (:id shape))))
;; --- render
(defn- render-object-bytes
[type id scale]
(if (= :pdf type)
(let [bytes (wasm/render-shape-pdf id scale)]
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale type)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
:type type
:scale scale
:objects (count objects)
:file-id (str (:file-id params))
:page-id (str (:page-id params)))
(->> (ensure-module!)
(p/mcat (fn [_] (fetch-objects params)))
(p/mcat (fn [scene]
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
(serialize/serialize-scene! scene)
(l/dbg :hint "wasm render: scene serialized")
;; So fonts from a previous request don't leak into this one.
(wasm/clear-fonts!)
(->> (p/all [(fetch-font-variants params)
(provision-images! scene params)
(provision-fallback-fonts! scene)])
(p/mcat
(fn [[variants _]]
(let [resolve-font (make-resolve-font (or variants []) params)]
;; Before rendering, so the relayout below sees real
;; font metrics. Deduped across objects: a partition
;; sharing one family downloads its TTF once.
(wasm/provision-fonts! (map :id objects) resolve-font))))
(p/mcat
(fn [_]
(relayout-text! scene)
(p/run
(fn [{:keys [id] :as object}]
(let [bytes (render-object-bytes type id scale)
path (sh/tempfile :prefix "penpot.tmp.wasm."
:suffix (mime/get-extension type))]
(l/dbg :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` returns a plain value (zip append) or
;; a promise (single export's file move); `p/do`
;; normalizes both to a thenable.
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; After the request, never mid-render, so an image can't
;; disappear under a running export.
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed"
:detail (explain cause)
:internal-uri (str (cf/get-internal-uri))
:cause cause)
;; A panic can leave the mem buffer allocated or the instance
;; aborted; drop it so the next request rebuilds a fresh one.
(reset! module* nil)
(p/rejected cause)))))
(defn with-scope
"Runs `f`, a fn of a 2-arg render fn. Every render goes to the same worker,
one at a time, so the cancel check runs as each render's turn comes up."
[job-id f]
(pool/with-worker
(fn [worker]
(let [chain (serializer)
live (volatile! worker)
signal (when job-id (jobs/cancel-signal job-id))
opts {:cancel-buffer (some-> signal (.-buffer))
:cancelled? (when job-id #(jobs/cancelled? job-id))}]
(when job-id
;; Between objects the worker sees the flag; inside a render only
;; terminating the thread stops it. Cleared on the way out so a later
;; cancel cannot terminate a worker that is by then somebody else's.
(jobs/on-cancel job-id (fn [] (pool/terminate! @live))))
(->> (p/do (f (fn [params on-object]
(chain #(pool/render-on worker params on-object opts)))))
(p/fnly (fn [_ _] (vreset! live nil))))))))
(defn render
"Public entry. `enqueue!` keeps concurrent exports off each other's toes on
the shared WASM instance."
[params on-object]
(enqueue! (fn [] (render* params on-object))))
(with-scope (:job-id params) (fn [render*] (render* params on-object))))

View File

@ -0,0 +1,61 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.router
"Method + path dispatch.
Requests arrive with whatever prefix the proxy in front uses (`/api/export`
in devenv, `/` when talking to the process directly), so routes are matched
on the remainder after that prefix."
(:require
[app.common.exceptions :as ex]
[app.handlers.jobs :as jobs.handlers]
[cuerdas.core :as str]))
(def ^:private mount-point "/api/export")
(defn- route-path
[path]
(let [path (or path "/")
path (if (str/starts-with? path mount-point)
(subs path (count mount-point))
path)
path (str/rtrim path "/")]
(if (str/empty? path) "/" path)))
(defn- job-id
[path prefix]
(let [id (subs path (count prefix))]
(when-not (or (str/empty? id) (str/includes? id "/"))
id)))
(defn create
"Builds the request handler. `legacy-handler` serves the original
`POST /api/export` command multiplex."
[legacy-handler]
(fn [{:keys [:request/method :request/path] :as exchange}]
(let [path (route-path path)]
(cond
(and (= "post" method) (= "/" path))
(legacy-handler exchange)
(and (= "post" method) (= "/jobs" path))
(jobs.handlers/create exchange)
(and (= "get" method) (str/starts-with? path "/jobs/"))
(if-let [id (job-id path "/jobs/")]
(jobs.handlers/fetch exchange id)
(ex/raise :type :not-found :code :object-not-found :hint "unknown route"))
(and (= "delete" method) (str/starts-with? path "/jobs/"))
(if-let [id (job-id path "/jobs/")]
(jobs.handlers/cancel exchange id)
(ex/raise :type :not-found :code :object-not-found :hint "unknown route"))
:else
(ex/raise :type :not-found
:code :route-not-found
:hint (str "no route for " method " " path))))))

View File

@ -43,9 +43,9 @@
path in devenv and inside the bundle, so it is a constant."
"resources/wasm")
(def image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
256)
(def image-cache-size
"Byte budget the image store is trimmed to between requests."
(* 256 1024 1024))
(defn- read-result-bytes
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
@ -121,9 +121,9 @@
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
(defn fonts-for-shapes
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
partition overwhelmingly share families, so deduping here means one download
and one `_store_font` per family rather than one per object."
"Distinct font families needed by every subtree in `shape-ids`. Objects in one
export overwhelmingly share families, so deduping here means one download and
one `_store_font` per family rather than one per object."
[shape-ids]
(into [] (comp (mapcat fonts-for-shape)
(d/distinct-xf font-key))
@ -156,10 +156,14 @@
"Recomputes a text shape's layout with the currently provisioned fonts. Text is
laid out at serialize time using the fallback font (real fonts aren't uploaded
yet), so this must run again after `provision-fonts!` or glyph metrics/line
breaks are wrong."
breaks are wrong.
Forced, because provisioning a font changes nothing `update_layout` keys on:
it early-returns while the content is unchanged and the layout still matches
its container, which is exactly the case here."
[shape-id]
(let [buf (uuid/get-u32 shape-id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(h/call wasm/internal-module "_force_update_shape_text_layout_for"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))))
(defn image-cached?
@ -205,10 +209,10 @@
(h/call module "_store_image")))
(defn evict-images!
"Evicts least-recently-used images until the store retains at most `max-mb`
megabytes. Returns the number evicted."
[max-mb]
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
"Evicts least-recently-used images until the store retains at most `max-bytes`
bytes. Returns the number evicted."
[max-bytes]
(h/call wasm/internal-module "_evict_images_to_budget" max-bytes))
(defn provision-fonts!
"Resolves and uploads every font needed by `shape-ids`, each family fetched

View File

@ -0,0 +1,246 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.pool
"Pool of headless render workers.
Mirrors `app.browser`: a `generic-pool` whose objects are `worker_threads`
instead of browsers, so acquisition and eviction behave the same way for both
render backends. A worker is expensive to build (it boots its own render-wasm
module), hence the pooling.
Acquisition is not capped: the admission scheduler is the backpressure, and
the idle watchdog guarantees a wedged worker gives its slot back.
Workers run the same bundle as the main thread; `app.core/start` branches on
`isMainThread`. Without the `wasm-export` flag no worker is spawned at all;
with it there is always at least one, since a headless render has nowhere
else to go."
(:require
["generic-pool" :as gp]
["node:path" :as path]
["node:process" :as proc]
["node:worker_threads" :as wt]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.config :as cf]
[promesa.core :as p]))
(l/set-level! :info)
(defonce pool (atom nil))
(defonce ^:private worker-id (atom 0))
(def ^:private ready-timeout-ms 60000)
(defn- idle-timeout-ms
"How long a render may go silent before the worker is presumed wedged. Reset
on every message, so a long export keeps its worker as long as it keeps
reporting objects; only a thread stuck inside Skia, which reports nothing and
emits no `exit`, runs it out."
[]
(* 1000 (cf/get :wasm-worker-idle-timeout 300)))
(defn- worker-script
[]
(path/resolve (aget (.-argv proc/default) 1)))
(defn- create-worker
[]
(p/create
(fn [resolve reject]
(let [script (worker-script)
id (swap! worker-id inc)
worker (new wt/Worker script)
timer (js/setTimeout
(fn []
(l/error :hint "render worker did not become ready" :worker-id id)
(.terminate ^js worker)
(reject (ex/error :type :internal
:code :worker-not-ready
:hint "render worker did not become ready")))
ready-timeout-ms)]
(unchecked-set worker "__id" id)
(unchecked-set worker "__alive" true)
(.on ^js worker "error"
(fn [cause]
(l/error :hint "render worker error" :worker-id id :cause cause)
(unchecked-set worker "__alive" false)
;; A worker that dies while booting has to fail its own creation;
;; rejecting after `resolve` is a no-op, so this is safe for the
;; errors that arrive once it is already in the pool.
(js/clearTimeout timer)
(reject cause)))
(.on ^js worker "exit"
(fn [code]
(l/info :hint "render worker exited" :worker-id id :code code)
(unchecked-set worker "__alive" false)))
;; Not `.once`: a stray message before the handshake would consume the
;; listener and leave the worker hanging until `ready-timeout-ms`.
(letfn [(on-ready [data]
(when (= "ready" (unchecked-get data "type"))
(js/clearTimeout timer)
(.off ^js worker "message" on-ready)
(l/info :origin "factory" :action "create" :worker-id id)
(resolve worker)))]
(.on ^js worker "message" on-ready))))))
(def ^:private worker-pool-factory
#js {:create create-worker
:destroy (fn [worker]
(l/info :origin "factory" :action "destroy"
:worker-id (unchecked-get worker "__id"))
(.terminate ^js worker))
:validate (fn [worker]
(p/resolved (true? (unchecked-get worker "__alive"))))})
(defn capacity
"How many renders can run at once, and so how many headless jobs the
scheduler may admit. Zero exactly when headless export is off, which is also
when no job is headless, so a headless job always has a worker to wait for."
[]
(if (contains? cf/flags :wasm-export)
;; Clamped rather than rejected: a bad value should not stop the exporter
;; from booting, and a headless render has no other backend to fall back to.
(max 1 (cf/get :wasm-worker-pool-max 2))
0))
(defn init
[]
(let [configured (cf/get :wasm-worker-pool-max 2)
max-workers (capacity)]
(when (and (pos? max-workers) (not= configured max-workers))
(l/warn :hint "wasm-worker-pool-max raised to the minimum of one"
:configured configured))
(if (pos? max-workers)
(let [opts #js {:max max-workers
:min (min max-workers (cf/get :wasm-worker-pool-min 1))
:testOnBorrow true
:evictionRunIntervalMillis 30000
:numTestsPerEvictionRun 2
:idleTimeoutMillis 300000}]
(l/info :hint "initializing render worker pool" :opts opts)
(reset! pool (gp/createPool worker-pool-factory opts)))
(l/info :hint "render worker pool disabled, wasm export is off"))
(p/resolved nil)))
(defn stop
[]
(when-let [instance @pool]
(l/info :hint "finalizing render worker pool")
(reset! pool nil)
(p/do
(.drain ^js instance)
(.clear ^js instance))))
(defn- run-on-worker
"Settles when the worker reports the render finished, failed, or the thread
went away. That last case matters: a terminated worker (how a cancel stops a
render mid-Skia) emits `exit` and never `error`, and a promise left pending
there would keep its pool slot borrowed for the life of the process."
[^js worker params cancel-buffer on-object]
(p/create
(fn [resolve reject]
(let [timer (volatile! nil)]
(letfn [(disarm []
(when-let [t @timer]
(js/clearTimeout t)
(vreset! timer nil)))
(rearm []
(disarm)
(vreset! timer (js/setTimeout
(fn []
(l/error :hint "render worker went silent, terminating"
:worker-id (unchecked-get worker "__id"))
(cleanup)
;; Terminating is what frees the pool slot:
;; the `exit` it raises has no listener left.
(unchecked-set worker "__alive" false)
(.terminate ^js worker)
(reject (ex/error :type :internal
:code :render-timeout
:hint "render worker stopped responding")))
(idle-timeout-ms))))
(cleanup []
(disarm)
(.off worker "message" on-message)
(.off worker "error" on-error)
(.off worker "exit" on-exit))
(on-error [cause]
(cleanup)
(reject cause))
(on-exit [code]
(cleanup)
(reject (ex/error :type :internal
:code :worker-exited
:hint (str "render worker exited with code " code))))
(on-message [data]
(rearm)
(case (unchecked-get data "type")
;; A failure while the main thread handles the object (moving
;; the file, appending to the zip) has to end the render too,
;; or nothing ever settles this promise.
"object" (try
(on-object (t/decode-str (unchecked-get data "payload")))
(catch :default cause
(cleanup)
(reject cause)))
"done" (do (cleanup) (resolve nil))
"error" (do (cleanup)
(reject (ex/error :type :internal
:code (or (some-> (unchecked-get data "code") keyword)
:wasm-render-error)
:hint (unchecked-get data "message"))))
nil))]
(.on worker "message" on-message)
(.once worker "error" on-error)
(.once worker "exit" on-exit)
(rearm)
(.postMessage worker #js {:type "render"
:params (t/encode-str params)
:cancel cancel-buffer}))))))
(defn with-worker
"Acquires one worker for the whole of `f`, a fn of that worker."
[f]
(let [instance @pool]
(->> (p/do (.acquire ^js instance))
(p/mcat (fn [worker]
(->> (p/do (f worker))
(p/fmap (fn [result]
(.release ^js instance worker)
result))
(p/merr (fn [cause]
;; The module may be aborted or mid-write, and
;; a terminated worker cannot be reused.
(-> (p/do (.destroy ^js instance worker))
(p/handle (fn [_ _] (p/rejected cause))))))))))))
(defn render-on
"Renders `params` on an already acquired worker."
[worker params on-object {:keys [cancel-buffer cancelled?]}]
(if (and cancelled? (cancelled?))
(p/rejected (ex/error :type :internal
:code :job-cancelled
:hint "export job was cancelled"))
(run-on-worker worker params cancel-buffer on-object)))
(defn terminate!
[^js worker]
(when worker
(unchecked-set worker "__alive" false)
(.terminate worker)))

View File

@ -0,0 +1,452 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.render
"Headless render pipeline: renders exports with the render-wasm Skia pipeline,
with no browser and no WebGL.
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
images -> relayout text with the real fonts -> render each object.
This runs inside a render worker (`app.wasm.worker`), one WASM design state
per worker, so the synchronous Skia calls never block the process that serves
HTTP. `app.renderer.wasm` is the main-thread side that drives it.
Moved here verbatim from `app.renderer.wasm`; git reads it as a new file only
because that namespace still exists as the proxy. Reviewable as a rename:
`git show <base>:exporter/src/app/renderer/wasm.cljs | diff -u - <this file>`.
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
browser path."
(:require
["node:fs" :as fs]
["undici" :as http]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.fonts :as cfnt]
;; Required for side effects: these register the transit read handlers and
;; deftype impls the `get-page` response is decoded into.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.types.shape.images :as images]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[promesa.core :as p]))
;; --- module lifecycle (one shared, lazily-initialized instance)
(defonce ^:private module* (atom nil))
(defn- ensure-module!
[]
(or @module*
(reset! module* (wasm/init!))))
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri),
;; in a deployment the exporter reaches the backend over the container network
(defn- internal-uri
"Absolute URI for `path` on the internal (backend) endpoint."
[path]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join path)
(str)))
(defn- error-detail
"Node's fetch reports every transport failure as a bare `TypeError: fetch
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
nested `cause` chain that the logger does not print. Flattens the chain into
one readable string."
[cause]
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
(take-while some?)
(take 5)
(map (fn [^js e]
(let [code (unchecked-get e "code")
msg (or (unchecked-get e "message") (str e))]
(if code (str code ": " msg) msg))))
(str/join " <- ")))
(defn- fetch!
"`undici/fetch` that fails with an ex-info carrying the target uri and the
unwrapped cause chain, so a failed request says what actually went wrong and
against which endpoint."
[uri opts]
(->> (p/do (http/fetch uri opts))
(p/merr (fn [cause]
(p/rejected (ex-info "http fetch failed"
{:uri uri :detail (error-detail cause)}
cause))))))
(defn- explain
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
[cause]
(or (:detail (ex-data cause))
(error-detail cause)))
(defn- rpc-headers
"Auth headers for backend RPC calls (management key + bearer)."
[token]
#js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " token)})
(defn- asset-headers
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
authentication types\")."
[token]
#js {"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)})
;; --- shape bundle fetch (backend RPC)
(defn- fetch-objects
"Fetches the exported roots and their children from the backend via the
`get-page` RPC (`:object-id`, as the browser render path does), using the
same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token objects]}]
(let [headers (rpc-headers token)
root-ids (into #{} (map :id) objects)
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
(seq root-ids) (assoc :object-id root-ids)
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-page")]
(l/dbg :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:roots (count root-ids))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
:uri uri
:status (.-status resp)
:body resp-body)
(p/rejected (ex-info "get-page failed"
{:status (.-status resp)
:body resp-body}))))))))
(p/fmap t/decode-str)
(p/fmap :objects))))
;; --- font resolution
;;
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
;; reports it. Custom (team) fonts resolve through the file's font variants,
;; google fonts through the shared `app.common.fonts` catalog; builtin
;; fonts through its bundled family + the frontend's static `/fonts/`.
(defn- fetch-font-variants
"Team (custom) font variants for the file, or nil — a failure here degrades
to fallback fonts, it does not fail the export."
[{:keys [file-id share-id token]}]
(let [headers (rpc-headers token)
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-font-variants")]
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [s] (when s (t/decode-str s))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: get-font-variants failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- fetch-ttf-bytes
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
here degrades to fallback fonts, it does not fail the export."
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
([uri opts]
(->> (fetch! uri opts)
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font fetch failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
;; variant (a gfont id+weight+style, a builtin file name).
(defonce ^:private font-bytes* (atom {}))
(defn- cached-ttf-bytes
[cache-key fetch-fn]
(if-let [bytes (get @font-bytes* cache-key)]
(p/resolved bytes)
(->> (fetch-fn)
(p/fmap (fn [buf]
(when buf (swap! font-bytes* assoc cache-key buf))
buf)))))
(defn- fetch-asset-bytes
[asset-id {:keys [token]}]
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
#js {:method "GET" :headers (asset-headers token)}))
(defn- fetch-gfont-bytes
[ttf-url]
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
(defn- fetch-builtin-font-bytes
[ttf-file]
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
variants first, matching uuid+weight+style then degrading to uuid+weight then
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
every builtin family to; google catalog otherwise."
[variants params]
(fn [{:keys [id weight style]}]
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
style-str (if (zero? style) "normal" "italic")
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)
(= (name (:font-style v)) style-str)))
variants)
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)))
variants)
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(cond
(:ttf-file-id variant)
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
:else
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
;; --- fallback fonts (emoji + per-script noto fonts)
;;
;; Emoji and non-latin scripts render through fallback families, not through
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
;; provisioning above never uploads them. Must run per request, since
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text. Deduped because
several languages map to one noto family and provisioning is concurrent —
otherwise they all miss the byte cache at once and refetch the same TTF."
[scene]
(let [texts (for [shape (vals scene)
:when (= :text (:type shape))
node (or (some->> (:content shape) (tree-seq :children :children)) [])
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some cfnt/contains-emoji? texts))
langs (reduce cfnt/collect-used-languages #{} texts)]
(distinct
(cond-> (cfnt/add-noto-fonts [] langs)
emoji? (cfnt/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
font-id-only key would serve the first downloaded variant for every other one."
[{:keys [font-id weight style]}]
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
(p/resolved nil)))
(defn- provision-fallback-fonts!
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
:weight weight
:style style
:emoji? (boolean is-emoji)
:fallback? (boolean is-fallback)}
buf)
(l/warn :hint "wasm render: fallback font unavailable"
:font-id font-id)))))
(p/resolved nil))))
(p/all)))
;; --- image resolution
;;
;; Image fills reference file-media ids; the encoded bytes go straight to
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
;; once per request rather than per rendered object.
(defn- fetch-file-media-bytes
"Downloads an image fill's encoded bytes by file-media id."
[media-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(do
(l/warn :hint "wasm render: image fetch non-200"
:media-id (str media-id)
:uri uri
:status (.-status resp))
(p/resolved nil)))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: image fetch failed"
:media-id (str media-id) :uri uri
:detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
(p/fmap (fn [buf]
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
(wasm/store-image! image-id buf))
(l/warn :hint "wasm render: image unavailable"
:media-id (str image-id))))))))
(p/all))))
(defn- relayout-text!
"Recomputes layout for every text shape, once the real fonts are provisioned
(serialize-time layout used the fallback)."
[scene]
(doseq [shape (vals scene)
:when (= :text (:type shape))]
(wasm/update-text-layout! (:id shape))))
;; --- render
(defn- check-cancelled!
"Cancellation is cooperative: a render already inside Skia cannot be
interrupted, so the flag is only observed between objects. Killing a job
mid-object is the caller's job (terminating the worker)."
[{:keys [cancelled?] :as _params}]
(when (and cancelled? (cancelled?))
(ex/raise :type :internal
:code :job-cancelled
:hint "export job was cancelled")))
(defn- render-object-bytes
[type id scale]
(if (= :pdf type)
(let [bytes (wasm/render-shape-pdf id scale)]
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale type)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
:type type
:scale scale
:objects (count objects)
:file-id (str (:file-id params))
:page-id (str (:page-id params)))
(->> (ensure-module!)
(p/mcat (fn [_] (fetch-objects params)))
(p/mcat (fn [scene]
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
(serialize/serialize-scene! scene)
(l/dbg :hint "wasm render: scene serialized")
;; So fonts from a previous request don't leak into this one.
(wasm/clear-fonts!)
(->> (p/all [(fetch-font-variants params)
(provision-images! scene params)
(provision-fallback-fonts! scene)])
(p/mcat
(fn [[variants _]]
(let [resolve-font (make-resolve-font (or variants []) params)]
;; Before rendering, so the relayout below sees real
;; font metrics. Deduped across objects: shapes
;; sharing one family download its TTF once.
(wasm/provision-fonts! (map :id objects) resolve-font))))
(p/mcat
(fn [_]
(relayout-text! scene)
(p/run
(fn [{:keys [id] :as object}]
(check-cancelled! params)
(let [bytes (render-object-bytes type id scale)
path (sh/tempfile :prefix "penpot.tmp.wasm."
:suffix (mime/get-extension type))]
(l/dbg :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` returns a plain value (zip append) or
;; a promise (single export's file move); `p/do`
;; normalizes both to a thenable.
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; After the request, never mid-render, so an image can't
;; disappear under a running export.
(let [evicted (wasm/evict-images! (cf/get :wasm-worker-image-cache-size wasm/image-cache-size))]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed"
:detail (explain cause)
:internal-uri (str (cf/get-internal-uri))
:cause cause)
;; A panic can leave the mem buffer allocated or the instance
;; aborted; drop it so the next request rebuilds a fresh one.
(reset! module* nil)
(p/rejected cause)))))
(defn render
"Public entry. Renders every object of `params`, calling `on-object` with
`{:id :filename :path ...}` as each one is written out."
[params on-object]
(render* params on-object))

View File

@ -0,0 +1,71 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.worker
"Render worker entry point.
Owns one render-wasm module and renders one export request at a time. The
Skia calls are synchronous, so running them here is what lets several exports
progress at once: the main thread keeps serving HTTP, zipping and uploading
while this thread is blocked inside a render.
Messages in: {type: \"render\", params: <transit>, cancel: SharedArrayBuffer}
Messages out: {type: \"ready\"}
{type: \"object\", payload: <transit>} one per rendered object
{type: \"done\"} | {type: \"error\", message, code}"
(:require
["node:worker_threads" :as wt]
[app.common.logging :as l]
[app.common.transit :as t]
[app.wasm.render :as render]
[promesa.core :as p]))
(defn- post!
[message]
(.postMessage ^js wt/parentPort message))
(defn- cancelled-fn
[buffer]
(if (some? buffer)
(let [signal (js/Int32Array. buffer)]
(fn [] (pos? (js/Atomics.load signal 0))))
(constantly false)))
(defn- handle-render
[data]
(let [params (-> (unchecked-get data "params")
(t/decode-str)
(assoc :cancelled? (cancelled-fn (unchecked-get data "cancel"))))]
(->> (render/render params
(fn [object]
(post! #js {:type "object" :payload (t/encode-str object)})))
(p/fmap (fn [_] (post! #js {:type "done"})))
(p/merr (fn [cause]
(l/warn :hint "render worker: request failed" :cause cause)
(post! #js {:type "error"
:message (or (ex-message cause) (str cause))
:code (some-> cause ex-data :code name)})
(p/resolved nil))))))
(defn- on-message
[data]
(case (unchecked-get data "type")
"render" (handle-render data)
(l/warn :hint "render worker: unknown message" :type (unchecked-get data "type"))))
(defonce ^:private listening
;; `defonce` survives a hot reload, so a reload does not stack a second
;; listener on the port. The indirection through the var keeps the reloaded
;; `on-message` in play instead of pinning the one captured at boot.
(delay
(.on ^js wt/parentPort "message" (fn [data] (on-message data)))
true))
(defn main
[& _]
@listening
(post! #js {:type "ready"})
(l/info :hint "render worker ready"))

View File

@ -0,0 +1,31 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.export-shapes-test
"Chunking of the browser backend."
(:require
[app.common.uuid :as uuid]
[app.handlers.export-shapes :as export-shapes]
[cljs.test :as t :include-macros true]))
(defn- exports
[n type scale]
(let [file-id (uuid/next)
page-id (uuid/next)]
(mapv (fn [i]
{:file-id file-id
:page-id page-id
:object-id (uuid/next)
:name (str "shape-" i)
:suffix ""
:scale scale
:type type})
(range n))))
(t/deftest browser-exports-are-chunked
(let [parts (export-shapes/prepare-exports (exports 120 :png 1) "token" false)]
(t/is (= 3 (count parts)))
(t/is (= [50 50 20] (mapv (comp count :objects) parts)))))

View File

@ -0,0 +1,86 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.jobs-test
"Job state machine. Runs without redis: a store write with no connection is
reported and swallowed, so only the in-process record is exercised."
(:require
[app.common.uuid :as uuid]
[app.jobs :as jobs]
[cljs.test :as t :include-macros true]
[promesa.core :as p]))
(defn- create!
[]
(jobs/create! {:profile-id (uuid/next)
:cmd :export-shapes
:backend "wasm"
:total 10
:name "test"
:resource-id (uuid/next)}
(constantly (p/resolved nil))))
(t/deftest progress-does-not-resurrect-a-finished-job
(t/testing "a render reporting after the export failed cannot undo the failure"
(t/async done
(p/let [job (create!)
_ (jobs/start! job)
_ (jobs/fail! (jobs/lookup (:id job)) (ex-info "boom" {}))
;; `job` is the snapshot handed to the work when it started, which
;; is what a straggling render still holds.
_ (jobs/progress! job 7)]
(let [current (jobs/lookup (:id job))]
(t/is (= "error" (:state current)))
(t/is (= "boom" (:error current)))
(t/is (not= 7 (:done current))))
(jobs/release! (:id job))
(done)))))
(t/deftest first-terminal-state-wins
(t/testing "a failure arriving after a cancellation leaves the job cancelled"
(t/async done
(p/let [job (create!)
_ (jobs/start! job)
_ (jobs/cancel! (:id job))
_ (jobs/fail! job (ex-info "too late" {}))]
(let [current (jobs/lookup (:id job))]
(t/is (= "cancelled" (:state current)))
(t/is (nil? (:error current))))
(jobs/release! (:id job))
(done)))))
(t/deftest cancel-is-recorded-before-the-callbacks-run
(t/testing "a queued job dropped by its own cancel callback still ends cancelled"
(t/async done
(let [seen (atom ::not-called)]
(p/let [job (create!)
;; What `scheduler/drop-queued!` does: it takes the job off the
;; queue and releases it. Anything the lifecycle wrote after
;; the callbacks ran would be dropped on the floor, so by the
;; time one is called the record has to be terminal already.
_ (jobs/on-cancel (:id job)
(fn []
(reset! seen (:state (jobs/lookup (:id job))))
(jobs/release! (:id job))))
_ (jobs/cancel! (:id job))]
(t/is (= "cancelled" @seen))
(t/is (nil? (jobs/lookup (:id job))))
(done))))))
(t/deftest writes-stop-once-the-job-is-released
(t/testing "a late write for a job the scheduler already settled is dropped"
(t/async done
(p/let [job (create!)
_ (jobs/start! job)
_ (jobs/complete! (jobs/lookup (:id job)) {:uri "http://example/x"
:filename "x.zip"
:mtype "application/zip"})
ended (jobs/lookup (:id job))
_ (jobs/release! (:id job))
_ (jobs/progress! job 3)]
(t/is (= "ended" (:state ended)))
(t/is (nil? (jobs/lookup (:id job))))
(done)))))

View File

@ -10,15 +10,23 @@
[cljs.test :as t]
[clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]]
[exporter-tests.export-shapes-test]
[exporter-tests.jobs-test]
[exporter-tests.renderer-svg-test]
[exporter-tests.scheduler-test]
[exporter-tests.shell-test]
[exporter-tests.wasm-pool-test]
[goog.object :as gobj]))
(enable-console-print!)
(def test-namespaces
['exporter-tests.renderer-svg-test
'exporter-tests.shell-test])
['exporter-tests.export-shapes-test
'exporter-tests.jobs-test
'exporter-tests.renderer-svg-test
'exporter-tests.scheduler-test
'exporter-tests.shell-test
'exporter-tests.wasm-pool-test])
(assert (every? find-ns-obj test-namespaces)
"test-namespaces contains a namespace that isn't required in runner.cljs")

View File

@ -0,0 +1,81 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.scheduler-test
"Admission control. A headless job leases one render worker for its whole run,
so no more of them may start than there are workers."
(:require
[app.common.uuid :as uuid]
[app.jobs :as jobs]
[app.jobs.scheduler :as scheduler]
[app.wasm.pool :as pool]
[cljs.test :as t :include-macros true]
[promesa.core :as p]))
(defn- pin-capacity!
"Fixes the worker count for the test. Returns the thunk that puts it back;
`with-redefs` cannot be used here, since the scheduler keeps admitting jobs
after the body of the test has returned."
[n]
(let [original pool/capacity]
(set! pool/capacity (constantly n))
(fn [] (set! pool/capacity original))))
(defn- create!
[backend run-fn]
(jobs/create! {:profile-id (uuid/next)
:cmd :export-shapes
:backend backend
:total 1
:name "test"
:resource-id (uuid/next)}
run-fn))
(defn- gate
"A promise and the fn that settles it, standing in for a render in flight."
[]
(let [resolve* (volatile! nil)
pending (p/create (fn [resolve _] (vreset! resolve* resolve)))]
[pending (fn [] (@resolve* nil))]))
(t/deftest headless-jobs-wait-for-a-render-worker
(t/testing "with one worker, the second headless job stays queued until the first ends"
(t/async done
(let [restore! (pin-capacity! 1)
[render open] (gate)
started (atom [])]
(p/let [job1 (create! "wasm" (fn [_] (swap! started conj :one) render))
job2 (create! "wasm" (fn [_] (swap! started conj :two) (p/resolved nil)))]
(let [p1 (scheduler/submit! job1)
p2 (scheduler/submit! job2)]
(p/do
(p/delay 10)
(t/is (= [:one] @started))
(t/is (= "running" (:state (jobs/lookup (:id job1)))))
(t/is (= "queued" (:state (jobs/lookup (:id job2)))))
(open)
(p/all [p1 p2])
(t/is (= [:one :two] @started))
(restore!)
(done))))))))
(t/deftest a-browser-job-is-not-held-back-by-the-worker-pool
(t/testing "the headless cap applies to headless jobs only"
(t/async done
(let [restore! (pin-capacity! 1)
[render open] (gate)
started (atom [])]
(p/let [job1 (create! "wasm" (fn [_] (swap! started conj :wasm) render))
job2 (create! "browser" (fn [_] (swap! started conj :browser) (p/resolved nil)))]
(let [p1 (scheduler/submit! job1)
p2 (scheduler/submit! job2)]
(p/do
(p/delay 10)
(t/is (= [:wasm :browser] @started))
(open)
(p/all [p1 p2])
(restore!)
(done))))))))

View File

@ -0,0 +1,46 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns exporter-tests.wasm-pool-test
"Worker leasing, against a stub pool: `with-worker` must give the worker back
however its body ends."
(:require
[app.wasm.pool :as pool]
[cljs.test :as t :include-macros true]
[promesa.core :as p]))
(defn- stub-pool!
"Installs a pool whose acquire/release/destroy only count calls."
[]
(let [calls (atom {:acquired 0 :released 0 :destroyed 0})]
(reset! pool/pool
#js {:acquire (fn [] (swap! calls update :acquired inc) (p/resolved ::worker))
:release (fn [_] (swap! calls update :released inc) (p/resolved nil))
:destroy (fn [_] (swap! calls update :destroyed inc) (p/resolved nil))})
calls))
(t/deftest releases-the-worker-when-the-body-succeeds
(t/async done
(let [calls (stub-pool!)]
(p/let [result (pool/with-worker (fn [_] (p/resolved :ok)))]
(t/is (= :ok result))
(t/is (= 1 (:acquired @calls)))
(t/is (= 1 (:released @calls)))
(t/is (= 0 (:destroyed @calls)))
(reset! pool/pool nil)
(done)))))
(t/deftest gives-the-worker-back-when-the-body-throws-synchronously
(t/testing "a raise out of the scope body must not leave the worker borrowed"
(t/async done
(let [calls (stub-pool!)]
(->> (pool/with-worker (fn [_] (throw (ex-info "cancelled" {}))))
(p/hmap (fn [_ cause]
(t/is (some? cause))
(t/is (= 1 (:acquired @calls)))
(t/is (= 1 (+ (:released @calls) (:destroyed @calls))))
(reset! pool/pool nil)
(done))))))))

View File

@ -8,6 +8,7 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.event :as ev]
[app.main.data.exports.wasm :as wasm.exports]
[app.main.data.helpers :as dsh]
@ -141,35 +142,57 @@
:name page-name}))))))
(defn- initialize-export-status
[exports cmd resource]
"`job` is only present on the job API path; without it the widget counts the
exports the client submitted, exactly as it always has."
[exports cmd resource {:keys [job-id total status backend] :as job}]
(ptk/reify ::initialize-export-status
ptk/UpdateEvent
(update [_ state]
(assoc state :export {:in-progress true
:resource-id (:id resource)
:healthy? true
:error false
:progress 0
:widget-visible true
:detail-visible true
:exports exports
:last-update (ct/now)
:cmd cmd}))))
(assoc state :export (cond-> {:in-progress true
:resource-id (:id resource)
:healthy? true
:error false
:progress 0
:widget-visible true
:detail-visible true
:exports exports
:last-update (ct/now)
:cmd cmd}
(some? job)
(assoc :job-id job-id
:total total
:status status
:backend backend))))))
(defn- update-export-status
[{:keys [done status resource-uri filename mtype] :as data}]
[{:keys [done total status resource-uri filename mtype] :as data}]
(ptk/reify ::update-export-status
ptk/UpdateEvent
(update [_ state]
(let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now))
healthy? (< time-diff 6000)]
healthy? (< time-diff 6000)
;; The legacy path has no server-side figures to track; it keeps
;; reporting progress over the client's own list.
job? (some? (get-in state [:export :job-id]))]
(cond-> state
job?
(update :export assoc :status status)
(and job? (some? total))
(update :export assoc :total total)
(= status "running")
(update :export assoc :progress done :last-update (ct/now) :healthy? healthy?)
(= status "error")
(update :export assoc :in-progress false :error (:cause data) :last-update (ct/now) :healthy? healthy?)
(= status "cancelling")
(update :export assoc :last-update (ct/now) :healthy? healthy?)
(= status "cancelled")
(update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?)
(= status "ended")
(update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?))))
@ -178,17 +201,78 @@
(when (= status "ended")
(dom/trigger-download-uri filename mtype resource-uri)))))
;; The exporter is at capacity. Not a crash: the widget says so and the user
;; retries, instead of the generic error dialog.
(def ^:private saturation-codes #{:queue-full})
(defn- export-failed
"Reports a failure that happened before the export ever started, so the widget
settles instead of waiting for progress that will never arrive."
[exports cmd cause]
(ptk/reify ::export-failed
ptk/UpdateEvent
(update [_ state]
(assoc state :export {:in-progress false
:widget-visible true
:detail-visible true
:healthy? true
:progress 0
:total (count exports)
:exports exports
:cmd cmd
:error (or (ex-message cause) true)
:error-code (:code (ex-data cause))
:last-update (ct/now)}))))
(defn cancel-export
"Stops the running export. Only reachable on the job API path, where the
exporter can actually abort the work.
The widget settles from here rather than from the job's `cancelled` message:
the outcome is known once the request returns, and waiting on a round trip
through redis and the websocket would leave it stuck whenever that message is
missed."
[]
(ptk/reify ::cancel-export
ptk/WatchEvent
(watch [_ state _]
(when-let [job-id (get-in state [:export :job-id])]
(let [resource-id (get-in state [:export :resource-id])
settle (rx/concat
(rx/of (update-export-status {:status "cancelled"}))
(->> (rx/of (clear-export-state resource-id))
(rx/delay default-timeout)))]
(rx/concat
;; Stopping is not instantaneous: the request has to reach the
;; exporter and the work has to unwind.
(rx/of (update-export-status {:status "cancelling"}))
(->> (rp/cmd! :cancel-export-job {:job-id job-id})
(rx/mapcat (fn [_] settle))
;; Already finished, or the exporter is gone; either way
;; there is nothing left to stop.
(rx/catch (fn [_] settle)))))))))
;; TODO: Remove once we support WASM SVG export
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
(defn- wasm-export-enabled?
"WASM export is available when the `wasm-export/v1` feature is active AND
render-wasm is active for the current file. When render-wasm is inactive its
shape tree isn't loaded, so a client-side WASM render would crash."
shape tree isn't loaded, so a client-side WASM render would crash.
This governs the client-side render only; it says nothing about the exporter."
[state]
(and (features/active-feature? state "wasm-export/v1")
(features/active-feature? state "render-wasm/v1")))
(defn- wasm-export-available?
"Whether the *exporter* renders with render-wasm. Its `enable-wasm-export`
flag has to be on too, otherwise the browser backend does the work and the
job API would promise capabilities the server does not have."
[state]
(and (wasm-export-enabled? state)
(contains? cf/flags :wasm-export)))
(defn- use-wasm-export?
"Whether to take the client-side WASM export path for `export`."
[state export]
@ -223,7 +307,7 @@
:profile-id profile-id
:cmd :export-shapes
:wait true
:is-wasm (wasm-export-enabled? state)})]
:is-wasm (wasm-export-available? state)})]
(rx/concat
(dwp/force-persist-and-wait 400)
@ -252,7 +336,7 @@
:cmd cmd
:profile-id profile-id
:force-multiple true
:is-wasm (wasm-export-enabled? state)}
:is-wasm (wasm-export-available? state)}
(some? name)
(assoc :name name))
@ -266,7 +350,8 @@
stopper
(rx/filter #(or (= "ended" (:status %))
(= "error" (:status %)))
(= "error" (:status %))
(= "cancelled" (:status %)))
progress-stream)]
(swap! st/ongoing-tasks conj :export)
@ -276,11 +361,30 @@
(rx/of ::dwp/force-persist)
;; Launch the exportation process and stores the resource id
;; locally.
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource))))
;; locally. With wasm export active the job API is used instead: it
;; answers with the exporter's own object count and gives a handle
;; to cancel.
(->> (if (wasm-export-available? state)
(->> (rp/cmd! :create-export-job params)
(rx/map (fn [{job-id :id :keys [total] :as job}]
(vreset! resource-id (:resource-id job))
(initialize-export-status exports cmd
{:id (:resource-id job)}
{:job-id job-id
:total total
:status (:state job)
:backend (:backend job)}))))
(->> (rp/cmd! :export params)
(rx/map (fn [{:keys [id] :as resource}]
(vreset! resource-id id)
(initialize-export-status exports cmd resource nil)))))
(rx/catch (fn [cause]
;; Saturation is an answer, not a fault.
(if (contains? saturation-codes (:code (ex-data cause)))
(rx/of (export-failed exports cmd cause))
(rx/concat
(rx/of (export-failed exports cmd cause))
(rx/throw cause))))))
;; We proceed to update the export state with incoming
;; progress updates. We delay the stopper for give some time
@ -297,7 +401,8 @@
;; for ensure that after some security time, the stream is
;; completely closed.
(->> progress-stream
(rx/filter #(= "ended" (:status %)))
(rx/filter #(or (= "ended" (:status %))
(= "cancelled" (:status %))))
(rx/take 1)
(rx/delay default-timeout)
(rx/map #(clear-export-state @resource-id))
@ -316,7 +421,7 @@
(watch [_ state _]
(let [params (select-keys (:export state) [:exports :cmd])]
(when (seq params)
(rx/of (request-multiple-export params)))))))
(rx/of (request-export params)))))))
(defn export-shapes-event
[exports origin]

View File

@ -276,6 +276,28 @@
(let [default {:wait false :blob? false}]
(send-export (merge default params))))
(defmethod cmd! :create-export-job
[_ params]
(->> (http/send! {:method :post
:uri (u/join cf/public-uri "api/export/jobs")
:body (http/transit-data params)
:headers {"x-external-session-id" (cf/external-session-id)
"x-event-origin" (::ev/origin (meta params))}
:credentials "include"
:response-type :text})
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)))
(defmethod cmd! :cancel-export-job
[_ {:keys [job-id]}]
(->> (http/send! {:method :delete
:uri (u/join cf/public-uri "api/export/jobs/" (str job-id))
:headers {"x-external-session-id" (cf/external-session-id)}
:credentials "include"
:response-type :text})
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)))
(defn- multipart-upload
[id params]
(->> (http/send! {:method :post

View File

@ -218,11 +218,25 @@
theme (or (:theme profile) theme/default)
is-default-theme? (= theme/default theme)
error? (:error state)
;; The exporter is at capacity: worth its own wording, so the user
;; knows retrying later is the thing to do.
busy? (= :queue-full (:error-code state))
healthy? (:healthy? state)
detail-visible? (:detail-visible state)
widget-visible? (:widget-visible state)
progress (:progress state)
items (:exports state)
job-id (:job-id state)
status (:status state)
queued? (and (some? job-id) (= "queued" status))
cancelling? (and (some? job-id) (= "cancelling" status))
cancelled? (and (some? job-id) (= "cancelled" status))
;; Only the wasm backend can actually stop: a browser render holds its
;; pool slot until playwright gives up.
cancellable? (and (some? job-id)
(= "wasm" (:backend state))
(:in-progress state)
(not cancelling?))
total (or (:total state) (count items))
complete? (= progress total)
circ (* 2 Math/PI 12)
@ -236,6 +250,8 @@
color
(cond
error? clr/new-danger
(or cancelling?
cancelled?) clr/new-warning
healthy? (if is-default-theme?
clr/new-primary
clr/new-primary-light)
@ -248,11 +264,20 @@
title
(cond
busy? (tr "workspace.options.exporting-busy")
error? (tr "workspace.options.exporting-object-error")
cancelling? (tr "workspace.options.exporting-cancelling")
cancelled? (tr "workspace.options.exporting-cancelled")
queued? (tr "workspace.options.exporting-queued")
complete? (tr "workspace.options.exporting-complete")
healthy? (tr "workspace.options.exporting-object")
(not healthy?) (tr "workspace.options.exporting-object-slow"))
cancel-export
(mf/use-fn
(fn []
(st/emit! (de/cancel-export))))
retry-last-operation
(mf/use-fn
(fn []
@ -294,11 +319,25 @@
[:div {:class (stl/css :export-progress-title)}
[:div {:class (stl/css :title-text)} title]
(if error?
(cond
error?
[:button {:class (stl/css :retry-btn)
:on-click retry-last-operation}
(tr "workspace.options.retry")]
cancellable?
[:*
[:button {:class (stl/css :retry-btn)
:on-click cancel-export}
(tr "workspace.options.cancel-export")]
[:span {:class (stl/css :progress)}
(dm/str progress " / " total)]]
;; A counter for work that is being abandoned says nothing useful.
(or cancelling? cancelled?)
nil
:else
[:span {:class (stl/css :progress)}
(dm/str progress " / " total)])]

View File

@ -7775,10 +7775,30 @@ msgstr "Remove export"
msgid "workspace.options.export.suffix"
msgstr "Suffix"
#: src/app/main/ui/exports/assets.cljs:325
msgid "workspace.options.cancel-export"
msgstr "Cancel"
#: src/app/main/ui/exports/assets.cljs:252
msgid "workspace.options.exporting-complete"
msgstr "Export complete"
#: src/app/main/ui/exports/assets.cljs:259
msgid "workspace.options.exporting-cancelled"
msgstr "Export cancelled"
#: src/app/main/ui/exports/assets.cljs:258
msgid "workspace.options.exporting-cancelling"
msgstr "Cancelling..."
#: src/app/main/ui/exports/assets.cljs:261
msgid "workspace.options.exporting-queued"
msgstr "Waiting..."
#: src/app/main/ui/exports/assets.cljs:256
msgid "workspace.options.exporting-busy"
msgstr "Export service is busy, please try again later"
#: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273
msgid "workspace.options.exporting-object"
msgstr "Exporting…"

View File

@ -7564,10 +7564,30 @@ msgstr "Eliminar exportación"
msgid "workspace.options.export.suffix"
msgstr "Sufijo"
#: src/app/main/ui/exports/assets.cljs:325
msgid "workspace.options.cancel-export"
msgstr "Cancelar"
#: src/app/main/ui/exports/assets.cljs:252
msgid "workspace.options.exporting-complete"
msgstr "Exportación completa"
#: src/app/main/ui/exports/assets.cljs:259
msgid "workspace.options.exporting-cancelled"
msgstr "Exportación cancelada"
#: src/app/main/ui/exports/assets.cljs:258
msgid "workspace.options.exporting-cancelling"
msgstr "Cancelando..."
#: src/app/main/ui/exports/assets.cljs:261
msgid "workspace.options.exporting-queued"
msgstr "Esperando..."
#: src/app/main/ui/exports/assets.cljs:256
msgid "workspace.options.exporting-busy"
msgstr "La cola de exportación está llena, inténtalo de nuevo en unos momentos"
#: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273
msgid "workspace.options.exporting-object"
msgstr "Exportando…"

View File

@ -743,15 +743,14 @@ pub extern "C" fn is_image_cached(
}
/// Evicts least-recently-used images until the store retains at most
/// `max_mb` megabytes of image data. Called by the headless exporter between
/// `max_bytes` bytes of image data. Called by the headless exporter between
/// requests — never mid-render, so an image can't disappear under a running
/// export; evicted images are re-provisioned by later requests that need
/// them. Returns the number of evicted images.
#[no_mangle]
#[wasm_error]
pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result<u32> {
let max_bytes = (max_mb as usize) * 1024 * 1024;
let evicted = get_resources().images.evict_to_budget(max_bytes);
pub extern "C" fn evict_images_to_budget(max_bytes: u32) -> Result<u32> {
let evicted = get_resources().images.evict_to_budget(max_bytes as usize);
Ok(evicted as u32)
}