🐛 Unmark the storage objects that a restore brings back

Restoring a file only un-marked the database rows (`file`,
`file_media_object`, `file_change`, `file_data`, `file_thumbnail`,
`file_tagged_object_thumbnail`), not the storage objects those rows
reference. An object that was already marked for deletion stayed
marked while a live row pointed at it, and those foreign keys are `NO
ACTION`: the storage GC cannot delete it and skips it forever (and
before the GC learned to skip, it cost the whole nightly run).
Restoring a team had the same gap with its font variants.

Both restores now unmark the storage objects of the rows they bring
back: the media and the thumbnail of the file media objects, the media
of the file and object thumbnails, the `storage-ref-id` of the file
data, and the four files of a font variant. Objects referenced by other
rows are left alone.

AI-assisted-by: deepseek-v4.1-flash
This commit is contained in:
Andrey Antukh 2026-10-12 13:11:29 +00:00
parent 0947e666c0
commit 575b68d2c9
3 changed files with 181 additions and 1 deletions

View File

@ -1320,6 +1320,40 @@
"UPDATE file_tagged_object_thumbnail SET deleted_at = null
WHERE file_id = ANY(?::uuid[])")
(def ^:private sql:restore-storage-objects
"WITH ids AS (SELECT unnest(?::uuid[]) AS file_id)
UPDATE storage_object
SET deleted_at = null
WHERE deleted_at IS NOT NULL
AND (id IN (SELECT media_id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.media_id IS NOT NULL
UNION
SELECT thumbnail_id
FROM file_media_object AS fmo
JOIN ids ON (ids.file_id = fmo.file_id)
WHERE fmo.deleted_at IS NULL
AND fmo.thumbnail_id IS NOT NULL
UNION
SELECT ft.media_id
FROM file_thumbnail AS ft
JOIN ids ON (ids.file_id = ft.file_id)
WHERE ft.deleted_at IS NULL
AND ft.media_id IS NOT NULL
UNION
SELECT fot.media_id
FROM file_tagged_object_thumbnail AS fot
JOIN ids ON (ids.file_id = fot.file_id)
WHERE fot.deleted_at IS NULL
AND fot.media_id IS NOT NULL)
OR id::text IN (SELECT fd.metadata->>'storage-ref-id'
FROM file_data AS fd
JOIN ids ON (ids.file_id = fd.file_id)
WHERE fd.deleted_at IS NULL))")
(defn- restore-files
[conn file-ids]
(let [file-ids (db/create-array conn "uuid" file-ids)]
@ -1328,7 +1362,13 @@
(db/exec-one! conn [sql:restore-file-changes file-ids])
(db/exec-one! conn [sql:restore-file-data file-ids])
(db/exec-one! conn [sql:restore-file-thumbnails file-ids])
(db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])))
(db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])
;; NOTE: the storage objects referenced by the rows we just brought back
;; must stop being marked for deletion. The storage GC cannot delete an
;; object that a live row references (those foreign keys are `NO ACTION`),
;; so a marked object that comes back to life is skipped forever otherwise.
(db/exec-one! conn [sql:restore-storage-objects file-ids])))
(def ^:private sql:restore-projects
"UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")

View File

@ -720,6 +720,15 @@
:id team-id})))
:deleted))
(def ^:private sql:restore-font-file-objects
"UPDATE storage_object
SET deleted_at = null
WHERE deleted_at IS NOT NULL
AND id IN (SELECT unnest(array[woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id])
FROM team_font_variant
WHERE team_id = ?::uuid
AND deleted_at IS NULL)")
(defn- restore-team*
[{:keys [::db/conn] :as cfg} team-id]
(db/update! conn :team
@ -730,6 +739,11 @@
{:deleted-at nil}
{:team-id team-id})
;; Same as in `restore-files`: the font objects of the variants we just
;; brought back must stop being marked for deletion, or the storage GC will
;; skip them forever (those foreign keys are `NO ACTION`).
(db/exec-one! conn [sql:restore-font-file-objects team-id])
(doseq [{:keys [id]} (db/query conn :project
{:team-id team-id}
{::sql/columns [:id]})]

View File

@ -0,0 +1,126 @@
;; 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.restore-storage-objects-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc.commands.files :as files]
[app.srepl.main :as srepl]
[app.storage :as sto]
[app.system :as sys]
[backend-tests.helpers :as th]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
;; Restoring a file brings its rows back to life, so the storage objects those
;; rows reference must stop being marked for deletion: otherwise the storage GC
;; hits the foreign key and skips them forever.
(defn- put-object!
[bucket]
(sto/put-object! (::sto/storage th/*system*)
{::sto/content (sto/content "sobject")
:content-type "text/plain"
:bucket bucket}))
(defn- find-object
[id]
(th/db-get :storage-object {:id id} {::db/remove-deleted false}))
(defn- mark-object!
[id]
(th/db-update! :storage-object {:deleted-at (ct/in-past {:days 1})} {:id id}))
(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-unmarks-the-objects-its-rows-reference
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
;; The media object's thumbnail points at an object of another bucket:
;; that is the anomaly found in production. The marking task decides
;; with the table of the object's bucket, so it does not see that
;; reference and ends up marking an object that a live row still uses.
media-id (:id (put-object! "file-media-object"))
thumb-id (:id (put-object! "file-object-thumbnail"))
bystander (:id (put-object! "file-object-thumbnail"))
mobject (th/create-file-media-object* {:file-id (:id file)
:media-id media-id})]
(th/db-update! :file-media-object
{:thumbnail-id thumb-id
:deleted-at (ct/in-past {:days 1})}
{:id (:id mobject)})
(t/testing "the fixture reproduces the anomaly"
(t/is (= "file-object-thumbnail"
(:bucket (meta (sto/get-object (::sto/storage th/*system*) thumb-id))))))
;; The objects end up marked as deleted, as the marking task leaves them.
(mark-object! media-id)
(mark-object! thumb-id)
(mark-object! bystander)
(restore-file! (:id file))
(t/testing "the objects referenced by the restored rows are alive again"
(t/is (nil? (:deleted-at (find-object media-id))))
(t/is (nil? (:deleted-at (find-object thumb-id)))))
(t/testing "objects of other rows are left alone"
(t/is (some? (:deleted-at (find-object bystander)))))))
(t/deftest restoring-a-file-unmarks-the-objects-of-its-data
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)})
sobject (:id (put-object! "file-data"))
data-id (uuid/next)]
(th/db-update! :file {:deleted-at (ct/in-past {:days 1})} {:id (:id file)})
(th/db-insert! :file-data
{:file-id (:id file)
:id data-id
:type "main"
:backend "fs"
:deleted-at (ct/in-past {:days 1})
:metadata (db/json {"storage-ref-id" (str sobject)})})
(mark-object! sobject)
(restore-file! (:id file))
(t/is (nil? (:deleted-at (find-object sobject))))))
(t/deftest restoring-a-team-unmarks-the-objects-of-its-fonts
(let [profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
sobject (:id (put-object! "team-font-variant"))]
(th/db-insert! :team-font-variant
{:id (uuid/next)
:team-id (:id team)
:profile-id (:id profile)
:font-id (uuid/next)
:font-family "somefont"
:font-weight 400
:font-style "normal"
:deleted-at (ct/in-past {:days 1})
:woff1-file-id sobject})
(mark-object! sobject)
(with-redefs [sys/system th/*system*]
(srepl/restore-team! (:id team)))
(t/is (nil? (:deleted-at (find-object sobject))))))