;; 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 (ns app.redis "The msgbus abstraction implemented using redis as underlying backend." (:require [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] [app.metrics :as mtx] [app.redis.script :as-alias rscript] [app.util.cache :as cache] [app.util.time :as dt] [app.worker :as-alias wrk] [clojure.core :as c] [clojure.java.io :as io] [clojure.spec.alpha :as s] [cuerdas.core :as str] [integrant.core :as ig] [promesa.core :as p] [promesa.exec :as px]) (:import clojure.lang.IDeref clojure.lang.MapEntry io.lettuce.core.KeyValue io.lettuce.core.RedisClient io.lettuce.core.RedisCommandInterruptedException io.lettuce.core.RedisCommandTimeoutException io.lettuce.core.RedisException io.lettuce.core.RedisURI io.lettuce.core.ScriptOutputType io.lettuce.core.api.StatefulConnection io.lettuce.core.api.StatefulRedisConnection io.lettuce.core.api.async.RedisAsyncCommands io.lettuce.core.api.async.RedisScriptingAsyncCommands io.lettuce.core.api.sync.RedisCommands io.lettuce.core.codec.ByteArrayCodec io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.StatefulRedisPubSubConnection io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources io.netty.util.HashedWheelTimer io.netty.util.Timer java.lang.AutoCloseable java.time.Duration)) (set! *warn-on-reflection* true) (declare initialize-resources) (declare shutdown-resources) (declare connect*) (s/def ::timer #(instance? Timer %)) (s/def ::default-connection #(or (instance? StatefulRedisConnection %) (and (instance? IDeref %) (instance? StatefulRedisConnection (deref %))))) (s/def ::pubsub-connection #(or (instance? StatefulRedisPubSubConnection %) (and (instance? IDeref %) (instance? StatefulRedisPubSubConnection (deref %))))) (s/def ::connection (s/or :default ::default-connection :pubsub ::pubsub-connection)) (s/def ::connection-holder (s/keys :req [::connection])) (s/def ::redis-uri #(instance? RedisURI %)) (s/def ::resources #(instance? ClientResources %)) (s/def ::pubsub-listener #(instance? RedisPubSubListener %)) (s/def ::uri ::us/not-empty-string) (s/def ::timeout ::dt/duration) (s/def ::connect? ::us/boolean) (s/def ::io-threads ::us/integer) (s/def ::worker-threads ::us/integer) (s/def ::cache cache/cache?) (s/def ::redis (s/keys :req [::resources ::redis-uri ::timer ::mtx/metrics] :opt [::connection ::cache])) (defmethod ig/prep-key ::redis [_ cfg] (let [cpus (px/get-available-processors) threads (max 1 (int (* cpus 0.2)))] (merge {::timeout (dt/duration "10s") ::io-threads (max 3 threads) ::worker-threads (max 3 threads)} (d/without-nils cfg)))) (defmethod ig/pre-init-spec ::redis [_] (s/keys :req [::uri ::mtx/metrics] :opt [::timeout ::connect? ::io-threads ::worker-threads])) (defmethod ig/init-key ::redis [_ {:keys [::connect?] :as cfg}] (let [state (initialize-resources cfg)] (cond-> state connect? (assoc ::connection (connect* cfg {}))))) (defmethod ig/halt-key! ::redis [_ state] (shutdown-resources state)) (def default-codec (RedisCodec/of StringCodec/UTF8 ByteArrayCodec/INSTANCE)) (def string-codec (RedisCodec/of StringCodec/UTF8 StringCodec/UTF8)) (defn- create-cache [{:keys [::wrk/executor] :as cfg}] (letfn [(on-remove [key val cause] (l/trace :hint "evict connection (cache)" :key key :reason cause) (some-> val d/close!))] (cache/create :executor executor :on-remove on-remove :keepalive "5m"))) (defn- initialize-resources "Initialize redis connection resources" [{:keys [::uri ::io-threads ::worker-threads ::connect?] :as cfg}] (l/info :hint "initialize redis resources" :uri uri :io-threads io-threads :worker-threads worker-threads :connect? connect?) (let [timer (HashedWheelTimer.) resources (.. (DefaultClientResources/builder) (ioThreadPoolSize ^long io-threads) (computationThreadPoolSize ^long worker-threads) (timer ^Timer timer) (build)) redis-uri (RedisURI/create ^String uri) cfg (-> cfg (assoc ::resources resources) (assoc ::timer timer) (assoc ::redis-uri redis-uri))] (assoc cfg ::cache (create-cache cfg)))) (defn- shutdown-resources [{:keys [::resources ::cache ::timer]}] (cache/invalidate! cache) (when resources (.shutdown ^ClientResources resources)) (when timer (.stop ^Timer timer))) (defn connect* [{:keys [::resources ::redis-uri] :as state} {:keys [timeout codec type] :or {codec default-codec type :default}}] (us/assert! ::resources resources) (let [client (RedisClient/create ^ClientResources resources ^RedisURI redis-uri) timeout (or timeout (::timeout state)) conn (case type :default (.connect ^RedisClient client ^RedisCodec codec) :pubsub (.connectPubSub ^RedisClient client ^RedisCodec codec))] (l/trc :hint "connect" :hid (hash client)) (.setTimeout ^StatefulConnection conn ^Duration timeout) (reify IDeref (deref [_] conn) AutoCloseable (close [_] (ex/ignoring (.close ^StatefulConnection conn)) (ex/ignoring (.shutdown ^RedisClient client)) (l/trc :hint "disconnect" :hid (hash client)))))) (defn connect [state & {:as opts}] (let [connection (connect* state opts)] (-> state (assoc ::connection connection) (dissoc ::cache) (vary-meta assoc `d/close! (fn [_] (d/close! connection)))))) (defn get-or-connect [{:keys [::cache] :as state} key options] (us/assert! ::redis state) (let [create (fn [_] (connect* state options)) connection (cache/get cache key create)] (-> state (dissoc ::cache) (assoc ::connection connection)))) (defn add-listener! [{:keys [::connection] :as conn} listener] (us/assert! ::pubsub-connection connection) (us/assert! ::pubsub-listener listener) (.addListener ^StatefulRedisPubSubConnection @connection ^RedisPubSubListener listener) conn) (defn publish! [{:keys [::connection]} topic message] (us/assert! ::us/string topic) (us/assert! ::us/bytes message) (us/assert! ::default-connection connection) (let [pcomm (.async ^StatefulRedisConnection @connection)] (.publish ^RedisAsyncCommands pcomm ^String topic ^bytes message))) (defn subscribe! "Blocking operation, intended to be used on a thread/agent thread." [{:keys [::connection]} & topics] (us/assert! ::pubsub-connection connection) (try (let [topics (into-array String (map str topics)) cmd (.sync ^StatefulRedisPubSubConnection @connection)] (.subscribe ^RedisPubSubCommands cmd topics)) (catch RedisCommandInterruptedException cause (throw (InterruptedException. (ex-message cause)))))) (defn unsubscribe! "Blocking operation, intended to be used on a thread/agent thread." [{:keys [::connection]} & topics] (us/assert! ::pubsub-connection connection) (try (let [topics (into-array String (map str topics)) cmd (.sync ^StatefulRedisPubSubConnection @connection)] (.unsubscribe ^RedisPubSubCommands cmd topics)) (catch RedisCommandInterruptedException cause (throw (InterruptedException. (ex-message cause)))))) (defn rpush! [{:keys [::connection]} key payload] (us/assert! ::default-connection connection) (us/assert! (or (and (vector? payload) (every? bytes? payload)) (bytes? payload))) (try (let [cmd (.sync ^StatefulRedisConnection @connection) data (if (vector? payload) payload [payload]) vals (make-array (. Class (forName "[B")) (count data))] (loop [i 0 xs (seq data)] (when xs (aset ^"[[B" vals i ^bytes (first xs)) (recur (inc i) (next xs)))) (.rpush ^RedisCommands cmd ^String key ^"[[B" vals)) (catch RedisCommandInterruptedException cause (throw (InterruptedException. (ex-message cause)))))) (defn blpop! [{:keys [::connection]} timeout & keys] (us/assert! ::default-connection connection) (try (let [keys (into-array Object (map str keys)) cmd (.sync ^StatefulRedisConnection @connection) timeout (/ (double (inst-ms timeout)) 1000.0)] (when-let [res (.blpop ^RedisCommands cmd ^double timeout ^"[Ljava.lang.String;" keys)] (MapEntry/create (.getKey ^KeyValue res) (.getValue ^KeyValue res)))) (catch RedisCommandInterruptedException cause (throw (InterruptedException. (ex-message cause)))))) (defn open? [{:keys [::connection]}] (us/assert! ::pubsub-connection connection) (.isOpen ^StatefulConnection @connection)) (defn pubsub-listener [& {:keys [on-message on-subscribe on-unsubscribe]}] (reify RedisPubSubListener (message [_ pattern topic message] (when on-message (on-message pattern topic message))) (message [_ topic message] (when on-message (on-message nil topic message))) (psubscribed [_ pattern count] (when on-subscribe (on-subscribe pattern nil count))) (punsubscribed [_ pattern count] (when on-unsubscribe (on-unsubscribe pattern nil count))) (subscribed [_ topic count] (when on-subscribe (on-subscribe nil topic count))) (unsubscribed [_ topic count] (when on-unsubscribe (on-unsubscribe nil topic count))))) (def ^:private scripts-cache (atom {})) (def noop-fn (constantly nil)) (s/def ::rscript/name qualified-keyword?) (s/def ::rscript/path ::us/not-empty-string) (s/def ::rscript/keys (s/every any? :kind vector?)) (s/def ::rscript/vals (s/every any? :kind vector?)) (s/def ::rscript/script (s/keys :req [::rscript/name ::rscript/path] :opt [::rscript/keys ::rscript/vals])) (defn eval! [{:keys [::mtx/metrics ::connection] :as state} script] (us/assert! ::redis state) (us/assert! ::default-connection connection) (us/assert! ::rscript/script script) (let [cmd (.async ^StatefulRedisConnection @connection) keys (into-array String (map str (::rscript/keys script))) vals (into-array String (map str (::rscript/vals script))) sname (::rscript/name script)] (letfn [(on-error [cause] (if (instance? io.lettuce.core.RedisNoScriptException cause) (do (l/error :hint "no script found" :name sname :cause cause) (->> (load-script) (p/mcat eval-script))) (if-let [on-error (::rscript/on-error script)] (on-error cause) (p/rejected cause)))) (eval-script [sha] (let [tpoint (dt/tpoint)] (->> (.evalsha ^RedisScriptingAsyncCommands cmd ^String sha ^ScriptOutputType ScriptOutputType/MULTI ^"[Ljava.lang.String;" keys ^"[Ljava.lang.String;" vals) (p/fmap (fn [result] (let [elapsed (tpoint)] (mtx/run! metrics {:id :redis-eval-timing :labels [(name sname)] :val (inst-ms elapsed)}) (l/trace :hint "eval script" :name (name sname) :sha sha :params (str/join "," (::rscript/vals script)) :elapsed (dt/format-duration elapsed)) result))) (p/merr on-error)))) (read-script [] (-> script ::rscript/path io/resource slurp)) (load-script [] (l/trace :hint "load script" :name sname) (->> (.scriptLoad ^RedisScriptingAsyncCommands cmd ^String (read-script)) (p/fmap (fn [sha] (swap! scripts-cache assoc sname sha) sha))))] (p/await! (if-let [sha (get @scripts-cache sname)] (eval-script sha) (->> (load-script) (p/mapcat eval-script))))))) (defn timeout-exception? [cause] (instance? RedisCommandTimeoutException cause)) (defn exception? [cause] (instance? RedisException cause))