mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
✨ Use export job API and allow cancelling wasm exports
This commit is contained in:
parent
db175114ab
commit
bc550abac9
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)])]
|
||||
|
||||
|
||||
@ -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…"
|
||||
|
||||
@ -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…"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user