penpot/backend/test/backend_tests/file_change_retention_test.clj
Andrey Antukh 35baa06606
✨ Migrate legacy file payloads to file_data and delete orphans (#12785)
* ✨ 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
2026-11-04 12:25:01 +01:00

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)))))))