From afd68fa09d20ba56a479223c89aba567d797dada Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 13 Feb 2024 17:34:32 +0100 Subject: [PATCH] :bug: Properly handle fdata features on file-gc task It also adds a schema validation process after cleaning. If file does not validates it will be skiped. --- backend/src/app/binfile/common.clj | 3 - backend/src/app/tasks/file_gc.clj | 474 ++++++++++-------- backend/test/backend_tests/rpc_file_test.clj | 50 +- .../rpc_file_thumbnails_test.clj | 2 +- 4 files changed, 284 insertions(+), 245 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index aceb4ef7b..4f9af0e7f 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -141,8 +141,6 @@ " WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")] (db/exec! conn [sql ids]))))) - -;; NOTE: Will be used in future, commented for satisfy linter (def ^:private sql:get-libraries "WITH RECURSIVE libs AS ( SELECT fl.id @@ -409,7 +407,6 @@ (update :colors relink-colors) (d/without-nils)))))) - (defn- upsert-file! [conn file] (let [sql (str "INSERT INTO file (id, project_id, name, revn, is_shared, data, created_at, modified_at) " diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index b5b9e4dd9..031bf357a 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -11,7 +11,8 @@ inactivity (the default threshold is 72h)." (:require [app.binfile.common :as bfc] - [app.common.files.migrations :as pmg] + [app.common.files.migrations :as fmg] + [app.common.files.validate :as cfv] [app.common.logging :as l] [app.common.thumbnails :as thc] [app.common.types.components-list :as ctkl] @@ -29,9 +30,256 @@ [clojure.spec.alpha :as s] [integrant.core :as ig])) -(declare ^:private get-candidates) (declare ^:private clean-file!) +(defn- decode-file + [cfg {:keys [id] :as file}] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] + (-> file + (update :features db/decode-pgarray #{}) + (update :data blob/decode) + (update :data feat.fdata/process-pointers deref) + (update :data feat.fdata/process-objects (partial into {})) + (update :data assoc :id id) + (fmg/migrate-file)))) + +(defn- update-file! + [{:keys [::db/conn] :as cfg} {:keys [id] :as file}] + (let [file (if (contains? (:features file) "fdata/objects-map") + (feat.fdata/enable-objects-map file) + file) + + file (if (contains? (:features file) "fdata/pointer-map") + (binding [pmap/*tracked* (pmap/create-tracked)] + (let [file (feat.fdata/enable-pointer-map file)] + (feat.fdata/persist-pointers! cfg id) + file)) + file) + + file (-> file + (update :features db/encode-pgarray conn "text") + (update :data blob/encode))] + + (db/update! conn :file + {:has-media-trimmed true + :data (:data file)} + {:id id}))) + +(defn- process-file! + [cfg file] + (try + (let [file (decode-file cfg file) + file (clean-file! cfg file)] + (cfv/validate-file-schema! file) + (update-file! cfg file)) + (catch Throwable cause + (l/err :hint "error on cleaning file (skiping)" + :file-id (str (:id file)) + :cause cause)))) + +(def ^:private + sql:get-candidates + "SELECT f.id, + f.data, + f.revn, + f.features, + f.modified_at + FROM file AS f + WHERE f.has_media_trimmed IS false + AND f.modified_at < now() - ?::interval + ORDER BY f.modified_at DESC + FOR UPDATE + SKIP LOCKED") + +(defn- get-candidates + [{:keys [::db/conn ::min-age ::file-id]}] + (if (uuid? file-id) + (do + (l/warn :hint "explicit file id passed on params" :file-id (str file-id)) + (db/query conn :file {:id file-id})) + + (let [min-age (db/interval min-age)] + (db/cursor conn [sql:get-candidates min-age] {:chunk-size 1})))) + +(def ^:private sql:mark-file-media-object-deleted + "UPDATE file_media_object + SET deleted_at = now() + WHERE file_id = ? AND id != ALL(?::uuid[]) + RETURNING id") + +(defn- clean-file-media! + "Performs the garbage collection of file media objects." + [{:keys [::db/conn]} {:keys [id data] :as file}] + (let [used (bfc/collect-used-media data) + ids (db/create-array conn "uuid" used) + unused (->> (db/exec! conn [sql:mark-file-media-object-deleted id ids]) + (into #{} (map :id)))] + + (doseq [id unused] + (l/trc :hint "mark deleted" + :rel "file-media-object" + :id (str id) + :file-id (str id))) + + [(count unused) file])) + +(def ^:private sql:mark-file-object-thumbnails-deleted + "UPDATE file_tagged_object_thumbnail + SET deleted_at = now() + WHERE file_id = ? AND object_id != ALL(?::text[]) + RETURNING object_id") + +(defn- clean-file-object-thumbnails! + [{:keys [::db/conn]} {:keys [data] :as file}] + (let [file-id (:id file) + using (->> (vals (:pages-index data)) + (into #{} (comp + (mapcat (fn [{:keys [id objects]}] + (->> (ctt/get-frames objects) + (map #(assoc % :page-id id))))) + (mapcat (fn [{:keys [id page-id]}] + (list + (thc/fmt-object-id file-id page-id id "frame") + (thc/fmt-object-id file-id page-id id "component"))))))) + + ids (db/create-array conn "text" using) + unused (->> (db/exec! conn [sql:mark-file-object-thumbnails-deleted file-id ids]) + (into #{} (map :object-id)))] + + (doseq [object-id unused] + (l/trc :hint "mark deleted" + :rel "file-tagged-object-thumbnail" + :object-id object-id + :file-id (str file-id))) + + [(count unused) file])) + +(def ^:private sql:mark-file-thumbnails-deleted + "UPDATE file_thumbnail + SET deleted_at = now() + WHERE file_id = ? AND revn < ? + RETURNING revn") + +(defn- clean-file-thumbnails! + [{:keys [::db/conn]} {:keys [id revn] :as file}] + (let [unused (->> (db/exec! conn [sql:mark-file-thumbnails-deleted id revn]) + (into #{} (map :revn)))] + + (doseq [revn unused] + (l/trc :hint "mark deleted" + :rel "file-thumbnail" + :revn revn + :file-id (str id))) + + [(count unused) file])) + + +(def ^:private sql:get-files-for-library + "SELECT f.id, f.data, f.modified_at, f.features + FROM file AS f + LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id) + WHERE fl.library_file_id = ? + AND f.deleted_at IS null + ORDER BY f.modified_at ASC") + +(defn- clean-deleted-components! + "Performs the garbage collection of unreferenced deleted components." + [{:keys [::db/conn] :as cfg} {:keys [data] :as file}] + (let [file-id (:id file) + + get-used-components + (fn [data components] + ;; Find which of the components are used in the file. + (into #{} + (filter #(ctf/used-in? data file-id % :component)) + components)) + + get-unused-components + (fn [components files] + ;; Find and return a set of unused components (on all files). + (reduce (fn [components {:keys [data]}] + (if (seq components) + (->> (get-used-components data components) + (set/difference components)) + (reduced components))) + + components + files)) + + process-fdata + (fn [data unused] + (reduce (fn [data id] + (l/trc :hint "delete component" + :component-id (str id) + :file-id (str file-id)) + (ctkl/delete-component data id)) + data + unused)) + + deleted (into #{} (ctkl/deleted-components-seq data)) + + unused (->> (db/cursor conn [sql:get-files-for-library file-id] {:chunk-size 1}) + (map (partial decode-file cfg)) + (cons file) + (get-unused-components deleted) + (mapv :id) + (set)) + + file (update file :data process-fdata unused)] + + [(count unused) file])) + +(def ^:private sql:get-changes + "SELECT id, data FROM file_change + WHERE file_id = ? AND data IS NOT NULL + ORDER BY created_at ASC") + +(def ^:private sql:mark-deleted-data-fragments + "UPDATE file_data_fragment + SET deleted_at = now() + WHERE file_id = ? + AND id != ALL(?::uuid[]) + RETURNING id") + +(defn- clean-data-fragments! + [{:keys [::db/conn]} {:keys [id data] :as file}] + (let [used (->> (db/cursor conn [sql:get-changes id]) + (into (feat.fdata/get-used-pointer-ids data) + (comp (map :data) + (map blob/decode) + (mapcat feat.fdata/get-used-pointer-ids)))) + + unused (let [ids (db/create-array conn "uuid" used)] + (->> (db/exec! conn [sql:mark-deleted-data-fragments id ids]) + (into #{} (map :id))))] + + (doseq [id unused] + (l/trc :hint "mark deleted" + :rel "file-data-fragment" + :id (str id) + :file-id (str id))) + + [(count unused) file])) + +(defn- clean-file! + [cfg {:keys [id] :as file}] + (let [[n1 file] (clean-file-media! cfg file) + [n2 file] (clean-file-thumbnails! cfg file) + [n3 file] (clean-file-object-thumbnails! cfg file) + [n4 file] (clean-deleted-components! cfg file) + [n5 file] (clean-data-fragments! cfg file)] + + (l/dbg :hint "file clened" + :file-id (str id) + :modified-at (dt/format-instant (:modified-at file)) + :media-objects n1 + :thumbnails n2 + :object-thumbnails n3 + :components n4 + :data-fragments n5) + + file)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HANDLER ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -55,7 +303,7 @@ (assoc ::min-age min-age)) total (reduce (fn [total file] - (clean-file! cfg file) + (process-file! cfg file) (inc total)) 0 (get-candidates cfg))] @@ -69,223 +317,3 @@ (db/rollback! conn)) {:processed total}))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMPL -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:private - sql:get-candidates - "SELECT f.id, - f.data, - f.revn, - f.features, - f.modified_at - FROM file AS f - WHERE f.has_media_trimmed IS false - AND f.modified_at < now() - ?::interval - ORDER BY f.modified_at DESC - FOR UPDATE - SKIP LOCKED") - -(defn- get-candidates - [{:keys [::db/conn ::min-age ::file-id]}] - (if (uuid? file-id) - (do - (l/warn :hint "explicit file id passed on params" :file-id (str file-id)) - (->> (db/query conn :file {:id file-id}) - (map #(update % :features db/decode-pgarray #{})))) - - (let [min-age (db/interval min-age)] - (->> (db/cursor conn [sql:get-candidates min-age] {:chunk-size 1}) - (map #(update % :features db/decode-pgarray #{})))))) - -(def ^:private sql:mark-file-media-object-deleted - "UPDATE file_media_object - SET deleted_at = now() - WHERE file_id = ? AND id != ALL(?::uuid[]) - RETURNING id") - -(defn- clean-file-media! - "Performs the garbage collection of file media objects." - [conn file-id data] - (let [used (bfc/collect-used-media data) - ids (db/create-array conn "uuid" used) - unused (->> (db/exec! conn [sql:mark-file-media-object-deleted file-id ids]) - (into #{} (map :id)))] - - (doseq [id unused] - (l/trc :hint "mark deleted" - :rel "file-media-object" - :id (str id) - :file-id (str file-id))) - - (count unused))) - - -(def ^:private sql:mark-file-object-thumbnails-deleted - "UPDATE file_tagged_object_thumbnail - SET deleted_at = now() - WHERE file_id = ? AND object_id != ALL(?::text[]) - RETURNING object_id") - -(defn- clean-file-object-thumbnails! - [{:keys [::db/conn]} file-id data] - (let [using (->> (vals (:pages-index data)) - (into #{} (comp - (mapcat (fn [{:keys [id objects]}] - (->> (ctt/get-frames objects) - (map #(assoc % :page-id id))))) - (mapcat (fn [{:keys [id page-id]}] - (list - (thc/fmt-object-id file-id page-id id "frame") - (thc/fmt-object-id file-id page-id id "component"))))))) - - ids (db/create-array conn "text" using) - unused (->> (db/exec! conn [sql:mark-file-object-thumbnails-deleted file-id ids]) - (into #{} (map :object-id)))] - - (doseq [object-id unused] - (l/trc :hint "mark deleted" - :rel "file-tagged-object-thumbnail" - :object-id object-id - :file-id (str file-id))) - - (count unused))) - - -(def ^:private sql:mark-file-thumbnails-deleted - "UPDATE file_thumbnail - SET deleted_at = now() - WHERE file_id = ? AND revn < ? - RETURNING revn") - -(defn- clean-file-thumbnails! - [{:keys [::db/conn]} file-id revn] - (let [unused (->> (db/exec! conn [sql:mark-file-thumbnails-deleted file-id revn]) - (into #{} (map :revn)))] - - (doseq [revn unused] - (l/trc :hint "mark deleted" - :rel "file-thumbnail" - :revn revn - :file-id (str file-id))) - - (count unused))) - - -(def ^:private sql:get-files-for-library - "SELECT f.id, f.data, f.modified_at - FROM file AS f - LEFT JOIN file_library_rel AS fl ON (fl.file_id = f.id) - WHERE fl.library_file_id = ? - AND f.deleted_at IS null - ORDER BY f.modified_at ASC") - -(defn- clean-deleted-components! - "Performs the garbage collection of unreferenced deleted components." - [{:keys [::db/conn] :as cfg} file-id data] - (letfn [(get-used-components [fdata components] - ;; Find which of the components are used in the file. - (into #{} - (filter #(ctf/used-in? fdata file-id % :component)) - components)) - - (get-unused-components [components files-data] - ;; Find and return a set of unused components (on all files). - (reduce (fn [components fdata] - (if (seq components) - (->> (get-used-components fdata components) - (set/difference components)) - (reduced components))) - - components - files-data))] - - (let [deleted (into #{} (ctkl/deleted-components-seq data)) - unused (->> (db/cursor conn [sql:get-files-for-library file-id] {:chunk-size 1}) - (map (fn [{:keys [id data] :as file}] - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)] - (-> (blob/decode data) - (feat.fdata/process-pointers deref))))) - (cons data) - (get-unused-components deleted) - (mapv :id))] - - (doseq [id unused] - (l/trc :hint "delete component" :component-id (str id) :file-id (str file-id))) - - - (when-let [data (some->> (seq unused) - (reduce ctkl/delete-component data) - (blob/encode))] - (db/update! conn :file - {:data data} - {:id file-id})) - - (count unused)))) - - -(def ^:private sql:get-changes - "SELECT id, data FROM file_change - WHERE file_id = ? AND data IS NOT NULL - ORDER BY created_at ASC") - -(def ^:private sql:mark-deleted-data-fragments - "UPDATE file_data_fragment - SET deleted_at = now() - WHERE file_id = ? - AND id != ALL(?::uuid[]) - RETURNING id") - -(defn- clean-data-fragments! - [conn file-id data] - (let [used (->> (db/cursor conn [sql:get-changes file-id]) - (into (feat.fdata/get-used-pointer-ids data) - (comp (map :data) - (map blob/decode) - (mapcat feat.fdata/get-used-pointer-ids)))) - - unused (let [ids (db/create-array conn "uuid" used)] - (->> (db/exec! conn [sql:mark-deleted-data-fragments file-id ids]) - (into #{} (map :id))))] - - (doseq [id unused] - (l/trc :hint "mark deleted" - :rel "file-data-fragment" - :id (str id) - :file-id (str file-id))) - - (count unused))) - - -(defn- clean-file! - [{:keys [::db/conn] :as cfg} {:keys [id data revn modified-at] :as file}] - - (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id) - pmap/*tracked* (pmap/create-tracked)] - (let [data (-> (blob/decode data) - (assoc :id id) - (pmg/migrate-data)) - - nfm (clean-file-media! conn id data) - nfot (clean-file-object-thumbnails! cfg id data) - nft (clean-file-thumbnails! cfg id revn) - nc (clean-deleted-components! cfg id data) - ndf (clean-data-fragments! conn id data)] - - (l/dbg :hint "file clened" - :file-id (str id) - :modified-at (dt/format-instant modified-at) - :media-objects nfm - :thumbnails nft - :object-thumbnails nfot - :components nc - :data-fragments ndf) - - ;; Mark file as trimmed - (db/update! conn :file - {:has-media-trimmed true} - {:id id}) - - (feat.fdata/persist-pointers! cfg id)))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 510aadd89..a684227c8 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -154,7 +154,7 @@ ;; Check the number of fragments before adding the page (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] - (t/is (= 1 (count rows)))) + (t/is (= 2 (count rows)))) ;; Add page (update-file! @@ -172,15 +172,15 @@ ;; Check the number of fragments (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] - (t/is (= 2 (count rows)))) + (t/is (= 5 (count rows)))) ;; The objects-gc should remove unused fragments (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 0 (:processed res)))) + (t/is (= 1 (:processed res)))) ;; Check the number of fragments (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] - (t/is (= 2 (count rows)))) + (t/is (= 4 (count rows)))) ;; Add shape to page that should add a new fragment (update-file! @@ -203,7 +203,7 @@ ;; Check the number of fragments (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] - (t/is (= 3 (count rows)))) + (t/is (= 5 (count rows)))) ;; The file-gc should mark for remove unused fragments (let [res (th/run-task! :file-gc {:min-age 0})] @@ -211,12 +211,13 @@ ;; The objects-gc should remove unused fragments (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 0 (:processed res)))) + (t/is (= 1 (:processed res)))) ;; Check the number of fragments; should be 3 because changes ;; are also holding pointers to fragments; - (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] - (t/is (= 3 (count rows)))) + (let [rows (th/db-query :file-data-fragment {:file-id (:id file) + :deleted-at nil})] + (t/is (= 6 (count rows)))) ;; Lets proceed to delete all changes (th/db-delete! :file-change {:file-id (:id file)}) @@ -224,7 +225,6 @@ {:has-media-trimmed false} {:id (:id file)}) - ;; The file-gc should remove fragments related to changes ;; snapshots previously deleted. (let [res (th/run-task! :file-gc {:min-age 0})] @@ -233,11 +233,11 @@ ;; Check the number of fragments; (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] ;; (pp/pprint rows) - (t/is (= 3 (count rows))) + (t/is (= 8 (count rows))) (t/is (= 2 (count (remove (comp some? :deleted-at) rows))))) (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (= 6 (:processed res)))) (let [rows (th/db-query :file-data-fragment {:file-id (:id file)})] (t/is (= 2 (count rows))))))) @@ -367,7 +367,7 @@ (t/is (= 1 (:processed res)))) (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (= 2 (:processed res)))) ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of @@ -432,6 +432,11 @@ page-id (first (get-in file [:data :pages]))] + + (let [rows (th/db-query :file-data-fragment {:file-id (:id file) + :deleted-at nil})] + (t/is (= (count rows) 1))) + ;; Update file inserting a new image object (update-file! :file-id (:id file) @@ -491,6 +496,10 @@ (let [res (th/run-task! :objects-gc {:min-age 0})] (t/is (= 1 (:processed res)))) + (let [rows (th/db-query :file-data-fragment {:file-id (:id file) + :deleted-at nil})] + (t/is (= (count rows) 2))) + ;; retrieve file and check trimmed attribute (let [row (th/db-get :file {:id (:id file)})] (t/is (true? (:has-media-trimmed row)))) @@ -521,11 +530,16 @@ ;; Now, we have deleted the usage of pointers to the ;; file-media-objects, if we paste file-gc, they should be marked ;; as deleted. + (let [res (th/run-task! :file-gc {:min-age 0})] (t/is (= 1 (:processed res)))) (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 5 (:processed res)))) + (t/is (= 6 (:processed res)))) + + (let [rows (th/db-query :file-data-fragment {:file-id (:id file) + :deleted-at nil})] + (t/is (= (count rows) 3))) ;; Now that file-gc have deleted the file-media-object usage, ;; lets execute the touched-gc task, we should see that two of @@ -681,7 +695,6 @@ (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id file-id})] (t/is (= 2 (count rows))) (t/is (= 1 (count (remove (comp some? :deleted-at) rows)))) - (t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame") (-> rows first :object-id)))) @@ -689,7 +702,7 @@ ;; thumbnail lets execute the objects-gc task which remove ;; the rows and mark as touched the storage object rows (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 2 (:processed res)))) + (t/is (= 3 (:processed res)))) ;; Now that objects-gc have deleted the object thumbnail lets ;; execute the touched-gc task @@ -719,7 +732,7 @@ (let [res (th/run-task! :objects-gc {:min-age 0})] ;; (pp/pprint res) - (t/is (= 1 (:processed res)))) + (t/is (= 2 (:processed res)))) ;; We still have th storage objects in the table (let [rows (th/db-query :storage-object {:deleted-at nil})] @@ -736,6 +749,7 @@ ;; (pp/pprint rows) (t/is (= 0 (count rows))))))) + (t/deftest permissions-checks-creating-file (let [profile1 (th/create-profile* 1) profile2 (th/create-profile* 2) @@ -1147,7 +1161,7 @@ (t/is (= 1 (count (remove (comp some? :deleted-at) rows))))) (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 2 (:processed res)))) + (t/is (= 3 (:processed res)))) (let [rows (th/db-query :file-tagged-object-thumbnail {:file-id (:id file)})] (t/is (= 1 (count rows))))))) @@ -1203,7 +1217,7 @@ (t/is (= 1 (count (remove (comp some? :deleted-at) rows))))) (let [res (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 1 (:processed res)))) + (t/is (= 2 (:processed res)))) (let [rows (th/db-query :file-thumbnail {:file-id (:id file)})] (t/is (= 1 (count rows))))))) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 88e2ac2d2..1ad3c6e09 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -222,7 +222,7 @@ (t/is (= 1 (:processed result)))) (let [result (th/run-task! :objects-gc {:min-age 0})] - (t/is (= 1 (:processed result)))) + (t/is (= 2 (:processed result)))) ;; check if row1 related thumbnail row still exists (let [[row :as rows] (th/db-query :file-thumbnail