mirror of
https://github.com/penpot/penpot.git
synced 2026-11-12 13:00:37 +00:00
* ✨ Partition file_change by file_id Partition `file_change` by `HASH (file_id)` in 16 partitions, with the same bounds as `file_data`, so a join between them is resolved partition by partition. The primary key becomes `(file_id, id)`, which a partitioned table needs, and every access filters by `file_id`. The new table carries a `kind` column (1 snapshot, 2 revision) as a reserve: the queries still tell them apart by the label, and `kind` takes over when they are rewritten. The payload of a snapshot stays in `file_data`; the `data` column is kept for reading old `legacy-db` payloads. The four snapshot commands require `file-id`, because it prunes the partition. The objects GC locks and deletes by the pair, in batches. The old table is kept as `file_change_legacy` as the way back. The automatic migration refuses to run above 10 GiB or 2M rows and points to the runbook; big instances partition online with that runbook, which copies by id, follows the `updated_at` watermark in delta passes and checks that no row of the source is missing from the target. Closes #12416 AI-assisted-by: deepseek-v4.1-flash * 🐛 Address the review of the file_change partition The production runbook promised a rollback it did not have: the section was a sketch, and its statements could write to the table about to be dropped. The rollback is removed, and so is every comment that described it. `file_change_legacy` stays as a frozen copy of the previous layout. The cutover check now aborts the transaction when a row of the old table is missing in the new one, instead of leaving the decision to the operator. The five secondary indexes of the new table are built after the last delta pass: the bulk and the delta passes only need the primary key, so the batches no longer maintain them. The copy of 0158 is now replayed in a test against the pre-0158 shape with a labelled and an unlabelled row, plus an empty instance that must leave no legacy table behind. The updated_at assertion of the snapshot mutations is bounded by the test clock, and the repair revision stops writing the `features` of the file, like the normal save. AI-assisted-by: deepseek-v4.1-flash * 📎 Fix linter issues * 🐛 Fix stale helper call in rpc_file_snapshot_test The suite does not load: `th/run-pending-tasks!` does not exist in backend-tests.helpers. The pending jobs runner is `run-pending-jobs` (no arguments), the name every other call site of the suite uses. AI-assisted-by: inkling-2-small-free * 📚 Align the partition change with the 2.20.0 milestone 2.19.0 is already shipped; this work targets 2.20.0. The operations file of the partition procedure carries the 2.20.0 prefix, the references that point at it (the migration, the runbook table and the memory) follow, and the RPC doc/changes tags of the snapshot commands record 2.20 as the release that makes `file-id` required. AI-assisted-by: inkling-2-small-free * 🐛 Fix review findings on the partition rebase The 0157 legacy test was missed by the rebase and inserted file_change rows without the new mandatory `kind` column, so the backend suite fails against the migrated schema. The inserts now carry `:kind` (snapshot-kind for the labelled rows, revision-kind for the rest). The post-cutover verification block of the runbook terminates the first query with a semicolon so the block pastes into psql without a syntax error. The guard message of the migration names the step (0158-partition-file-change) instead of "0158", ambiguous since two migrations share that number. Closes #12416 AI-assisted-by: inkling-2-small-free * 🐛 Fix the stale label in the snapshot restore test The rename block of snapshots-crud runs before the restore block, so the restored version is already named "label1-updated" when the restore publishes its message. The command names the version by the label the row has at that moment, not by the one it had when it was created, so the test was asserting a stale value. Assert the current label, and say why in the comment. AI-assisted-by: deepseek-v4.1-flash * ♻️ Split the version event tests into one test per command The workspace version events and the plugin proxy send the file-id of the open file on every snapshot command, because the command needs it to prune the partition of file_change. Both tests checked that with a doseq and t/testing blocks, so a failure named the whole group and not the command that broke. Give each command its own test. The workspace ones share a helper that runs the event and returns the command and params it sends; the plugin ones keep their own mock of rp/cmd!. AI-assisted-by: deepseek-v4.1-flash
310 lines
14 KiB
Clojure
310 lines
14 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 INC Sucursal en España SL
|
|
|
|
(ns backend-tests.objects-gc-test
|
|
(:require
|
|
[app.common.logging :as l]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.features.file-changes :as fchanges]
|
|
[app.rpc :as-alias rpc]
|
|
[app.storage :as sto]
|
|
[app.tasks.objects-gc :as ogc]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]
|
|
[mockery.core :refer [with-mocks]]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(defn- create-deleted-file!
|
|
"Creates a file and applies the deletion cascade on it (the same task the RPC
|
|
uses), so its children are marked as deleted for the given date. Returns the
|
|
file."
|
|
[profile i deleted-at]
|
|
(let [file (th/create-file* i {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)})]
|
|
(th/run-task! :delete-object {:object :file
|
|
:deleted-at deleted-at
|
|
:id (:id file)})
|
|
file))
|
|
|
|
(defn- block-file!
|
|
"Leaves an unmarked `file_media_object` pointing at the file, which is what
|
|
happens in production: a child that the deletion cascade did not mark. Its
|
|
foreign key towards `file` is `NO ACTION`, so it blocks the file deletion."
|
|
[file]
|
|
(let [media (sto/put-object! (::sto/storage th/*system*)
|
|
{::sto/content (sto/content "media")
|
|
:content-type "text/plain"
|
|
:bucket "file-media-object"})]
|
|
(th/create-file-media-object* {:file-id (:id file)
|
|
:media-id (:id media)})))
|
|
|
|
(defn- find-file
|
|
[id]
|
|
(th/db-get :file {:id id} {::db/remove-deleted false}))
|
|
|
|
(defn- chunk-error
|
|
"The first captured log record that reports a skipped chunk for `obj`,
|
|
waiting for it because logging is asynchronous."
|
|
[records obj]
|
|
(let [deadline (+ (System/currentTimeMillis) 5000)]
|
|
(loop []
|
|
(let [record (first (filter (fn [record]
|
|
(let [props (::l/props record)]
|
|
(and (= obj (get props :obj))
|
|
(= "error on chunk deletion, skipping it"
|
|
(get props :hint)))))
|
|
@records))]
|
|
(cond
|
|
record record
|
|
(> (System/currentTimeMillis) deadline) nil
|
|
:else (do (Thread/sleep 10) (recur)))))))
|
|
|
|
(t/deftest deletes-the-pending-files
|
|
(let [profile (th/create-profile* 1)
|
|
file (create-deleted-file! profile 1 (ct/in-past "10m"))
|
|
result (th/run-task! :objects-gc {})]
|
|
|
|
(t/is (pos? (:processed result)))
|
|
(t/is (nil? (find-file (:id file))))))
|
|
|
|
(t/deftest deletes-the-pending-file-changes
|
|
;; The pending file changes are locked per file, with a constant `file_id`,
|
|
;; so the rows of a file that is pending deletion are gone after the run.
|
|
(let [profile (th/create-profile* 1)
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)})
|
|
|
|
_ (th/db-insert! :file-change
|
|
{:file-id (:id file)
|
|
:kind fchanges/revision-kind
|
|
:deleted-at (ct/in-past "10m")})
|
|
|
|
result (th/run-task! :objects-gc {})]
|
|
|
|
(t/is (pos? (:processed result)))
|
|
(t/is (nil? (th/db-get :file-change {:file-id (:id file)}
|
|
{::db/remove-deleted false})))))
|
|
|
|
(t/deftest deletes-the-pending-file-changes-of-several-chunks
|
|
;; More pending rows than the chunk size: the plan walks the cursor and each
|
|
;; chunk locks its own rows, one query per file, with a constant `file_id`
|
|
;; so the planner prunes the partition.
|
|
(let [profile (th/create-profile* 1)
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)})
|
|
ids (repeatedly 5 uuid/next)
|
|
_ (doseq [id ids]
|
|
(th/db-insert! :file-change
|
|
{:id id
|
|
:file-id (:id file)
|
|
:kind fchanges/revision-kind
|
|
:deleted-at (ct/in-past "10m")}))
|
|
result (th/run-task! :objects-gc {:chunk-size 2})]
|
|
|
|
(t/is (pos? (:processed result)))
|
|
(t/is (empty? (th/db-query :file-change {:file-id (:id file)}
|
|
{::db/remove-deleted false})))))
|
|
|
|
(t/deftest a-chunk-spanning-several-files-deletes-all-of-them
|
|
;; The rows of one chunk can belong to different files. Each group is locked
|
|
;; with its own `file_id`, and every row is still deleted; buffering the
|
|
;; groups instead of locking them would leave them behind.
|
|
(let [profile (th/create-profile* 1)
|
|
opts {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)}
|
|
files [(th/create-file* 1 opts)
|
|
(th/create-file* 2 opts)]
|
|
|
|
_ (doseq [file files]
|
|
(dotimes [_ 2]
|
|
(th/db-insert! :file-change
|
|
{:file-id (:id file)
|
|
:kind fchanges/revision-kind
|
|
:deleted-at (ct/in-past "10m")})))
|
|
|
|
;; one chunk holds the rows of both files
|
|
result (th/run-task! :objects-gc {:chunk-size 4})]
|
|
|
|
(t/is (pos? (:processed result)))
|
|
(doseq [file files]
|
|
(t/is (empty? (th/db-query :file-change {:file-id (:id file)}
|
|
{::db/remove-deleted false}))))))
|
|
|
|
(t/deftest profile-cascade-keeps-profile-deletion-date
|
|
;; The cascade marks the owned rows with the deletion date of the profile,
|
|
;; like the RPC delete-profile flow does, not with the timestamp of the run.
|
|
;; The team survives because only the profiles chunk runs here, so its row
|
|
;; still shows that date.
|
|
(let [profile (th/create-profile* 1)
|
|
_ (th/db-update! :profile {:deleted-at (ct/in-past "30m")} {:id (:id profile)})
|
|
|
|
deleted-at (:deleted-at (th/db-get :profile {:id (:id profile)}
|
|
{::db/remove-deleted false}))
|
|
|
|
cfg {::db/pool th/*pool*
|
|
::sto/storage (::sto/storage th/*system*)
|
|
::ogc/threshold deleted-at}
|
|
total (#'ogc/delete-profiles-chunk cfg [(:id profile)])]
|
|
|
|
(t/testing "the profile is gone and the chunk reports it"
|
|
(t/is (= 1 total))
|
|
(t/is (nil? (th/db-get :profile {:id (:id profile)} {::db/remove-deleted false}))))
|
|
|
|
(t/testing "the owned team keeps the deletion date of the profile"
|
|
(t/is (= deleted-at (:deleted-at (th/db-get :team {:id (:default-team-id profile)}
|
|
{::db/remove-deleted false})))))))
|
|
|
|
(t/deftest blocked-chunk-does-not-stop-the-run
|
|
;; Chunks of one file: the blocked file takes down its own chunk and nothing
|
|
;; else, so the clean file of the next chunk is still deleted and the run
|
|
;; finishes. The next run retries the blocked file.
|
|
(let [profile (th/create-profile* 1)
|
|
blocked (create-deleted-file! profile 1 (ct/in-past "30m"))
|
|
clean (create-deleted-file! profile 2 (ct/in-past "20m"))
|
|
|
|
_ (block-file! blocked)
|
|
|
|
records (atom [])
|
|
_ (add-watch l/log-record ::capture
|
|
(fn [_ _ _ record] (swap! records conj record)))
|
|
result (try
|
|
(th/run-task! :objects-gc {:chunk-size 1})
|
|
(finally
|
|
(remove-watch l/log-record ::capture)))]
|
|
|
|
(t/testing "the clean file of the next chunk is deleted"
|
|
(t/is (nil? (find-file (:id clean)))))
|
|
|
|
(t/testing "the blocked file is still pending"
|
|
(t/is (some? (find-file (:id blocked)))))
|
|
|
|
(t/testing "the failed chunk is reported"
|
|
(t/is (some? (chunk-error records "file"))))
|
|
|
|
(t/testing "the run finishes"
|
|
(t/is (pos? (:processed result))))))
|
|
|
|
(defn- submitted-events
|
|
[audit-mock]
|
|
(->> (:call-args-list @audit-mock)
|
|
(map second)))
|
|
|
|
(defn- events-named
|
|
[audit-mock event-name]
|
|
(filter #(= event-name (:name %))
|
|
(submitted-events audit-mock)))
|
|
|
|
(t/deftest gc-emits-hard-delete-objects-for-teams
|
|
;; When the GC physically deletes a team, the projection must drop its row,
|
|
;; so the event carries the purged ids.
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(with-redefs [cf/flags (conj cf/flags :audit-log)]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 71 {:profile-id (:id profile)})]
|
|
(th/command! {::th/type :delete-team
|
|
::rpc/profile-id (:id profile)
|
|
:id (:id team)})
|
|
;; Run the queued cascade so the default project is marked deleted too,
|
|
;; then purge everything the way production does.
|
|
(th/run-pending-jobs)
|
|
(th/reset-mock! audit-mock)
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false})))
|
|
(let [events (filter #(contains? (:props %) :team-ids)
|
|
(events-named audit-mock "hard-delete-objects"))]
|
|
(t/is (= 1 (count events)))
|
|
(let [event (first events)]
|
|
(t/is (= "command" (:type event)))
|
|
(t/is (= [(:id team)] (get-in event [:props :team-ids])))
|
|
(t/is (= "objects-gc" (get-in event [:context :triggered-by])))))))))
|
|
|
|
(t/deftest gc-emits-hard-delete-objects-for-projects
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(with-redefs [cf/flags (conj cf/flags :audit-log)]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 72 {:profile-id (:id profile)})
|
|
project (th/create-project* 72 {:profile-id (:id profile)
|
|
:team-id (:id team)})]
|
|
(th/command! {::th/type :delete-project
|
|
::rpc/profile-id (:id profile)
|
|
:id (:id project)})
|
|
(th/run-pending-jobs)
|
|
(th/reset-mock! audit-mock)
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :project {:id (:id project)} {::db/remove-deleted false})))
|
|
(let [events (filter #(contains? (:props %) :project-ids)
|
|
(events-named audit-mock "hard-delete-objects"))]
|
|
(t/is (= 1 (count events)))
|
|
(let [event (first events)]
|
|
(t/is (= "command" (:type event)))
|
|
(t/is (= [(:id project)] (get-in event [:props :project-ids])))
|
|
(t/is (= "objects-gc" (get-in event [:context :triggered-by])))))))))
|
|
|
|
(t/deftest gc-emits-hard-delete-objects-for-files
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(with-redefs [cf/flags (conj cf/flags :audit-log)]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 73 {:profile-id (:id profile)})
|
|
project (th/create-project* 73 {:profile-id (:id profile)
|
|
:team-id (:id team)})
|
|
file (th/create-file* 73 {:profile-id (:id profile)
|
|
:project-id (:id project)})]
|
|
(th/command! {::th/type :delete-file
|
|
::rpc/profile-id (:id profile)
|
|
:id (:id file)})
|
|
(th/run-pending-jobs)
|
|
(th/reset-mock! audit-mock)
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :file {:id (:id file)} {::db/remove-deleted false})))
|
|
(let [events (filter #(contains? (:props %) :file-ids)
|
|
(events-named audit-mock "hard-delete-objects"))]
|
|
(t/is (= 1 (count events)))
|
|
(let [event (first events)]
|
|
(t/is (= "command" (:type event)))
|
|
(t/is (= [(:id file)] (get-in event [:props :file-ids])))
|
|
(t/is (= "objects-gc" (get-in event [:context :triggered-by])))))))))
|
|
|
|
(t/deftest gc-emits-no-event-without-audit-log-flag
|
|
;; The GC must stay silent when the audit log is off, even though the row is
|
|
;; still purged.
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 74 {:profile-id (:id profile)})]
|
|
(th/command! {::th/type :delete-team
|
|
::rpc/profile-id (:id profile)
|
|
:id (:id team)})
|
|
(th/run-pending-jobs)
|
|
(th/reset-mock! audit-mock)
|
|
(th/run-task! :objects-gc {:skip-delay true})
|
|
(t/is (nil? (th/db-get :team {:id (:id team)} {::db/remove-deleted false})))
|
|
(t/is (empty? (:call-args-list @audit-mock))))))
|
|
|
|
(t/deftest gc-emits-events-only-for-committed-chunks
|
|
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
|
|
(with-redefs [cf/flags (conj cf/flags :audit-log)]
|
|
(let [profile (th/create-profile* 1)
|
|
rolled-back (create-deleted-file! profile 1 (ct/in-past "30m"))
|
|
blocked (create-deleted-file! profile 2 (ct/in-past "20m"))
|
|
clean (create-deleted-file! profile 3 (ct/in-past "10m"))]
|
|
(block-file! blocked)
|
|
(th/reset-mock! audit-mock)
|
|
(let [result (th/run-task! :objects-gc {:chunk-size 2})
|
|
events (filter #(contains? (:props %) :file-ids)
|
|
(events-named audit-mock "hard-delete-objects"))]
|
|
(t/testing "the failed chunk keeps both files"
|
|
(t/is (some? (find-file (:id rolled-back))))
|
|
(t/is (some? (find-file (:id blocked)))))
|
|
(t/testing "the next chunk commits and reports only its deleted file"
|
|
(t/is (nil? (find-file (:id clean))))
|
|
(t/is (pos? (:processed result)))
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= [(:id clean)] (get-in (first events) [:props :file-ids])))))))))
|