mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
* ✨ Add upload_session_chunk indirection for chunked uploads Chunks now live in the upload_session_chunk table with non-deleting foreign keys to storage_object and upload_session, instead of tempfile objects with session metadata. Reads go through a JOIN, so chunk state never scans storage_object. Uploads validate the live session, reject duplicate indexes, and store objects in the new upload-session bucket without extra metadata. Assemble removes mappings and marks the session consumed; objects-gc procedurally purges consumed and stalled sessions, touching referenced objects first. Touched-gc and deleted-gc handle the new bucket, and upload-session-gc is removed. Closes #11644 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix quota, give-up and coverage for session chunks Exclude consumed sessions from the sessions-per-profile quota so finished uploads free their slot at once. Remove chunk mappings before the gc-deleted give-up delete to respect the NO ACTION keys. Catch java.sql.SQLException for duplicate chunks. Cover the profile-owned session purge and the UNIQUE race backstop with tests. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Align chunked upload tests with upload_session_chunk Drop the duplicate-index tests written against metadata-backed chunks; the UNIQUE mapping makes those cases unrepresentable and the new tests cover them. Rewrite the rejected-duplicate tests to expect :validation/:chunk-already-exists and assert against the upload_session_chunk table, and scope the chunk-too-large "nothing stored" check to the mapping table. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Use NO ACTION DEFERRABLE session FKs in single migration Fold the profile FK change into 0154 so the feature ships one migration. All three upload session FKs use ON DELETE NO ACTION DEFERRABLE: identical to RESTRICT in normal operation, but deferrable for tooling that relies on SET CONSTRAINTS ALL DEFERRED. Extend the RESTRICT test to the direct profile delete. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Reserve chunk slot before writing blob in upload-chunk Make object_id nullable and insert the mapping with NULL inside the session-locking transaction, then write the blob outside it and link it with a conditional update. A failed write removes the mapping and reraises; a mid-flight death leaves a NULL row and the client starts a new session. AI-assisted-by: muse-spark-1.3-contributor * 🔥 Remove redundant session_id index on upload_session_chunk The UNIQUE(session_id, chunk_index) btree already serves session_id-only lookups and the session FK check through its leftmost column, so the standalone index only taxed the per-chunk INSERT path. Verified with EXPLAIN on an equivalent table shape. AI-assisted-by: muse-spark-1.3-contributor * ⚡ Merge chunk touch and delete into single RETURNING query Replace the SELECT-then-DELETE round-trip in delete-upload-sessions! with DELETE ... RETURNING object_id, touching each returned object. Same semantics, one less query per purged session. Follows the RETURNING pattern already used in file-gc. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Let objects-gc own chunk mapping deletion Assemble-chunks now only marks the session as consumed; the chunk mappings stay until objects-gc purges them (touching the chunk objects first), leaving a single procedural deletion path for consumed, stalled and profile-purge sessions. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix font-deletion GC expectations for chunk objects Update final storage-gc-touched counts to include the two chunk objects touched by objects-gc when purging consumed upload sessions (8/5/5 instead of 6/3/3). AI-assisted-by: muse-spark-1.3-contributor * 🐛 Release chunk reservation when the link UPDATE fails Review feedback on #11651: the link UPDATE in upload-chunk could leave a NULL reservation behind, blocking retries of the same index with :chunk-already-exists. Remove the reservation when the link fails so the client can retry in the same session; the orphaned blob stays touched for touched-gc. Also realign the process-bucket! cond branches in gc-touched. Tests: chunked-upload-link-failure-releases-slot and chunked-upload-null-reservation-blocks-retry. AI-assisted-by: muse-spark-1.3-contributor
121 lines
8.1 KiB
Markdown
121 lines
8.1 KiB
Markdown
# Backend Storage
|
|
|
|
## Abstraction
|
|
|
|
- `app.storage` stores binary objects.
|
|
- Each object has a `storage_object` database row.
|
|
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
|
|
- The backend stores the binary content.
|
|
- Supported backends are `:fs` and `:s3`.
|
|
- FS uses one root directory and a UUID-derived path.
|
|
- S3 uses one configured bucket and an optional prefix.
|
|
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
|
|
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
|
|
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
|
|
- Deprecated asset-storage config keys remain supported for migration.
|
|
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
|
|
|
|
## Object Lifecycle
|
|
|
|
- `put-object!` creates the database row before it writes backend content.
|
|
- Backend content is written only when the row is new.
|
|
- A failed backend write can leave an unreferenced database row.
|
|
- Callers often set `:touched-at` so garbage collection can remove such rows.
|
|
- `get-object` excludes rows with `deleted_at`.
|
|
- Existing object values can remain readable until physical deletion.
|
|
- `:expired-at` blocks reads after the expiration time.
|
|
- `del-object!` sets `deleted_at`. It does not remove backend content.
|
|
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
|
|
- `storage-gc-touched` finds references before it sets `deleted_at`.
|
|
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
|
|
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
|
|
|
|
## Connection Reuse Details
|
|
|
|
### `app.storage/resolve` patterns:
|
|
|
|
**1. Pool mode (default)** - `(sto/resolve cfg)`
|
|
- Returns storage abstraction from config
|
|
- Uses whatever database pool is available
|
|
- **Safe to call outside transaction context**
|
|
- Used in: `rpc/commands/media.clj:363`, `rpc/commands/auth.clj:327`, `rpc/commands/profile.clj:362`
|
|
|
|
**2. Connection reuse mode** - `(sto/resolve cfg ::db/reuse-conn true)`
|
|
- Internally calls `db/get-connection cfg` to obtain connectable
|
|
- Configures storage with the specific connection from config
|
|
- **Must be paired with transaction that owns this connection**
|
|
- Used in: `features/fdata.clj:100`, `rpc/commands/media.clj:425`, `rpc/commands/files_thumbnails.clj:307,319`, `binfile/v3.clj:722`
|
|
|
|
**3. Explicit configuration** - `(sto/configure storage conn)`
|
|
- Sets `::db/conn` on storage map directly
|
|
- Asserts `db/conn? connection` (storage.clj:349)
|
|
- Used inside `db/tx-run!` blocks where `conn` is already available
|
|
- Used in: `tasks/file_gc.clj:256`, `rpc/commands/files_thumbnails.clj:347,371`
|
|
|
|
### Key Warning (from function notes):
|
|
|
|
The improved note in `import-storage-objects` and `handle-persistence` warns:
|
|
**Do not reuse the main database connection for storage operations within a transaction.** The storage upload process can fail mid-operation, leaving orphaned objects on the backend. If the outer transaction aborts, pending storage objects become unreconciliable because the storage subsystem registers its pending state in separate transactions.
|
|
|
|
### Rule of Thumb for `sto/put-object!`:
|
|
|
|
Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `impl/put-object`) and does not directly use `::db/conn` or `::db/pool`, **all usage of `put-object!` will never run inside a common transaction** (if configured at all). The storage backend operations are independent of the database transaction boundary.
|
|
|
|
## Deduplication
|
|
|
|
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
|
|
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
|
|
- The lookup only considers rows with `status='valid'`; pending rows are invisible.
|
|
- A hit whose blob is missing is repaired in place: the same row/id is kept,
|
|
and `put-object!` rewrites the blob under that id. This heals all existing
|
|
references to the object. If the rewrite fails, the row is left live and
|
|
valid for a later retry.
|
|
- The lookup does not include file ID, profile ID, team ID, or organization ID.
|
|
- Objects can therefore share content across users and files within one bucket.
|
|
- Deleted objects are not reused.
|
|
- `tempfile` objects never use deduplication, even when the caller requests it.
|
|
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
|
|
|
|
## Bucket Rules
|
|
|
|
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
|
|
| --- | --- | --- | --- | --- |
|
|
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
|
|
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
|
|
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
|
|
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
|
|
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
|
|
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
|
|
| `tempfile` | Export files and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
|
|
| `upload-session` | Chunked-upload chunks. References: `upload_session_chunk.object_id` and `upload_session_chunk.session_id` (both NO ACTION DEFERRABLE: restrict semantics, procedural deletion). | No | Authentication required | No reference scan. A touched object is deleted after the delay; `gc-deleted` removes mappings before rows. |
|
|
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
|
|
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
|
|
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
|
|
|
|
- The valid bucket set lives in `app.storage/valid-buckets`.
|
|
- `file-media-object` is the default bucket for old rows without bucket metadata.
|
|
- Do not assign a new bucket without adding its access and cleanup behavior.
|
|
- The touched-object collector raises an internal error for an unknown bucket.
|
|
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, `upload-session`, and `organization`.
|
|
- It does not support `file-data-fragment` or `file-change`.
|
|
|
|
## Access Rules
|
|
|
|
- `app.http.assets` decides direct object authentication from the bucket.
|
|
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
|
|
- Other valid buckets require a session or access-token profile ID.
|
|
- File-media routes also require file read permission.
|
|
- Non-public direct responses set `content-disposition: attachment`.
|
|
- FS responses use `x-accel-redirect` for the configured asset path.
|
|
- S3 responses use a presigned URL and an HTTP redirect.
|
|
|
|
## File Data
|
|
|
|
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
|
|
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
|
|
- `db` stores encoded data in `file_data.data`.
|
|
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
|
|
- The `file_data.metadata.storage-ref-id` value points to the storage object.
|
|
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
|
|
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
|