Settings cache. Cache is a 1:1 mapping of what's in the DB. Cached lookup time is ~60µs, compared to ~1800µs for DB lookup. | (ns metabase.models.setting.cache (:require [clojure.core :as core] [clojure.java.jdbc :as jdbc] [metabase.db :as mdb] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.log :as log] [toucan2.core :as t2]) (:import (java.util.concurrent.locks ReentrantLock))) |
(set! *warn-on-reflection* true) | |
Whenever something changes in the Settings cache it will invoke (call-on-change old-cache new-cache Actual implementation is provided in [[metabase.models.setting]] rather than here (to prevent circular references). | (defmulti call-on-change {:arglists '([old new])} (constantly :default)) |
Setting cache is unique to the application DB; if it's swapped out for tests or mocking or whatever then use a new cache. | (def ^:private ^{:arglists '([])} cache* (mdb/memoize-for-application-db (fn [] (doto (atom nil) (add-watch :call-on-change (fn [_key _ref old new] (call-on-change old new))))))) |
Fetch the current contents of the Settings cache, a map of key (string) -> value (string). | (defn cache [] @(cache*)) |
Update the String value of a Setting in the Settings cache. | (defn update-cache! [setting-name, ^String new-value] (if (seq new-value) (swap! (cache*) assoc setting-name new-value) (swap! (cache*) dissoc setting-name))) |
CACHE SYNCHRONIZATION When running multiple Metabase instances (horizontal scaling), it is of course possible for one instance to update a Setting, and, since Settings are cached (to avoid tons of DB calls), for the other instances to then have an out-of-date cache. Thus we need a way for instances to know when their caches are out of date, so they can update them accordingly. Here is our solution: We will record the last time any Setting was updated in a special Setting called Since Because different machines can have out-of-sync clocks, we'll rely entirely on the application DB for caclulating
and comparing values of | |
Internal key used to store the last updated timestamp for Settings. | (def ^String settings-last-updated-key "settings-last-updated") |
Update the value of | (defn update-settings-last-updated! [] (log/debug "Updating value of settings-last-updated in DB...") ;; for MySQL, cast(current_timestamp AS char); for H2 & Postgres, cast(current_timestamp AS text) (let [current-timestamp-as-string-honeysql (h2x/cast (if (= (mdb/db-type) :mysql) :char :text) [:raw "current_timestamp"])] ;; attempt to UPDATE the existing row. If no row exists, `t2/update!` will return 0... (or (pos? (t2/update! :setting {:key settings-last-updated-key} {:value current-timestamp-as-string-honeysql})) ;; ...at which point we will try to INSERT a new row. Note that it is entirely possible two instances can both ;; try to INSERT it at the same time; one instance would fail because it would violate the PK constraint on ;; `key`, and throw a SQLException. As long as one instance updates the value, we are fine, so we can go ahead ;; and ignore that Exception if one is thrown. (try ;; Use `simple-insert!` because we do *not* want to trigger pre-insert behavior, such as encrypting `:value` (t2/insert! (t2/table-name (t2/resolve-model 'Setting)) :key settings-last-updated-key, :value current-timestamp-as-string-honeysql) (catch java.sql.SQLException e ;; go ahead and log the Exception anyway on the off chance that it *wasn't* just a race condition issue (log/errorf "Error updating Settings last updated value: %s" (with-out-str (jdbc/print-sql-exception-chain e))))))) ;; Now that we updated the value in the DB, go ahead and update our cached value as well, because we know about the ;; changes (swap! (cache*) assoc settings-last-updated-key (t2/select-one-fn :value 'Setting :key settings-last-updated-key))) |
Check whether our Settings cache is out of date. We know the cache is out of date if either of the following conditions is true:
| (defn- cache-out-of-date? [] (log/debug "Checking whether settings cache is out of date (requires DB call)...") (let [current-cache (cache)] (boolean (or ;; is the cache empty? (not current-cache) ;; if not, get the cached value of `settings-last-updated`, and if it exists... (when-let [last-known-update (core/get current-cache settings-last-updated-key)] ;; compare it to the value in the DB. This is done be seeing whether a row exists ;; WHERE value > <local-value> (u/prog1 (t2/select-one-fn :value 'Setting {:where [:and [:= :key settings-last-updated-key] [:> :value last-known-update]]}) (log/trace "last known Settings update: " (pr-str last-known-update)) (log/trace "actual last Settings update:" (pr-str <>)) (when <> (log/info (u/format-color :red "Settings have been changed on another instance, and will be reloaded here."))))))))) |
How often we should check whether the Settings cache is out of date (which requires a DB call)? | (def ^:const cache-update-check-interval-ms (u/minutes->ms 1)) |
(defonce ^:private last-update-check (atom 0)) | |
Has it has been more than a minute since the last time we checked for updates? | (defn- time-for-another-update-check? [] (> (- (System/currentTimeMillis) @last-update-check) cache-update-check-interval-ms)) |
Populate cache with the latest hotness from the db | (defn restore-cache! [] (log/debug "Refreshing Settings cache...") (reset! (cache*) (t2/select-fn->fn :key :value 'Setting))) |
(defonce ^:private ^ReentrantLock restore-cache-lock (ReentrantLock.)) | |
Check whether we need to repopulate the cache with fresh values from the DB (because the cache is either empty or
known to be out-of-date), and do so if needed. This is intended to be called every time a Setting value is
retrieved, so it should be efficient; thus the calculation ( | (defn restore-cache-if-needed! [] ;; There's a potential race condition here where two threads both call this at the exact same moment, and both get ;; `true` when they call `should-restore-cache`, and then both simultaneously try to update the cache (or, one ;; updates the cache, but the other calls `should-restore-cache?` and gets `true` before the other calls ;; `memo-swap!` (see below)) ;; ;; This is not desirable, since either situation would result in duplicate work. Better to just add a quick lock ;; here so only one of them does it, since at any rate waiting for the other thread to finish the task in progress is ;; certainly quicker than starting the task ourselves from scratch (when (time-for-another-update-check?) ;; if the lock is not already held by any thread, including this one... (when-not (.isLocked restore-cache-lock) ;; attempt to acquire the lock. Returns immediately if lock is already held. (when (.tryLock restore-cache-lock) (try (reset! last-update-check (System/currentTimeMillis)) (when (cache-out-of-date?) (restore-cache!)) (finally (.unlock restore-cache-lock))))))) |