penpot/exporter/src/app/redis.cljs
Elena Torró 66b4a99ac3
🎉 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
2026-08-31 14:42:51 +02:00

178 lines
6.4 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns app.redis
(: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]
[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 role]
(let [^js client (new redis/default uri)]
(.on client "connect"
(fn [] (l/info :hint "redis connection established" :uri uri :role role)))
(.on client "error"
(fn [cause] (l/error :hint "error on redis connection" :role role :cause cause)))
(.on client "close"
(fn [] (l/warn :hint "connection closed" :role role)))
(.on client "reconnect"
(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" :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
[]
(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)]
(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" [])))