* ✨ 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
11 KiB
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_changerow withdata IS NOT NULL AND label IS NOT NULL(a snapshot) gets afile_datarow withtype='snapshot',backend='db',metadata=NULL,created_atfromcreated_at,modified_atfromupdated_at, anddeleted_atcopied as is. Then the legacyfile_change.datais set toNULL. - Every
filerow withdata IS NOT NULLgets afile_datarow withtype='main'(andfile_id = id), the samebackend/metadatamapping and the timestamps copied one to one. Then the legacyfile.datais set toNULL. - Every
file_changerow withdata IS NOT NULL AND label IS NULL(an orphan revision) is deleted outright. No read path reaches these rows: snapshots resolve throughlabel IS NOT NULL, lagged changes read thechangescolumn (notdata), and nothing referencesfile_changerows from other tables. Rows carryingdeleted_atwould be collected byobjects-gcanyway; 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 notVACUUMmid-run to clean them: it would walk the whole TOAST. Drop them at the end. - No
ON CONFLICT DO NOTHINGhere 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).
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.
-- 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.
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.
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:
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):
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):
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):
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:
-- 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.
-- 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):
-- 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;
-- 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:
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.