From e8201e38b7816eadacc5e111e1c7234fff6cf6c4 Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Fri, 10 May 2024 23:22:09 +0700
Subject: [PATCH] Thread safe static viz (#42494)

---
 src/metabase/pulse/render/js_engine.clj       | 14 +++++---
 src/metabase/pulse/render/js_svg.clj          |  1 -
 test/metabase/pulse/render/body_test.clj      | 32 +++++++++++++++++--
 test/metabase/pulse/render/js_engine_test.clj | 10 ++++++
 4 files changed, 50 insertions(+), 7 deletions(-)

diff --git a/src/metabase/pulse/render/js_engine.clj b/src/metabase/pulse/render/js_engine.clj
index 634c298723c..916a0cce3aa 100644
--- a/src/metabase/pulse/render/js_engine.clj
+++ b/src/metabase/pulse/render/js_engine.clj
@@ -46,10 +46,16 @@
 (defn execute-fn-name
   "Executes `js-fn-name` in js context with args"
   ^Value [^Context context js-fn-name & args]
-  (let [fn-ref (.eval context "js" js-fn-name)
-        args   (into-array Object args)]
-    (assert (.canExecute fn-ref) (str "cannot execute " js-fn-name))
-    (.execute fn-ref args)))
+  ;; TODO: locking context is not ideal, but contexts are currently being shared with all threads and GraalVM doesn't
+  ;; support concurrent execution for js.
+  ;; There is a couple of idea we can try:
+  ;; - put a thread pool around context initialization
+  ;; - init a new context for each thread
+  (locking context
+    (let [fn-ref (.eval context "js" js-fn-name)
+          args   (into-array Object args)]
+      (assert (.canExecute fn-ref) (str "cannot execute " js-fn-name))
+      (.execute fn-ref args))))
 
 (defn execute-fn
   "fn-ref should be an executable org.graalvm.polyglot.Value return from a js engine. Invoke this function with args."
diff --git a/src/metabase/pulse/render/js_svg.clj b/src/metabase/pulse/render/js_svg.clj
index 7ab07f8ba1e..94ea37ac7ce 100644
--- a/src/metabase/pulse/render/js_svg.clj
+++ b/src/metabase/pulse/render/js_svg.clj
@@ -37,7 +37,6 @@
 (def ^:private static-viz-context-delay
   "Delay containing a graal js context. It has the chart bundle and the above `src-api` in its environment suitable
   for creating charts."
-  ;; todo is this thread safe? Should we have a resource pool on top of this? Or create them fresh for each invocation
   (delay (load-viz-bundle (js/context))))
 
 (defn- context
diff --git a/test/metabase/pulse/render/body_test.clj b/test/metabase/pulse/render/body_test.clj
index 8c76c19439c..47aa3ca8d41 100644
--- a/test/metabase/pulse/render/body_test.clj
+++ b/test/metabase/pulse/render/body_test.clj
@@ -8,6 +8,7 @@
    [hickory.select :as hik.s]
    [metabase.formatter :as formatter]
    [metabase.models :refer [Card]]
+   [metabase.pulse :as pulse]
    [metabase.pulse.render.body :as body]
    [metabase.pulse.render.test-util :as render.tu]
    [metabase.pulse.util :as pu]
@@ -692,7 +693,7 @@
   [content-to-match]
   (fn [loc]
     (let [{:keys [content]} (zip/node loc)]
-      (= content content-to-match ))))
+      (= content content-to-match))))
 
 (defn- parse-transform [s]
   (let [numbers (-> (re-find #"matrix\((.+)\)" s)
@@ -719,7 +720,7 @@
                     :Gizmo     {:axis dir}
                     :Widget    {:axis dir}},
                    :graph.dimensions ["PRICE" "CATEGORY"],
-                   :graph.metrics    ["count"]}) ]
+                   :graph.metrics    ["count"]})]
         (mt/with-temp [:model/Card {left-card-id :id} {:display                :bar
                                                        :visualization_settings (viz "left")
                                                        :dataset_query          q}
@@ -751,3 +752,30 @@
                                          :e)]
               (is (= 1 (count axis-label-element)))
               (is (< 200 axis-y-transform)))))))))
+
+(defn- render-card
+  [render-type card data]
+  (body/render render-type :attachment (pulse/defaulted-timezone card) card nil data))
+
+(defn execute-n-times-in-parallel
+  "Execute f n times in parallel and derefs all the results."
+  [f n]
+  (mapv deref (for [_ (range n)]
+               (future (f)))))
+
+(deftest render-cards-are-thread-safe-test-for-js-visualization
+  (mt/with-temp [:model/Card card {:dataset_query          (mt/mbql-query orders
+                                                                          {:aggregation [[:count]]
+                                                                           :breakout    [$orders.created_at]
+                                                                           :limit       1})
+                                   :display                :line
+                                   :visualization_settings {:graph.dimensions ["CREATED_AT"]
+                                                            :graph.metrics    ["count"]}}]
+    (let [data (:data (qp/process-query (:dataset_query card)))]
+      (is (every? some? (execute-n-times-in-parallel #(render-card :javascript_visualization card data) 3))))))
+
+(deftest render-cards-are-thread-safe-test-for-table
+  (mt/with-temp [:model/Card card {:dataset_query (mt/mbql-query venues {:limit 1})
+                                   :display       :table}]
+    (let [data (:data (qp/process-query (:dataset_query card)))]
+      (is (every? some? (execute-n-times-in-parallel #(render-card :table card data) 3))))))
diff --git a/test/metabase/pulse/render/js_engine_test.clj b/test/metabase/pulse/render/js_engine_test.clj
index e57f777f8c5..9f194ee7536 100644
--- a/test/metabase/pulse/render/js_engine_test.clj
+++ b/test/metabase/pulse/render/js_engine_test.clj
@@ -1,6 +1,7 @@
 (ns metabase.pulse.render.js-engine-test
   (:require
    [clojure.test :refer :all]
+   [metabase.pulse.render.body-test :as body-test]
    [metabase.pulse.render.js-engine :as js]))
 
 (set! *warn-on-reflection* true)
@@ -16,3 +17,12 @@
                          "curried function test")
       (let [curried (js/execute-fn-name context "curry_plus" 1)]
         (is (= 3 (.asLong (js/execute-fn curried 2))))))))
+
+(deftest thread-safe-execute-fn-name-test
+  (testing "execute-fn-name is thread safe"
+    (let [context (js/context)]
+      (js/load-js-string context "function plus (x, y) { return x + y }" "plus test")
+      (is (= (repeat 10 2)
+             (body-test/execute-n-times-in-parallel
+              #(.asLong (js/execute-fn-name context "plus" 1 1))
+              10))))))
-- 
GitLab