From bb3194ba5fbe4b507fdb8bb1bc57ba34635fdb9d Mon Sep 17 00:00:00 2001
From: Alexander Solovyov <alexander@solovyov.net>
Date: Tue, 23 Apr 2024 15:52:42 +0300
Subject: [PATCH] move cache business logic to model from api (#41677)

---
 src/metabase/api/cache.clj           | 113 ++++++---------------------
 src/metabase/models/cache_config.clj |  93 ++++++++++++++++++++++
 test/metabase/api/cache_test.clj     |   5 +-
 3 files changed, 121 insertions(+), 90 deletions(-)

diff --git a/src/metabase/api/cache.clj b/src/metabase/api/cache.clj
index eb8839ef4fb..ede39f7372e 100644
--- a/src/metabase/api/cache.clj
+++ b/src/metabase/api/cache.clj
@@ -2,16 +2,11 @@
   (:require
    [clojure.walk :as walk]
    [compojure.core :refer [GET]]
-   [java-time.api :as t]
    [metabase.api.common :as api]
    [metabase.api.common.validation :as validation]
-   [metabase.db.query :as mdb.query]
-   [metabase.events :as events]
-   [metabase.models :refer [CacheConfig Card]]
    [metabase.models.cache-config :as cache-config]
    [metabase.public-settings.premium-features :as premium-features :refer [defenterprise]]
-   [metabase.util :as u]
-   [metabase.util.i18n :refer [tru]]
+   [metabase.util.i18n :refer [tru trun]]
    [metabase.util.malli.schema :as ms]
    [toucan2.core :as t2]))
 
@@ -49,7 +44,6 @@
   []
   (drop-internal-fields (CacheStrategy)))
 
-(def ^:private CachingModel [:enum "root" "database" "dashboard" "question"])
 
 (defn- assert-valid-models [model ids premium?]
   (cond
@@ -68,98 +62,40 @@
                                     "question"  :model/Card)
                                   :id [:in ids]))))
 
-(defn- audit-caching-change! [id prev new]
-  (events/publish-event!
-   :event/cache-config-update
-   {:user-id  api/*current-user-id*
-    :model    :model/CacheConfig
-    :model-id id
-    :details  {:model     (or (:model prev) (:model new))
-               :model-id  (or (:model_id prev) (:model_id new))
-               :old-value (dissoc prev :model :model_id)
-               :new-value (dissoc new :model :model_id)}}))
-
 (api/defendpoint GET "/"
   "Return cache configuration."
   [:as {{:strs [model collection]
          :or   {model "root"}}
         :query-params}]
-  {model      (ms/QueryVectorOf CachingModel)
+  {model      (ms/QueryVectorOf cache-config/CachingModel)
    ;; note that `nil` in `collection` means all configurations not scoped to any particular collection
    collection [:maybe ms/PositiveInt]}
   (validation/check-has-application-permission :setting)
-  (let [items (if (premium-features/enable-cache-granular-controls?)
-                (t2/select :model/CacheConfig
-                           :model [:in model]
-                           {:left-join [:report_card      [:and
-                                                           [:= :model [:inline "question"]]
-                                                           [:= :model_id :report_card.id]
-                                                           [:= :report_card.collection_id collection]]
-                                        :report_dashboard [:and
-                                                           [:= :model [:inline "dashboard"]]
-                                                           [:= :model_id :report_dashboard.id]
-                                                           [:= :report_dashboard.collection_id collection]]]
-                            :where     [:case
-                                        [:= :model [:inline "question"]]  [:!= :report_card.id nil]
-                                        [:= :model [:inline "dashboard"]] [:!= :report_dashboard.id nil]
-                                        :else                             true]})
-                (t2/select :model/CacheConfig :model "root"))]
-    {:data (mapv cache-config/row->config items)}))
+  (when (and (not (premium-features/enable-cache-granular-controls?))
+             (not= model ["root"]))
+    (throw (premium-features/ee-feature-error (tru "Granular Caching"))))
+  {:data (cache-config/get-list model collection)})
 
 (api/defendpoint PUT "/"
   "Store cache configuration."
   [:as {{:keys [model model_id strategy] :as config} :body}]
-  {model    CachingModel
+  {model    cache-config/CachingModel
    model_id ms/IntGreaterThanOrEqualToZero
    strategy (CacheStrategyAPI)}
   (validation/check-has-application-permission :setting)
   (assert-valid-models model [model_id] (premium-features/enable-cache-granular-controls?))
-  (t2/with-transaction [_tx]
-    (let [data    (cache-config/config->row config)
-          current (t2/select-one :model/CacheConfig :model model :model_id model_id {:for :update})]
-      {:id (u/prog1 (mdb.query/update-or-insert! :model/CacheConfig {:model model :model_id model_id}
-                                                 (constantly data))
-             (audit-caching-change! <> current data))})))
+  {:id (cache-config/store! api/*current-user-id* config)})
 
 (api/defendpoint DELETE "/"
-  "Delete cache configuration."
+  "Delete cache configurations."
   [:as {{:keys [model model_id]} :body}]
-  {model    CachingModel
+  {model    cache-config/CachingModel
    model_id (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)}
   (validation/check-has-application-permission :setting)
   (assert-valid-models model model_id (premium-features/enable-cache-granular-controls?))
-  (when-let [current (seq (t2/select :model/CacheConfig :model model :model_id [:in model_id]))]
-    (t2/delete! :model/CacheConfig :model model :model_id [:in model_id])
-    (doseq [item current]
-      (audit-caching-change! (:id item)
-                             (select-keys item [:strategy :config :model :model_id])
-                             nil)))
+  (cache-config/delete! api/*current-user-id* model model_id)
   nil)
 
-(defn- invalidate-cache-configs [database dashboard question]
-  (let [conditions (for [[k vs] [[:database database]
-                                 [:dashboard dashboard]
-                                 [:question question]]
-                         v      vs]
-                     [:and [:= :model (name k)] [:= :model_id v]])]
-    (if (empty? conditions)
-      0
-      ;; using JVM date rather than DB time since it's what are used in cache tasks
-      (t2/query-one {:update (t2/table-name CacheConfig)
-                     :set    {:invalidated_at (t/offset-date-time)}
-                     :where  (into [:or] conditions)}))))
-
-(defn- invalidate-cards [database dashboard question]
-  (let [card-ids (concat
-                  question
-                  (when (seq database)
-                    (t2/select-fn-vec :id [:model/Card :id] :database_id [:in database]))
-                  (when (seq dashboard)
-                    (t2/select-fn-vec :card_id [:model/DashboardCard :card_id] :dashboard_id [:in dashboard])))]
-    (if (empty? card-ids)
-      0
-      (t2/update! Card :id [:in card-ids]
-                  {:cache_invalidated_at (t/offset-date-time)}))))
 
 (api/defendpoint POST "/invalidate"
   "Invalidate cache entries.
@@ -178,19 +114,18 @@
   (when-not (premium-features/enable-cache-granular-controls?)
     (throw (premium-features/ee-feature-error (tru "Granular Caching"))))
 
-  (let [cnt (if (= include :overrides)
-              (invalidate-cards database dashboard question)
-              (invalidate-cache-configs database dashboard question))]
-    (case [(= include :overrides) (pos? cnt)]
-      [true false]  {:status 404
-                     :body   {:message (tru "Could not find any cached questions for the given database, dashboard, or questions ids.")}}
-      [true true]   {:status 200
-                     :body   {:message (tru "Invalidated cache for {0} question(s)." cnt)
-                              :count   cnt}}
-      [false false] {:status 404
-                     :body   {:message (tru "Could not find a cache configuration to invalidate.")}}
-      [false true]  {:status 200
-                     :body   {:message (tru "Invalidated {0} cache configuration(s)." cnt)
-                              :count   cnt}})))
+  (let [cnt (cache-config/invalidate! {:databases       database
+                                       :dashboards      dashboard
+                                       :questions       question
+                                       :with-overrides? (= include :overrides)})]
+    {:status (if (= cnt -1) 404 200)
+     :body   {:count   cnt
+              :message (case [(= include :overrides) (if (pos? cnt) 1 cnt)]
+                         [true -1]  (tru "Could not find a question for the criteria you've specified.")
+                         [true 0]   (tru "No cached results to invalidate.")
+                         [true 1]   (trun "Invalidated a cached result." "Invalidated {0} cached results." cnt)
+                         [false -1] (tru "Nothing to invalidate.")
+                         [false 0]  (tru "No cache configuration to invalidate.")
+                         [false 1]  (trun "Invalidated cache configuration." "Invalidated {0} cache configurations." cnt))}}))
 
 (api/define-routes)
diff --git a/src/metabase/models/cache_config.clj b/src/metabase/models/cache_config.clj
index f7f5f450e47..10996885359 100644
--- a/src/metabase/models/cache_config.clj
+++ b/src/metabase/models/cache_config.clj
@@ -3,10 +3,15 @@
   (:require
    [java-time.api :as t]
    [medley.core :as m]
+   [metabase.db.query :as mdb.query]
+   [metabase.events :as events]
    [metabase.models.interface :as mi]
+   [metabase.util :as u]
    [methodical.core :as methodical]
    [toucan2.core :as t2]))
 
+(def CachingModel "Caching is configurable for those models" [:enum "root" "database" "dashboard" "question"])
+
 (def CacheConfig "Cache configuration" :model/CacheConfig)
 
 (doto :model/CacheConfig
@@ -20,6 +25,17 @@
    :config   mi/transform-json
    :state    mi/transform-json})
 
+(defn- audit-caching-change! [user-id id prev new]
+  (events/publish-event!
+   :event/cache-config-update
+   {:user-id  user-id
+    :model    :model/CacheConfig
+    :model-id id
+    :details  {:model     (or (:model prev) (:model new))
+               :model-id  (or (:model_id prev) (:model_id new))
+               :old-value (dissoc prev :model :model_id)
+               :new-value (dissoc new :model :model_id)}}))
+
 ;;; API
 
 (defn root-strategy
@@ -50,3 +66,80 @@
    :model_id model_id
    :strategy (:type strategy)
    :config   (dissoc strategy :type)})
+
+(defn get-list
+  "Get a list of cache configurations for given `models` and a `collection`."
+  [models collection]
+  (->> (t2/select
+        :model/CacheConfig
+        :model [:in models]
+        {:left-join [:report_card      [:and
+                                        [:= :model [:inline "question"]]
+                                        [:= :model_id :report_card.id]
+                                        [:= :report_card.collection_id collection]]
+                     :report_dashboard [:and
+                                        [:= :model [:inline "dashboard"]]
+                                        [:= :model_id :report_dashboard.id]
+                                        [:= :report_dashboard.collection_id collection]]]
+         :where     [:case
+                     [:= :model [:inline "question"]]  [:!= :report_card.id nil]
+                     [:= :model [:inline "dashboard"]] [:!= :report_dashboard.id nil]
+                     :else                             true]})
+       (mapv row->config)))
+
+(defn store!
+  "Store cache configuration in DB."
+  [user-id {:keys [model model_id] :as config}]
+  (t2/with-transaction [_tx]
+    (let [data    (config->row config)
+          current (t2/select-one :model/CacheConfig :model model :model_id model_id {:for :update})]
+      (u/prog1 (mdb.query/update-or-insert! :model/CacheConfig {:model model :model_id model_id}
+                                            (constantly data))
+        (audit-caching-change! user-id <> current data)))))
+
+(defn delete!
+  "Delete cache configuration (possibly multiple), identified by a `model` and a vector of `model-ids`."
+  [user-id model model-ids]
+  (when-let [current (seq (t2/select :model/CacheConfig :model model :model_id [:in model-ids]))]
+    (t2/delete! :model/CacheConfig :model model :model_id [:in model-ids])
+    (doseq [item current]
+      (audit-caching-change! user-id
+                             (:id item)
+                             (select-keys item [:strategy :config :model :model_id])
+                             nil))))
+
+
+;;; Invalidation
+
+(defn- invalidate-cards [databases dashboards questions]
+  (let [card-ids (concat
+                  questions
+                  (when (seq databases)
+                    (t2/select-fn-vec :id [:model/Card :id] :database_id [:in databases]))
+                  (when (seq dashboards)
+                    (t2/select-fn-vec :card_id [:model/DashboardCard :card_id] :dashboard_id [:in dashboards])))]
+    (if (empty? card-ids)
+      -1
+      (t2/update! :model/Card :id [:in card-ids]
+                  {:cache_invalidated_at (t/offset-date-time)}))))
+
+(defn- invalidate-cache-configs [databases dashboards questions]
+  (let [conditions (for [[k vs] [[:database databases]
+                                 [:dashboard dashboards]
+                                 [:question questions]]
+                         v      vs]
+                     [:and [:= :model (name k)] [:= :model_id v]])]
+    (if (empty? conditions)
+      -1
+      ;; using JVM date rather than DB time since it's what are used in cache tasks
+      (t2/query-one {:update (t2/table-name CacheConfig)
+                     :set    {:invalidated_at (t/offset-date-time)}
+                     :where  (into [:or] conditions)}))))
+
+(defn invalidate!
+  "Invalidate cache configuration. Accepts lists of ids for different types of models. If `with-overrides?` is passed,
+  then invalidates cache on each individual card suitable for criteria."
+  [{:keys [databases dashboards questions with-overrides?]}]
+  (if with-overrides?
+    (invalidate-cards databases dashboards questions)
+    (invalidate-cache-configs databases dashboards questions)))
diff --git a/test/metabase/api/cache_test.clj b/test/metabase/api/cache_test.clj
index 3cb14ca5c62..8837ba4e75a 100644
--- a/test/metabase/api/cache_test.clj
+++ b/test/metabase/api/cache_test.clj
@@ -15,7 +15,10 @@
                  (mt/user-http-request :crowberto :put 402 "cache/"
                                        {:model    "question"
                                         :model_id 1
-                                        :strategy {:type "nocache"}}))))
+                                        :strategy {:type "nocache"}})))
+          (is (= "Granular Caching is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/"
+                 (mt/user-http-request :crowberto :get 402 "cache/"
+                                       :model "question"))))
         (testing "Can operate on root settings though"
           (is (mt/user-http-request :crowberto :put 200 "cache/"
                                     {:model    "root"
-- 
GitLab