mirror of
https://github.com/penpot/penpot.git
synced 2026-10-04 09:46:15 +00:00
* ✨ Add Malli schema for storage metadata with dual decode Phase 1 of the storage_object.metadata migration: reads accept both Transit and plain JSON (sniffed by the marker) and always return the normalized shape; writes validate against a closed per-bucket Malli schema and still serialize as Transit unless the new :storage-metadata-as-json config flag is set. The 0155 migration normalizes existing rows inside Transit (reference to bucket, default bucket, drop of chunk leftovers) and is idempotent; large instances should fake it and run the batched script instead. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Address review findings on storage metadata Phase 1 Collapse the dead :reference leg of the gc-touched bucket fallback (the decode always sets :bucket on non-nil metadata, so it is only reachable with a NULL column) and fix its comment. Pin the write flag off in the transit-assuming metadata tests so the suite proves the same with the flag set, and add coverage for the flag rollback contract, JSON hash survival, NULL metadata in gc-touched, and the 0155 normalization statements. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Backfill NULLs, canonical buckets, comment fix Backfill NULL metadata columns in 0155 via coalesce (the key-missing rule already matches them), derive valid-buckets from the Malli schema dispatch entries so the list lives in one place, and correct the lookup-bucket fallback comment to NULL columns. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Defer corrupt metadata rows in storage gc-touched Decode touched rows individually so one non-map metadata value no longer aborts the whole chunk: corrupt rows are logged and deferred exactly one day in the same transaction, keeping their metadata intact for a later repair, while healthy rows process normally. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Address storage metadata phase 1 review findings Address the review findings on the storage metadata phase 1 branch: - Fix put-and-delete-object: it stored the object with ::sto/expired-at, so the row was already deleted and del-object! returned false. Add delete-expired-object-returns-false to keep the expired-delete case covered. - Cache the Malli decoder and encoder per process. Building them compiles the closed multi-dispatch schema, and decode-metadata runs on every read path (get-object, dedup probes, GC batches). - Catch Exception instead of Throwable in try-decode-row so JVM Errors are not deferred as corrupt metadata. - Add penpot_storage_gc_poison_total, emitted from storage-gc-touched; wire ::mtx/metrics into its handler. - Anchor the encoding sniff to the start of the document so a plain JSON value that begins with a Transit-looking prefix is not read as Transit. - Cover every bucket on both encodings, a JSON roundtrip through the jsonb column, nil metadata, the canonical bucket set and a poison-only GC chunk. - Rename private check-metadata! to check-metadata. AI-assisted-by: deepseek-v4.1-flash * ♻️ Simplify the storage metadata schema to a single map Replace the per-bucket :multi dispatch with a single closed map: the bucket is validated with ::sm/one-of over metadata-buckets (now a plain set) and the remaining keys are typed optional fields. Per-bucket enforcement shrinks to a one-line :fn guard requiring :file-id and :id for file-data, whose ids the GC reads to resolve references. - Drop the dead (sm/register! ::metadata ...): nothing references the schema by keyword. - Define tempfile-bucket and upload-session-bucket in the schema and alias them from app.storage, removing duplicated literals. - Keep content-type required and the map closed, so an unknown bucket or key still fails fast on write. AI-assisted-by: deepseek-v4.1-flash * ♻️ Drop input coercion from encode-metadata encode-metadata no longer runs the json-transformer decoder before validation. On the write path its only effect was coercing string UUIDs to UUID, and every producer already passes native UUIDs (the RPC profile-id, uuid/random, or binfile ids decoded as ::sm/uuid). Reads keep decoding, so stored Transit or JSON values still come back as native types. - Replace encode-accepts-string-uuids with encode-rejects-string-uuids, pinning the stricter contract. - Pass native UUIDs in encode-writes-plain-json-with-flag. AI-assisted-by: deepseek-v4.1-flash * 📚 Document each statement in the storage metadata migration Move the per-statement rules out of the header and add a comment to each UPDATE explaining what it does: drop chunk leftovers, promote the legacy "~:reference" to "~:bucket", drop residual "~:reference", and backfill the default bucket. The header keeps the scope, the encoding note, the `->` vs `?` note and the large-instance warning. AI-assisted-by: deepseek-v4.1-flash * 📚 Unwrap wrapped lines in the backend storage memory One line per bullet or paragraph, as mem:memory-maintenance requires. Only formatting; no content change. AI-assisted-by: deepseek-v4.1-flash * ♻️ Defer storage GC poison rows in their own transaction process-chunk! no longer takes poison-ids; it only processes the healthy chunk. The deferral moves to defer-poison! and process-touched! runs it in its own transaction, separate from the freeze/delete work. The loop still drains while there is chunk or poison, so a batch made only of poison rows does not leave healthy rows behind the LIMIT 10 waiting for the next run. Add a regression test: ten poison rows plus one healthy row with a later touched_at are all handled in the same run. AI-assisted-by: deepseek-v4.1-flash * ♻️ Declare per-bucket metadata requirements in one map Replace the file-data-specific predicate with bucket-requirements, a map from bucket to the extra keys it must carry. metadata-buckets is derived from its keys and a single generic :fn enforces presence, so a new bucket and its contract are one entry. organization now requires :organization-id; file-data keeps requiring :file-id and :id. Update the http-assets test helper to set organization-id for its organization objects. AI-assisted-by: deepseek-v4.1-flash * ♻️ Drop the ! suffix from storage GC helpers Rename the internal helpers in app.storage.gc-touched (process-chunk, defer-poison, mark-freeze-in-bulk, ...) to drop the trailing !. AI-assisted-by: deepseek-v4.1-flash * ♻️ Drop the ! suffix from storage GC deleted helpers Rename the internal helpers in app.storage.gc-deleted (clean-deleted, delete-sobjects, delete-give-up, ...) to drop the trailing !. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix dedup lookup for JSON-encoded storage metadata get-database-object-by-hash only matched the Transit keys, so once the :storage-metadata-as-json flag wrote plain JSON rows the dedup stopped finding them and duplicated blobs. Match both encodings with a UNION ALL of two indexable branches. - Add migration 0156 with the plain-key dedup index; the legacy 0068 index stays until Transit support is removed. - Cover it with a JSON dedup test and a Transit -> JSON cross test. AI-assisted-by: deepseek-v4.1-flash
190 lines
6.5 KiB
Clojure
190 lines
6.5 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 SUBSIDIARY SL
|
|
|
|
(ns app.storage.gc-deleted
|
|
"A task responsible to permanently delete already marked as deleted
|
|
storage files. The storage objects are practically never marked to
|
|
be deleted directly by the api call.
|
|
|
|
The touched-gc is responsible of collecting the usage of the object
|
|
and mark it as deleted. Only the TMP files are are created with
|
|
expiration date in future."
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.logging :as l]
|
|
[app.common.time :as ct]
|
|
[app.db :as db]
|
|
[app.storage :as sto]
|
|
[app.storage.impl :as impl]
|
|
[clojure.set :as set]
|
|
[integrant.core :as ig]))
|
|
|
|
(def ^:private max-attempts
|
|
"Maximum number of deletion attempts before giving up and accepting
|
|
the orphan blob."
|
|
7)
|
|
|
|
(def ^:private chunk-size
|
|
"Number of rows to process per transaction."
|
|
25)
|
|
|
|
(def ^:private sql:lock-sobjects
|
|
"SELECT id FROM storage_object
|
|
WHERE id = ANY(?::uuid[])
|
|
FOR UPDATE
|
|
SKIP LOCKED")
|
|
|
|
(defn- lock-ids
|
|
"Perform a select before delete for proper object locking and
|
|
prevent concurrent operations and we proceed only with successfully
|
|
locked objects."
|
|
[conn ids]
|
|
(let [ids (db/create-array conn "uuid" ids)]
|
|
(->> (db/exec! conn [sql:lock-sobjects ids])
|
|
(into #{} (map :id))
|
|
(not-empty))))
|
|
|
|
(def ^:private sql:delete-sobjects
|
|
"DELETE FROM storage_object
|
|
WHERE id = ANY(?::uuid[])")
|
|
|
|
(defn- delete-sobjects
|
|
[conn ids]
|
|
(let [ids (db/create-array conn "uuid" ids)]
|
|
(-> (db/exec-one! conn [sql:delete-sobjects ids])
|
|
(db/get-update-count))))
|
|
|
|
(def ^:private sql:delete-upload-session-chunks
|
|
"DELETE FROM upload_session_chunk
|
|
WHERE object_id = ANY(?::uuid[])")
|
|
|
|
(defn- delete-upload-session-chunks
|
|
"Remove the chunk mappings for the given storage object ids. This must run
|
|
before the storage_object rows are deleted: the upload_session_chunk
|
|
foreign keys are ON DELETE NO ACTION."
|
|
[conn ids]
|
|
(let [ids (db/create-array conn "uuid" ids)]
|
|
(db/exec-one! conn [sql:delete-upload-session-chunks ids])))
|
|
|
|
(def ^:private sql:increment-attempts-and-defer
|
|
"UPDATE storage_object
|
|
SET deletion_attempts = deletion_attempts + 1,
|
|
deleted_at = NOW() + INTERVAL '1 day'
|
|
WHERE id = ANY(?::uuid[])")
|
|
|
|
(defn- increment-attempts-and-defer
|
|
[conn ids]
|
|
(let [ids (db/create-array conn "uuid" ids)]
|
|
(db/exec-one! conn [sql:increment-attempts-and-defer ids])))
|
|
|
|
(def ^:private sql:delete-give-up
|
|
"DELETE FROM storage_object
|
|
WHERE id = ANY(?::uuid[])
|
|
AND deletion_attempts >= ?")
|
|
|
|
(defn- delete-give-up
|
|
[conn ids]
|
|
(let [ids (db/create-array conn "uuid" ids)]
|
|
(db/exec-one! conn [sql:delete-give-up ids max-attempts])))
|
|
|
|
(defn- process-chunk
|
|
"Attempt to delete a chunk of storage objects from a specific backend.
|
|
|
|
This function runs inside the caller's transaction (clean-deleted) —
|
|
it does NOT open its own transaction. The caller is responsible for
|
|
ensuring the rows are locked via FOR UPDATE SKIP LOCKED before calling.
|
|
|
|
Returns the number of successfully deleted objects, or 0 if no rows
|
|
could be locked."
|
|
[conn storage backend-id ids]
|
|
(if-let [locked-ids (lock-ids conn ids)]
|
|
(let [fail-ids (try
|
|
(-> (impl/resolve-backend storage backend-id)
|
|
(impl/del-objects-in-bulk locked-ids))
|
|
(catch Throwable cause
|
|
(l/err :hint "error on physical deletion, will retry"
|
|
:ids locked-ids
|
|
:cause cause)
|
|
locked-ids))
|
|
ok-ids (set/difference locked-ids fail-ids)]
|
|
|
|
(doseq [id ok-ids]
|
|
(l/dbg :hint "permanently delete storage object"
|
|
:id (str id)
|
|
:backend (name backend-id)))
|
|
|
|
(when (seq ok-ids)
|
|
;; NOTE: the chunk mappings must be removed before the
|
|
;; storage_object rows (NO ACTION foreign keys). It only affects
|
|
;; objects of the upload-session bucket; for any other bucket the
|
|
;; delete matches no rows.
|
|
(delete-upload-session-chunks conn ok-ids)
|
|
(delete-sobjects conn ok-ids))
|
|
|
|
(when (seq fail-ids)
|
|
(increment-attempts-and-defer conn fail-ids)
|
|
;; NOTE: same NO ACTION ordering as above: the give-up DELETE below
|
|
;; removes storage_object rows, so chunk mappings must go first.
|
|
;; Deferred objects keep their rows; only the mapping of a
|
|
;; permanently given-up object disappears early, and that object is
|
|
;; already deleted-marked.
|
|
(delete-upload-session-chunks conn fail-ids)
|
|
(let [given-up (delete-give-up conn fail-ids)]
|
|
(when (pos? (db/get-update-count given-up))
|
|
(l/wrn :hint "giving up on orphan blob after max attempts"
|
|
:ids fail-ids
|
|
:max-attempts max-attempts))))
|
|
|
|
(count ok-ids))
|
|
0))
|
|
|
|
(defn- group-by-backend
|
|
[items]
|
|
(d/group-by (comp keyword :backend) :id #{} items))
|
|
|
|
(def ^:private sql:get-deleted-chunk
|
|
"SELECT id, backend
|
|
FROM storage_object
|
|
WHERE deleted_at IS NOT NULL
|
|
AND deleted_at <= ?
|
|
AND status = 'valid'
|
|
ORDER BY deleted_at ASC
|
|
LIMIT ?
|
|
FOR UPDATE
|
|
SKIP LOCKED")
|
|
|
|
(defn- get-deleted-chunk
|
|
[conn size]
|
|
(db/exec! conn [sql:get-deleted-chunk (ct/now) size]))
|
|
|
|
(defn- clean-deleted
|
|
[cfg]
|
|
(loop [total 0]
|
|
(let [deleted (db/tx-run! cfg
|
|
(fn [{:keys [::db/conn ::sto/storage]}]
|
|
(let [chunk (get-deleted-chunk conn chunk-size)]
|
|
(when (seq chunk)
|
|
(let [by-backend (group-by-backend chunk)]
|
|
(reduce-kv (fn [acc backend-id ids]
|
|
(+ acc (process-chunk conn storage backend-id ids)))
|
|
0
|
|
by-backend))))))]
|
|
(if deleted
|
|
(recur (+ total deleted))
|
|
total))))
|
|
|
|
(defmethod ig/assert-key ::handler
|
|
[_ params]
|
|
(assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")
|
|
(assert (db/pool? (::db/pool params)) "expect valid db pool"))
|
|
|
|
(defmethod ig/init-key ::handler
|
|
[_ cfg]
|
|
(fn [_]
|
|
(let [total (clean-deleted cfg)]
|
|
(l/inf :hint "task finished" :total total)
|
|
{:deleted total})))
|