🐛 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
parent 7bb027e172
commit 8036f4a4c7
No known key found for this signature in database
3 changed files with 68 additions and 16 deletions

View File

@ -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!

View File

@ -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."

View File

@ -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)