From 2767fce7adff7bf4a18fb1229e02abb137698172 Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Tue, 16 Jul 2024 17:38:50 +0700
Subject: [PATCH] Batch processing usage metadata (#45007)

---
 .clj-kondo/config.edn                         |   3 +
 deps.edn                                      |   1 +
 e2e/test/scenarios/dashboard/tabs.cy.spec.js  |  78 +++++-----
 .../middleware/update_used_cards_test.clj     |  19 +--
 src/metabase/events/view_log.clj              |  53 +++++--
 src/metabase/lib/expression.cljc              |   3 +-
 src/metabase/metabot.clj                      |   3 +-
 src/metabase/models/data_permissions.clj      |   2 +-
 src/metabase/query_processor/execute.clj      |   4 +-
 .../middleware/process_userland_query.clj     |  18 ++-
 .../middleware/update_used_cards.clj          |  42 ++++--
 src/metabase/util.cljc                        |  20 ++-
 src/metabase/util/grouper.clj                 |  51 +++++++
 .../xrays/automagic_dashboards/comparison.clj |  50 +++----
 test/metabase/api/activity_test.clj           | 114 ++++++++-------
 test/metabase/api/dashboard_test.clj          | 138 +++++++++---------
 test/metabase/events/view_log_test.clj        | 109 ++++++++------
 .../process_userland_query_test.clj           |  42 +++---
 .../middleware/update_used_cards_test.clj     |  94 +++++++-----
 test/metabase/test/util/async.clj             |   4 +-
 test/metabase/util/grouper_test.clj           |  19 +++
 21 files changed, 527 insertions(+), 340 deletions(-)
 create mode 100644 src/metabase/util/grouper.clj
 create mode 100644 test/metabase/util/grouper_test.clj

diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn
index f67ee94adcc..d4d4eecd728 100644
--- a/.clj-kondo/config.edn
+++ b/.clj-kondo/config.edn
@@ -115,6 +115,7 @@
   :discouraged-namespace
   {camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
    clojure.tools.logging  {:message "Use metabase.util.log instead of clojure.tools.logging directly"}
+   grouper.core           {:message "Use metabase.util.grouper instead of grouper.core"}
    honeysql.core          {:message "Use Honey SQL 2. Honey SQL 1 is removed as a dependency in Metabase 49+."}
    honeysql.format        {:message "Use Honey SQL 2. Honey SQL 1 is removed as a dependency in Metabase 49+."}
    honeysql.helpers       {:message "Use Honey SQL 2. Honey SQL 1 is removed as a dependency in Metabase 49+."}
@@ -579,6 +580,7 @@
     metabase.util.encryption                                      encryption
     metabase.util.encryption-test                                 encryption-test
     metabase.util.files                                           u.files
+    metabase.util.grouper                                         grouper
     metabase.util.honey-sql-2                                     h2x
     metabase.util.i18n                                            i18n
     metabase.util.i18n.impl                                       i18n.impl
@@ -657,6 +659,7 @@
   metabase.test/with-temp-empty-app-db                                                 clojure.core/let
   metabase.test/with-temp-file                                                         clojure.core/let
   metabase.test/with-user-in-groups                                                    clojure.core/let
+  metabase.test/with-grouper-batches!                                                  clojure.core/fn
   metabase.upload-test/with-upload-table!                                              clojure.core/let
   metabase.util.files/with-open-path-to-resource                                       clojure.core/let
   metabase.util.malli.defn/defn                                                        schema.core/defn
diff --git a/deps.edn b/deps.edn
index f9fbb07473c..57656e1cd1c 100644
--- a/deps.edn
+++ b/deps.edn
@@ -71,6 +71,7 @@
   hiccup/hiccup                             {:mvn/version "1.0.5"}              ; HTML templating
   inflections/inflections                   {:mvn/version "0.14.1"}             ; Clojure/Script library used for prularizing words
   instaparse/instaparse                     {:mvn/version "1.4.12"}             ; Make your own parser
+  junegunn/grouper                          {:mvn/version "0.1.1"}              ; Batch processing helper
   clj-commons/clj-yaml                      {:mvn/version "1.0.27"}             ; Clojure wrapper for YAML library SnakeYAML
   io.github.camsaul/toucan2                 {:mvn/version "1.0.538"}
   io.github.eerohele/pp                     {:git/tag "2024-01-04.60"           ; super fast pretty-printing library
diff --git a/e2e/test/scenarios/dashboard/tabs.cy.spec.js b/e2e/test/scenarios/dashboard/tabs.cy.spec.js
index 307912a350c..52e76e1e02f 100644
--- a/e2e/test/scenarios/dashboard/tabs.cy.spec.js
+++ b/e2e/test/scenarios/dashboard/tabs.cy.spec.js
@@ -91,10 +91,22 @@ const TAB_2 = {
   name: "Tab 2",
 };
 
+const changeSynchronousBatchUpdateSetting = value => {
+  cy.request("PUT", "/api/setting/synchronous-batch-updates", {
+    value: value,
+  });
+};
+
 describe("scenarios > dashboard > tabs", () => {
   beforeEach(() => {
     restore();
     cy.signInAsAdmin();
+    changeSynchronousBatchUpdateSetting(true);
+  });
+
+  afterEach(() => {
+    cy.signInAsAdmin();
+    changeSynchronousBatchUpdateSetting(false);
   });
 
   it("should only display cards on the selected tab", () => {
@@ -455,12 +467,10 @@ describe("scenarios > dashboard > tabs", () => {
     };
 
     firstQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0);
+      expect(r.view_count).to.equal(1);
     });
     secondQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0);
+      expect(r.view_count).to.equal(1);
     });
 
     // Visit first tab and confirm only first card was queried
@@ -470,12 +480,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@secondTabQuerySpy").should("not.have.been.called");
     cy.wait("@firstTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 3 = 1 (previously) + 1 (firstQuestion) + 1 (firstTabQuery)
+        expect(r.view_count).to.equal(3); // 1 (previously) + 1 (firstQuestion) + 1 (firstTabQuery)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 2 = 1 (previously) + 1 (secondQuestion)
+        expect(r.view_count).to.equal(2); // 1 (previously) + 1 (secondQuestion)
       });
     });
 
@@ -485,12 +493,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@firstTabQuerySpy").should("have.been.calledOnce");
     cy.wait("@secondTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 4 = 3 (previously) + 1 (firstQuestion)
+        expect(r.view_count).to.equal(4); // 3 (previously) + 1 (firstQuestion)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 4 = 2 (previously) + 1 (secondQuestion) + 1 (secondTabQuery)
+        expect(r.view_count).to.equal(4); // 2 (previously) + 1 (secondQuestion) + 1 (secondTabQuery)
       });
     });
 
@@ -501,12 +507,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@secondTabQuerySpy").should("have.been.calledOnce");
 
     firstQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0); // 5 = 4 (previously) + 1 (firstQuestion)
+      expect(r.view_count).to.equal(5); // 4 (previously) + 1 (firstQuestion)
     });
     secondQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0); // 5 = 4 (previously) + 1 (secondQuestion)
+      expect(r.view_count).to.equal(5); // 4 (previously) + 1 (secondQuestion)
     });
 
     // Go to public dashboard
@@ -536,12 +540,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@publicSecondTabQuerySpy").should("not.have.been.called");
     cy.wait("@publicFirstTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 7 = 5 (previously) + 1 (firstQuestion) + 1 (publicFirstTabQuery)
+        expect(r.view_count).to.equal(7); // 5 (previously) + 1 (firstQuestion) + 1 (publicFirstTabQuery)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 6 = 5 (previously) + 1 (secondQuestion)
+        expect(r.view_count).to.equal(6); // 5 (previously) + 1 (secondQuestion)
       });
     });
 
@@ -551,12 +553,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@publicFirstTabQuerySpy").should("have.been.calledOnce");
     cy.wait("@publicSecondTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 8 = 7 (previously) + 1 (firstQuestion)
+        expect(r.view_count).to.equal(8); // 7 (previously) + 1 (firstQuestion)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 8 = 6 (previously) + 1 (secondQuestion) + 1 (publicSecondTabQuery)
+        expect(r.view_count).to.equal(8); // 6 (previously) + 1 (secondQuestion) + 1 (publicSecondTabQuery)
       });
     });
 
@@ -566,12 +566,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@publicSecondTabQuerySpy").should("have.been.calledOnce");
     cy.wait("@publicFirstTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 10 = 8 (previously) + 1 (firstQuestion) + 1 (publicFirstTabQuery)
+        expect(r.view_count).to.equal(10); // 8 (previously) + 1 (firstQuestion) + 1 (publicFirstTabQuery)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 9 = 8 (previously) + 1 (secondQuestion)
+        expect(r.view_count).to.equal(9); // 8 (previously) + 1 (secondQuestion)
       });
     });
   });
@@ -607,12 +605,10 @@ describe("scenarios > dashboard > tabs", () => {
     };
 
     firstQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0); // 1 (firstQuestion)
+      expect(r.view_count).to.equal(1); // 1 (firstQuestion)
     });
     secondQuestion().then(r => {
-      // Disable view_count updates to handle perf issues (for now) (#44359)
-      expect(r.view_count).to.equal(0); // 1 (secondQuestion)
+      expect(r.view_count).to.equal(1); // 1 (secondQuestion)
     });
 
     cy.intercept(
@@ -640,12 +636,10 @@ describe("scenarios > dashboard > tabs", () => {
 
     cy.wait("@firstTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 3 = 1 (previously) + 1 (firstQuestion) + 1 (first tab query)
+        expect(r.view_count).to.equal(3); // 1 (previously) + 1 (firstQuestion) + 1 (first tab query)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 2 = 1 (previously) + 1 (secondQuestion)
+        expect(r.view_count).to.equal(2); // 1 (previously) + 1 (secondQuestion)
       });
     });
 
@@ -654,12 +648,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@firstTabQuerySpy").should("have.been.calledOnce");
     cy.wait("@secondTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 4 = 3 (previously) + 1 (firstQuestion)
+        expect(r.view_count).to.equal(4); // 3 (previously) + 1 (firstQuestion)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 4 = 2 (previously) + 1 (secondQuestion) + 1 (second tab query)
+        expect(r.view_count).to.equal(4); // 2 (previously) + 1 (secondQuestion) + 1 (second tab query)
       });
     });
 
@@ -669,12 +661,10 @@ describe("scenarios > dashboard > tabs", () => {
     cy.get("@secondTabQuerySpy").should("have.been.calledOnce");
     cy.wait("@firstTabQuery").then(r => {
       firstQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 6 = 4 (previously) + 1 (firstQuestion) + 1 (first tab query)
+        expect(r.view_count).to.equal(6); // 4 (previously) + 1 (firstQuestion) + 1 (first tab query)
       });
       secondQuestion().then(r => {
-        // Disable view_count updates to handle perf issues (for now) (#44359)
-        expect(r.view_count).to.equal(0); // 5 = 4 (previously) + 1 (secondQuestion)
+        expect(r.view_count).to.equal(5); // 4 (previously) + 1 (secondQuestion)
       });
     });
   });
diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/update_used_cards_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/update_used_cards_test.clj
index 36b682bd504..18a7cbf9a0c 100644
--- a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/update_used_cards_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/update_used_cards_test.clj
@@ -8,12 +8,13 @@
    [toucan2.core :as t2]))
 
 (deftest sandboxing-test
-  (qp.updated-used-cards-test/with-used-cards-setup
-    (met/with-gtaps! {:gtaps {:categories {:query (mt/mbql-query categories {:filter [:< $id 3]})}}}
-      (let [gtap-card-id (:id (t2/query-one {:select [:c.id]
-                                             :from   [[:report_card :c]]
-                                             :left-join [[:sandboxes :s] [:= :s.card_id :c.id]]
-                                             :where     [:= :s.group_id (:id &group)]}))]
-        (qp.updated-used-cards-test/do-test
-         gtap-card-id
-         #(qp/process-query (mt/mbql-query categories)))))))
+  (mt/test-helpers-set-global-values!
+    (qp.updated-used-cards-test/with-used-cards-setup!
+      (met/with-gtaps! {:gtaps {:categories {:query (mt/mbql-query categories {:filter [:< $id 3]})}}}
+        (let [gtap-card-id (:id (t2/query-one {:select [:c.id]
+                                               :from   [[:report_card :c]]
+                                               :left-join [[:sandboxes :s] [:= :s.card_id :c.id]]
+                                               :where     [:= :s.group_id (:id &group)]}))]
+          (qp.updated-used-cards-test/do-test!
+           gtap-card-id
+           #(qp/process-query (mt/mbql-query categories))))))))
diff --git a/src/metabase/events/view_log.clj b/src/metabase/events/view_log.clj
index 287bdc81831..e03741270b9 100644
--- a/src/metabase/events/view_log.clj
+++ b/src/metabase/events/view_log.clj
@@ -7,21 +7,54 @@
    [metabase.models.query.permissions :as query-perms]
    [metabase.public-settings.premium-features :as premium-features]
    [metabase.util :as u]
+   [metabase.util.grouper :as grouper]
    [metabase.util.log :as log]
    [methodical.core :as m]
    [steffan-westcott.clj-otel.api.trace.span :as span]
    [toucan2.core :as t2]))
 
-(defn increment-view-counts!
-  "Increments the view_count column for a model given a list of ids.
-   Assumes the model has one primary key `id`, and the column for the view count is named `view_count`"
-  [_model & _ids]
-  ;; Disable view_count updates to handle perf issues  (for now) (#44359)
-  #_
-  (when (seq ids)
-    (t2/query {:update (t2/table-name model)
-               :set    {:view_count [:+ :view_count [:inline 1]]}
-               :where  [:in :id ids]})))
+(defn- group-by-frequency
+  "Given a list of items, returns a map of frequencies to items.
+    (group-by-frequency [:a :a :b :b :c :c :c])
+    ;; => {2 [:a :b] 3 [:c]}"
+  [items]
+  (reduce (fn [acc [item cnt]]
+            (update acc cnt u/conjv item))
+          {}
+          (frequencies items)))
+
+(defn- increment-view-counts!*
+  [items]
+  (log/debugf "Increment view counts of %d items" (count items))
+  (try
+    (let [model->ids (reduce (fn [acc {:keys [id model]}]
+                               (update acc model conj id))
+                             {}
+                             items)]
+      (doseq [[model ids] model->ids]
+        (let [cnt->ids (group-by-frequency ids)]
+          (t2/query {:update (t2/table-name model)
+                     :set    {:view_count [:+ :view_count (into [:case]
+                                                                (mapcat (fn [[cnt ids]]
+                                                                          [[:in :id ids] cnt])
+                                                                        cnt->ids))]}
+                     :where  [:in :id (apply concat (vals cnt->ids))]}))))
+    (catch Exception e
+      (log/error e "Failed to increment view counts"))))
+
+(def ^:private increment-view-count-interval-seconds 20)
+
+(defonce ^:private
+  increase-view-count-queue
+  (delay (grouper/start!
+          increment-view-counts!*
+          :capacity 500
+          :interval (* increment-view-count-interval-seconds 1000))))
+
+(defn- increment-view-counts!
+  "Increment the view count of the given `model` and `model-id`."
+  [model model-id]
+  (grouper/submit! @increase-view-count-queue {:model model :id model-id}))
 
 (defn- record-views!
   "Simple base function for recording a view of a given `model` and `model-id` by a certain `user`."
diff --git a/src/metabase/lib/expression.cljc b/src/metabase/lib/expression.cljc
index 24a1baaf360..a62a1277415 100644
--- a/src/metabase/lib/expression.cljc
+++ b/src/metabase/lib/expression.cljc
@@ -21,6 +21,7 @@
    [metabase.lib.util.match :as lib.util.match]
    [metabase.shared.util.i18n :as i18n]
    [metabase.types :as types]
+   [metabase.util :as u]
    [metabase.util.malli :as mu]
    [metabase.util.malli.registry :as mr]))
 
@@ -201,7 +202,7 @@
   [stage
    expression
    {:keys [add-to-fields?], :or {add-to-fields? true}, :as _options}]
-  (cond-> (update stage :expressions (fnil conj []) expression)
+  (cond-> (update stage :expressions u/conjv expression)
     ;; if there are explicit fields selected, add the expression to them
     (and (vector? (:fields stage))
          add-to-fields?)
diff --git a/src/metabase/metabot.clj b/src/metabase/metabot.clj
index a64cb1ea06a..65bf6f17c14 100644
--- a/src/metabase/metabot.clj
+++ b/src/metabase/metabot.clj
@@ -9,6 +9,7 @@
    [metabase.metabot.settings :as metabot-settings]
    [metabase.metabot.util :as metabot-util]
    [metabase.models :refer [Table]]
+   [metabase.util :as u]
    [metabase.util.log :as log]
    [potemkin :as p]
    [toucan2.core :as t2]))
@@ -117,7 +118,7 @@
                      database-id user_prompt best-model-id)
           (update model
                   :prompt_template_versions
-                  (fnil conj [])
+                  u/conjv
                   (format "%s:%s" prompt_template version)))
         (log/infof "No model inferred for database '%s' with prompt '%s'." database-id user_prompt)))
     (log/warn "Metabot is not enabled")))
diff --git a/src/metabase/models/data_permissions.clj b/src/metabase/models/data_permissions.clj
index 89f64ca69ec..8a0fc5b72f1 100644
--- a/src/metabase/models/data_permissions.clj
+++ b/src/metabase/models/data_permissions.clj
@@ -108,7 +108,7 @@
                           [:data_permissions :p] [:= :p.group_id :pg.id]]
                    :where [:= :pgm.user_id user-id]})
        (reduce (fn [m {:keys [user_id perm_type db_id] :as row}]
-                 (update-in m [user_id perm_type db_id] (fnil conj []) row))
+                 (update-in m [user_id perm_type db_id] u/conjv row))
                {})))
 
 (defn- relevant-permissions-for-user-perm-and-db
diff --git a/src/metabase/query_processor/execute.clj b/src/metabase/query_processor/execute.clj
index c683835c2bc..38b0eaf8d25 100644
--- a/src/metabase/query_processor/execute.clj
+++ b/src/metabase/query_processor/execute.clj
@@ -1,10 +1,10 @@
 (ns metabase.query-processor.execute
   (:require
-   #_[metabase.query-processor.middleware.update-used-cards :as update-used-cards]
    [metabase.lib.schema.id :as lib.schema.id]
    [metabase.query-processor.middleware.cache :as cache]
    [metabase.query-processor.middleware.enterprise :as qp.middleware.enterprise]
    [metabase.query-processor.middleware.permissions :as qp.perms]
+   [metabase.query-processor.middleware.update-used-cards :as update-used-cards]
    [metabase.query-processor.pipeline :as qp.pipeline]
    [metabase.query-processor.schema :as qp.schema]
    [metabase.query-processor.setup :as qp.setup]
@@ -43,7 +43,7 @@
   e.g.
 
     (f (f query rff)) -> (f query rff)"
-  [#_#'update-used-cards/update-used-cards! ;; Disable report_card updates to handle perf issues  (for now) (#44359)
+  [#'update-used-cards/update-used-cards!
    #'add-native-form-to-result-metadata
    #'add-preprocessed-query-to-result-metadata-for-userland-query
    #'cache/maybe-return-cached-results
diff --git a/src/metabase/query_processor/middleware/process_userland_query.clj b/src/metabase/query_processor/middleware/process_userland_query.clj
index ce7a0e732cd..435962026b8 100644
--- a/src/metabase/query_processor/middleware/process_userland_query.clj
+++ b/src/metabase/query_processor/middleware/process_userland_query.clj
@@ -20,6 +20,7 @@
    #_
    [metabase.query-processor.store :as qp.store]
    [metabase.query-processor.util :as qp.util]
+   [metabase.util.grouper :as grouper]
    [metabase.util.log :as log]
    [metabase.util.malli :as mu]
    #_{:clj-kondo/ignore [:discouraged-namespace]}
@@ -37,6 +38,19 @@
 ;;; |                                              Save Query Execution                                              |
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
+(def ^:private field-usage-interval-seconds 20)
+
+(defonce ^:private
+  field-usages-queue
+  (delay (grouper/start!
+          (fn [field-usages]
+            (try
+              (t2/insert! :model/FieldUsage field-usages)
+              (catch Throwable e
+                (log/error e "Error saving field usages"))))
+          :capacity 500
+          :interval (* field-usage-interval-seconds 1000))))
+
 ;; TODO - I'm not sure whether this should happen async as is currently the case, or should happen synchronously e.g.
 ;; in the completing arity of the rf
 ;;
@@ -51,7 +65,9 @@
     (log/warn "Cannot save QueryExecution, missing :context")
     (let [qe-id (t2/insert-returning-pk! QueryExecution (dissoc query-execution :json_query))]
       (when (seq field-usages)
-        (t2/insert! :model/FieldUsage (map #(assoc % :query_execution_id qe-id) field-usages))))))
+        (let [queue @field-usages-queue]
+          (doseq [field-usage field-usages]
+            (grouper/submit! queue (assoc field-usage :query_execution_id qe-id))))))))
 
 (defn- save-execution-metadata!
   "Save a `QueryExecution` row containing `execution-info`. Done asynchronously when a query is finished."
diff --git a/src/metabase/query_processor/middleware/update_used_cards.clj b/src/metabase/query_processor/middleware/update_used_cards.clj
index 2ff9d7d3812..9cef0aaec94 100644
--- a/src/metabase/query_processor/middleware/update_used_cards.clj
+++ b/src/metabase/query_processor/middleware/update_used_cards.clj
@@ -1,9 +1,10 @@
 (ns metabase.query-processor.middleware.update-used-cards
   (:require
+   [java-time.api :as t]
    [metabase.lib.metadata :as lib.metadata]
    [metabase.query-processor.schema :as qp.schema]
    [metabase.query-processor.store :as qp.store]
-   [metabase.query-processor.util :as qp.util]
+   [metabase.util.grouper :as grouper]
    [metabase.util.log :as log]
    [metabase.util.malli :as mu]
    #_{:clj-kondo/ignore [:discouraged-namespace]}
@@ -11,15 +12,29 @@
 
 (set! *warn-on-reflection* true)
 
+(def ^:private update-used-card-interval-seconds 20)
+
 (defn- update-used-cards!*
-  [used-card-ids]
-  (when (seq used-card-ids)
-    (qp.util/with-execute-async
-      (fn []
-        (try
-          (t2/update! :model/Card :id [:in used-card-ids] {:last_used_at :%now})
-          (catch Throwable e
-            (log/error e "Error updating used cards")))))))
+  [card-id-timestamps]
+  (let [card-id->timestamp (update-vals (group-by :id card-id-timestamps)
+                                        (fn [xs] (apply t/max (map :timestamp xs))))]
+    (log/debugf "Update last_used_at of %d cards" (count card-id->timestamp))
+    (try
+      (t2/update! :model/Card :id [:in (keys card-id->timestamp)]
+                  {:last_used_at (into [:case]
+                                       (mapcat (fn [[id timestamp]]
+                                                 [[:= :id id] [:greatest [:coalesce :last_used_at (t/offset-date-time 0)] timestamp]])
+                                               card-id->timestamp))})
+      (catch Throwable e
+        (log/error e "Error updating used cards")))))
+
+(defonce ^:private
+  update-used-cards-queue
+  (delay
+   (grouper/start!
+    update-used-cards!*
+    :capacity 500
+    :interval (* update-used-card-interval-seconds 1000))))
 
 (mu/defn update-used-cards! :- ::qp.schema/qp
   "Middleware that get all card-ids that were used during a query execution and updates their `last_used_at`.
@@ -34,7 +49,10 @@
   [qp :- ::qp.schema/qp]
   (mu/fn [query :- ::qp.schema/query
           rff   :- ::qp.schema/rff]
-    (letfn [(rff* [metadata]
-             (update-used-cards!* (set (lib.metadata/invoked-ids (qp.store/metadata-provider) :metadata/card)))
-             (rff metadata))]
+    (let [now  (t/offset-date-time)
+          rff* (fn [metadata]
+                 (doseq [card-id (distinct (lib.metadata/invoked-ids (qp.store/metadata-provider) :metadata/card))]
+                   (grouper/submit! @update-used-cards-queue {:id        card-id
+                                                              :timestamp now}))
+                 (rff metadata))]
       (qp query rff*))))
diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc
index d99b39efb63..3c9bcc0d836 100644
--- a/src/metabase/util.cljc
+++ b/src/metabase/util.cljc
@@ -1,14 +1,14 @@
 (ns metabase.util
   "Common utility functions useful throughout the codebase."
   (:require
-   #?@(:clj  ([clojure.math.numeric-tower :as math]
-              [me.flowthing.pp :as pp]
-              [metabase.config :as config]
-              #_{:clj-kondo/ignore [:discouraged-namespace]}
-              [metabase.util.jvm :as u.jvm]
-              [metabase.util.string :as u.str]
-              [potemkin :as p]
-              [ring.util.codec :as codec]))
+   #?@(:clj ([clojure.math.numeric-tower :as math]
+             [me.flowthing.pp :as pp]
+             [metabase.config :as config]
+             #_{:clj-kondo/ignore [:discouraged-namespace]}
+             [metabase.util.jvm :as u.jvm]
+             [metabase.util.string :as u.str]
+             [potemkin :as p]
+             [ring.util.codec :as codec]))
    [camel-snake-kebab.internals.macros :as csk.macros]
    [clojure.data :refer [diff]]
    [clojure.pprint :as pprint]
@@ -1009,6 +1009,10 @@
                          m))]
      (with-meta ret (meta m)))))
 
+(def conjv
+  "Like `conj` but returns a vector instead of a list"
+  (fnil conj []))
+
 (defn string-byte-count
   "Number of bytes in a string using UTF-8 encoding."
   [s]
diff --git a/src/metabase/util/grouper.clj b/src/metabase/util/grouper.clj
new file mode 100644
index 00000000000..e94866b693a
--- /dev/null
+++ b/src/metabase/util/grouper.clj
@@ -0,0 +1,51 @@
+(ns metabase.util.grouper
+  "Our wrapper for grouper -- the batch processing utility.
+
+  Note:
+  - These utilities should only be used for scenarios where data consistency is not a requirement,
+    Execution is best effort and may not occur as the batched items are not persisted.
+  - Suitable for use cases that can tolerate lag time in processing. For example, updating
+    last_used_at of cards after a query execution. Things like recording view_log should not use
+    grouper since it's important to have the data immediately available.
+
+
+  Batch processing can be disabled by setting the environment variable `MB_DISABLE_GROUPER_BATCH_PROCESSING=true`."
+  (:require
+   #_{:clj-kondo/ignore [:discouraged-namespace]}
+   [grouper.core :as grouper]
+   [metabase.models.setting :refer [defsetting]]
+   [metabase.util.i18n :refer [deferred-tru]]
+   [potemkin :as p])
+  (:import
+   (grouper.core Grouper)))
+
+(set! *warn-on-reflection* true)
+
+(comment
+ p/keep-me
+ Grouper/keep-me)
+
+(defsetting synchronous-batch-updates
+  (deferred-tru "Process batches updates synchronously. If true, all `submit!` calls will be processed immediately. Default is false.")
+  ;; Should be used for testing purposes only, currently set by some e2e tests
+  :type       :boolean
+  :default    false
+  :export?    true
+  ;; :admin instead of :internal because we want to change this during e2e testing
+  :visibility :admin)
+
+(p/import-vars
+ [grouper
+  start!
+  shutdown!])
+
+(defn submit!
+  "A wrapper of [[grouper.core/submit!]] that returns nil instead of a promise.
+  We use grouper for fire-and-forget scenarios, so we don't care about the result."
+  [& args]
+  (let [p (apply grouper/submit! args)]
+    (when (synchronous-batch-updates)
+      ;; wake up the group immediately and wait for it to finish
+      (.wakeUp ^Grouper (first args))
+      (deref p))
+    nil))
diff --git a/src/metabase/xrays/automagic_dashboards/comparison.clj b/src/metabase/xrays/automagic_dashboards/comparison.clj
index c94a14a4a79..7766da8eb77 100644
--- a/src/metabase/xrays/automagic_dashboards/comparison.clj
+++ b/src/metabase/xrays/automagic_dashboards/comparison.clj
@@ -92,15 +92,15 @@
                          (update :name #(format "%s (%s)" % (comparison-name right)))
                          vector)]
           (update dashboard :dashcards conj (merge (populate/card-defaults)
-                                                       {:col                    0
-                                                        :row                    row
-                                                        :size_x                 populate/grid-width
-                                                        :size_y                 height
-                                                        :card                   card
-                                                        :card_id                (:id card)
-                                                        :series                 series
-                                                        :visualization_settings {:graph.y_axis.auto_split false
-                                                                                 :graph.series_labels     [(:name card) (:name (first series))]}})))
+                                                   {:col                    0
+                                                    :row                    row
+                                                    :size_x                 populate/grid-width
+                                                    :size_y                 height
+                                                    :card                   card
+                                                    :card_id                (:id card)
+                                                    :series                 series
+                                                    :visualization_settings {:graph.y_axis.auto_split false
+                                                                             :graph.series_labels     [(:name card) (:name (first series))]}})))
         (let [width        (/ populate/grid-width 2)
               series-left  (map clone-card (:series card-left))
               series-right (map clone-card (:series card-right))
@@ -112,23 +112,23 @@
                              (assoc-in [:visualization_settings :graph.colors] [color-right]))]
           (-> dashboard
               (update :dashcards conj (merge (populate/card-defaults)
-                                                 {:col                    0
-                                                  :row                    row
-                                                  :size_x                 width
-                                                  :size_y                 height
-                                                  :card                   card-left
-                                                  :card_id                (:id card-left)
-                                                  :series                 series-left
-                                                  :visualization_settings {}}))
+                                             {:col                    0
+                                              :row                    row
+                                              :size_x                 width
+                                              :size_y                 height
+                                              :card                   card-left
+                                              :card_id                (:id card-left)
+                                              :series                 series-left
+                                              :visualization_settings {}}))
               (update :dashcards conj (merge (populate/card-defaults)
-                                                 {:col                    width
-                                                   :row                    row
-                                                   :size_x                 width
-                                                   :size_y                 height
-                                                   :card                   card-right
-                                                   :card_id                (:id card-right)
-                                                   :series                 series-right
-                                                   :visualization_settings {}}))))))
+                                             {:col                    width
+                                               :row                    row
+                                               :size_x                 width
+                                               :size_y                 height
+                                               :card                   card-right
+                                               :card_id                (:id card-right)
+                                               :series                 series-right
+                                               :visualization_settings {}}))))))
 
     (populate/add-text-card dashboard {:text                   (:text card)
                                        :width                  (/ populate/grid-width 2)
diff --git a/test/metabase/api/activity_test.clj b/test/metabase/api/activity_test.clj
index 5bac8459909..51d6f9a2c83 100644
--- a/test/metabase/api/activity_test.clj
+++ b/test/metabase/api/activity_test.clj
@@ -24,63 +24,67 @@
 (deftest most-recently-viewed-dashboard-views-test
   (clear-recent-views-for-user :crowberto)
   (clear-recent-views-for-user :rasta)
-  (mt/with-temp [Card      card-1  {:name       "rand-name"
-                                    :creator_id (mt/user->id :crowberto)
-                                    :display    "table"}
-                 Dashboard dash-1  {:name        "rand-name2"
-                                    :description "rand-name2"
-                                    :creator_id  (mt/user->id :crowberto)}
-                 Dashboard dash-2  {:name        "rand-name2"
-                                    :description "rand-name2"
-                                    :creator_id  (mt/user->id :crowberto)}
-                 Dashboard dash-3  {:name        "rand-name2"
-                                    :description "rand-name2"
-                                    :archived    true
-                                    :creator_id  (mt/user->id :crowberto)}
-                 Table     table-1 {:name "rand-name"}]
-    (mt/with-test-user :crowberto
-      (doseq [{:keys [topic event]} [{:topic :event/dashboard-read :event {:object-id (:id dash-1)}}
-                                     {:topic :event/dashboard-read :event {:object-id (:id dash-2)}}
-                                     {:topic :event/dashboard-read :event {:object-id (:id dash-3)}}
-                                     {:topic :event/card-query :event {:card-id (:id card-1)}}
-                                     {:topic :event/table-read :event {:object table-1}}]]
-        (events/publish-event! topic (assoc event :user-id (mt/user->id :crowberto))))
-      (testing "most_recently_viewed_dashboard endpoint shows the current user's most recently viewed non-archived dashboard."
-        (is (= (assoc dash-2 :collection nil :view_count 0)
-               (mt/user-http-request :crowberto :get 200 "activity/most_recently_viewed_dashboard")))))
-    (mt/with-test-user :rasta
-      (testing "If nothing has been viewed, return a 204"
-        (is (nil? (mt/user-http-request :rasta :get 204
-                                        "activity/most_recently_viewed_dashboard"))))
-      (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :rasta)})
-      (testing "Only the user's own views are returned."
-        (is (= (assoc dash-1 :collection nil :view_count 0)
-               (mt/user-http-request :rasta :get 200
-                                     "activity/most_recently_viewed_dashboard"))))
-      (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :rasta)})
-      (testing "If the user has no permissions for the dashboard, return a 204"
-        (mt/with-non-admin-groups-no-root-collection-perms
-          (is (nil? (mt/user-http-request :rasta :get 204
-                                          "activity/most_recently_viewed_dashboard"))))))))
+  (mt/test-helpers-set-global-values!
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (mt/with-temp [Card      card-1  {:name       "rand-name"
+                                        :creator_id (mt/user->id :crowberto)
+                                        :display    "table"}
+                     Dashboard dash-1  {:name        "rand-name2"
+                                        :description "rand-name2"
+                                        :creator_id  (mt/user->id :crowberto)}
+                     Dashboard dash-2  {:name        "rand-name2"
+                                        :description "rand-name2"
+                                        :creator_id  (mt/user->id :crowberto)}
+                     Dashboard dash-3  {:name        "rand-name2"
+                                        :description "rand-name2"
+                                        :archived    true
+                                        :creator_id  (mt/user->id :crowberto)}
+                     Table     table-1 {:name "rand-name"}]
+        (mt/with-test-user :crowberto
+          (doseq [{:keys [topic event]} [{:topic :event/dashboard-read :event {:object-id (:id dash-1)}}
+                                         {:topic :event/dashboard-read :event {:object-id (:id dash-2)}}
+                                         {:topic :event/dashboard-read :event {:object-id (:id dash-3)}}
+                                         {:topic :event/card-query :event {:card-id (:id card-1)}}
+                                         {:topic :event/table-read :event {:object table-1}}]]
+            (events/publish-event! topic (assoc event :user-id (mt/user->id :crowberto))))
+          (testing "most_recently_viewed_dashboard endpoint shows the current user's most recently viewed non-archived dashboard."
+            (is (= (assoc dash-2 :collection nil :view_count 1)
+                   (mt/user-http-request :crowberto :get 200 "activity/most_recently_viewed_dashboard")))))
+        (mt/with-test-user :rasta
+          (testing "If nothing has been viewed, return a 204"
+            (is (nil? (mt/user-http-request :rasta :get 204
+                                            "activity/most_recently_viewed_dashboard"))))
+          (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :rasta)})
+          (testing "Only the user's own views are returned."
+            (is (= (assoc dash-1 :collection nil :view_count 2)
+                   (mt/user-http-request :rasta :get 200
+                                         "activity/most_recently_viewed_dashboard"))))
+          (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :rasta)})
+          (testing "If the user has no permissions for the dashboard, return a 204"
+            (mt/with-non-admin-groups-no-root-collection-perms
+              (is (nil? (mt/user-http-request :rasta :get 204
+                                              "activity/most_recently_viewed_dashboard"))))))))))
 
-(deftest most-recently-viewed-dashboard-views-include-colllection-test
-  (mt/with-temp [:model/Collection coll   {:name "Analytics"}
-                 :model/Dashboard  dash-1 {:collection_id (t2/select-one-pk :model/Collection :personal_owner_id (mt/user->id :crowberto))}
-                 :model/Dashboard  dash-2 {:collection_id (:id coll)}]
-    (mt/with-model-cleanup [:model/RecentViews]
-      (mt/with-test-user :crowberto
-        (testing "view a dashboard in a personal collection"
-          (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :crowberto)})
-          (let [crowberto-personal-coll (t2/select-one :model/Collection :personal_owner_id (mt/user->id :crowberto))]
-            (is (= (assoc dash-1 :collection (assoc crowberto-personal-coll :is_personal true) :view_count 0)
-                   (mt/user-http-request :crowberto :get 200
-                                         "activity/most_recently_viewed_dashboard")))))
+(deftest most-recently-viewed-dashboard-views-include-collection-test
+  (mt/test-helpers-set-global-values!
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (mt/with-temp [:model/Collection coll   {:name "Analytics"}
+                     :model/Dashboard  dash-1 {:collection_id (t2/select-one-pk :model/Collection :personal_owner_id (mt/user->id :crowberto))}
+                     :model/Dashboard  dash-2 {:collection_id (:id coll)}]
+        (mt/with-model-cleanup [:model/RecentViews]
+          (mt/with-test-user :crowberto
+            (testing "view a dashboard in a personal collection"
+              (events/publish-event! :event/dashboard-read {:object-id (:id dash-1) :user-id (mt/user->id :crowberto)})
+              (let [crowberto-personal-coll (t2/select-one :model/Collection :personal_owner_id (mt/user->id :crowberto))]
+                (is (= (assoc dash-1 :collection (assoc crowberto-personal-coll :is_personal true) :view_count 1)
+                       (mt/user-http-request :crowberto :get 200
+                                             "activity/most_recently_viewed_dashboard")))))
 
-        (testing "view a dashboard in a public collection"
-          (events/publish-event! :event/dashboard-read {:object-id (:id dash-2) :user-id (mt/user->id :crowberto)})
-          (is (= (assoc dash-2 :collection (assoc coll :is_personal false) :view_count 0)
-                 (mt/user-http-request :crowberto :get 200
-                                       "activity/most_recently_viewed_dashboard"))))))))
+            (testing "view a dashboard in a public collection"
+              (events/publish-event! :event/dashboard-read {:object-id (:id dash-2) :user-id (mt/user->id :crowberto)})
+              (is (= (assoc dash-2 :collection (assoc coll :is_personal false) :view_count 1)
+                     (mt/user-http-request :crowberto :get 200
+                                           "activity/most_recently_viewed_dashboard"))))))))))
 
 (deftest recent-views-test
   (clear-recent-views-for-user :crowberto)
diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj
index 978b9c066df..dc5e25a80de 100644
--- a/test/metabase/api/dashboard_test.clj
+++ b/test/metabase/api/dashboard_test.clj
@@ -745,74 +745,76 @@
 
 (deftest update-dashboard-test
   (testing "PUT /api/dashboard/:id"
-    (t2.with-temp/with-temp [Dashboard {dashboard-id :id} {:name "Test Dashboard"}]
-      (with-dashboards-in-writeable-collection [dashboard-id]
-        (testing "GET before update"
-          (is (= (merge dashboard-defaults {:name          "Test Dashboard"
-                                            :creator_id    (mt/user->id :rasta)
-                                            :collection    false
-                                            :collection_id true})
-                 (dashboard-response (t2/select-one Dashboard :id dashboard-id)))))
-
-        (testing "PUT response"
-          (let [put-response (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
-                                                   {:name        "My Cool Dashboard"
-                                                    :description "Some awesome description"
-                                                    :cache_ttl   1234
-                                                    ;; these things should fail to update
-                                                    :creator_id  (mt/user->id :trashbird)})
-                get-response (mt/user-http-request :rasta :get 200 (format "dashboard/%d" dashboard-id))]
-            (is (=? (merge dashboard-defaults {:name           "My Cool Dashboard"
-                                               :dashcards      []
-                                               :tabs           []
-                                               :description    "Some awesome description"
-                                               :creator_id     (mt/user->id :rasta)
-                                               :cache_ttl      1234
-                                               :last-edit-info {:timestamp true :id true :first_name "Rasta"
-                                                                :last_name "Toucan" :email "rasta@metabase.com"}
-                                               :collection     true
-                                               :collection_id  true})
-                    (dashboard-response put-response)))
-            (testing "A PUT should return the updated value so a follow-on GET is not needed (#34828)"
-              (is (= (update put-response :last-edit-info dissoc :timestamp)
-                     (update get-response :last-edit-info dissoc :timestamp))))))
-
-        (testing "GET after update"
-          (is (= (merge dashboard-defaults {:name          "My Cool Dashboard"
-                                            :description   "Some awesome description"
-                                            :cache_ttl     1234
-                                            :creator_id    (mt/user->id :rasta)
-                                            :collection    false
-                                            :collection_id true
-                                            :view_count    0})
-                 (dashboard-response (t2/select-one Dashboard :id dashboard-id)))))
-
-        (testing "No-op PUT: Do not return 500"
-          (t2.with-temp/with-temp [Card {card-id :id} {}
-                                   DashboardCard dashcard {:card_id card-id, :dashboard_id dashboard-id}]
-            ;; so, you can't actually set `:cards` with THIS endpoint (you have to use PUT /api/dashboard/:id/cards)
-            ;; but the e2e tests are trying to do it. With Toucan 1, it would silently do nothing and return truthy for
-            ;; whatever reason (I'm guessing it was a bug?) if you did something like (update! Dashboard 1 {}). Toucan 2
-            ;; returns falsey, since it doesn't do anything, which is what Toucan 1 SAID it was supposed to do.
-            ;;
-            ;; In the interest of un-busting the e2e tests let's just check to make sure the endpoint no-ops
-            (is (=? {:id dashboard-id}
-                    (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
-                                          {:cards [(select-keys dashcard [:id :card_id :row_col :size_x :size_y])]})))))))
-   (testing "auto_apply_filters test"
-     (doseq [enabled? [true false]]
-       (t2.with-temp/with-temp [Dashboard {dashboard-id :id} {:name               "Test Dashboard"
-                                                              :auto_apply_filters enabled?}]
-         (testing "Can set it"
-           (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
-                                 {:auto_apply_filters (not enabled?)})
-           (is (= (not enabled?)
-                  (t2/select-one-fn :auto_apply_filters Dashboard :id dashboard-id))))
-         (testing "If not in put it is not changed"
-           (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
-                                 {:description "foo"})
-           (is (= (not enabled?)
-                  (t2/select-one-fn :auto_apply_filters Dashboard :id dashboard-id)))))))))
+    (mt/test-helpers-set-global-values!
+      (mt/with-temporary-setting-values [synchronous-batch-updates true]
+        (t2.with-temp/with-temp [Dashboard {dashboard-id :id} {:name "Test Dashboard"}]
+          (with-dashboards-in-writeable-collection [dashboard-id]
+            (testing "GET before update"
+              (is (= (merge dashboard-defaults {:name          "Test Dashboard"
+                                                :creator_id    (mt/user->id :rasta)
+                                                :collection    false
+                                                :collection_id true})
+                     (dashboard-response (t2/select-one Dashboard :id dashboard-id)))))
+
+            (testing "PUT response"
+              (let [put-response (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
+                                                       {:name        "My Cool Dashboard"
+                                                        :description "Some awesome description"
+                                                        :cache_ttl   1234
+                                                        ;; these things should fail to update
+                                                        :creator_id  (mt/user->id :trashbird)})
+                    get-response (mt/user-http-request :rasta :get 200 (format "dashboard/%d" dashboard-id))]
+                (is (=? (merge dashboard-defaults {:name           "My Cool Dashboard"
+                                                   :dashcards      []
+                                                   :tabs           []
+                                                   :description    "Some awesome description"
+                                                   :creator_id     (mt/user->id :rasta)
+                                                   :cache_ttl      1234
+                                                   :last-edit-info {:timestamp true :id true :first_name "Rasta"
+                                                                    :last_name "Toucan" :email "rasta@metabase.com"}
+                                                   :collection     true
+                                                   :collection_id  true})
+                        (dashboard-response put-response)))
+                (testing "A PUT should return the updated value so a follow-on GET is not needed (#34828)"
+                  (is (= (update put-response :last-edit-info dissoc :timestamp)
+                         (update get-response :last-edit-info dissoc :timestamp))))))
+
+            (testing "GET after update"
+              (is (= (merge dashboard-defaults {:name          "My Cool Dashboard"
+                                                :description   "Some awesome description"
+                                                :cache_ttl     1234
+                                                :creator_id    (mt/user->id :rasta)
+                                                :collection    false
+                                                :collection_id true
+                                                :view_count    1})
+                     (dashboard-response (t2/select-one Dashboard :id dashboard-id)))))
+
+            (testing "No-op PUT: Do not return 500"
+              (t2.with-temp/with-temp [Card {card-id :id} {}
+                                       DashboardCard dashcard {:card_id card-id, :dashboard_id dashboard-id}]
+                ;; so, you can't actually set `:cards` with THIS endpoint (you have to use PUT /api/dashboard/:id/cards)
+                ;; but the e2e tests are trying to do it. With Toucan 1, it would silently do nothing and return truthy for
+                ;; whatever reason (I'm guessing it was a bug?) if you did something like (update! Dashboard 1 {}). Toucan 2
+                ;; returns falsey, since it doesn't do anything, which is what Toucan 1 SAID it was supposed to do.
+                ;;
+                ;; In the interest of un-busting the e2e tests let's just check to make sure the endpoint no-ops
+                (is (=? {:id dashboard-id}
+                        (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
+                                              {:cards [(select-keys dashcard [:id :card_id :row_col :size_x :size_y])]})))))))
+       (testing "auto_apply_filters test"
+         (doseq [enabled? [true false]]
+           (t2.with-temp/with-temp [Dashboard {dashboard-id :id} {:name               "Test Dashboard"
+                                                                  :auto_apply_filters enabled?}]
+             (testing "Can set it"
+               (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
+                                     {:auto_apply_filters (not enabled?)})
+               (is (= (not enabled?)
+                      (t2/select-one-fn :auto_apply_filters Dashboard :id dashboard-id))))
+             (testing "If not in put it is not changed"
+               (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id)
+                                     {:description "foo"})
+               (is (= (not enabled?)
+                      (t2/select-one-fn :auto_apply_filters Dashboard :id dashboard-id)))))))))))
 
 
 (deftest update-dashboard-guide-columns-test
diff --git a/test/metabase/events/view_log_test.clj b/test/metabase/events/view_log_test.clj
index 3819e48eae9..740785598f4 100644
--- a/test/metabase/events/view_log_test.clj
+++ b/test/metabase/events/view_log_test.clj
@@ -6,6 +6,7 @@
    [metabase.api.embed-test :as embed-test]
    [metabase.api.public-test :as public-test]
    [metabase.events :as events]
+   [metabase.events.view-log :as events.view-log]
    [metabase.http-client :as client]
    [metabase.models.data-permissions :as data-perms]
    [metabase.models.permissions-group :as perms-group]
@@ -13,6 +14,8 @@
    [metabase.util :as u]
    [toucan2.core :as t2]))
 
+(set! *warn-on-reflection* true)
+
 (defn latest-view
   "Returns the most recent view for a given user and model ID"
   [user-id model-id]
@@ -100,52 +103,74 @@
           (testing "Card read events are not recorded when viewing a dashboard"
             (is (nil? (latest-view (u/id user) (u/id card))))))))))
 
-;; Disable view_count updates to handle perf issues  (for now) (#44359)
-#_
 (deftest card-read-view-count-test
-  (mt/with-temp [:model/User user {}
-                 :model/Card card {:creator_id (u/id user)}]
-    (testing "Card read events are recorded by a card's view_count"
-      (is (= 0 (:view_count card))
-          "view_count should be 0 before the event is published")
-      (events/publish-event! :event/card-read {:object-id (:id card) :user-id (u/the-id user) :context :question})
-      (is (= 1 (t2/select-one-fn :view_count :model/Card (:id card))))
-      (events/publish-event! :event/card-read {:object-id (:id card) :user-id (u/the-id user) :context :question})
-      (is (= 2 (t2/select-one-fn :view_count :model/Card (:id card)))))))
-
-;; Disable view_count updates to handle perf issues  (for now) (#44359)
-#_
+  (mt/test-helpers-set-global-values!
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (mt/with-temp [:model/User user {}
+                     :model/Card card {:creator_id (u/id user)}]
+        (testing "Card read events are recorded by a card's view_count"
+          (is (= 0 (:view_count card))
+              "view_count should be 0 before the event is published")
+          (events/publish-event! :event/card-read {:object-id (:id card) :user-id (u/the-id user) :context :question})
+          (is (= 1 (t2/select-one-fn :view_count :model/Card (:id card))))
+          (events/publish-event! :event/card-read {:object-id (:id card) :user-id (u/the-id user) :context :question})
+          (is (= 2 (t2/select-one-fn :view_count :model/Card (:id card)))))))))
+
 (deftest dashboard-read-view-count-test
-  (mt/with-temp [:model/User          user      {}
-                 :model/Dashboard     dashboard {:creator_id (u/id user)}
-                 :model/Card          card      {:name "Dashboard Test Card"}
-                 :model/DashboardCard _dashcard {:dashboard_id (u/id dashboard) :card_id (u/id card)}]
-    (let [dashboard (t2/hydrate dashboard [:dashcards :card])]
-      (testing "Dashboard read events are recorded by a dashboard's view_count"
-        (is (= 0 (:view_count dashboard) (:view_count card))
-            "view_count should be 0 before the event is published")
-        (events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id (u/the-id user)})
-        (is (= 1 (t2/select-one-fn :view_count :model/Dashboard (:id dashboard))))
-        (is (= 0 (t2/select-one-fn :view_count :model/Card (:id card)))
-            "view_count for cards on the dashboard should not be incremented")
-        (events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id (u/the-id user)})
-        (is (= 2 (t2/select-one-fn :view_count :model/Dashboard (:id dashboard))))))))
-
-;; Disable view_count updates to handle perf issues  (for now) (#44359)
-#_
+  (mt/test-helpers-set-global-values!
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (mt/with-temp [:model/User          user      {}
+                     :model/Dashboard     dashboard {:creator_id (u/id user)}
+                     :model/Card          card      {:name "Dashboard Test Card"}
+                     :model/DashboardCard _dashcard {:dashboard_id (u/id dashboard) :card_id (u/id card)}]
+        (let [dashboard (t2/hydrate dashboard [:dashcards :card])]
+          (testing "Dashboard read events are recorded by a dashboard's view_count"
+            (is (= 0 (:view_count dashboard) (:view_count card))
+                "view_count should be 0 before the event is published")
+            (events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id (u/the-id user)})
+            (is (= 1 (t2/select-one-fn :view_count :model/Dashboard (:id dashboard))))
+            (is (= 0 (t2/select-one-fn :view_count :model/Card (:id card)))
+                "view_count for cards on the dashboard should not be incremented")
+            (events/publish-event! :event/dashboard-read {:object-id (:id dashboard) :user-id (u/the-id user)})
+            (is (= 2 (t2/select-one-fn :view_count :model/Dashboard (:id dashboard))))))))))
+
 (deftest table-read-view-count-test
-  (mt/with-temp [:model/User  user  {}
-                 :model/Table table {}]
-    (testing "Card read events are recorded by a card's view_count"
-      (is (= 0 (:view_count table))
-          "view_count should be 0 before the event is published")
-      (events/publish-event! :event/table-read {:object table :user-id (u/the-id user)})
-      (is (= 1 (t2/select-one-fn :view_count :model/Table (:id table)))
-          "view_count should be incremented")
-      (events/publish-event! :event/table-read {:object table :user-id (u/the-id user)})
-      (is (= 2 (t2/select-one-fn :view_count :model/Table (:id table)))
-          "view_count should be incremented"))))
+  (mt/test-helpers-set-global-values!
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (mt/with-temp [:model/User  user  {}
+                     :model/Table table {}]
+        (testing "Card read events are recorded by a card's view_count"
+          (is (= 0 (:view_count table))
+              "view_count should be 0 before the event is published")
+          (events/publish-event! :event/table-read {:object table :user-id (u/the-id user)})
+          (is (= 1 (t2/select-one-fn :view_count :model/Table (:id table)))
+              "view_count should be incremented")
+          (events/publish-event! :event/table-read {:object table :user-id (u/the-id user)})
+          (is (= 2 (t2/select-one-fn :view_count :model/Table (:id table)))
+              "view_count should be incremented"))))))
 
+(deftest increment-view-counts!*-test
+  (mt/with-temp [:model/Card  {card-1-id :id} {}
+                 :model/Card  {card-2-id :id} {:view_count 2}
+                 :model/Table {table-id :id}  {}]
+    (t2/with-call-count [call-count]
+      (testing "increment-view-counts!* update the view_count correctly"
+        (#'events.view-log/increment-view-counts!* [;; table-id : 1 views, card-id-1: 2 views, card-id 2: 2 views
+                                                    {:model :model/Table :id table-id}
+                                                    {:model :model/Card  :id card-1-id}
+                                                    {:model :model/Card  :id card-1-id}
+                                                    {:model :model/Card  :id card-2-id}
+                                                    {:model :model/Card  :id card-2-id}])
+        (is (= 2 ;; one for update card, one for table
+               (call-count))
+            "and groups db calls by frequency")
+        (is (= 1 (t2/select-one-fn :view_count :model/Table table-id))
+            "view_count for table-id should be 1")
+        (is (= 2 (t2/select-one-fn :view_count :model/Card card-1-id))
+            "view_count for card-1 should be 2")
+        (is (= 4 ;; 2 old + 2 new
+               (t2/select-one-fn :view_count :model/Card card-2-id))
+            "view_count for card-2 should be 2")))))
 
 ;;; ---------------------------------------- API tests begin -----------------------------------------
 
diff --git a/test/metabase/query_processor/middleware/process_userland_query_test.clj b/test/metabase/query_processor/middleware/process_userland_query_test.clj
index be214857575..bc3cbe1c0d8 100644
--- a/test/metabase/query_processor/middleware/process_userland_query_test.clj
+++ b/test/metabase/query_processor/middleware/process_userland_query_test.clj
@@ -179,26 +179,28 @@
 #_
 (deftest save-field-usage-test
   (testing "execute an userland query will capture field usages"
-    (mt/with-model-cleanup [:model/FieldUsage]
-      (mt/with-temp [:model/Field {field-id :id} {:table_id (mt/id :products)
-                                                  :name     "very_interesting_field"
-                                                  :base_type :type/Integer}
-                     :model/Card card            {:dataset_query (mt/mbql-query products
-                                                                                {:filter [:> [:field field-id nil] 1]})}]
-        (binding [qp.util/*execute-async?* false
-                  qp.pipeline/*execute*    (fn [_driver _query respond]
-                                             (respond {} []))]
-          (mt/user-http-request :crowberto :post 202 (format "/card/%d/query" (:id card)))
-          (is (=? [{:filter_op                  :>
-                    :breakout_temporal_unit     nil
-                    :breakout_binning_strategy  nil
-                    :breakout_binning_bin_width nil
-                    :breakout_binning_num_bins  nil
-                    :used_in                    :filter
-                    :aggregation_function       nil
-                    :field_id                   field-id
-                    :query_execution_id         (mt/malli=? pos-int?)}]
-                  (t2/select :model/FieldUsage :field_id field-id))))))))
+    (mt/test-helpers-set-global-values!
+      (mt/with-model-cleanup [:model/FieldUsage]
+        (mt/with-temporary-setting-values [synchronous-batch-updates true]
+          (mt/with-temp [:model/Field {field-id :id} {:table_id (mt/id :products)
+                                                      :name     "very_interesting_field"
+                                                      :base_type :type/Integer}
+                         :model/Card card            {:dataset_query (mt/mbql-query products
+                                                                                    {:filter [:> [:field field-id nil] 1]})}]
+            (binding [qp.util/*execute-async?* false
+                      qp.pipeline/*execute*    (fn [_driver _query respond]
+                                                 (respond {} []))]
+              (mt/user-http-request :crowberto :post 202 (format "/card/%d/query" (:id card)))
+              (is (=? [{:filter_op                  :>
+                        :breakout_temporal_unit     nil
+                        :breakout_binning_strategy  nil
+                        :breakout_binning_bin_width nil
+                        :breakout_binning_num_bins  nil
+                        :used_in                    :filter
+                        :aggregation_function       nil
+                        :field_id                   field-id
+                        :query_execution_id         (mt/malli=? pos-int?)}]
+                      (t2/select :model/FieldUsage :field_id field-id))))))))))
 
 (deftest query-result-should-not-contains-preprocessed-query-test
   (let [query (mt/mbql-query venues {:limit 1})]
diff --git a/test/metabase/query_processor/middleware/update_used_cards_test.clj b/test/metabase/query_processor/middleware/update_used_cards_test.clj
index d79ed0f1719..696021fcd67 100644
--- a/test/metabase/query_processor/middleware/update_used_cards_test.clj
+++ b/test/metabase/query_processor/middleware/update_used_cards_test.clj
@@ -1,74 +1,88 @@
 (ns metabase.query-processor.middleware.update-used-cards-test
   (:require
    [clojure.test :refer :all]
+   [java-time.api :as t]
    [metabase.dashboard-subscription-test :as dashboard-subscription-test]
    [metabase.pulse :as pulse]
    [metabase.pulse-test :as pulse-test]
    [metabase.query-processor :as qp]
+   [metabase.query-processor.middleware.update-used-cards :as qp.update-used-cards]
    [metabase.query-processor.pipeline :as qp.pipeline]
    [metabase.query-processor.util :as qp.util]
    [metabase.test :as mt]
-   #_
    [toucan2.core :as t2]))
 
-(defmacro with-used-cards-setup
+(defmacro with-used-cards-setup!
   [& body]
-  `(binding [qp.pipeline/*execute*    (fn [_driver# _query# respond#] (respond# {} []))
-             qp.util/*execute-async?* false]
-     ~@body))
+  `(mt/test-helpers-set-global-values!
+     (binding [qp.pipeline/*execute*    (fn [_driver# _query# respond#] (respond# {} []))
+               qp.util/*execute-async?* false]
+       ~@body)))
 
-#_
 (defn- card-last-used-at
   [card-id]
   (t2/select-one-fn :last_used_at :model/Card card-id))
 
-(defn do-test
+(defn do-test!
   "Check if `last_used_at` of `card-id` is nil, then execute `f`, then check that `last_used_at` is non nil."
-  [_card-id thunk]
+  [card-id thunk]
   (assert (fn? thunk))
-  (testing "last_used_at should be nil to start with"
-    ;; Disable last_used_at updates to handle perf issues  (for now) (#44359)
-    #_
-    (is (nil? (card-last-used-at card-id))))
-  (thunk)
-  (testing "last_used_at should be updated to non nil"
-    ;; Disable last_used_at updates to handle perf issues  (for now) (#44359)
-    #_
-    (is (some? (card-last-used-at card-id)))))
+  (let [original-last-used-at (card-last-used-at card-id)]
+   (mt/with-temporary-setting-values [synchronous-batch-updates true]
+     (thunk))
+   (testing "last_used_at should be updated after executing the query"
+     (is (not= original-last-used-at (card-last-used-at card-id))))))
 
-(deftest ^:parallel nested-cards-test
-  (with-used-cards-setup
+(deftest nested-cards-test
+  (with-used-cards-setup!
     (mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query venues)}]
-      (do-test card-id #(qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-id)}))))))
+      (do-test! card-id #(qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-id)}))))))
 
-(deftest ^:parallel joined-card-test
-  (with-used-cards-setup
+(deftest joined-card-test
+  (with-used-cards-setup!
     (mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query products)}]
-      (do-test card-id #(qp/process-query (mt/mbql-query orders  {:joins [{:fields       "all",
-                                                                           :source-table (format "card__%d" card-id)
-                                                                           :condition    [:= $orders.product_id &product.products.id]
-                                                                           :alias        "product"}]}))))))
+      (do-test! card-id #(qp/process-query (mt/mbql-query orders  {:joins [{:fields       "all",
+                                                                            :source-table (format "card__%d" card-id)
+                                                                            :condition    [:= $orders.product_id &product.products.id]
+                                                                            :alias        "product"}]}))))))
 
-(deftest ^:parallel card-reference-in-native-query-test
-  (with-used-cards-setup
+(deftest card-reference-in-native-query-test
+  (with-used-cards-setup!
     (mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query venues)}]
-      (do-test card-id #(qp/process-query (mt/native-query {:query         "SELECT * FROM {{#card}}"
-                                                            :template-tags {"#card" {:card-id      card-id
-                                                                                     :display-name "card"
-                                                                                     :id           "card"
-                                                                                     :name         "card"
-                                                                                     :type         "card"}}}))))))
+      (do-test! card-id #(qp/process-query (mt/native-query {:query         "SELECT * FROM {{#card}}"
+                                                             :template-tags {"#card" {:card-id      card-id
+                                                                                      :display-name "card"
+                                                                                      :id           "card"
+                                                                                      :name         "card"
+                                                                                      :type         "card"}}}))))))
 
-(deftest ^:parallel alert-test
-  (with-used-cards-setup
+(deftest alert-test
+  (with-used-cards-setup!
     (mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query venues)}]
       (pulse-test/with-pulse-for-card [pulse {:card card-id}]
-        (do-test card-id #(pulse/send-pulse! pulse))))))
+        (do-test! card-id #(pulse/send-pulse! pulse))))))
 
-(deftest ^:parallel dashboard-subscription-test
-  (with-used-cards-setup
+(deftest dashboard-subscription-test
+  (with-used-cards-setup!
     (mt/with-temp [:model/Dashboard dash          {}
                    :model/Card      {card-id :id} {:dataset_query (mt/mbql-query venues)}]
       (dashboard-subscription-test/with-dashboard-sub-for-card [pulse {:card      card-id
                                                                        :dashboard dash}]
-        (do-test card-id #(pulse/send-pulse! pulse))))))
+        (do-test! card-id #(pulse/send-pulse! pulse))))))
+
+(deftest update-used-card-timestamp-test
+  (let [now           (t/offset-date-time)
+        one-hour-ago  (t/minus now (t/hours 1))
+        two-hours-ago (t/minus now (t/hours 2))]
+    (testing "update with multiple cards of the same ids will set timestamp to the latest"
+      (mt/with-temp
+        [:model/Card {card-id-1 :id} {:last_used_at two-hours-ago}]
+        (#'qp.update-used-cards/update-used-cards!* [{:id card-id-1 :timestamp two-hours-ago}
+                                                     {:id card-id-1 :timestamp one-hour-ago}])
+        (is (= one-hour-ago (t2/select-one-fn :last_used_at :model/Card card-id-1)))))
+
+    (testing "if the existing last_used_at is greater than the updating values, do not override it"
+      (mt/with-temp
+        [:model/Card {card-id-2 :id} {:last_used_at now}]
+        (#'qp.update-used-cards/update-used-cards!* [{:id card-id-2 :timestamp one-hour-ago}])
+        (is (= now (t2/select-one-fn :last_used_at :model/Card card-id-2)))))))
diff --git a/test/metabase/test/util/async.clj b/test/metabase/test/util/async.clj
index 6c3d9a8d3a3..9fa75330304 100644
--- a/test/metabase/test/util/async.clj
+++ b/test/metabase/test/util/async.clj
@@ -1,7 +1,9 @@
-(ns metabase.test.util.async
+(ns ^{:added "0.50.0"} metabase.test.util.async
   (:require
    [clojure.core.async :as a]))
 
+(set! *warn-on-reflection* true)
+
 (defmacro with-open-channels
   "Like [[with-open]], but closes core.async channels at the conclusion of `body`."
   [[binding chan & more] & body]
diff --git a/test/metabase/util/grouper_test.clj b/test/metabase/util/grouper_test.clj
new file mode 100644
index 00000000000..de5c875abdb
--- /dev/null
+++ b/test/metabase/util/grouper_test.clj
@@ -0,0 +1,19 @@
+(ns metabase.util.grouper-test
+  (:require
+   [clojure.test :refer :all]
+   [metabase.test :as mt]
+   [metabase.util :as u]
+   [metabase.util.grouper :as grouper]))
+
+(deftest synchronous-batch-updates-test
+  (testing "with grouper disabled, the submitted item should be processed immediately"
+    (mt/with-temporary-setting-values [synchronous-batch-updates true]
+      (let [processed?  (atom nil)
+            g           (grouper/start!
+                         (fn [items]
+                           (reset! processed? items))
+                         :capacity 5
+                         :interval (* 10 1000))]
+        (u/with-timeout 1000
+          (grouper/submit! g 1))
+        (is (= [1] @processed?))))))
-- 
GitLab