mirror of
https://github.com/penpot/penpot.git
synced 2026-08-29 16:18:48 +00:00
🐛 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:
parent
9b86a4bb01
commit
4c4b1e5f59
@ -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!
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user