mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 03:50:33 +00:00
* ✨ Migrate legacy file payloads to file_data and delete orphans Copy file.data and labelled file_change.data to file_data on boot (type main/snapshot, backend db, deleted_at preserved), then delete file_change rows with data but no label, which no read path reaches. Large instances pre-migrate with batched statements and fake this step. Closes #12784. AI-assisted-by: muse-spark-1.3-contributor * 📚 Add 2.19.0 operations runbook for legacy file_data migration Manual pre-migration procedure for big instances, same statements as migration 0157 in batched transactions, following the convention of the file-change partition runbook. Closes #12784. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Address review feedback on the legacy file data migration Fix the issues raised in the review of the migration that moves the legacy file payloads to file_data. The migration deletes the unlabelled file_change rows that carry data, but restore-files still cleared their date, and the retention test asserted they came back. A row without a date never reaches objects-gc, which is the leak fixed in #12419. Restore now unmarks only snapshots, the rows that carry a label, and the retention test checks that the legacy payload keeps its date instead of coming back. Cover the conflict path that had no test: a pre-existing snapshot that collides with a labelled change row, and a second run that must leave the legacy columns null and the orphan row absent. The runbook analyses file_data after the inserts, so the planner does not wait for autovacuum. Drop app.srepl.procs.fdata-storage: the copy it performed is what the migration replaces and nothing requires it. Closes #12784 AI-assisted-by: deepseek-v4.1-flash
324 lines
11 KiB
Markdown
324 lines
11 KiB
Markdown
# Migrate legacy file payloads to `file_data`
|
|
|
|
Online procedure to move the legacy file payloads (`file.data` and labelled
|
|
`file_change.data`) to `file_data` and to delete the orphan `file_change`
|
|
rows that carry `data` without a `label`.
|
|
|
|
- Migration: `backend/src/app/migrations/sql/0157-migrate-legacy-file-data.sql`
|
|
- Issue: <https://github.com/penpot/penpot/issues/12784>
|
|
|
|
The result has to be **exactly** the end state of the migration, because on a
|
|
big instance the migration is marked as applied and never runs. This procedure
|
|
runs the same statements by hand, in small batched transactions.
|
|
|
|
Use this only if the automatic migration would take too long on the instance
|
|
(millions of snapshots, hundreds of GB in TOAST). Small and medium instances
|
|
run the migration automatically on boot and need nothing from here.
|
|
|
|
Run it before upgrading to 2.19.0.
|
|
|
|
## What changes
|
|
|
|
- Every `file_change` row with `data IS NOT NULL AND label IS NOT NULL`
|
|
(a snapshot) gets a `file_data` row with `type='snapshot'`,
|
|
`backend='db'`, `metadata=NULL`, `created_at` from `created_at`,
|
|
`modified_at` from `updated_at`, and `deleted_at` copied as is. Then the
|
|
legacy `file_change.data` is set to `NULL`.
|
|
- Every `file` row with `data IS NOT NULL` gets a `file_data` row with
|
|
`type='main'` (and `file_id = id`), the same `backend`/`metadata` mapping
|
|
and the timestamps copied one to one. Then the legacy `file.data` is set
|
|
to `NULL`.
|
|
- Every `file_change` row with `data IS NOT NULL AND label IS NULL` (an
|
|
orphan revision) is deleted outright. No read path reaches these rows:
|
|
snapshots resolve through `label IS NOT NULL`, lagged changes read the
|
|
`changes` column (not `data`), and nothing references `file_change` rows
|
|
from other tables. Rows carrying `deleted_at` would be collected by
|
|
`objects-gc` anyway; undated ones would sit in the table and its TOAST
|
|
for ever.
|
|
|
|
`deleted_at` is copied on purpose: the write path always writes it to
|
|
`file_data`, the GC only collects `file_data` rows carrying it, and reads
|
|
never filter on `file_data.deleted_at`. Without the copy, migrated rows of
|
|
deleted files and snapshots would be orphaned: nobody reads them and nobody
|
|
collects them.
|
|
|
|
## Why this mechanism
|
|
|
|
A single-statement copy inside the transaction of the startup does not fit a
|
|
big instance, so the copy runs here in small batched transactions:
|
|
|
|
- One statement is one transaction. Never wrap several batches in one
|
|
transaction. Run a single stream of batches at a time.
|
|
- `FOR UPDATE SKIP LOCKED`: the batches never block the application and are
|
|
never blocked by it. A row skipped by a lock stays in the index and the
|
|
next execution picks it up.
|
|
- Partial temp indexes hold only the pending rows, so every execution finds
|
|
its next batch without rescanning what is done, and migrated rows
|
|
(`data = NULL`) and deleted orphans drop out of the index on their own.
|
|
No cursor is needed. Do not `VACUUM` mid-run to clean them: it would walk
|
|
the whole TOAST. Drop them at the end.
|
|
- No `ON CONFLICT DO NOTHING` here on purpose: a duplicate key must abort
|
|
the batch (atomically: it migrates nothing) so the conflict is
|
|
investigated instead of silently skipped. The automatic migration is
|
|
lenient so a small instance never fails to boot; here strictness is what
|
|
keeps the data auditable.
|
|
|
|
---
|
|
|
|
## Phase 0 — Before anything
|
|
|
|
### 0.1 Mark the migration as applied
|
|
|
|
Do this first: it registers the automatic migration as done, so a backend
|
|
restart mid-procedure never runs it on boot (it would copy the whole legacy
|
|
payload inside the startup transaction).
|
|
|
|
```sql
|
|
INSERT INTO migrations (module, step)
|
|
VALUES ('main', '0157-migrate-legacy-file-data');
|
|
```
|
|
|
|
The module is `main` and the step has no `.sql` (see
|
|
`backend/src/app/util/migrations.clj`).
|
|
|
|
### 0.2 Counters
|
|
|
|
Repeat the counters any time to follow the progress. Each batch loop ends
|
|
when it returns 0; the check in phase 3 confirms nothing is pending.
|
|
|
|
```sql
|
|
-- Remaining legacy rows.
|
|
SELECT (SELECT count(*) FROM file_change
|
|
WHERE data IS NOT NULL AND label IS NOT NULL) AS snapshots_remaining,
|
|
(SELECT count(*) FROM file_change
|
|
WHERE data IS NOT NULL AND label IS NULL) AS orphans_remaining,
|
|
(SELECT count(*) FROM file
|
|
WHERE data IS NOT NULL) AS files_remaining;
|
|
```
|
|
|
|
Optional, reference only: how much is already migrated. Skip it unless
|
|
curious: it is a full count over the partitioned table and can take a
|
|
while on a big instance. For progress, the remaining counters above and
|
|
the per-batch counts are enough.
|
|
|
|
```sql
|
|
SELECT (SELECT count(*) FROM file_data
|
|
WHERE type = 'snapshot' AND backend = 'db') AS snapshots_in_file_data,
|
|
(SELECT count(*) FROM file_data
|
|
WHERE type = 'main' AND backend = 'db') AS mains_in_file_data;
|
|
```
|
|
|
|
## Phase 1 — Temp indexes
|
|
|
|
Required by the batch loops, created before them. `CONCURRENTLY` so writes
|
|
are not blocked; each statement is its own transaction.
|
|
|
|
```sql
|
|
CREATE INDEX CONCURRENTLY tmp_file_change_mig ON file_change (id)
|
|
WHERE data IS NOT NULL AND label IS NOT NULL;
|
|
CREATE INDEX CONCURRENTLY tmp_file_change_orphan_mig ON file_change (id)
|
|
WHERE data IS NOT NULL AND label IS NULL;
|
|
CREATE INDEX CONCURRENTLY tmp_file_mig ON file (id)
|
|
WHERE data IS NOT NULL;
|
|
```
|
|
|
|
A failed `CONCURRENTLY` build stays `INVALID` and the planner ignores it
|
|
silently, so verify all three indexes exist and are valid:
|
|
|
|
```sql
|
|
SELECT indexrelid::regclass AS index, indisvalid AS valid
|
|
FROM pg_index
|
|
WHERE indexrelid::regclass::text LIKE 'tmp_%mig';
|
|
```
|
|
|
|
## Phase 2 — Batch loops
|
|
|
|
Repeat each statement until it returns 0. Lower `LIMIT` if a batch hits
|
|
`statement_timeout` or memory pressure (`SET statement_timeout = '15min'`
|
|
per batch is the usual guard).
|
|
|
|
### 2.1 Delete orphans
|
|
|
|
Orphans first (or interleaved with the other two; the row sets are
|
|
disjoint, and deleting TOAST-heavy rows early frees space for the rest):
|
|
|
|
```sql
|
|
WITH doomed AS (
|
|
SELECT id
|
|
FROM file_change
|
|
WHERE data IS NOT NULL
|
|
AND label IS NULL
|
|
ORDER BY id
|
|
LIMIT 10000
|
|
FOR UPDATE SKIP LOCKED
|
|
),
|
|
del AS (
|
|
DELETE FROM file_change fc
|
|
USING doomed
|
|
WHERE fc.id = doomed.id
|
|
RETURNING fc.id
|
|
)
|
|
SELECT count(*) AS deleted
|
|
FROM del;
|
|
```
|
|
|
|
### 2.2 Migrate snapshots
|
|
|
|
Repeat until it returns 0 (`~120 MB` per transaction at `LIMIT 10000`):
|
|
|
|
```sql
|
|
WITH batch AS (
|
|
SELECT id, file_id, data, created_at, updated_at, deleted_at
|
|
FROM file_change
|
|
WHERE data IS NOT NULL
|
|
AND label IS NOT NULL
|
|
ORDER BY id
|
|
LIMIT 10000
|
|
FOR UPDATE SKIP LOCKED
|
|
),
|
|
-- NO ON CONFLICT on purpose: duplicates must abort (see above).
|
|
-- upd reads from ins (RETURNING), not from batch.
|
|
ins AS (
|
|
INSERT INTO file_data (file_id, id, created_at, modified_at, deleted_at,
|
|
type, backend, metadata, data)
|
|
SELECT file_id, id, created_at, updated_at, deleted_at,
|
|
'snapshot', 'db', NULL, data
|
|
FROM batch
|
|
RETURNING file_id, id
|
|
),
|
|
-- Only the origin of what was really inserted is nulled. On conflict
|
|
-- the whole transaction aborts, so ins == batch whenever this runs.
|
|
upd AS (
|
|
UPDATE file_change fc
|
|
SET data = NULL
|
|
FROM ins
|
|
WHERE ins.file_id = fc.file_id
|
|
AND ins.id = fc.id
|
|
RETURNING fc.id
|
|
)
|
|
SELECT count(*) AS migrated
|
|
FROM upd;
|
|
```
|
|
|
|
### 2.3 Migrate files
|
|
|
|
Repeat until it returns 0 (tune `LIMIT` to the average `file.data` size;
|
|
start at 5000):
|
|
|
|
```sql
|
|
WITH batch AS (
|
|
SELECT id, data, created_at, modified_at, deleted_at
|
|
FROM file
|
|
WHERE data IS NOT NULL
|
|
ORDER BY id
|
|
LIMIT 5000
|
|
FOR UPDATE SKIP LOCKED
|
|
),
|
|
ins AS (
|
|
INSERT INTO file_data (file_id, id, created_at, modified_at, deleted_at,
|
|
type, backend, metadata, data)
|
|
SELECT id, id, created_at, modified_at, deleted_at,
|
|
'main', 'db', NULL, data
|
|
FROM batch
|
|
RETURNING file_id, id
|
|
),
|
|
upd AS (
|
|
UPDATE file f
|
|
SET data = NULL
|
|
FROM ins
|
|
WHERE ins.file_id = f.id
|
|
AND ins.id = f.id
|
|
RETURNING f.id
|
|
)
|
|
SELECT count(*) AS migrated
|
|
FROM upd;
|
|
```
|
|
|
|
### 2.4 Duplicate keys
|
|
|
|
If a batch aborts on duplicate key, stop and do not blind-retry it: it
|
|
means `file_data` already holds that key. Locate the overlap:
|
|
|
|
```sql
|
|
-- Snapshots with legacy data AND a file_data key already present.
|
|
SELECT c.file_id, c.id, c.created_by, c.deleted_at,
|
|
pg_size_pretty(length(c.data)) AS legacy_size,
|
|
pg_size_pretty(length(fd.data)) AS file_data_size,
|
|
fd.modified_at AS file_data_modified_at
|
|
FROM file_change c
|
|
JOIN file_data fd ON fd.file_id = c.file_id
|
|
AND fd.id = c.id
|
|
AND fd.type = 'snapshot'
|
|
WHERE c.data IS NOT NULL
|
|
ORDER BY c.id
|
|
LIMIT 20;
|
|
|
|
-- Files with legacy data AND a file_data key already present.
|
|
SELECT f.id,
|
|
pg_size_pretty(length(f.data)) AS legacy_size,
|
|
pg_size_pretty(length(fd.data)) AS file_data_size,
|
|
fd.modified_at AS file_data_modified_at
|
|
FROM file f
|
|
JOIN file_data fd ON fd.file_id = f.id
|
|
AND fd.id = f.id
|
|
AND fd.type = 'main'
|
|
WHERE f.data IS NOT NULL
|
|
ORDER BY f.id
|
|
LIMIT 20;
|
|
```
|
|
|
|
Resolve manually, one row at a time, only when `file_data` holds equal or
|
|
newer data (compare `modified_at` and sizes above): the legacy copy is
|
|
redundant, null the origin, leave `file_data` untouched.
|
|
|
|
```sql
|
|
-- UPDATE file_change SET data = NULL WHERE file_id = '...' AND id = '...';
|
|
-- UPDATE file SET data = NULL WHERE id = '...';
|
|
```
|
|
|
|
## Phase 3 — Verification and cleanup
|
|
|
|
Each loop above already ended returning 0; confirm nothing is pending (if
|
|
anything shows up, repeat that loop):
|
|
|
|
```sql
|
|
-- Remaining legacy rows: all three must read 0.
|
|
SELECT (SELECT count(*) FROM file_change
|
|
WHERE data IS NOT NULL AND label IS NOT NULL) AS snapshots_remaining,
|
|
(SELECT count(*) FROM file_change
|
|
WHERE data IS NOT NULL AND label IS NULL) AS orphans_remaining,
|
|
(SELECT count(*) FROM file
|
|
WHERE data IS NOT NULL) AS files_remaining;
|
|
```
|
|
|
|
```sql
|
|
-- Spot check: migrated rows exist in file_data with backend 'db'.
|
|
SELECT fd.file_id, fd.id, fd.type, fd.backend,
|
|
pg_size_pretty(length(fd.data)) AS size_in_file_data
|
|
FROM file_data fd
|
|
ORDER BY fd.file_id, fd.id
|
|
LIMIT 5;
|
|
```
|
|
|
|
Drop the temp indexes first, so the vacuum below skips them (each batch
|
|
left its dead entries behind), then reclaim the dead tuples and refresh
|
|
planner stats. Plain `VACUUM` does not block writes; `VERBOSE` prints
|
|
progress, which matters on big tables:
|
|
|
|
```sql
|
|
DROP INDEX CONCURRENTLY tmp_file_change_mig;
|
|
DROP INDEX CONCURRENTLY tmp_file_change_orphan_mig;
|
|
DROP INDEX CONCURRENTLY tmp_file_mig;
|
|
VACUUM (ANALYZE ON, VERBOSE ON) file_change;
|
|
VACUUM (ANALYZE ON, VERBOSE ON) file;
|
|
-- file_data is partitioned into 16 tables: the statement walks all of
|
|
-- them, so it can take a while. Without it the planner stats of the fresh
|
|
-- inserts stay stale until autovacuum catches up.
|
|
VACUUM (ANALYZE ON, VERBOSE ON) file_data;
|
|
```
|
|
|
|
## Phase 4 — Upgrade
|
|
|
|
The migration was marked as applied in phase 0, so the new version skips
|
|
it on boot. Deploy normally.
|