A failure after the old parent is dropped would leave files half-converted; in one transaction it rolls back completely. Tested on a fresh Postgres 17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
4.7 KiB
TypeScript
90 lines
4.7 KiB
TypeScript
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||
|
||
const TRIGGERS = [
|
||
`files_release_extension AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_extension()`,
|
||
`files_release_search AFTER INSERT OR UPDATE OF release_id, filename, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_search()`,
|
||
`files_release_totals AFTER INSERT OR DELETE OR UPDATE OF release_id, size ON files FOR EACH ROW EXECUTE FUNCTION files_release_totals()`,
|
||
`files_zz_release_complete AFTER INSERT OR DELETE OR UPDATE OF release_id, size, real_name ON files FOR EACH ROW EXECUTE FUNCTION files_release_complete()`,
|
||
]
|
||
|
||
/**
|
||
* files.id (and binaries.file_id, the id sequence) to 64 bits (CONCEPT.md
|
||
* D39): seeding adds ~14M files a day, and 32 bits run out at 2.1 billion.
|
||
* Postgres can't change the type of a partition key, so the partitions are
|
||
* detached, rewritten one by one as plain tables (files_p0: ~100 GB with its
|
||
* segment lists, 1–2 hours on homelab's HDD) and attached to a new 64-bit
|
||
* parent. A CHECK per partition, added before its rewrite, spares the attach
|
||
* scan. Run with the writers stopped and Postgres on wal_level=minimal, or the
|
||
* rewrite also goes through the WAL (LOG.md 2026-10-05 (8)).
|
||
*/
|
||
export default class extends BaseSchema {
|
||
// One transaction: a failure anywhere leaves files as it was (it drops and recreates the parent).
|
||
|
||
async up() {
|
||
const q = (sql: string) => this.db.rawQuery(sql)
|
||
const { rows: partitions } = await q(
|
||
`SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
|
||
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid WHERE i.inhparent = 'files'::regclass ORDER BY c.relname`
|
||
)
|
||
const { rows: ts } = await q(`SELECT t.spcname FROM pg_class c JOIN pg_tablespace t ON t.oid = c.reltablespace WHERE c.oid = 'files'::regclass`)
|
||
const tablespace = ts[0]?.spcname ? ` TABLESPACE ${ts[0].spcname}` : ''
|
||
|
||
// The sequence belongs to the old parent: keep it when that goes.
|
||
await q(`ALTER SEQUENCE files_id_seq OWNED BY NONE`)
|
||
await q(`ALTER SEQUENCE files_id_seq AS bigint`)
|
||
await q(`ALTER TABLE binaries DROP CONSTRAINT IF EXISTS binaries_file_id_foreign`)
|
||
await q(`ALTER TABLE binaries ALTER COLUMN file_id TYPE bigint`)
|
||
|
||
for (const p of partitions) {
|
||
await q(`ALTER TABLE files DETACH PARTITION ${p.name}`)
|
||
// Triggers cloned from the parent may stay behind on a detached partition; the new parent clones them again.
|
||
for (const t of TRIGGERS) await q(`DROP TRIGGER IF EXISTS ${t.split(' ')[0]} ON ${p.name}`)
|
||
const [, from, to] = /FROM \((.+)\) TO \((.+)\)/.exec(p.bound.replaceAll("'", ''))!
|
||
const conditions = [from === 'MINVALUE' ? null : `id >= ${from}`, to === 'MAXVALUE' ? null : `id < ${to}`].filter(Boolean)
|
||
if (conditions.length > 0) await q(`ALTER TABLE ${p.name} ADD CONSTRAINT ${p.name}_bound CHECK (${conditions.join(' AND ')})`)
|
||
}
|
||
await q(`DROP TABLE files`)
|
||
|
||
for (const p of partitions) {
|
||
await q(`ALTER TABLE ${p.name} ALTER COLUMN id TYPE bigint`)
|
||
}
|
||
|
||
await q(`
|
||
CREATE TABLE files (
|
||
id bigint NOT NULL DEFAULT nextval('files_id_seq'),
|
||
filename varchar(255) NOT NULL,
|
||
poster varchar(255) NOT NULL,
|
||
date bigint NOT NULL,
|
||
parts integer NOT NULL,
|
||
groups jsonb,
|
||
created_at timestamptz,
|
||
updated_at timestamptz,
|
||
file_key varchar(40),
|
||
release_id bigint,
|
||
archive jsonb,
|
||
real_name text,
|
||
size bigint,
|
||
content_type varchar(16),
|
||
segments bytea NOT NULL,
|
||
first_message_id text
|
||
) PARTITION BY RANGE (id)${tablespace}`)
|
||
await q(`ALTER SEQUENCE files_id_seq OWNED BY files.id`)
|
||
await q(`ALTER TABLE files ADD CONSTRAINT files_pkey PRIMARY KEY (id)`)
|
||
await q(`ALTER TABLE files ADD CONSTRAINT files_release_id_foreign FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE SET NULL`)
|
||
await q(`CREATE INDEX files_content_type_index ON files (content_type)`)
|
||
await q(`CREATE INDEX files_first_message_id_idx ON files (first_message_id)`)
|
||
await q(`CREATE INDEX files_name_idx ON files ((coalesce(real_name, filename::text)))`)
|
||
await q(`CREATE INDEX files_release_id_index ON files (release_id)`)
|
||
for (const p of partitions) {
|
||
await q(`ALTER TABLE files ATTACH PARTITION ${p.name} ${p.bound}`)
|
||
await q(`ALTER TABLE ${p.name} DROP CONSTRAINT IF EXISTS ${p.name}_bound`)
|
||
}
|
||
for (const t of TRIGGERS) await q(`CREATE TRIGGER ${t}`)
|
||
await q(`ALTER TABLE binaries ADD CONSTRAINT binaries_file_id_foreign FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL NOT VALID`)
|
||
}
|
||
|
||
async down() {
|
||
throw new Error('Not reversible: ids above 2^31 may exist.')
|
||
}
|
||
}
|