mirror of
https://github.com/penpot/penpot.git
synced 2026-11-09 11:30:54 +00:00
* ✨ Migrate legacy file payloads to file_data and delete orphans Copy file.data and labelled file_change.data to file_data on boot (type main/snapshot, backend db, deleted_at preserved), then delete file_change rows with data but no label, which no read path reaches. Large instances pre-migrate with batched statements and fake this step. Closes #12784. AI-assisted-by: muse-spark-1.3-contributor * 📚 Add 2.19.0 operations runbook for legacy file_data migration Manual pre-migration procedure for big instances, same statements as migration 0157 in batched transactions, following the convention of the file-change partition runbook. Closes #12784. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Address review feedback on the legacy file data migration Fix the issues raised in the review of the migration that moves the legacy file payloads to file_data. The migration deletes the unlabelled file_change rows that carry data, but restore-files still cleared their date, and the retention test asserted they came back. A row without a date never reaches objects-gc, which is the leak fixed in #12419. Restore now unmarks only snapshots, the rows that carry a label, and the retention test checks that the legacy payload keeps its date instead of coming back. Cover the conflict path that had no test: a pre-existing snapshot that collides with a labelled change row, and a second run that must leave the legacy columns null and the orphan row absent. The runbook analyses file_data after the inserts, so the planner does not wait for autovacuum. Drop app.srepl.procs.fdata-storage: the copy it performed is what the migration replaces and nothing requires it. Closes #12784 AI-assisted-by: deepseek-v4.1-flash
210 lines
8.7 KiB
Clojure
210 lines
8.7 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.file-change-retention-test
|
|
"Retention of `file_change` rows through the file lifecycle.
|
|
|
|
A file has two kinds of row. Durable history: the snapshots, which a restore
|
|
brings back. And ephemeral revisions: the xlog of an edit, born already
|
|
scheduled for deletion one hour later, which nobody needs once the sessions
|
|
that were going to read them are gone.
|
|
|
|
The invariant under test is that a deletion date is assigned once and never
|
|
moved: restoring a file must not unmark a revision, and deleting a file must
|
|
not push a revision's date out to the trash lifetime. When either happens the
|
|
revision becomes invisible to `objects-gc`, which only looks at rows that
|
|
carry a date, and it stays in the table for ever."
|
|
(:require
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.db :as db]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.files :as files]
|
|
[app.util.blob :as blob]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(def ^:private test-now
|
|
"The instant every test pins the clock to. Whole seconds on purpose: a value
|
|
with a sub-second part does not survive the round trip through the database,
|
|
and the test could not compare it for equality."
|
|
(ct/inst "2026-10-01T00:00:00Z"))
|
|
|
|
(def ^:private revision-lifetime
|
|
"The lifetime the `update-file` flow gives a revision when it inserts it."
|
|
(ct/duration {:hours 1}))
|
|
|
|
(defn- create-file
|
|
[i]
|
|
(let [profile (th/create-profile* i {:is-active true})]
|
|
[profile (th/create-file* i {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)})]))
|
|
|
|
(defn- insert-change
|
|
"Inserts a `file_change` row directly, so the test builds the exact shape it
|
|
needs. The row class is decided by what the caller passes: a revision carries
|
|
only `changes`, a snapshot carries `label`, and a legacy payload row carries
|
|
`data` without a label. Returns the new row id."
|
|
[file-id {:keys [label data changes created-at deleted-at created-by]}]
|
|
(let [created-at (or created-at (ct/now))
|
|
id (uuid/next)]
|
|
(th/db-insert! :file-change
|
|
{:id id
|
|
:file-id file-id
|
|
:created-at created-at
|
|
:updated-at created-at
|
|
:deleted-at deleted-at
|
|
:revn 1
|
|
:version 0
|
|
:created-by (or created-by "system")
|
|
:label label
|
|
:data data
|
|
:changes changes}
|
|
{::db/return-keys false})
|
|
id))
|
|
|
|
(defn- insert-revision
|
|
"A revision exactly as the `update-file` flow writes it: no label, no data and
|
|
`changes` filled, already scheduled for deletion one hour after creation."
|
|
[file-id created-at]
|
|
(insert-change file-id {:created-at created-at
|
|
:changes (blob/encode {:foo "bar"})
|
|
:deleted-at (ct/plus created-at revision-lifetime)}))
|
|
|
|
(defn- find-change
|
|
[id]
|
|
(th/db-get :file-change {:id id} {::db/remove-deleted false}))
|
|
|
|
(defn- find-file
|
|
[id]
|
|
(th/db-get :file {:id id} {::db/remove-deleted false}))
|
|
|
|
(defn- restore-file
|
|
[file-id]
|
|
(db/tx-run! th/*system*
|
|
(fn [{:keys [::db/conn]}]
|
|
(#'files/restore-files conn [file-id]))))
|
|
|
|
(t/deftest restoring-a-file-leaves-the-revision-schedule-alone
|
|
(binding [ct/*clock* (ct/fixed-clock test-now)]
|
|
(let [[_ file] (create-file 1)
|
|
file-id (:id file)
|
|
trash-at (ct/in-future {:days 7})
|
|
old-at (ct/in-past {:hours 2})
|
|
|
|
revision-id (insert-revision file-id old-at)
|
|
revision-at (:deleted-at (find-change revision-id))
|
|
|
|
snapshot-id (insert-change file-id {:created-at old-at
|
|
:created-by "user"
|
|
:label "a snapshot"
|
|
:deleted-at trash-at})
|
|
|
|
legacy-id (insert-change file-id {:created-at old-at
|
|
:data (blob/encode {:legacy true})
|
|
:deleted-at trash-at})]
|
|
|
|
(restore-file file-id)
|
|
|
|
(t/testing "the revision keeps the schedule it was born with"
|
|
(t/is (= revision-at (:deleted-at (find-change revision-id)))))
|
|
|
|
(t/testing "the snapshot comes back"
|
|
(t/is (nil? (:deleted-at (find-change snapshot-id)))))
|
|
|
|
(t/testing "the legacy payload row keeps its date"
|
|
(t/is (= trash-at (:deleted-at (find-change legacy-id))))))))
|
|
|
|
(t/deftest deleting-a-file-does-not-push-the-revision-schedule-out
|
|
(binding [ct/*clock* (ct/fixed-clock test-now)]
|
|
(let [[_ file] (create-file 1)
|
|
file-id (:id file)
|
|
trash-at (ct/in-future {:days 7})
|
|
old-at (ct/in-past {:hours 2})
|
|
system-at (ct/in-future {:days 2})
|
|
|
|
revision-id (insert-revision file-id old-at)
|
|
revision-at (:deleted-at (find-change revision-id))
|
|
|
|
snapshot-id (insert-change file-id {:created-at old-at
|
|
:created-by "user"
|
|
:label "a snapshot"})
|
|
|
|
system-id (insert-change file-id {:created-at old-at
|
|
:label "internal/snapshot/1"
|
|
:deleted-at system-at})]
|
|
|
|
(th/run-task! :delete-object {:object :file
|
|
:deleted-at trash-at
|
|
:id file-id})
|
|
|
|
(t/testing "the revision keeps the schedule it was born with"
|
|
(t/is (= revision-at (:deleted-at (find-change revision-id)))))
|
|
|
|
(t/testing "the snapshot takes the file's deletion date"
|
|
(t/is (= (:deleted-at (find-file file-id))
|
|
(:deleted-at (find-change snapshot-id)))))
|
|
|
|
(t/testing "the system snapshot keeps the date it already had"
|
|
(t/is (= system-at (:deleted-at (find-change system-id))))))))
|
|
|
|
(t/deftest updating-a-snapshot-cannot-reach-a-revision
|
|
;; The snapshot paths clear the deletion mark of the row they touch, which is
|
|
;; only legitimate for durable history. They reach a row through
|
|
;; `sql:snapshots`, which filters `label IS NOT NULL`, and a revision never
|
|
;; carries one. This test pins that: the row has to keep the date it was born
|
|
;; with, and the RPC has to reject the id. If a future change loosens that
|
|
;; read filter, this goes red instead of the revision losing its date in
|
|
;; silence.
|
|
(binding [ct/*clock* (ct/fixed-clock test-now)]
|
|
(let [[profile file] (create-file 1)
|
|
file-id (:id file)
|
|
revision-id (insert-revision file-id (ct/in-past {:hours 2}))
|
|
revision-at (:deleted-at (find-change revision-id))
|
|
|
|
out (th/command! {::th/type :update-file-snapshot
|
|
::rpc/profile-id (:id profile)
|
|
:id revision-id
|
|
:label "renamed"})]
|
|
|
|
(t/testing "the RPC rejects the id"
|
|
(t/is (th/ex-info? (:error out)))
|
|
(t/is (= :not-found (:type (ex-data (:error out))))))
|
|
|
|
(t/testing "and the revision keeps the date it was born with"
|
|
(t/is (= revision-at (:deleted-at (find-change revision-id))))))))
|
|
|
|
(t/deftest a-deleted-then-restored-file-does-not-leak-its-revisions
|
|
;; The lifecycle that leaked 358,517 rows in production: a file is edited, it
|
|
;; ends up in the trash, it comes back, and the revisions that were scheduled
|
|
;; to die an hour after they were written have to die anyway.
|
|
(binding [ct/*clock* (ct/fixed-clock test-now)]
|
|
(let [[_ file] (create-file 1)
|
|
file-id (:id file)
|
|
|
|
revision-id (insert-revision file-id (ct/in-past {:hours 2}))
|
|
revision-at (:deleted-at (find-change revision-id))]
|
|
|
|
(th/run-task! :delete-object {:object :file
|
|
:deleted-at (ct/in-future {:days 7})
|
|
:id file-id})
|
|
|
|
(t/testing "deleting the file leaves the revision schedule alone"
|
|
(t/is (= revision-at (:deleted-at (find-change revision-id)))))
|
|
|
|
(restore-file file-id)
|
|
|
|
(t/testing "restoring the file leaves it alone too"
|
|
(t/is (= revision-at (:deleted-at (find-change revision-id)))))
|
|
|
|
(th/run-task! :objects-gc {})
|
|
|
|
(t/testing "so the collector takes it"
|
|
(t/is (nil? (find-change revision-id)))))))
|