Explorar o código

fix: offline extends cycle

Tienson Qin hai 5 días
pai
achega
77f4629b22
Modificáronse 1 ficheiros con 202 adicións e 122 borrados
  1. 202 122
      deps/db-sync/src/logseq/db_sync/cycle.cljs

+ 202 - 122
deps/db-sync/src/logseq/db_sync/cycle.cljs

@@ -1,64 +1,87 @@
 (ns logseq.db-sync.cycle
-  "Generic cycle / bad-ref repair utilities for DataScript graphs.
+  "Generic cycle repair utilities for DataScript graphs.
 
-  Goal:
-  - Support multiple ref attributes that can form chains/cycles, e.g.
-    :block/parent, :logseq.property.class/extends, etc.
-    * Cycle repair (after rebase): detect & break cycles, preferably breaking edges
-      introduced by local rebase (if available).
+  Supports:
+  - :block/parent (cardinality-one)
+  - :logseq.property.class/extends (cardinality-many)
 
-  Notes:
-  - We assume attributes are single-valued refs (cardinality-one).
-  - We intentionally keep repairs as simple datoms (db/retract + db/add) to avoid
-    triggering complex outliner logic."
+  Runs AFTER rebase:
+  - Detect cycles reachable from touched entities and break enough edges until
+    the graph becomes acyclic (within a bounded number of iterations).
 
+  Notes:
+  - Repairs are plain datoms: [:db/retract e attr v] (and optional add).
+  - For cardinality-many attrs, we retract ONE edge on the detected cycle path
+    per iteration; we iterate to handle multiple/overlapping cycles."
   (:refer-clojure :exclude [cycle])
   (:require
    [datascript.core :as d]
    [logseq.db :as ldb]))
 
-;; FIXME: `extends` cardinality-many
-
 ;; -----------------------------------------------------------------------------
-;; Configure which ref attributes should be repaired, and how to find a safe target
+;; Config
 ;; -----------------------------------------------------------------------------
 
-(def ^:private repair-attrs
-  "Ref attributes that can form chains/cycles and should be repaired client-side."
-  #{:block/parent
-    :logseq.property.class/extends})
+(defn- entid
+  "Resolve to an entity id.
+   Accepts eid, entity map, or keyword ident."
+  [db x]
+  (cond
+    (nil? x) nil
+    (number? x) x
+    (map? x) (:db/id x)
+    (keyword? x) (d/entid db x)
+    :else x))
 
 (defn- safe-target-for-block-parent
   "Default safe target for :block/parent.
-   We attach to the page entity by default. If your tree requires a page-root BLOCK
-   instead of the page entity, replace this to return that block eid."
+   If your outliner requires a page-root BLOCK instead of the page entity,
+   replace this to return that block eid."
   [db e _attr _bad-v]
   (some-> (d/entity db e) :block/page :db/id))
 
 (defn- safe-target-for-class-extends
-  "Default safe target for :logseq.property.class/extends."
-  [_db _e _attr _bad-v]
-  :logseq.class/Root)
+  "Default safe target for :logseq.property.class/extends.
+   Must resolve to an existing entity. If you prefer 'retract only', return nil."
+  [db _e _attr _bad-v]
+  (d/entid db :logseq.class/Root))
 
 (def ^:private default-attr-opts
-  {;; Keep blocks inside a sane container
+  {;; Cardinality-one
    :block/parent
-   {:safe-target-fn safe-target-for-block-parent}
+   {:cardinality :one
+    :safe-target-fn safe-target-for-block-parent}
 
-   ;; For class inheritance cycles, safest default is to retract the edge.
+   ;; Cardinality-many
    :logseq.property.class/extends
-   {:safe-target-fn safe-target-for-class-extends}})
+   {:cardinality :many
+    :safe-target-fn safe-target-for-class-extends}})
+
+(def ^:private repair-attrs
+  (set (keys default-attr-opts)))
 
 ;; -----------------------------------------------------------------------------
 ;; Basics
 ;; -----------------------------------------------------------------------------
 
-(defn ref-eid
-  "Read a cardinality-one ref attribute as eid."
-  [db e attr]
-  (some-> (d/entity db e) (get attr) :db/id))
-
-(defn touched-eids
+(defn- ref-eids
+  "Read a ref attribute as a set of eids.
+
+  Supports:
+  - cardinality-one: returns #{v} or #{}
+  - cardinality-many: returns #{v1 v2 ...} or #{}"
+  [db e attr {:keys [cardinality] :or {cardinality :one}}]
+  (let [v (some-> (d/entity db e) (get attr))]
+    (case cardinality
+      :many (cond
+              (nil? v) #{}
+              (set? v) (into #{} (keep #(entid db %)) v)
+              (sequential? v) (into #{} (keep #(entid db %)) v)
+              :else (let [id (entid db v)] (if id #{id} #{})))
+      ;; :one
+      (let [id (entid db v)] (if id #{id} #{})))))
+
+(defn- touched-eids
   "Collect entity ids whose `attr` was added/changed (added=true) in tx-data."
   [tx-data attr]
   (->> tx-data
@@ -66,7 +89,7 @@
                (when (and added (= a attr)) e)))
        distinct))
 
-(defn touched-eids-many
+(defn- touched-eids-many
   "Collect touched entity ids for repair attrs.
    Returns {attr #{eid ...}}"
   [tx-data]
@@ -77,120 +100,176 @@
           repair-attrs))
 
 ;; -----------------------------------------------------------------------------
-;; Cycle detection
+;; Cycle detection (DFS; cardinality-one and cardinality-many)
 ;; -----------------------------------------------------------------------------
 
-(defn reachable-cycle
-  "Detect a ref-cycle reachable by repeatedly following (e --attr--> v).
+(defn- reachable-cycle
+  "Detect a ref-cycle reachable from `start-eid` following edges of `attr`.
 
   Returns a vector like [a b c a] or nil.
-  Only follows `attr` edges.
+  For cardinality-many attrs, we DFS across ALL outgoing edges.
 
-  `skip?` can be used to ignore certain edges in traversal."
-  [db start-eid attr {:keys [skip?] :as _attr-opts}]
+  attr-opts:
+  - :cardinality :one|:many
+  - :skip? (fn [db from attr to] -> boolean) ; ignore a specific edge"
+  [db start-eid attr {:keys [skip?] :as attr-opts}]
   (let [visited  (volatile! #{})
         stack    (volatile! [])
         in-stack (volatile! #{})
         cycle    (volatile! nil)]
-    (letfn [(next-eid [e]
-              (let [v (ref-eid db e attr)]
-                (when (and v (not (and skip? (skip? db e attr v))))
-                  v)))
+    (letfn [(neighbors [e]
+              (->> (ref-eids db e attr attr-opts)
+                   (remove nil?)
+                   (remove (fn [to] (and skip? (skip? db e attr to))))
+                   vec))
+            (record-cycle! [e]
+              (let [stk @stack
+                    idx (.indexOf stk e)]
+                (when (>= idx 0)
+                  (vreset! cycle (conj (subvec stk idx) e)))))
             (dfs! [e]
               (when-not @cycle
                 (cond
-                  (contains? @in-stack e)
-                  (let [stk @stack
-                        idx (.indexOf stk e)]
-                    (when (>= idx 0)
-                      (vreset! cycle (conj (subvec stk idx) e))))
-
-                  (contains? @visited e)
-                  nil
-
+                  (contains? @in-stack e) (record-cycle! e)
+                  (contains? @visited e)  nil
                   :else
                   (do
                     (vswap! visited conj e)
                     (vswap! in-stack conj e)
                     (vswap! stack conj e)
-                    (when-let [n (next-eid e)]
-                      (dfs! n))
+                    (doseq [n (neighbors e)]
+                      (when-not @cycle
+                        (dfs! n)))
                     (vswap! stack pop)
                     (vswap! in-stack disj e)))))]
       (dfs! start-eid)
       @cycle)))
 
+;; -----------------------------------------------------------------------------
+;; Breaking cycles
+;; -----------------------------------------------------------------------------
+
+(defn- cycle-edges
+  "Convert cycle path [a b c a] into directed edges [[a b] [b c] [c a]]."
+  [cycle]
+  (when (and (vector? cycle) (>= (count cycle) 2))
+    (mapv (fn [i] [(nth cycle i) (nth cycle (inc i))])
+          (range (dec (count cycle))))))
+
 (defn- pick-victim
   "Pick which node in the cycle to detach.
 
-  Inputs:
-  - cycle: [a b c a]
-  - local-touched?: (fn [eid] -> boolean) ; edge likely introduced by local rebase
-  - remote-touched?: (fn [eid] -> boolean) ; edge likely introduced by remote tx
-
-  Strategy:
-  1) Prefer nodes touched by local rebase
-  2) else nodes touched by remote
-  3) else first node"
-  [cycle local-touched? remote-touched?]
-  (let [nodes (vec (distinct (butlast cycle)))]
-    (or (some (fn [e] (when (local-touched? e) e)) nodes)
-        (some (fn [e] (when (remote-touched? e) e)) nodes)
+  Preference:
+  1) touched by local rebase
+  2) touched by remote
+  3) first node"
+  [cycle {:keys [local-touched remote-touched]}]
+  (let [nodes (vec (distinct (butlast cycle)))
+        local?  (fn [e] (contains? (or local-touched #{}) e))
+        remote? (fn [e] (contains? (or remote-touched #{}) e))]
+    (or (some (fn [e] (when (local? e) e)) nodes)
+        (some (fn [e] (when (remote? e) e)) nodes)
         (first nodes))))
 
-(defn break-cycle-tx
-  "Generate tx to break one cycle for one attr.
-
-  We detach victim by retracting its current (e attr v) and optionally add a safe
-  target from `safe-target-fn`. If safe-target-fn returns nil, we just retract.
-
-  touched-info:
-  - {:local-touched #{...} :remote-touched #{...}} ; per attr
-  "
-  [db cycle attr {:keys [safe-target-fn skip?] :as _attr-opts} {:keys [local-touched remote-touched]}]
-  (when (seq cycle)
-    (let [local-touched?  (fn [e] (contains? (or local-touched #{}) e))
-          remote-touched? (fn [e] (contains? (or remote-touched #{}) e))
-          victim          (pick-victim cycle local-touched? remote-touched?)]
-      (when victim
-        (let [bad-v (ref-eid db victim attr)]
-          (when (and bad-v (not (and skip? (skip? db victim attr bad-v))))
-            (let [safe (when safe-target-fn (safe-target-fn db victim attr bad-v))]
-              (cond
-                (and safe (not= safe bad-v))
-                [[:db/retract victim attr bad-v]
-                 [:db/add     victim attr safe]]
-
-                :else
-                [[:db/retract victim attr bad-v]]))))))))
-
-(defn apply-cycle-repairs!
-  "Detect & break cycles AFTER rebase.
+(defn- break-cycle-edge!
+  "Given a detected cycle, retract ONE edge on the cycle path.
+
+  Returns a tx vector (possibly with an add), or nil."
+  [db cycle attr {:keys [safe-target-fn skip?] :as attr-opts} touched]
+  (let [edges (cycle-edges cycle)
+        victim (pick-victim cycle touched)
+        [_from bad-v] (some (fn [[from _to :as e]]
+                              (when (= from victim) e))
+                            edges)
+        bad-v (entid db bad-v)]
+    (when (and victim bad-v)
+      (when-not (and skip? (skip? db victim attr bad-v))
+        ;; Ensure the edge still exists in current db.
+        (when (contains? (ref-eids db victim attr attr-opts) bad-v)
+          (let [safe (when safe-target-fn (safe-target-fn db victim attr bad-v))]
+            (prn :debug
+                 :victim victim
+                 :page-id (:db/id (:block/page (d/entity db victim)))
+                 :attr attr
+                 :safe safe
+                 :bad-v bad-v
+                 :fix-tx (cond
+                           (and safe (not= safe bad-v))
+                           [[:db/retract victim attr bad-v]
+                            [:db/add     victim attr safe]]
+
+                           :else
+                           [[:db/retract victim attr bad-v]]))
+            (cond
+              (and safe (not= safe bad-v))
+              [[:db/retract victim attr bad-v]
+               [:db/add     victim attr safe]]
+
+              :else
+              [[:db/retract victim attr bad-v]])))))))
+
+;; -----------------------------------------------------------------------------
+;; Iterative repair (THIS is the key fix)
+;; -----------------------------------------------------------------------------
+
+(defn- apply-cycle-repairs!
+  "Detect & break cycles AFTER rebase, iterating until stable.
+
+  Why iteration:
+  - With cardinality-many (extends), you can end up with multiple disjoint or
+    overlapping cycles after merging remote + local edges.
+  - Breaking one cycle edge may still leave other cycles (e.g. 1<->2 and 2<->3).
 
   Inputs:
-  - candidates-by-attr: {attr #{eid ...}}  (usually union of remote+local touched)
+  - candidates-by-attr: {attr #{eid ...}}
   - touched-by-attr: {attr {:local-touched #{...} :remote-touched #{...}}}
-  - attr-opts: {attr {:safe-target-fn ... :skip? ...}}
+  - attr-opts: {attr {:cardinality :one|:many :safe-target-fn ... :skip? ...}}
 
-  We de-dup repairs by `distinct` tx vectors to reduce repeated work."
+  We cap iterations to avoid infinite loops if something keeps reintroducing cycles."
   [transact! temp-conn candidates-by-attr touched-by-attr attr-opts]
-  (let [db @temp-conn
-        tx (->> candidates-by-attr
-                (mapcat (fn [[attr es]]
-                          (let [opts (get attr-opts attr {})
-                                touched (get touched-by-attr attr {})]
-                            (keep (fn [e]
-                                    (when-let [cycle (reachable-cycle db e attr opts)]
-                                      (prn :debug :detected-cycle cycle)
-                                      (break-cycle-tx db cycle attr opts touched)))
-                                  es))))
-                distinct
-                (apply concat))]
-    (when (seq tx)
-      (prn :debug :tx tx)
-      (transact! temp-conn tx {:outliner-op :fix-cycle :gen-undo-ops? false}))))
-
-(defn union-candidates
+  (let [max-iterations 16]
+    (loop [it 0
+           ;; Track edges we already retracted to avoid repeating identical work.
+           ;; Edge key is [e attr v].
+           seen-edges #{}]
+      (when (< it max-iterations)
+        (let [db @temp-conn
+              ;; Collect a set of edge-breaking txs for this iteration.
+              ;; We dedupe by edge key, not by tx vector.
+              {tx :tx
+               seen-edges' :seen}
+              (reduce
+               (fn [acc [attr es]]
+                 (let [opts    (merge {:cardinality :one} (get attr-opts attr {}))
+                       touched (get touched-by-attr attr {})]
+                   (reduce
+                    (fn [{:keys [tx seen] :as acc2} e]
+                      (if-let [cycle (reachable-cycle db e attr opts)]
+                        (do
+                          (prn :debug :cycle-detected cycle)
+                          (if-let [t (break-cycle-edge! db cycle attr opts touched)]
+                            (let [[_op e2 a2 v2] (first t)
+                                  edge [e2 a2 v2]]
+                              ;; Skip if we've already retracted this exact edge.
+                              (if (contains? seen edge)
+                                acc2
+                                {:tx (into tx t)
+                                 :seen (conj seen edge)}))
+                            acc2))
+                        acc2))
+                    acc
+                    es)))
+               {:tx [] :seen seen-edges}
+               candidates-by-attr)]
+
+          (if (seq tx)
+            (do
+              (transact! temp-conn tx {:outliner-op :fix-cycle :gen-undo-ops? false})
+              (recur (inc it) seen-edges'))
+            ;; No more cycles detected from these candidates => done
+            nil))))))
+
+(defn- union-candidates
   "Union remote + local candidates: {attr #{...}}"
   [remote-by-attr local-by-attr]
   (reduce
@@ -202,7 +281,7 @@
    {}
    (distinct (concat (keys remote-by-attr) (keys local-by-attr)))))
 
-(defn touched-info-by-attr
+(defn- touched-info-by-attr
   "Build {attr {:remote-touched #{...} :local-touched #{...}}}."
   [remote-by-attr local-by-attr]
   (reduce
@@ -214,13 +293,14 @@
    (distinct (concat (keys remote-by-attr) (keys local-by-attr)))))
 
 (defn fix-cycle!
+  "Entry point: call AFTER rebase.
+
+  - Computes candidates from remote + rebase tx reports for configured attrs.
+  - Iteratively breaks cycles until stable."
   [temp-conn remote-tx-report rebase-tx-report]
   (let [remote-touched-by-attr (touched-eids-many (:tx-data remote-tx-report))
-        local-touched-by-attr (touched-eids-many (:tx-data rebase-tx-report))
-        ;; Union candidates (remote + local) for cycle detection
-        candidates-by-attr (union-candidates remote-touched-by-attr local-touched-by-attr)
-
-        ;; Per-attr touched info to prefer breaking local edges first
-        touched-info (touched-info-by-attr remote-touched-by-attr local-touched-by-attr)]
+        local-touched-by-attr  (touched-eids-many (:tx-data rebase-tx-report))
+        candidates-by-attr     (union-candidates remote-touched-by-attr local-touched-by-attr)
+        touched-info           (touched-info-by-attr remote-touched-by-attr local-touched-by-attr)]
     (when (seq candidates-by-attr)
       (apply-cycle-repairs! ldb/transact! temp-conn candidates-by-attr touched-info default-attr-opts))))