fix: offline parent cycle

This commit is contained in:
Tienson Qin
2026-01-15 09:15:11 +08:00
parent 2f1b70b865
commit 2f7cb2575a
2 changed files with 230 additions and 175 deletions

View File

@@ -1,136 +1,226 @@
(ns logseq.db-sync.cycle
(:require [datascript.core :as d]
[datascript.impl.entity :as de :refer [Entity]]))
"Generic cycle / bad-ref repair utilities for DataScript graphs.
(def special-attrs
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).
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."
(: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
;; -----------------------------------------------------------------------------
(def ^:private repair-attrs
"Ref attributes that can form chains/cycles and should be repaired client-side."
#{:block/parent
:logseq.property.class/extends})
(defn- ref->eid [db ref]
(cond
(nil? ref) nil
(number? ref) (when (pos? ref) ref)
(vector? ref) (d/entid db ref)
(keyword? ref) (d/entid db [:db/ident ref])
:else nil))
(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."
[db e _attr _bad-v]
(some-> (d/entity db e) :block/page :db/id))
(defn- attr-updates-from-tx [tx-data attr]
(defn- safe-target-for-class-extends
"Default safe target for :logseq.property.class/extends."
[_db _e _attr _bad-v]
:logseq.class/Root)
(def ^:private default-attr-opts
{;; Keep blocks inside a sane container
:block/parent
{:safe-target-fn safe-target-for-block-parent}
;; For class inheritance cycles, safest default is to retract the edge.
:logseq.property.class/extends
{:safe-target-fn safe-target-for-class-extends}})
;; -----------------------------------------------------------------------------
;; 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
"Collect entity ids whose `attr` was added/changed (added=true) in tx-data."
[tx-data attr]
(->> tx-data
(keep (fn [[e a _v _t added]]
(when (and added (= a attr)) e)))
distinct))
(defn touched-eids-many
"Collect touched entity ids for repair attrs.
Returns {attr #{eid ...}}"
[tx-data]
(reduce (fn [m attr]
(let [xs (touched-eids tx-data attr)]
(if (seq xs) (assoc m attr (set xs)) m)))
{}
repair-attrs))
;; -----------------------------------------------------------------------------
;; Cycle detection
;; -----------------------------------------------------------------------------
(defn reachable-cycle
"Detect a ref-cycle reachable by repeatedly following (e --attr--> v).
Returns a vector like [a b c a] or nil.
Only follows `attr` edges.
`skip?` can be used to ignore certain edges in traversal."
[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)))
(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
:else
(do
(vswap! visited conj e)
(vswap! in-stack conj e)
(vswap! stack conj e)
(when-let [n (next-eid e)]
(dfs! n))
(vswap! stack pop)
(vswap! in-stack disj e)))))]
(dfs! start-eid)
@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)
(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.
Inputs:
- candidates-by-attr: {attr #{eid ...}} (usually union of remote+local touched)
- touched-by-attr: {attr {:local-touched #{...} :remote-touched #{...}}}
- attr-opts: {attr {:safe-target-fn ... :skip? ...}}
We de-dup repairs by `distinct` tx vectors to reduce repeated work."
[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
"Union remote + local candidates: {attr #{...}}"
[remote-by-attr local-by-attr]
(reduce
(fn [acc tx]
(cond
(and (vector? tx)
(= :db/add (first tx))
(= attr (nth tx 2)))
(conj acc {:entity (nth tx 1)
:value (nth tx 3)})
(fn [m attr]
(let [r (get remote-by-attr attr #{})
l (get local-by-attr attr #{})
u (into (set r) l)]
(if (seq u) (assoc m attr u) m)))
{}
(distinct (concat (keys remote-by-attr) (keys local-by-attr)))))
(and (map? tx) (contains? tx attr))
(let [entity (or (:db/id tx)
(:block/uuid tx)
(:db/ident tx))
value (get tx attr)]
(if (some? entity)
(conj acc {:entity entity
:value value})
acc))
:else acc))
[]
tx-data))
(defn- normalize-entity-ref [entity]
(cond
(vector? entity) entity
(uuid? entity) [:block/uuid entity]
(keyword? entity) [:db/ident entity]
:else entity))
(defn- next-parent-eid [db attr eid updates-by-eid]
(if (contains? updates-by-eid eid)
(get updates-by-eid eid)
(when-let [entity (d/entity db eid)]
(let [value (get entity attr)]
(cond
(instance? Entity value) (:db/id value)
:else (ref->eid db (normalize-entity-ref value)))))))
(defn- cycle-from-eid? [db attr start-eid target-eid updates-by-eid]
(loop [seen #{target-eid}
current start-eid]
(cond
(nil? current) false
(contains? seen current) true
:else (recur (conj seen current)
(next-parent-eid db attr current updates-by-eid)))))
(defn- normalize-entity-ref-for-result [db entity-ref]
(if (number? entity-ref)
(when-let [ent (d/entity db entity-ref)]
[:block/uuid (:block/uuid ent)])
entity-ref))
(defn detect-cycle
"Returns a map with cycle details when applying tx-data would introduce a cycle.
Otherwise returns nil."
[db tx-data]
(defn touched-info-by-attr
"Build {attr {:remote-touched #{...} :local-touched #{...}}}."
[remote-by-attr local-by-attr]
(reduce
(fn [_ attr]
(let [updates (attr-updates-from-tx tx-data attr)
updates-by-eid
(reduce
(fn [acc {:keys [entity value]}]
(let [entity-ref (normalize-entity-ref entity)
eid (ref->eid db entity-ref)
value-ref (normalize-entity-ref value)
value-eid (ref->eid db value-ref)]
(if eid
(assoc acc eid value-eid)
acc)))
{}
updates)
result
(reduce
(fn [_ {:keys [entity value]}]
(if (nil? value)
nil
(let [entity-ref (normalize-entity-ref entity)
eid (ref->eid db entity-ref)
value-ref (normalize-entity-ref value)
value-eid (ref->eid db value-ref)]
(when (and eid value-eid
(cycle-from-eid? db attr value-eid eid updates-by-eid))
{:attr attr
:entity (normalize-entity-ref-for-result db entity-ref)}))))
nil
updates)]
(when result
(reduced result))))
nil
special-attrs))
(fn [m attr]
(let [r (get remote-by-attr attr #{})
l (get local-by-attr attr #{})]
(assoc m attr {:remote-touched r :local-touched l})))
{}
(distinct (concat (keys remote-by-attr) (keys local-by-attr)))))
(defn server-values-for
"Returns a map of entity refs to the server's current value for attr."
[db tx-data attr]
(let [updates (attr-updates-from-tx tx-data attr)]
(reduce
(fn [acc {:keys [entity]}]
(let [entity-ref (normalize-entity-ref entity)
eid (ref->eid db entity-ref)
current-raw (when eid (get (d/entity db eid) attr))
current (cond
(nil? current-raw) nil
(= attr :logseq.property.class/extends)
(if (instance? Entity current-raw)
(:db/ident current-raw)
current-raw)
(= attr :block/parent)
(let [parent-uuid (cond
(instance? Entity current-raw) (:block/uuid current-raw)
(number? current-raw) (:block/uuid (d/entity db current-raw))
:else nil)]
(when parent-uuid
[:block/uuid parent-uuid]))
:else current-raw)]
(assoc acc [:block/uuid (:block/uuid (d/entity db eid))] current)))
{}
updates)))
(defn fix-cycle!
[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)]
(when (seq candidates-by-attr)
(apply-cycle-repairs! ldb/transact! temp-conn candidates-by-attr touched-info default-attr-opts))))

View File

@@ -10,8 +10,8 @@
[logseq.common.path :as path]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[logseq.db-sync.checksum :as db-sync-checksum]
[logseq.db-sync.cycle :as db-sync-cycle]
[logseq.db-sync.checksum :as sync-checksum]
[logseq.db-sync.cycle :as sync-cycle]
[logseq.db-sync.malli-schema :as db-sync-schema]
[logseq.db-sync.order :as sync-order]
[logseq.db.common.normalize :as db-normalize]
@@ -321,43 +321,6 @@
(contains? deleted-ids id)))))
tx-data))
(defn- remove-attr-updates
[db tx-data attr entity-ref]
(remove (fn [tx]
(and (vector? tx)
(= attr (nth tx 2 nil))
(entity-ref-matches? db entity-ref (nth tx 1 nil))))
tx-data))
(defn- tx-entity-ref
[db entity-ref]
(or (entity-ref->entid db entity-ref)
(normalize-entity-ref entity-ref)))
(defn- replace-parent-with-page-root
[db tx-data entity-ref]
(let [entity-entid (entity-ref->entid db entity-ref)
page-uuid (when entity-entid
(some-> (d/entity db entity-entid) :block/page :block/uuid))
page-ref (when page-uuid [:block/uuid page-uuid])]
(cond-> (remove-attr-updates db tx-data :block/parent entity-ref)
page-ref
(conj [:db/add (tx-entity-ref db entity-ref) :block/parent page-ref]))))
(defn- fix-cycle-updates
[db tx-data]
(loop [tx-data tx-data
attempt 0]
(if (>= attempt 4)
tx-data
(if-let [{:keys [attr entity]} (db-sync-cycle/detect-cycle db tx-data)]
(let [tx-data' (case attr
:block/parent (replace-parent-with-page-root db tx-data entity)
:logseq.property.class/extends (remove-attr-updates db tx-data attr entity)
(remove-attr-updates db tx-data attr entity))]
(recur tx-data' (inc attempt)))
tx-data))))
(defn- keep-last-update
[db tx-data]
(let [properties (->> (distinct (map :a tx-data))
@@ -378,8 +341,7 @@
sanitized-tx-data (->> tx-data
db-normalize/replace-attr-retract-with-retract-entity-v2
(keep-last-update db)
(drop-invalid-refs deleted-ids)
(fix-cycle-updates db))]
(drop-invalid-refs deleted-ids))]
(when (not= tx-data sanitized-tx-data)
(log/info :db-sync/tx-sanitized
{:diff (data/diff tx-data sanitized-tx-data)}))
@@ -656,12 +618,12 @@
reversed-tx-report (when has-local-changes?
(ldb/transact! temp-conn reversed-tx-data tx-meta))
;; 2. transact remote tx-data
tx-report (ldb/transact! temp-conn tx-data tx-meta)
_ (reset! *remote-tx-report tx-report)
remote-tx-report (ldb/transact! temp-conn tx-data tx-meta)
_ (reset! *remote-tx-report remote-tx-report)
computed-checksum (when expected-checksum
(db-sync-checksum/next-checksum
(sync-checksum/next-checksum
(client-op/get-local-checksum repo)
(db-sync-checksum/filter-tx-data tx-report)))]
(sync-checksum/filter-tx-data remote-tx-report)))]
(reset! *computed-checksum computed-checksum)
;; (when (and expected-checksum (not= expected-checksum computed-checksum))
@@ -679,7 +641,7 @@
(nil? (d/entity db e)))
(d/entity (:db-after reversed-tx-report) e)))
(:tx-data reversed-tx-report)))
remote-deleted-blocks (->> (outliner-pipeline/filter-deleted-blocks (:tx-data tx-report))
remote-deleted-blocks (->> (outliner-pipeline/filter-deleted-blocks (:tx-data remote-tx-report))
(map #(d/entity db (:db/id %))))
deleted-nodes (concat local-deleted-blocks remote-deleted-blocks)
deleted-ids (set (keep :block/uuid deleted-nodes))
@@ -708,9 +670,12 @@
(prn :debug :pending-tx-data pending-tx-data
:rebased-tx-data rebased-tx-data)
(when (seq rebased-tx-data)
(ldb/transact! temp-conn rebased-tx-data {:gen-undo-ops? false}))))]
(sync-order/fix-duplicate-orders! temp-conn (concat (:tx-data tx-report)
(:tx-data rebase-tx-report))))))))
(ldb/transact! temp-conn rebased-tx-data {:gen-undo-ops? false}))))
fix-cycle-tx-report (sync-cycle/fix-cycle! temp-conn remote-tx-report rebase-tx-report)]
(sync-order/fix-duplicate-orders! temp-conn
(mapcat :tx-data [remote-tx-report
rebase-tx-report
fix-cycle-tx-report])))))))
{:listen-db (fn [{:keys [tx-data tx-meta]}]
(when (and has-local-changes? (not (:rtc-tx? tx-meta)))
(swap! *rebased-tx-data into tx-data)))})