From c18c44f57523b317f663fce3efb43d640ebd02ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=81lvaro=20Tejero-Cantero?=
<807608+alvorithm@users.noreply.github.com>
Date: Wed, 5 Aug 2026 17:19:50 +0200
Subject: [PATCH] :sparkles: 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`.
---
backend/resources/app/templates/debug.tmpl | 2 +
backend/src/app/http/debug.clj | 136 +++++++++++++--------
backend/src/app/srepl/main.clj | 28 +++--
common/src/app/common/flags.cljc | 4 +
4 files changed, 108 insertions(+), 62 deletions(-)
diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl
index 32e0732411..657f4efee9 100644
--- a/backend/resources/app/templates/debug.tmpl
+++ b/backend/resources/app/templates/debug.tmpl
@@ -222,6 +222,7 @@ Debug Main Page
+ {% if graph-enabled %}
Export graph (Ladybug):
Given a FILE-ID, builds the graph projection and downloads
@@ -237,6 +238,7 @@ Debug Main Page
+ {% endif %}
Import binfile:
Import penpot file in binary format.
diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj
index 352a10a922..0f56564577 100644
--- a/backend/src/app/http/debug.clj
+++ b/backend/src/app/http/debug.clj
@@ -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]))
diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj
index 99a40dde80..de58fcece2 100644
--- a/backend/src/app/srepl/main.clj
+++ b/backend/src/app/srepl/main.clj
@@ -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))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc
index 9988c1a9f8..cad624cd99 100644
--- a/common/src/app/common/flags.cljc
+++ b/common/src/app/common/flags.cljc
@@ -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.