Use export job API and allow cancelling wasm exports

This commit is contained in:
Elena Torro 2026-08-26 11:22:46 +02:00
parent db175114ab
commit bc550abac9
5 changed files with 231 additions and 25 deletions

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

@ -7702,10 +7702,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

@ -7491,10 +7491,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…"