mirror of
https://github.com/penpot/penpot.git
synced 2026-11-03 00:20:41 +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
407 lines
14 KiB
Clojure
407 lines
14 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
|
|
"Objects storage abstraction layer."
|
|
(:refer-clojure :exclude [resolve])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.logging :as l]
|
|
[app.common.schema :as sm]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.metrics :as mtx]
|
|
[app.storage.fs :as sfs]
|
|
[app.storage.impl :as impl]
|
|
[app.storage.s3 :as ss3]
|
|
[app.storage.schema :as stsch]
|
|
[cuerdas.core :as str]
|
|
[datoteka.fs :as fs]
|
|
[integrant.core :as ig])
|
|
(:import
|
|
java.io.InputStream))
|
|
|
|
(defn get-legacy-backend
|
|
[]
|
|
(when-let [name (cf/get :assets-storage-backend)]
|
|
(l/wrn :hint "using deprecated configuration, please read 2.11 release notes"
|
|
:href "https://github.com/penpot/penpot/releases/tag/2.11.0")
|
|
(case name
|
|
:assets-fs :fs
|
|
:assets-s3 :s3
|
|
nil)))
|
|
|
|
(def default-bucket
|
|
stsch/default-bucket)
|
|
|
|
(def tempfile-bucket
|
|
"Bucket name for temporary file uploads (10-minute expiry)."
|
|
stsch/tempfile-bucket)
|
|
|
|
(def upload-session-bucket
|
|
"Bucket name for chunked-upload chunks."
|
|
stsch/upload-session-bucket)
|
|
|
|
(def valid-buckets
|
|
stsch/metadata-buckets)
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; Storage Module State
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private schema:backends
|
|
[:map-of :keyword
|
|
[:maybe
|
|
[:or ::ss3/backend ::sfs/backend]]])
|
|
|
|
(def ^:private valid-backends?
|
|
(sm/validator schema:backends))
|
|
|
|
(def ^:private schema:storage
|
|
[:map {:title "storage"}
|
|
[::backends schema:backends]
|
|
[::backend [:enum :s3 :fs]]
|
|
[::mtx/metrics ::mtx/metrics]
|
|
::db/pool])
|
|
|
|
(def valid-storage?
|
|
(sm/validator schema:storage))
|
|
|
|
(sm/register! ::storage schema:storage)
|
|
|
|
(defmethod ig/assert-key ::storage
|
|
[_ params]
|
|
(assert (db/pool? (::db/pool params)) "expected valid database pool")
|
|
(assert (valid-backends? (::backends params)) "expected valid backends map"))
|
|
|
|
(defmethod ig/init-key ::storage
|
|
[_ {:keys [::backends ::db/pool] :as cfg}]
|
|
(let [backend (or (get-legacy-backend)
|
|
(cf/get :objects-storage-backend)
|
|
:fs)
|
|
backends (d/without-nils backends)]
|
|
|
|
(l/dbg :hint "initialize"
|
|
:default (d/name backend)
|
|
:available (str/join "," (map d/name (keys backends))))
|
|
|
|
(-> (d/without-nils cfg)
|
|
(assoc ::backends backends)
|
|
(assoc ::backend backend)
|
|
(assoc ::db/pool pool))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; Database Objects
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn get-metadata
|
|
[params]
|
|
(reduce-kv (fn [res k _]
|
|
(if (qualified-keyword? k)
|
|
(dissoc res k)
|
|
res))
|
|
params
|
|
params))
|
|
|
|
;; Matches both metadata encodings while Transit rows still exist. The
|
|
;; UNION ALL keeps each branch indexable: migration 0068 covers the legacy
|
|
;; "~:" keys and 0156 covers the plain JSON keys. Phase 3 drops the legacy
|
|
;; branch once no "~:" rows remain.
|
|
(def ^:private sql:get-database-object-by-hash
|
|
"(
|
|
select * from storage_object
|
|
where (metadata->>'~:hash') = ?
|
|
and (metadata->>'~:bucket') = ?
|
|
and backend = ?
|
|
and deleted_at is null
|
|
and status = 'valid'
|
|
limit 1
|
|
) union all (
|
|
select * from storage_object
|
|
where (metadata->>'hash') = ?
|
|
and (metadata->>'bucket') = ?
|
|
and backend = ?
|
|
and deleted_at is null
|
|
and status = 'valid'
|
|
limit 1
|
|
) limit 1")
|
|
|
|
;; NOTE: metadata is left encoded; row->storage-object is responsible for
|
|
;; decoding it.
|
|
(defn- get-database-object-by-hash
|
|
[connectable backend bucket hash]
|
|
(let [backend (name backend)]
|
|
(db/exec-one! connectable [sql:get-database-object-by-hash
|
|
hash bucket backend
|
|
hash bucket backend])))
|
|
|
|
(defn- promote-object!
|
|
[storage object]
|
|
(let [ds (db/get-connectable storage)
|
|
res (-> (db/update! ds :storage-object
|
|
{:status "valid"}
|
|
{:id (:id object)}
|
|
{::db/return-keys false})
|
|
(db/get-update-count))]
|
|
(when-not (pos? res)
|
|
;; The pending row disappeared while the blob was being written
|
|
;; (e.g. reclaimed by :storage-pending-gc); make it observable.
|
|
(l/wrn :hint "unable to promote storage object, pending row not found"
|
|
:id (str (:id object))))
|
|
res))
|
|
|
|
(defn row->storage-object [res]
|
|
(let [mdata (or (some-> (:metadata res) (stsch/decode-metadata)) {})]
|
|
(impl/storage-object
|
|
(:id res)
|
|
(:size res)
|
|
(:created-at res)
|
|
(:deleted-at res)
|
|
(:touched-at res)
|
|
(keyword (:backend res))
|
|
mdata)))
|
|
|
|
(def ^:private sql:get-storage-object
|
|
"SELECT *
|
|
FROM storage_object
|
|
WHERE id = ?
|
|
AND (deleted_at IS NULL)
|
|
AND status = 'valid'")
|
|
|
|
(defn- get-database-object
|
|
[conn id]
|
|
(some-> (db/exec-one! conn [sql:get-storage-object id])
|
|
(row->storage-object)))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; API
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn object->relative-path
|
|
[{:keys [id] :as obj}]
|
|
(impl/id->path id))
|
|
|
|
(defn file-url->path
|
|
[url]
|
|
(when url
|
|
(fs/path (java.net.URI. (str url)))))
|
|
|
|
(dm/export impl/content)
|
|
(dm/export impl/wrap-with-hash)
|
|
(dm/export impl/object?)
|
|
|
|
(defn- emit-op!
|
|
"Record a logical storage operation. Recording never fails: metrics
|
|
must not change storage behavior."
|
|
([storage op bucket]
|
|
(emit-op! storage op bucket nil))
|
|
([storage op bucket object]
|
|
(mtx/run! (::mtx/metrics storage)
|
|
:id :storage-operations :inc 1
|
|
:labels [op
|
|
(mtx/label bucket "unknown")
|
|
(mtx/label (or (some-> object :backend) (::backend storage))
|
|
"unknown")])))
|
|
|
|
(defn- emit-dedup!
|
|
"Record a deduplication outcome. Recording never fails."
|
|
[storage result bucket]
|
|
(mtx/run! (::mtx/metrics storage)
|
|
:id :storage-dedup :inc 1
|
|
:labels [(name result) (mtx/label bucket "unknown")]))
|
|
|
|
(defn get-object
|
|
[storage id]
|
|
(assert (valid-storage? storage))
|
|
(let [ds (db/get-connectable storage)]
|
|
(get-database-object ds id)))
|
|
|
|
(defn put-object!
|
|
"Creates a new object with the provided content."
|
|
[{:keys [::backend ::db/pool] :as storage}
|
|
{:keys [::content ::expired-at ::touched-at ::touch] :as params}]
|
|
(assert (valid-storage? storage))
|
|
(assert (impl/content? content) "expected an instance of content")
|
|
|
|
(let [id (or (::id params) (uuid/random))
|
|
mdata (cond-> (get-metadata params)
|
|
(satisfies? impl/IContentHash content)
|
|
(assoc :hash (impl/get-hash content)))
|
|
|
|
touched-at (if touch
|
|
(or touched-at (ct/now))
|
|
touched-at)
|
|
|
|
backend' (impl/resolve-backend storage backend)
|
|
|
|
bucket (:bucket mdata)
|
|
dedupable? (and (::deduplicate? params)
|
|
(:hash mdata)
|
|
(some? bucket)
|
|
(not= tempfile-bucket bucket)
|
|
(not= upload-session-bucket bucket))
|
|
|
|
hit (when dedupable?
|
|
(get-database-object-by-hash pool backend bucket (:hash mdata)))]
|
|
|
|
;; NOTE: for now we don't reuse the deleted objects, but in futute
|
|
;; we can consider reusing deleted objects if we found a duplicated
|
|
;; one and is marked for deletion but still not deleted.
|
|
|
|
;; PHASE 1: deduplication lookup (see `dedupable?` and `hit` above).
|
|
(if-some [hit hit]
|
|
|
|
;; PHASE 2: an existing reference is found: reuse or repair it.
|
|
;; The `exists` op is emitted only after a successful probe so every
|
|
;; count stays paired with its `hit`/`repair` outcome.
|
|
(if (impl/exists-object? backend' hit)
|
|
|
|
;; PHASE 2a: healthy reference. Optionally refresh touched_at
|
|
;; and reuse the object as it is.
|
|
(do
|
|
(emit-op! storage "exists" bucket)
|
|
(emit-dedup! storage :hit bucket)
|
|
(when touch
|
|
(db/update! pool :storage-object
|
|
{:touched-at touched-at}
|
|
{:id (:id hit)}
|
|
{::db/return-keys false}))
|
|
(row->storage-object (cond-> hit touch (assoc :touched-at touched-at))))
|
|
|
|
;; PHASE 2b: the referenced blob is missing (a stale/broken row).
|
|
;; Repair the reference in place: rewrite the incoming content
|
|
;; under the same id, restoring the blob for all existing
|
|
;; references to it. If the write fails, the exception propagates
|
|
;; and the row stays live and valid, so a later matching upload
|
|
;; retries the heal.
|
|
(let [object (row->storage-object hit)]
|
|
(l/wrn :hint "blob not found on reusing storage object"
|
|
:id (:id object)
|
|
:backend (name backend))
|
|
(impl/put-object backend' object content)
|
|
(emit-op! storage "exists" bucket)
|
|
(emit-op! storage "repair" bucket)
|
|
(emit-dedup! storage :repair bucket)
|
|
(promote-object! storage object)
|
|
object))
|
|
|
|
;; PHASE 3: no dedup hit: create a fresh object. The row is
|
|
;; inserted in 'pending' state so it is not visible to the normal
|
|
;; lifecycle (dedup, gc, reads) until the blob has been written
|
|
;; and the object promoted to 'valid'.
|
|
(let [row (db/insert! pool :storage-object
|
|
{:id id
|
|
:size (impl/get-size content)
|
|
:backend (name backend)
|
|
:metadata (stsch/encode-metadata mdata)
|
|
:deleted-at expired-at
|
|
:touched-at touched-at
|
|
:status "pending"})
|
|
object (row->storage-object row)]
|
|
(impl/put-object backend' object content)
|
|
(emit-op! storage "put" bucket)
|
|
(emit-dedup! storage (if dedupable? :miss :skip) bucket)
|
|
(promote-object! storage object)
|
|
object))))
|
|
|
|
(defn touch-object!
|
|
"Mark object as touched. Takes the object id (UUID). The metric labels
|
|
come from the updated row itself (RETURNING); no row, no metric."
|
|
[storage id]
|
|
(assert (valid-storage? storage))
|
|
(let [ds (db/get-connectable storage)
|
|
res (db/update! ds :storage-object
|
|
{:touched-at (ct/now)}
|
|
{:id id}
|
|
{::db/return-keys [:id :backend :metadata]})]
|
|
(when-some [object (some-> res row->storage-object)]
|
|
(emit-op! storage "touch" (-> object meta :bucket) object))
|
|
(some? res)))
|
|
|
|
(defn get-object-data
|
|
"Return an input stream instance of the object content."
|
|
^InputStream
|
|
[storage object]
|
|
(assert (valid-storage? storage))
|
|
(when (or (nil? (:expired-at object))
|
|
(ct/is-after? (:expired-at object) (ct/now)))
|
|
(let [result (-> (impl/resolve-backend storage (:backend object))
|
|
(impl/get-object-data object))]
|
|
(emit-op! storage "get-data" (-> object meta :bucket) object)
|
|
result)))
|
|
|
|
(defn get-object-bytes
|
|
"Returns a byte array of object content."
|
|
[storage object]
|
|
(assert (valid-storage? storage))
|
|
(when (or (nil? (:expired-at object))
|
|
(ct/is-after? (:expired-at object) (ct/now)))
|
|
(let [result (-> (impl/resolve-backend storage (:backend object))
|
|
(impl/get-object-bytes object))]
|
|
(emit-op! storage "get-bytes" (-> object meta :bucket) object)
|
|
result)))
|
|
|
|
(defn get-object-url
|
|
([storage object]
|
|
(get-object-url storage object nil))
|
|
([storage object options]
|
|
(assert (valid-storage? storage))
|
|
(when (or (nil? (:expired-at object))
|
|
(ct/is-after? (:expired-at object) (ct/now)))
|
|
(-> (impl/resolve-backend storage (:backend object))
|
|
(impl/get-object-url object options)))))
|
|
|
|
(defn get-object-path
|
|
"Get the Path to the object. Only works with `:fs` type of
|
|
storages."
|
|
[storage object]
|
|
(assert (valid-storage? storage))
|
|
(let [backend (impl/resolve-backend storage (:backend object))]
|
|
(when (and (= :fs (::type backend))
|
|
(or (nil? (:expired-at object))
|
|
(ct/is-after? (:expired-at object) (ct/now))))
|
|
(-> (impl/get-object-url backend object nil) file-url->path))))
|
|
|
|
(defn del-object!
|
|
"Mark the object as deleted (soft delete: the backend content is
|
|
removed by the GC). Takes the object id (UUID). Only a live row
|
|
(deleted_at IS NULL) is deleted, so a repeated call is a no-op that
|
|
returns false. The metric labels come from the updated row itself
|
|
(RETURNING); no row, no metric."
|
|
[storage id]
|
|
(assert (valid-storage? storage))
|
|
(let [ds (db/get-connectable storage)
|
|
res (db/update! ds :storage-object
|
|
{:deleted-at (ct/now)}
|
|
["id = ? AND deleted_at IS NULL" id]
|
|
{::db/return-keys [:id :backend :metadata]})]
|
|
(when-some [object (some-> res row->storage-object)]
|
|
(emit-op! storage "del" (-> object meta :bucket) object))
|
|
(some? res)))
|
|
|
|
(dm/export impl/calculate-hash)
|
|
(dm/export impl/get-hash)
|
|
(dm/export impl/get-size)
|
|
|
|
(defn configure
|
|
[storage connection]
|
|
(assert (db/connection? connection))
|
|
(assert (valid-storage? storage))
|
|
(assoc storage ::db/conn connection))
|
|
|
|
(defn resolve
|
|
"Resolves the storage instance with preconfigured backend. You can
|
|
specify to reuse the database connection from provided
|
|
cfg/system (default false)."
|
|
[cfg & {:as opts}]
|
|
(let [storage (::storage cfg)]
|
|
(if (::db/reuse-conn opts false)
|
|
(configure storage (db/get-connection cfg))
|
|
storage)))
|