Files
paper-atlas/paper_atlas/postgres/migration.py
codex-bot 4e62710bba
Metadata integrity checks / metadata (push) Successful in 1m7s
Preserve NUL text through PostgreSQL migration and resume
2026-11-07 03:27:57 +08:00

280 lines
17 KiB
Python

"""Resumable, unpublished COPY followed by complete ordered content verification."""
import argparse
from contextlib import closing
import fcntl
import hashlib
import json
from pathlib import Path
import re
import shutil
import sqlite3
import uuid
import zlib
from paper_atlas.db.submissions import timestamp
from paper_atlas.postgres.client import connect, SEARCH_VECTOR
from paper_atlas.postgres.schema import TABLES, inspect, fingerprint, quote, select_sql, table_sql
from paper_atlas.postgres import text
def digest_file(path):
digest = hashlib.sha256()
with path.open('rb') as stream:
while block := stream.read(8 * 1024 ** 2):
digest.update(block)
return digest.hexdigest()
def update_hash(digest, row):
# Raw JSON columns remain text: whitespace, escapes and source evidence survive.
data = json.dumps(tuple(row), ensure_ascii=False, separators=(',', ':')).encode()
digest.update(len(data).to_bytes(8, 'big')); digest.update(data)
def save(path, result):
path.parent.mkdir(parents=True, exist_ok=True)
part = path.with_suffix(path.suffix + '.part')
part.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
part.replace(path)
def free_space(path, minimum):
if path and shutil.disk_usage(path).free < minimum * 1024 ** 3:
raise RuntimeError('Migration storage headroom fell below the configured minimum')
def qualify(schema, table):
return quote(schema) + '.' + quote(table)
def add_constraints(pg, stage, plan):
def constraint(table, name, clause):
exists = pg.execute('''SELECT 1 FROM pg_constraint co JOIN pg_class cl ON cl.oid=co.conrelid
JOIN pg_namespace ns ON ns.oid=cl.relnamespace WHERE ns.nspname=%s AND cl.relname=%s AND co.conname=%s''',
(stage, table, name)).fetchone()
if not exists:
pg.execute('ALTER TABLE ' + qualify(stage, table) + ' ADD CONSTRAINT ' + quote(name) + ' ' + clause)
for table, spec in plan.items():
constraint(table, table + '_pkey', 'PRIMARY KEY (' + ','.join(map(quote, spec['pk'])) + ')')
for number, index in enumerate(spec['indexes']):
if index['origin'] == 'u':
constraint(table, table + '_unique_' + str(number), 'UNIQUE (' + ','.join(quote(n) for n, desc in index['terms']) + ')')
else:
terms = ','.join(quote(n) + (' DESC' if desc else '') for n, desc in index['terms'])
pg.execute('CREATE ' + ('UNIQUE ' if index['unique'] else '') + 'INDEX IF NOT EXISTS ' +
quote(index['name']) + ' ON ' + qualify(stage, table) + '(' + terms + ')')
for table, spec in plan.items():
for number, fk in enumerate(spec['foreign_keys']):
clause = ('FOREIGN KEY (' + ','.join(map(quote, fk['columns'])) + ') REFERENCES ' +
qualify(stage, fk['table']) + '(' + ','.join(map(quote, fk['references'])) + ') NOT VALID')
constraint(table, table + '_fk_' + str(number), clause)
for number, check in enumerate(spec['checks']):
constraint(table, table + '_check_' + str(number), 'CHECK (' + check + ') NOT VALID')
constraints = pg.execute('''SELECT cl.relname,co.conname FROM pg_constraint co
JOIN pg_class cl ON cl.oid=co.conrelid JOIN pg_namespace ns ON ns.oid=cl.relnamespace
WHERE ns.nspname=%s AND NOT co.convalidated ORDER BY cl.relname,co.conname''', (stage,)).fetchall()
for table, name in constraints:
pg.execute('ALTER TABLE ' + qualify(stage, table) + ' VALIDATE CONSTRAINT ' + quote(name))
def sequences(pg, source, stage, plan):
old = dict(source.execute('SELECT name,seq FROM sqlite_sequence'))
history = {'papers': ('paper_merges', 'former_paper_id'),
'submissions': ('submission_merges', 'former_submission_id'),
'decisions': ('decision_merges', 'former_decision_id')}
result = {}
for table, spec in plan.items():
if spec['pk'] != ['id']:
continue
value = max(source.execute('SELECT coalesce(max(id),0) FROM ' + quote(table)).fetchone()[0], old.get(table, 0))
if table in history:
audit, column = history[table]
value = max(value, source.execute('SELECT coalesce(max(' + quote(column) + '),0) FROM ' + quote(audit)).fetchone()[0])
sequence = pg.execute('SELECT pg_get_serial_sequence(%s,%s)', (qualify(stage, table), 'id')).fetchone()[0]
pg.execute('SELECT setval(%s,%s,%s)', (sequence, max(value, 1), value > 0))
result[table] = value
return result
def upgrade_text_codec(pg, control, stage, plan):
"""Upgrade committed legacy staging rows once; never recopy their source."""
changed = {}
with pg.transaction():
copied = {r[0] for r in pg.execute('SELECT name FROM ' + qualify(control, 'tables'))}
if not copied <= plan.keys():
raise ValueError('Unknown committed staging tables')
# An older run may have reached constraint validation. Replacing SOH
# can temporarily equal another legacy key even though the final mapping
# is bijective. Rebuild unpublished constraints/indexes after the upgrade.
constraints = pg.execute('''SELECT cl.relname,co.conname FROM pg_constraint co
JOIN pg_class cl ON cl.oid=co.conrelid JOIN pg_namespace ns ON ns.oid=cl.relnamespace
WHERE ns.nspname=%s AND co.contype IN ('f','p','u')
ORDER BY CASE WHEN co.contype='f' THEN 0 ELSE 1 END''', (stage,)).fetchall()
for table, name in constraints:
pg.execute('ALTER TABLE ' + qualify(stage, table) + ' DROP CONSTRAINT ' + quote(name))
indexes = pg.execute('''SELECT cl.relname FROM pg_index i JOIN pg_class cl ON cl.oid=i.indexrelid
JOIN pg_namespace ns ON ns.oid=cl.relnamespace WHERE ns.nspname=%s AND i.indisunique''', (stage,)).fetchall()
for name, in indexes:
pg.execute('DROP INDEX ' + qualify(stage, name))
for table in plan:
if table not in copied:
continue
columns = [quote(c['name']) for c in plan[table]['columns'] if c['type'] == 'TEXT']
if columns:
assignments = ','.join(c + '=replace(' + c + ',%s,%s)' for c in columns)
predicate = ' OR '.join('strpos(' + c + ',%s)>0' for c in columns)
params = [v for c in columns for v in (text.ESCAPE, text.encode(text.ESCAPE))]
params += [text.ESCAPE] * len(columns)
changed[table] = pg.execute('UPDATE ' + qualify(stage, table) + ' SET ' + assignments +
' WHERE ' + predicate, params).rowcount
pg.execute('UPDATE ' + qualify(control, 'run') + ' SET text_codec=%s WHERE id=1', (text.CODEC,))
return changed
def migrate(db, service, out, *, schema='research', storage_path=None, min_free_gib=8):
if not re.fullmatch(r'[a-z][a-z0-9_]{0,35}', schema):
raise ValueError('Use a short lowercase target schema name')
db, out = Path(db).resolve(), Path(out).resolve()
if not db.is_file() or out == db:
raise ValueError('Existing source and a distinct report are required')
if out.exists() and json.loads(out.read_text()).get('status') == 'complete':
raise ValueError('Completed report must be preserved')
free_space(storage_path, min_free_gib)
control = schema + '_migration'
report = {'status': 'preflight', 'source': str(db), 'service': service, 'schema': schema,
'started_at': timestamp(), 'tables': {}, 'scope': 'All 21 business tables; FTS5 internals rebuilt as native PostgreSQL GIN.'}
with Path(str(db) + '.maintenance.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if Path(str(db) + '-wal').exists() and Path(str(db) + '-wal').stat().st_size:
raise ValueError('Freeze writers and checkpoint SQLite WAL before migrating')
with closing(sqlite3.connect(db.as_uri() + '?mode=ro', uri=True)) as source, closing(connect(service)) as pg:
source.execute('PRAGMA query_only=ON'); source.execute('PRAGMA cache_size=-16384'); source.execute('BEGIN')
plan = inspect(source)
stat = db.stat()
identity = {'file_sha256': digest_file(db), 'bytes': stat.st_size, 'mtime_ns': stat.st_mtime_ns,
'schema_sha256': fingerprint(plan)}
report['source_identity'] = identity
advisory = zlib.crc32(schema.encode()) & 0x7fffffff
if not pg.execute('SELECT pg_try_advisory_lock(%s,%s)', (615173, advisory)).fetchone()[0]:
raise RuntimeError('Another migration holds the target lock')
if pg.execute('SELECT 1 FROM pg_namespace WHERE nspname=%s', (schema,)).fetchone():
raise ValueError('Target schema already exists; published data will never be overwritten')
pg.execute('CREATE SCHEMA IF NOT EXISTS ' + quote(control))
pg.execute('CREATE TABLE IF NOT EXISTS ' + qualify(control, 'run') +
'(id INTEGER PRIMARY KEY CHECK(id=1), source_json TEXT NOT NULL, stage TEXT NOT NULL, status TEXT NOT NULL)')
pg.execute('CREATE TABLE IF NOT EXISTS ' + qualify(control, 'tables') +
'(name TEXT PRIMARY KEY, rows BIGINT NOT NULL, sha256 TEXT NOT NULL, verified BOOLEAN NOT NULL DEFAULT false)')
pg.execute('ALTER TABLE ' + qualify(control, 'run') + " ADD COLUMN IF NOT EXISTS text_codec TEXT NOT NULL DEFAULT 'legacy'")
run = pg.execute('SELECT source_json,stage,status,text_codec FROM ' + qualify(control, 'run') + ' WHERE id=1').fetchone()
if run:
if json.loads(run[0]) != identity or run[2] == 'complete':
raise ValueError('Resume requires the exact same unpublished SQLite snapshot')
stage = run[1]
if run[3] == 'legacy':
report['legacy_text_upgrade_rows'] = upgrade_text_codec(pg, control, stage, plan)
elif run[3] != text.CODEC:
raise ValueError('Unsupported staging text storage codec')
else:
stage = schema + '_stage_' + uuid.uuid4().hex[:12]
with pg.transaction():
pg.execute('CREATE SCHEMA ' + quote(stage))
for table, spec in plan.items():
pg.execute(table_sql(stage, table, spec))
pg.execute('INSERT INTO ' + qualify(control, 'run') +
'(id,source_json,stage,status,text_codec) VALUES(1,%s,%s,%s,%s)',
(json.dumps(identity, sort_keys=True), stage, 'copying', text.CODEC))
report.update(status='copying', stage=stage, text_codec=text.CODEC,
content_hash_scope='Decoded original SQLite values; storage escapes are reversible.')
save(out, report)
try:
for table, spec in plan.items():
free_space(storage_path, min_free_gib)
previous = pg.execute('SELECT rows,sha256 FROM ' + qualify(control, 'tables') + ' WHERE name=%s', (table,)).fetchone()
if previous:
rows, digest = previous
else:
digest = hashlib.sha256(); rows = 0
with pg.transaction():
# Only this run's unpublished, incomplete table can be reset.
pg.execute('TRUNCATE ' + qualify(stage, table))
columns = ','.join(quote(c['name']) for c in spec['columns'])
with pg.cursor().copy('COPY ' + qualify(stage, table) + '(' + columns + ') FROM STDIN') as copy:
for row in source.execute(select_sql(None, table, spec)):
copy.write_row(tuple(map(text.encode, row))); update_hash(digest, row); rows += 1
if rows % 10000 == 0:
free_space(storage_path, min_free_gib)
if rows % 100000 == 0:
print(json.dumps({'stage': 'copying', 'table': table, 'rows': rows}), flush=True)
digest = digest.hexdigest()
pg.execute('INSERT INTO ' + qualify(control, 'tables') + '(name,rows,sha256) VALUES(%s,%s,%s)', (table, rows, digest))
report['tables'][table] = {'source_rows': rows, 'source_sha256': digest}
save(out, report)
print(json.dumps({'stage': 'copied', 'table': table, 'rows': rows}), flush=True)
report['status'] = 'validating_constraints'; save(out, report)
add_constraints(pg, stage, plan)
report['sequences'] = sequences(pg, source, stage, plan)
report['status'] = 'verifying_contents'; save(out, report)
for table, spec in plan.items():
digest = hashlib.sha256(); storage_digest = hashlib.sha256(); rows = 0
with pg.transaction(), pg.cursor(name='verify_' + table) as cursor:
cursor.itersize = 512
cursor.execute(select_sql(stage, table, spec))
for row in cursor:
update_hash(storage_digest, row)
update_hash(digest, tuple(map(text.decode, row))); rows += 1
entry = report['tables'][table]
entry.update(target_rows=rows, target_sha256=digest.hexdigest(),
target_storage_sha256=storage_digest.hexdigest())
if rows != entry['source_rows'] or digest.hexdigest() != entry['source_sha256']:
raise ValueError('Complete content verification failed for ' + table)
pg.execute('UPDATE ' + qualify(control, 'tables') + ' SET verified=true WHERE name=%s', (table,))
save(out, report)
print(json.dumps({'stage': 'verified', 'table': table, 'rows': rows}), flush=True)
report['status'] = 'building_search_index'; save(out, report)
free_space(storage_path, min_free_gib)
pg.execute('CREATE INDEX IF NOT EXISTS papers_search_gin ON ' + qualify(stage, 'papers') + ' USING gin (' + SEARCH_VECTOR + ')')
search = pg.execute('''SELECT am.amname,i.indisvalid,i.indisready,t.relname
FROM pg_index i JOIN pg_class ix ON ix.oid=i.indexrelid
JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace ns ON ns.oid=ix.relnamespace
JOIN pg_am am ON am.oid=ix.relam WHERE ns.nspname=%s AND ix.relname='papers_search_gin' ''', (stage,)).fetchone()
if search != ('gin', True, True, 'papers'):
raise ValueError('Native full-text index is not valid and ready')
for table in TABLES:
pg.execute('ANALYZE ' + qualify(stage, table))
invalid = pg.execute('''SELECT count(*) FROM pg_constraint co JOIN pg_class cl ON cl.oid=co.conrelid
JOIN pg_namespace ns ON ns.oid=cl.relnamespace WHERE ns.nspname=%s AND NOT co.convalidated''', (stage,)).fetchone()[0]
if invalid:
raise ValueError('Unvalidated constraints remain')
if (db.stat().st_size, db.stat().st_mtime_ns) != (identity['bytes'], identity['mtime_ns']):
raise ValueError('Source changed during migration')
if Path(str(db) + '-wal').exists() and Path(str(db) + '-wal').stat().st_size:
raise ValueError('A source writer committed during migration')
report.update(status='prepared', foreign_keys='all_validated', checks='all_validated', native_search_index=True)
save(out, report)
with pg.transaction():
pg.execute('COMMENT ON SCHEMA ' + quote(stage) + " IS 'paper-atlas:text-codec=" + text.CODEC + "'")
pg.execute('ALTER SCHEMA ' + quote(stage) + ' RENAME TO ' + quote(schema))
pg.execute('UPDATE ' + qualify(control, 'run') + " SET status='complete' WHERE id=1")
report.update(status='complete', finished_at=timestamp())
save(out, report)
return report
except BaseException as error:
report.update(status='error', error_type=type(error).__name__, error=str(error), failed_at=timestamp())
save(out, report)
raise
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db', required=True, help='Frozen SQLite source')
parser.add_argument('--pg-service', required=True, help='Protected libpq service name')
parser.add_argument('--schema', default='research')
parser.add_argument('--out', required=True)
parser.add_argument('--storage-path', help='Target filesystem path for free-space checks')
parser.add_argument('--min-free-gib', type=float, default=8)
args = parser.parse_args(argv)
result = migrate(args.db, args.pg_service, args.out, schema=args.schema,
storage_path=args.storage_path, min_free_gib=args.min_free_gib)
print(json.dumps(result, ensure_ascii=False), flush=True)