Third pass over PR #12189 after review-code on 225eb98de2. resolve-team-id now has a direct test: all three branches resolve while rows are live, and file, project and team soft-deletes each refuse their branch — the two new SQL predicates and the final team-row check are all pinned by it. From the same review: - failing-existence-check-keeps-the-subscription and the sibling a-failing-check-keeps-the-subscription are deleted: they could not distinguish a live relay from a dead one. The fail-open policy remains documented in the docstring and the memory; the memory no longer claims the pins exist. - team-readable? projects ::sql/columns like the rest of the backend; the fully-qualified keyword went away with the new alias. - The dead when-not guard in get-organization-owner-viewer-team is removed (resolve-team-id guarantees the row live); the let binding in resolve-team-id no longer shadows the destructured team-id. - docs/technical-guide names rpc/notifications.clj as the publisher and the tree lists the real top-level modules. Closes #12155 AI-assisted-by: inkling-2-small-free
6.6 KiB
title, desc
| title | desc |
|---|---|
| Backend app | Dive into self-hosting, configuration, developer insights (architecture, data model), integration, and troubleshooting. See Penpot's Technical Guide. |
Backend app
This app is in charge of CRUD of data, integrity validation and persistence into a database and also into a file system for media attachments.
To handle deletions it uses a garbage collector mechanism: no object in the
database is deleted instantly. Instead, a field deleted_at is set with the
date and time of the deletion, and every query ignores db rows that have this
field set. Then, an async task that runs periodically, locates rows whose
deletion date is older than a given threshold and permanently deletes them.
For this, and other possibly slow tasks, there is an internal async tasks worker, that may be used to queue tasks to be scheduled and executed when the backend is idle. Other tasks are email sending, collecting data for telemetry and detecting unused media attachment, for removing them from the file storage.
Backend structure
Penpot backend app code resides under backend/src/app path in the main repository.
@startuml BackendGeneral !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml !define DEVICONS https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/master/devicons !include DEVICONS/react.puml !include DEVICONS/java.puml !include DEVICONS/clojure.puml !include DEVICONS/postgresql.puml !include DEVICONS/redis.puml !include DEVICONS/chrome.puml
HIDE_STEREOTYPE()
Container(frontend_app, "Frontend app", "React / ClojureScript", "", "react")
System_Boundary(backend, "Backend") { Container(backend_app, "Backend app", "Clojure / JVM", "", "clojure") ContainerDb(db, "Database", "PostgreSQL", "", "postgresql") ContainerDb(redis, "Broker", "Redis", "", "redis") }
BiRel(frontend_app, backend_app, "Open", "websocket") Rel(frontend_app, backend_app, "Uses", "RPC API") Rel(backend_app, db, "Uses", "SQL") Rel(redis, backend_app, "Subscribes", "pub/sub") Rel(backend_app, redis, "Notifies", "pub/sub")
@enduml
▾ backend/src/app/
▸ cli/
▸ http/
▸ migrations/
▸ rpc/
▸ setup/
▸ srepl/
▸ util/
▸ tasks/
main.clj
config.clj
health.clj
http.clj
jobs.clj
metrics.clj
migrations.clj
nitrate.clj
rpc.clj
setup.clj
srepl.clj
system.clj
worker.clj
...
main.cljdefines the app global settings and the main entry point of the application, served by a JVM.config.cljdefines of the configuration options read from linux environment.httpcontains the HTTP server and the backend routes list.migrationscontains the SQL scripts that define the database schema, in the form of a sequence of migrations.rpcis the main module to handle the RPC API calls.http/websocket.cljmanages the websocket endpoint: clients subscribe to open files or teams and the server keeps the subscriptions; revoked access is re-checked and dropped.rpc/notifications.cljpublishes access-change events on the bus so the websocket re-checks the subscriptions it owns.setupinitializes the environment (loads config variables, sets up the database, executes migrations, loads initial data, etc).sreplsets up an interactive REPL shell, with some useful commands to be used to debug a running instance.clisets a command-line interface, with some more maintenance commands.metrics.cljhas some interceptors that watches RPC calls, calculate statistics and other metrics, and send them to external systems to store and analyze.worker.cljandtasksdefine some async tasks that are executed in parallel to the main http server (using java threads), and scheduled in a cron-like table. They are useful to do some garbage collection, data packing and similar periodic maintenance tasks.db.clj,emails.clj,media.clj,msgbus.clj,storage.clj,rlimits.cljare general libraries to use I/O resources (SQL database, send emails, handle multimedia objects, use REDIS messages, external file storage and semaphores).util/has a collection of generic utility functions.
RPC calls
The RPC (Remote Procedure Call) subsystem consists of a mechanism that allows to expose clojure functions as an HTTP endpoint. We take advantage of being using Clojure at both front and back ends, to avoid needing complex data conversions.
- Frontend initiates a "query" or "mutation" call to
:xxxmethod, and passes a Clojure object as params. - Params are string-encoded using transit, a format similar to JSON but more powerful.
- The call is mapped to
/api/rpc/query/xxxor/api/rpc/mutation/xxx. - The
rpcmodule receives the call, decode the parameters and executes the corresponding method insidesrc/app/rpc/queries/orsrc/app/rpc/mutations/. We have created adefmethodmacro to declare an RPC method and its parameter specs. - The result value is also transit-encoded and returned to the frontend.
This way, frontend can execute backend calls like it was calling an async function, with all the power of Clojure data structures.
PubSub
To manage subscriptions to a file, to be notified of changes, we use a redis server as a pub/sub broker. Whenever a user visits a file and opens a websocket, the backend creates a subscription in redis, with a topic that has the id of the file. If the user sends any change to the file, backend sends a notification to this topic, that is received by all subscribers. Then the notification is retrieved and sent to the user via the websocket.