penpot/backend/src/app/tasks/delete_object.clj
Andrey Antukh dd4e768707 ✨ Partition file_change by file_id
`file_change` is the biggest relation of the database: 683 GB in
production, of which almost all the TOAST is dead space (the legacy
data is already migrated to `file_data`; what is left is the xlog of
the revisions, 4.4 kB on average). It grows with every save of a file
and every version, and its rows are spread over the whole table, so
every query that filters by a file has to walk all of it.

The table is now partitioned by `HASH (file_id)` with 16 partitions, the
same bounds as `file_data`, so the queries that filter by a file can
prune to one partition and the join between the two tables is resolved
partition by partition. The primary key has to include the partition
key, so it becomes `(file_id, id)`, and every access path of the table
filters by `file_id` accordingly: the snapshot commands require it
(2.19), the frontend sends it, and the garbage collector locks the
pending rows by their `(file_id, id)` pair.

The migration creates the partitioned table with the final schema and
copies the rows, keeping the old one aside as `file_change_legacy` for
the rollback. The copy happens inside the transaction of the migration,
so it does not fit a big instance: there the migration is marked as
applied and the procedure of `operations/` does it by hand, in phases,
driven by the `updated_at` watermark.

Along the way:

- every mutation of the table moves `updated_at` to the moment of the
  mutation, so the phased copy does not lose the rows changed while it
  runs. It is never set to the `deleted_at` of the row, which for the
  system snapshots is a date in the future.
- a new `kind` column tells the snapshots (1) and the revisions (2) of
  the table apart, and the paths that insert in it fill it, including
  the repair path. It has no default on purpose, so an insert that
  forgets it fails instead of storing the wrong kind silently. It is a
  reserve: the queries still tell a snapshot from a revision by its
  label, and no constraint ties the two together.
- the revision does not repeat the features of the file anymore: they
  were 348 bytes written on every save and nobody read them from there.
- the `data_ref_id` and `data_backend` columns are gone, dead since the
  file storage refactor. The `data` column is kept: the `legacy-db`
  file data backend is still supported (migration 0157 moved the
  existing blobs, but the backend keeps reading and writing it).
- the legacy blobs of the snapshots were already moved to `file_data`
  by migration 0157 (on the big instances, by hand, with its runbook),
  so there is nothing to copy here: the `data` column travels with the
  row.
- the foreign key of the legacy table on `file_id` goes away with the
  copy: it is `NO ACTION`, so while the legacy table has rows, the
  objects GC cannot delete the files they point to.
- the quota of the snapshots of a team resolves the files of the team
  first and counts their versions with `= ANY`, instead of joining the
  team with every version of the table.
- the pool sets `enable_partitionwise_join` on every connection, so the
  join between the two partitioned tables is resolved partition by
  partition.
- the snapshot lookup reports an unknown snapshot with its own error
  instead of the generic "object not found" of the database helper, and
  the decode script fails instead of printing an empty JSON.
- the procedure of `operations/` stops the backend and the workers
  during the cutover: the pool keeps server-side prepared statements,
  and a prepared statement holds the OID of the table it was prepared
  with, so a connection would keep writing to the legacy table after
  the rename.
- the procedure creates the partitions with their final name, so the
  result is the schema a new installation gets, and its verification
  checks the names and not only how many there are.
- the index of the system snapshots is gone: the old one filtered them
  by `data IS NOT NULL`, and with the payload living in `file_data`
  the column no longer tells snapshots apart.
- the configuration guide, the memories and the docstring of the decode
  script keep offering the `legacy-db` backend, which is still
  supported.
- the tests fix the schema the migration leaves: the 16 partitions with
  their name, the primary key, the foreign keys, the check of the kind,
  the six indexes, the kept `data` column, and that an insert without a
  kind or with an unknown one fails. New tests cover the kind of both
  kinds of row and the resolution of the `legacy-db` blob.

Closes #12416

AI-assisted-by: glm-5.3-flash
AI-assisted-by: minimax-m3.1-flash
AI-assisted-by: muse-spark-1.3-contributor
2026-11-03 14:21:01 +01:00

211 lines
7.6 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.tasks.delete-object
"A generic task for object deletion cascade handling"
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[app.db.sql :as-alias sql]
[app.loggers.audit :as audit]
[app.rpc.commands.files :as files]
[app.rpc.commands.profile :as profile]
[app.storage :as sto]
[integrant.core :as ig]))
(def ^:dynamic *team-deletion* false)
(def ^:dynamic *cascade*
"Accumulates the ids marked deleted by the current cascade execution,
grouped by entity. Bound by the task entry point; nil on direct calls
(e.g. objects-gc, whose purges are announced by their own procs)."
nil)
(defn- record
[kind id]
(when (some? *cascade*)
(swap! *cascade* update kind conj id)))
(defn- top-exists?
"The cascade marks blindly, so an unknown top id would report phantom
rows. Every queue submission and SREPL call names a real row; only
check, never change behavior: a missing row just skips the run."
[{:keys [::db/conn]} {:keys [object id]}]
(let [object (when (some? object) (keyword (name object)))]
(if (contains? #{:team :project :file :profile} object)
(some? (db/get* conn object {:id id}
{::db/remove-deleted false
::sql/columns [:id]}))
true)))
(defmulti delete-object
(fn [_ props] (:object props)))
(defmethod delete-object :snapshot
[{:keys [::db/conn] :as cfg} {:keys [id file-id deleted-at]}]
(l/trc :obj "snapshot" :id (str id) :file-id (str file-id)
:deleted-at (ct/format-inst deleted-at))
(db/update! conn :file-change
{:deleted-at deleted-at
:updated-at (ct/now)}
{:id id :file-id file-id}
{::db/return-keys false})
(db/update! conn :file-data
{:deleted-at deleted-at}
{:id id :file-id file-id :type "snapshot"}
{::db/return-keys false}))
(defmethod delete-object :file
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at file-ids]}]
(when-let [file (db/get* conn :file {:id id}
{::db/remove-deleted false
::sql/columns [:id :is-shared]})]
(l/trc :obj "file" :id (str id)
:deleted-at (ct/format-inst deleted-at))
(db/update! conn :file
{:deleted-at deleted-at
:is-shared false}
{:id id}
{::db/return-keys false})
(when (and (:is-shared file)
(not *team-deletion*))
;; NOTE: we don't prevent file deletion on absorb operation failure
(try
(db/tx-run! cfg files/absorb-library id :file-ids file-ids)
(catch Throwable cause
(l/warn :hint "error on absorbing library"
:file-id id
:cause cause))))
;; Mark file change to be deleted, but only the rows that do not carry a
;; date yet. A revision is born already scheduled for deletion one hour
;; later, and rewriting its date here would push it out to the trash
;; lifetime; the collector would still take it, just much later than it
;; should.
(db/update! conn :file-change
{:deleted-at deleted-at
:updated-at (ct/now)}
{:file-id id :deleted-at nil}
{::db/return-keys false})
;; Mark file data fragment to be deleted
(db/update! conn :file-data
{:deleted-at deleted-at}
{:file-id id}
{::db/return-keys false})
;; Mark file media objects to be deleted
(db/update! conn :file-media-object
{:deleted-at deleted-at}
{:file-id id}
{::db/return-keys false})
;; Mark thumbnails to be deleted
(db/update! conn :file-thumbnail
{:deleted-at deleted-at}
{:file-id id}
{::db/return-keys false})
(db/update! conn :file-tagged-object-thumbnail
{:deleted-at deleted-at}
{:file-id id}
{::db/return-keys false})
(record :files id)))
(defmethod delete-object :project
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
(l/trc :obj "project" :id (str id)
:deleted-at (ct/format-inst deleted-at))
(db/update! conn :project
{:deleted-at deleted-at}
{:id id}
{::db/return-keys false})
(doseq [file (db/query conn :file
{:project-id id}
{::db/columns [:id :deleted-at]})]
(delete-object cfg (assoc file
:object :file
:deleted-at deleted-at)))
(record :projects id))
(defmethod delete-object :team
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
(l/trc :obj "team" :id (str id)
:deleted-at (ct/format-inst deleted-at))
(db/update! conn :team
{:deleted-at deleted-at}
{:id id}
{::db/return-keys false})
(db/update! conn :team-font-variant
{:deleted-at deleted-at}
{:team-id id}
{::db/return-keys false})
(binding [*team-deletion* true]
(doseq [project (db/query conn :project
{:team-id id}
{::db/columns [:id :deleted-at]})]
(delete-object cfg (assoc project
:object :project
:deleted-at deleted-at))))
(record :teams id))
(defmethod delete-object :profile
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
(l/trc :obj "profile" :id (str id)
:deleted-at (ct/format-inst deleted-at))
(db/update! conn :profile
{:deleted-at deleted-at}
{:id id}
{::db/return-keys false})
(doseq [team (profile/get-owned-teams conn id)]
(delete-object cfg (assoc team
:object :team
:deleted-at deleted-at))))
(defmethod delete-object :default
[_cfg props]
(l/wrn :obj (:object props) :hint "not implementation found"))
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool")
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage to be provided"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(db/tx-run! cfg
(fn [cfg]
(when (top-exists? cfg props)
(binding [*cascade* (atom {:teams [] :projects [] :files []})]
(delete-object cfg props)
(let [{:keys [teams projects files]} @*cascade*]
(when (or (seq teams) (seq projects) (seq files))
(audit/submit cfg {:name "delete-object-with-cascade"
:type "action"
:props {:object (:object props)
:id (:id props)
:deleted-at (:deleted-at props)
:cascade {:team-ids teams
:project-ids projects
:file-ids files}}
:context {:triggered-by "delete-object"}})))))))))