🐛 Fix list-column CSV ingest and serialize graph session access

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 <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-15 18:52:04 +02:00 committed by Andrey Antukh
parent 9b86a4bb01
commit 4c4b1e5f59
3 changed files with 68 additions and 16 deletions

View File

@ -51,6 +51,43 @@
(coll? v) (csv-escape-string (json/encode v)) (coll? v) (csv-escape-string (json/encode v))
:else (csv-escape-string (str 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 (defn- cypher-file-path
[^File file] [^File file]
(-> (.getAbsolutePath file) (-> (.getAbsolutePath file)
@ -59,11 +96,13 @@
(defn- write-node-csv! (defn- write-node-csv!
[^File file table rows] [^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")] (with-open [w (io/writer file :encoding "UTF-8")]
(.write w (str (str/join "," (map name columns)) "\n")) (.write w (str (str/join "," (map name columns)) "\n"))
(doseq [row rows] (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")))))) "\n"))))))
(defn- write-edge-csv! (defn- write-edge-csv!

View File

@ -66,8 +66,9 @@
(some-> (get @sessions (session-key profile-id)) (some-> (get @sessions (session-key profile-id))
(as-> current (as-> current
(when (= file-id (:file-id current)) (when (= file-id (:file-id current))
(let [result (graph.sync/apply-changes! (let [result (locking (:lock current)
conn (:index current) changes revn) (graph.sync/apply-changes!
conn (:index current) changes revn))
sync-at (ct/now)] sync-at (ct/now)]
(swap! sessions assoc-in [(session-key profile-id) :index] (swap! sessions assoc-in [(session-key profile-id) :index]
(:index result)) (:index result))
@ -152,8 +153,12 @@
:skip-validation? true) :skip-validation? true)
index (graph.sync/build-index file-id (:revn meta) (:projection meta)) index (graph.sync/build-index file-id (:revn meta) (:projection meta))
session 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 (-> {:db db
:conn conn :conn conn
:lock (Object.)
:file-id file-id :file-id file-id
:meta meta :meta meta
:index index :index index
@ -174,9 +179,10 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :missing-query :code :missing-query
:hint "cypher query is required")) :hint "cypher query is required"))
(if-let [{:keys [conn]} (get @sessions (session-key profile-id))] (if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))]
(-> (ladybug/query-on-connection! conn statement) (locking (or lock ::no-lock)
format-query-result) (-> (ladybug/query-on-connection! conn statement)
format-query-result))
(ex/raise :type :not-found (ex/raise :type :not-found
:code :graph-session-not-loaded :code :graph-session-not-loaded
:hint "load a file graph before running queries"))) :hint "load a file graph before running queries")))
@ -220,14 +226,15 @@
loaded. Queries the Ladybug database (not the sync index) so the view loaded. Queries the Ladybug database (not the sync index) so the view
reflects actual DB state, including drift." reflects actual DB state, including drift."
[profile-id] [profile-id]
(when-let [{:keys [conn file-id index]} (get @sessions (session-key profile-id))] (when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))]
(let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn) (locking (or lock ::no-lock)
{:keys [edges] edges-truncated? :truncated?} (export-edges conn)] (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
{:file-id (str file-id) {:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
:revn (:revn index) {:file-id (str file-id)
:truncated (boolean (or nodes-truncated? edges-truncated?)) :revn (:revn index)
:nodes nodes :truncated (boolean (or nodes-truncated? edges-truncated?))
:edges edges}))) :nodes nodes
:edges edges}))))
(defn console-context (defn console-context
"Build template data for the graph debug console page." "Build template data for the graph debug console page."

View File

@ -97,7 +97,13 @@
(defn- value->clj (defn- value->clj
[^Value value] [^Value value]
(when-not (.isNull 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 (cond
(instance? Long v) v (instance? Long v) v
(instance? Integer v) (long v) (instance? Integer v) (long v)