From 8036f4a4c7ffdefc1cd08f318d96999d3efa3b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Wed, 15 Jul 2026 18:52:04 +0200 Subject: [PATCH] :bug: Fix list-column CSV ingest and serialize graph session access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COPY failed on any file with container shapes: list-typed DDL columns (shapes UUID[], points STRING[], strokes JSON[], ...) were JSON-encoded in staging CSVs, which Ladybug's list parser rejects. Write Kuzu list literals instead, typed per column. Also: value->clj no longer crashes on LIST/STRUCT values (binding lacks value_get_value support; fall back to string), and the debug session Connection is now guarded by a per-session lock — it was shared unsynchronized between the msgbus sync loop and HTTP query/export handlers, and one lost DETACH DELETE was observed under concurrent refetch load. Signed-off-by: Álvaro Tejero Cantero --- backend/src/app/graph/bulk.clj | 43 +++++++++++++++++++++++++++++-- backend/src/app/graph/debug.clj | 33 ++++++++++++++---------- backend/src/app/graph/ladybug.clj | 8 +++++- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/backend/src/app/graph/bulk.clj b/backend/src/app/graph/bulk.clj index 2038c527b0..cc2ac5a1e8 100644 --- a/backend/src/app/graph/bulk.clj +++ b/backend/src/app/graph/bulk.clj @@ -51,6 +51,43 @@ (coll? v) (csv-escape-string (json/encode v)) :else (csv-escape-string (str v)))) +(defn- kuzu-list-element + "Format one element of a Ladybug LIST column for CSV COPY. Kuzu parses + the (CSV-unquoted) field as a list literal: bare values for UUID/number + elements, single-quoted strings (backslash-escaped) for STRING/JSON." + [elem-type v] + (cond + (nil? v) + "NULL" + + (contains? #{"STRING" "JSON"} elem-type) + (let [s (if (coll? v) (json/encode v) (csv-normalize-string (str v)))] + (str "'" (-> s + (str/replace "\\" "\\\\") + (str/replace "'" "\\'")) + "'")) + + :else + (str v))) + +(defn- kuzu-list-cell + "CSV cell for a LIST-typed column (`UUID[]`, `STRING[]`, `JSON[]`, …). + JSON-encoding the collection (as `csv-cell` does) is wrong here: Kuzu + expects its own list literal, e.g. `[id1,id2]` with bare elements." + [ladybug-type v] + (let [elem-type (subs ladybug-type 0 (- (count ladybug-type) 2)) + elems (if (coll? v) (seq v) [v])] + (csv-escape-string + (str "[" (str/join "," (map #(kuzu-list-element elem-type %) elems)) "]")))) + +(defn- csv-typed-cell + [ladybug-type v] + (if (and (some? v) + (string? ladybug-type) + (str/ends-with? ladybug-type "[]")) + (kuzu-list-cell ladybug-type v) + (csv-cell v))) + (defn- cypher-file-path [^File file] (-> (.getAbsolutePath file) @@ -59,11 +96,13 @@ (defn- write-node-csv! [^File file table rows] - (let [columns (nodes/column-keys table)] + (let [columns (nodes/column-keys table) + types (mapv #(nodes/column-ladybug-type table %) columns)] (with-open [w (io/writer file :encoding "UTF-8")] (.write w (str (str/join "," (map name columns)) "\n")) (doseq [row rows] - (.write w (str (str/join "," (map #(csv-cell (get row %)) columns)) + (.write w (str (str/join "," (map (fn [k t] (csv-typed-cell t (get row k))) + columns types)) "\n")))))) (defn- write-edge-csv! diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj index b410d275dc..18236ac5cc 100644 --- a/backend/src/app/graph/debug.clj +++ b/backend/src/app/graph/debug.clj @@ -66,8 +66,9 @@ (some-> (get @sessions (session-key profile-id)) (as-> current (when (= file-id (:file-id current)) - (let [result (graph.sync/apply-changes! - conn (:index current) changes revn) + (let [result (locking (:lock current) + (graph.sync/apply-changes! + conn (:index current) changes revn)) sync-at (ct/now)] (swap! sessions assoc-in [(session-key profile-id) :index] (:index result)) @@ -152,8 +153,12 @@ :skip-validation? true) index (graph.sync/build-index file-id (:revn meta) (:projection meta)) session + ;; :lock serializes access to the shared Connection between the + ;; msgbus sync loop (writes) and HTTP handlers (reads); the Java + ;; binding gives no thread-safety guarantee for one Connection. (-> {:db db :conn conn + :lock (Object.) :file-id file-id :meta meta :index index @@ -174,9 +179,10 @@ (ex/raise :type :validation :code :missing-query :hint "cypher query is required")) - (if-let [{:keys [conn]} (get @sessions (session-key profile-id))] - (-> (ladybug/query-on-connection! conn statement) - format-query-result) + (if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))] + (locking (or lock ::no-lock) + (-> (ladybug/query-on-connection! conn statement) + format-query-result)) (ex/raise :type :not-found :code :graph-session-not-loaded :hint "load a file graph before running queries"))) @@ -220,14 +226,15 @@ loaded. Queries the Ladybug database (not the sync index) so the view reflects actual DB state, including drift." [profile-id] - (when-let [{:keys [conn file-id index]} (get @sessions (session-key profile-id))] - (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn) - {:keys [edges] edges-truncated? :truncated?} (export-edges conn)] - {:file-id (str file-id) - :revn (:revn index) - :truncated (boolean (or nodes-truncated? edges-truncated?)) - :nodes nodes - :edges edges}))) + (when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))] + (locking (or lock ::no-lock) + (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn) + {:keys [edges] edges-truncated? :truncated?} (export-edges conn)] + {:file-id (str file-id) + :revn (:revn index) + :truncated (boolean (or nodes-truncated? edges-truncated?)) + :nodes nodes + :edges edges})))) (defn console-context "Build template data for the graph debug console page." diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj index 9d52b7334e..b60f3ef160 100644 --- a/backend/src/app/graph/ladybug.clj +++ b/backend/src/app/graph/ladybug.clj @@ -97,7 +97,13 @@ (defn- value->clj [^Value value] (when-not (.isNull value) - (let [v (.getValue value)] + (let [v (try + (.getValue value) + (catch Exception _ + ;; LIST/STRUCT values are not supported by the binding's + ;; getValue (\"value_get_value\"); fall back to the textual + ;; representation so console queries do not crash. + (.toString value)))] (cond (instance? Long v) v (instance? Integer v) (long v)