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.