Put the graph subsystem behind a flag, off by default (#11075)

`app.graph.ladybug` imports `com.ladybugdb.*` at namespace load. Two
namespaces reach the subsystem and both required it at the top level:
`app.http.debug`, which registers the `/dbg` routes, and
`app.srepl.main`, which loads with the REPL server. Every backend built
from this branch therefore linked the Ladybug native library into the
JVM at boot, whether or not a graph was ever used.

Add a `:graph` flag to `varia`, deliberately absent from `default` so
that a released Penpot ships with the subsystem off. Both require sites
now resolve `app.graph.*` at call time, so with the flag off no
`com.ladybugdb` class is loaded. The nine `/dbg` graph routes are
registered only when the flag is on, and 404 otherwise. The `/dbg` admin
gate is untouched: the flag decides which routes exist, not who may
reach them. When the flag is on, route init requires the subsystem
eagerly, so a missing or unusable native library fails the boot rather
than the first console request.

No tracked file turns the flag on. `backend/scripts/_env` leaves it out,
so a devenv boots with the subsystem off exactly as a released build
does, and `docker/images/docker-compose.yaml`, the self-hosting
distribution, is untouched. Whoever works on the graph turns it on for
one checkout through the gitignored `backend/scripts/_env.local`, which
every backend and exporter dev script sources right after `_env`.

Verified with `-verbose:class` over a boot's namespace load plus
`ig/init-key ::routes`: 9 `com.ladybugdb` classes before this change
with no flag set, 0 after it with the flag off, 9 with `enable-graph`.
This commit is contained in:
Álvaro Tejero-Cantero 2026-08-05 17:19:50 +02:00 committed by Álvaro Tejero Cantero
parent df80aca9cd
commit c18c44f575
No known key found for this signature in database
4 changed files with 108 additions and 62 deletions

View File

@ -222,6 +222,7 @@ Debug Main Page
</div>
</form>
</fieldset>
{% if graph-enabled %}
<fieldset>
<legend>Export graph (Ladybug):</legend>
<desc>Given a FILE-ID, builds the graph projection and downloads
@ -237,6 +238,7 @@ Debug Main Page
</div>
</form>
</fieldset>
{% endif %}
<fieldset>
<legend>Import binfile:</legend>
<desc>Import penpot file in binary format.</desc>

View File

@ -22,8 +22,6 @@
[app.config :as cf]
[app.db :as db]
[app.features.file-migrations :as feat.fmig]
[app.graph.debug :as graph.debug]
[app.graph.ingest :as graph.ingest]
[app.http.session :as session]
[app.rpc.commands.auth :as auth]
[app.rpc.commands.files-create :refer [create-file]]
@ -61,6 +59,7 @@
::yres/body (-> (io/resource "app/templates/debug.tmpl")
(tmpl/render {:version (:full cf/version)
:profile profile
:graph-enabled (contains? cf/flags :graph)
:current-clock ct/*clock*
:current-offset (if offset
(ct/format-duration offset)
@ -334,6 +333,16 @@
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GRAPH (flag: :graph)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; `app.graph.*` resolves at call time, never at the top of this namespace.
;; `app.graph.ladybug` imports `com.ladybugdb.*`, so requiring it links the
;; Ladybug native library into the JVM, and this namespace loads on every
;; backend boot. The routes below are registered only under the `:graph` flag,
;; so with the flag off nothing resolves and no native code loads.
(defn graph-export-handler
"Build (or rebuild) the Ladybug graph for a file and stream the `.lbug`
database. MVP: synchronous ingest on each request."
@ -344,7 +353,8 @@
:code :missing-arguments
:hint "missing file-id"))
(let [{:keys [db-path]} (graph.ingest/ingest-file! cfg file-id :skip-stats? true)]
(let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!)
{:keys [db-path]} (ingest-file! cfg file-id :skip-stats? true)]
(when-not (fs/exists? db-path)
(ex/raise :type :internal
:code :graph-file-not-found
@ -366,40 +376,44 @@
(defn graph-console-handler
[_cfg {:keys [::session/profile-id]}]
(graph-console-response (graph.debug/console-context profile-id)))
(let [console-context (requiring-resolve 'app.graph.debug/console-context)]
(graph-console-response (console-context profile-id))))
(defn graph-load-handler
[cfg {:keys [params ::session/profile-id]}]
(let [file-id (some-> (:file-id params) parse-uuid)]
(let [file-id (some-> (:file-id params) parse-uuid)
load-session! (requiring-resolve 'app.graph.debug/load-session!)]
(when-not file-id
(ex/raise :type :validation
:code :missing-arguments
:hint "missing file-id"))
(graph.debug/load-session! cfg profile-id file-id)
(load-session! cfg profile-id file-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}}))
(defn graph-unload-handler
[_cfg {:keys [::session/profile-id]}]
(graph.debug/unload-session! profile-id)
((requiring-resolve 'app.graph.debug/unload-session!) profile-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}})
(defn graph-reload-handler
"Re-ingest the currently loaded file into the in-memory graph session."
[cfg {:keys [::session/profile-id]}]
(if-let [file-id (some-> (graph.debug/session-info profile-id) :file-id)]
(do
(graph.debug/load-session! cfg profile-id file-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}})
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "load a file graph before reloading")))
(let [session-info (requiring-resolve 'app.graph.debug/session-info)
load-session! (requiring-resolve 'app.graph.debug/load-session!)]
(if-let [file-id (some-> (session-info profile-id) :file-id)]
(do
(load-session! cfg profile-id file-id)
{::yres/status 302
::yres/headers {"location" "/dbg/graph"}})
(ex/raise :type :not-found
:code :graph-session-not-loaded
:hint "load a file graph before reloading"))))
(defn graph-sync-status-handler
[_cfg {:keys [::session/profile-id]}]
(if-let [status (graph.debug/sync-status profile-id)]
(if-let [status ((requiring-resolve 'app.graph.debug/sync-status) profile-id)]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str status {:type :json-verbose})}
@ -411,7 +425,7 @@
"Export the in-memory session graph as plain JSON (not transit) for the
G6 graph view embedded in the console page."
[_cfg {:keys [::session/profile-id]}]
(if-let [data (graph.debug/export-graph-data! profile-id)]
(if-let [data ((requiring-resolve 'app.graph.debug/export-graph-data!) profile-id)]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode data)}
@ -470,18 +484,20 @@
(defn graph-query-handler
[_cfg {:keys [params ::session/profile-id] :as request}]
(let [query (:query params)]
(let [query (:query params)
query-session! (requiring-resolve 'app.graph.debug/query-session!)
console-context (requiring-resolve 'app.graph.debug/console-context)]
(try
(let [result (graph.debug/query-session! profile-id query)]
(let [result (query-session! profile-id query)]
(if (json-request? request)
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:query query
:query-result result}
{:type :json-verbose})}
(graph-console-response (graph.debug/console-context profile-id
:query query
:query-result result))))
(graph-console-response (console-context profile-id
:query query
:query-result result))))
(catch Throwable e
(let [error (or (:hint (ex-data e)) (ex-message e))]
(if (json-request? request)
@ -489,9 +505,9 @@
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:query query :error error}
{:type :json-verbose})}
(graph-console-response (graph.debug/console-context profile-id
:query query
:error error))))))))
(graph-console-response (console-context profile-id
:query query
:error error))))))))
(defn import-handler
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
@ -727,33 +743,49 @@
(assert (db/pool? (::db/pool params)) "expected a valid database pool")
(assert (session/manager? (::session/manager params)) "expected a valid session manager"))
(defn- graph-action-routes
[cfg]
[["/graph-export" {:handler (partial graph-export-handler cfg)}]
["/graph-load" {:handler (partial graph-load-handler cfg)}]
["/graph-query" {:handler (partial graph-query-handler cfg)}]
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
["/graph-data" {:handler (partial graph-data-handler cfg)}]
["/graph-files" {:handler (partial graph-files-handler cfg)}]])
(defmethod ig/init-key ::routes
[_ {:keys [::db/pool] :as cfg}]
[["/readyz" {:handler (partial health-handler cfg)}]
["/dbg" {:middleware [[session/authz cfg]
[with-authorization pool]]}
["" {:handler (partial index-handler cfg)}]
["/health" {:handler (partial health-handler cfg)}]
["/changelog" {:handler (partial changelog-handler cfg)}]
["/graph" {:handler (partial graph-console-handler cfg)}]
["/error/:id" {:handler (partial error-handler cfg)}]
["/error" {:handler (partial error-list-handler cfg)}]
["/actions" {:middleware [[errors]]}
["/set-virtual-clock"
{:handler (partial set-virtual-clock cfg)}]
["/resend-email-verification"
{:handler (partial resend-email-notification cfg)}]
["/handle-team-features"
{:handler (partial handle-team-features cfg)}]
["/file-export" {:handler (partial export-handler cfg)}]
["/graph-export" {:handler (partial graph-export-handler cfg)}]
["/graph-load" {:handler (partial graph-load-handler cfg)}]
["/graph-query" {:handler (partial graph-query-handler cfg)}]
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
["/graph-data" {:handler (partial graph-data-handler cfg)}]
["/graph-files" {:handler (partial graph-files-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
;; The graph routes are registered only under the `:graph` flag. Left
;; unregistered they 404, and nothing ever resolves `app.graph.*`. The `/dbg`
;; admin gate is unchanged: it covers the graph routes exactly as before.
(let [graph? (contains? cf/flags :graph)
actions (cond-> ["/actions" {:middleware [[errors]]}
["/set-virtual-clock"
{:handler (partial set-virtual-clock cfg)}]
["/resend-email-verification"
{:handler (partial resend-email-notification cfg)}]
["/handle-team-features"
{:handler (partial handle-team-features cfg)}]
["/file-export" {:handler (partial export-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]
graph? (into (graph-action-routes cfg)))
dbg (cond-> ["/dbg" {:middleware [[session/authz cfg]
[with-authorization pool]]}
["" {:handler (partial index-handler cfg)}]
["/health" {:handler (partial health-handler cfg)}]
["/changelog" {:handler (partial changelog-handler cfg)}]
["/error/:id" {:handler (partial error-handler cfg)}]
["/error" {:handler (partial error-list-handler cfg)}]
actions]
graph? (conj ["/graph" {:handler (partial graph-console-handler cfg)}]))]
(when graph?
;; With the flag on, the Ladybug native library belongs to this process,
;; so load it here. A missing or unusable library then fails the boot
;; instead of the first console request.
(require 'app.graph.debug 'app.graph.ingest))
[["/readyz" {:handler (partial health-handler cfg)}]
dbg]))

View File

@ -25,9 +25,6 @@
[app.db.sql :as-alias sql]
[app.features.fdata :as fdata]
[app.features.file-snapshots :as fsnap]
[app.graph.ingest :as graph.ingest]
[app.graph.ladybug :as graph.ladybug]
[app.graph.report :as graph.report]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.msgbus :as mbus]
@ -405,21 +402,30 @@
;; GRAPH / LADYBUG
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The graph namespaces resolve at call time, never at the top of this
;; namespace. `app.graph.ladybug` imports `com.ladybugdb.*`, and this namespace
;; loads with the REPL server on every boot, so a top-level require would link
;; the Ladybug native library into every backend, graph or not. Calling one of
;; the functions below loads the library at that point: the operator has asked
;; for it explicitly. The `:graph` flag gates the request path
;; (`app.http.debug`), not the REPL.
(defn graph-smoke-test!
"Execute a basic Ladybug smoke test (CREATE + count).
Uses the embedded Ladybug Java API. Use :db-path \":memory:\" (default)
or a filesystem path such as /tmp/test.lbug."
[& {:keys [db-path] :or {db-path ":memory:"}}]
(graph.ladybug/smoke-test! :db-path db-path))
((requiring-resolve 'app.graph.ladybug/smoke-test!) :db-path db-path))
(defn graph-query-test!
"Query Document count for a file's graph db (REPL diagnostic)."
[file-id & {:keys [db-path]}]
(let [file-id (h/parse-uuid file-id)
db-path (or db-path (graph.ladybug/db-path-for-file file-id))
stmt "MATCH (n:Document) RETURN count(n) AS Document_c;"]
(graph.ladybug/query-scalar! db-path stmt)))
(let [file-id (h/parse-uuid file-id)
db-path (or db-path ((requiring-resolve 'app.graph.ladybug/db-path-for-file) file-id))
query-scalar! (requiring-resolve 'app.graph.ladybug/query-scalar!)
stmt "MATCH (n:Document) RETURN count(n) AS Document_c;"]
(query-scalar! db-path stmt)))
(defn ingest-file-to-graph!
"Project a Penpot file into a per-file Ladybug database.
@ -432,8 +438,10 @@
- `:reset-db?` delete any existing db first (default true)
- `:skip-stats?` skip post-ingest MATCH count queries (default false)"
[file-id & opts]
(let [result (apply graph.ingest/ingest-file! sys/system file-id opts)]
(graph.report/print-ingest! result)
(let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!)
print-ingest! (requiring-resolve 'app.graph.report/print-ingest!)
result (apply ingest-file! sys/system file-id opts)]
(print-ingest! result)
result))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -100,6 +100,10 @@
:backend-svgo
;; If enabled, it makes the Google Fonts available.
:google-fonts-provider
;; Enables the Ladybug graph subsystem: the `/dbg` graph console and its
;; actions. Off by default. With the flag off, `app.graph.*` never loads,
;; so the Ladybug native library never enters the JVM.
:graph
;; Only for development.
:nrepl-server
;; Interactive repl. Only for development.