diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj index 834ec9571d1bfafae1c4a6b213b0f3c2e6b8a90d..0a8ef093752191d27b46346a9bcc9b0a7836c752 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj @@ -11,6 +11,13 @@ (declare load-one!) +(defn- without-references + "Remove references to other entities from a given one. Used to break circular dependencies when loading." + [entity] + (if (:dashcards entity) + (dissoc entity :dashcards) + (throw (ex-info "No known references found when breaking circular dependency!" {:entity entity})))) + (defn- load-deps! "Given a list of `deps` (hierarchies), [[load-one]] them all. If [[load-one]] throws because it can't find that entity in the filesystem, check if it's already loaded in @@ -22,29 +29,41 @@ (try (load-one! ctx dep) (catch Exception e - (if (and (= (:error (ex-data e)) ::not-found) - (serdes/load-find-local dep)) + (cond ;; It was missing but we found it locally, so just return the context. + (and (= (:error (ex-data e)) ::not-found) + (serdes/load-find-local dep)) ctx - ;; Different error, or couldn't find it locally, so rethrow. + + ;; It's a circular dep, strip off probable cause and retry. This will store an incomplete version + ;; of an entity, but this is not a problem - a full version is waiting to be stored up the stack. + (= (:error (ex-data e)) ::circular) + (do + (log/debug "Detected circular dependency" (serdes/log-path-str dep)) + (load-one! (update ctx :expanding disj dep) dep without-references)) + + :else (throw e)))))] (reduce loader ctx deps)))) (defn- load-one! - "Loads a single entity, specified by its `:serdes/meta` abstract path, into the appdb, doing some bookkeeping to avoid - cycles. + "Loads a single entity, specified by its `:serdes/meta` abstract path, into the appdb, doing some bookkeeping to + avoid cycles. - If the incoming entity has any dependencies, they are recursively processed first (postorder) so that any foreign key - references in this entity can be resolved properly. + If the incoming entity has any dependencies, they are recursively processed first (postorder) so that any foreign + key references in this entity can be resolved properly. This is mostly bookkeeping for the overall deserialization process - the actual load of any given entity is done by [[metabase.models.serialization/load-one!]] and its various overridable parts, which see. - Circular dependencies are not allowed, and are detected and thrown as an error." - [{:keys [expanding ingestion seen] :as ctx} path] + Error is thrown on a circular dependency, should be handled and retried at the caller. `modfn` is an optional + parameter to modify entity data after reading and before other processing (before loading dependencies, finding + local version, and storing in the db)." + [{:keys [expanding ingestion seen] :as ctx} path & [modfn]] (log/infof "Loading %s" (serdes/log-path-str path)) (cond - (expanding path) (throw (ex-info (format "Circular dependency on %s" (pr-str path)) {:path path})) + (expanding path) (throw (ex-info (format "Circular dependency on %s" (pr-str path)) {:path path + :error ::circular})) (seen path) ctx ; Already been done, just skip it. :else (let [ingested (try (serdes.ingest/ingest-one ingestion path) @@ -54,7 +73,10 @@ :deps-chain expanding :error ::not-found} e)))) + ingested (cond-> ingested + modfn modfn) deps (serdes/dependencies ingested) + _ (log/debug "Loading dependencies" deps) ctx (-> ctx (update :expanding conj path) (load-deps! deps) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj index 88dfc7aa7847c0418e3705021c327e36b5d76834..bf49cbe26d8c3f8ab4fae66ac3187f9ec37a1598 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj @@ -1223,3 +1223,36 @@ (serdes.load/load-metabase! (ingestion-in-memory @serialized)))) (is (= (str "qwe_" (:name coll)) (t2/select-one-fn :name Collection :id (:id card)))))))))) + +(deftest circular-links-test + (mt/with-empty-h2-app-db + (let [coll (ts/create! Collection :name "coll") + card (ts/create! Card :name "card" :collection_id (:id coll)) + dash1 (ts/create! Dashboard :name "dash1" :collection_id (:id coll)) + dash2 (ts/create! Dashboard :name "dash2" :collection_id (:id coll)) + dash3 (ts/create! Dashboard :name "dash3" :collection_id (:id coll)) + dc1 (ts/create! DashboardCard :dashboard_id (:id dash1) :card_id (:id card) + :visualization_settings {:click_behavior {:type "link" + :linkType "dashboard" + :targetId (:id dash2)}}) + dc2 (ts/create! DashboardCard :dashboard_id (:id dash2) :card_id (:id card) + :visualization_settings {:click_behavior {:type "link" + :linkType "dashboard" + :targetId (:id dash3)}}) + dc3 (ts/create! DashboardCard :dashboard_id (:id dash2) :card_id (:id card) + :visualization_settings {:click_behavior {:type "link" + :linkType "dashboard" + :targetId (:id dash1)}}) + ser (atom nil)] + (reset! ser (into [] (serdes.extract/extract {:no-settings true + :no-data-model true}))) + (t2/delete! DashboardCard :id [:in (map :id [dc1 dc2 dc3])]) + (testing "Circular dependencies are loaded correctly" + (is (serdes.load/load-metabase! (ingestion-in-memory @ser))) + (let [select-target #(-> % :visualization_settings :click_behavior :targetId)] + (is (= (:id dash2) + (t2/select-one-fn select-target DashboardCard :entity_id (:entity_id dc1)))) + (is (= (:id dash3) + (t2/select-one-fn select-target DashboardCard :entity_id (:entity_id dc2)))) + (is (= (:id dash1) + (t2/select-one-fn select-target DashboardCard :entity_id (:entity_id dc3)))))))))