forked from EduCraft/curriculum-project-hub
575 lines
37 KiB
Markdown
575 lines
37 KiB
Markdown
# Backup, migration, and disaster-recovery primary-source research
|
|
|
|
This note is the primary-source evidence pack for the Wayfinder ticket
|
|
“Audit backup, migration, and disaster recovery safety.” External claims use
|
|
only PostgreSQL, Prisma, systemd, and rsync first-party documentation. Project
|
|
recommendations are explicitly labelled as derived implications rather than
|
|
upstream guarantees.
|
|
|
|
PostgreSQL links below use `docs/current`, which was PostgreSQL 18 when this
|
|
research was performed on 2026-07-10. A production implementation must pin the
|
|
deployed PostgreSQL major and verify the same pages for that major. The Hub
|
|
currently depends on Prisma `^6.19.3`, so Prisma 6-specific command examples
|
|
remain relevant even where the live documentation also describes Prisma 7.
|
|
|
|
## Executive conclusions
|
|
|
|
1. `pg_dump` can take a consistent **single-database** snapshot while ordinary
|
|
reads and writes continue, but it does not include the Hub's workspace tree,
|
|
cluster-global roles/tablespaces, or a WAL chain. PostgreSQL now explicitly
|
|
says it is generally not the right regular production-backup mechanism
|
|
outside simple cases. It is a useful logical/export layer, not proof of
|
|
whole-service disaster recovery.
|
|
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
|
|
[continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
|
|
2. Database PITR requires a physical base backup plus a continuous, unbroken
|
|
WAL sequence from the backup start through the recovery target. A successful
|
|
base-backup command alone does not prove that chain exists.
|
|
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING),
|
|
[making a base backup](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-BASE-BACKUP))
|
|
3. `pg_verifybackup` is necessary but not sufficient: PostgreSQL explicitly
|
|
requires test restores followed by checks that the restored database works
|
|
and contains the expected data.
|
|
([`pg_verifybackup`](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
|
|
4. Prisma `migrate deploy` applies pending migrations, but it does not detect
|
|
schema drift and, on PostgreSQL, Prisma does not wrap a migration in a
|
|
transaction by default. A failed migration can therefore leave partial
|
|
schema changes requiring explicit operator recovery.
|
|
([Prisma production workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production#production-and-testing-environments),
|
|
[Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic),
|
|
[failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
|
|
5. systemd supplies process lifecycle orchestration, not an application-level
|
|
consistency barrier. It sends the configured termination signal, waits up
|
|
to `TimeoutStopSec=`, then can forcibly kill remaining processes; a
|
|
successful `systemctl stop` does not prove that an `ExecStop=` command or
|
|
application drain succeeded.
|
|
([`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=),
|
|
[`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6))
|
|
6. The Hub persists one logical service state across two stores: PostgreSQL
|
|
records `Project.workspaceDir`, runs, messages, locks, and file-change audit
|
|
rows, while workspaces contain project files. A workspace transcript helper
|
|
exists but is not wired into the production runner. PostgreSQL's snapshot/
|
|
PITR guarantees cover only PostgreSQL. A whole-service recovery point
|
|
therefore requires an application-defined database/workspace checkpoint or
|
|
a journaled/versioned reconciliation protocol; neither PostgreSQL nor
|
|
systemd creates one automatically.
|
|
([Hub schema](../../../hub/prisma/schema.prisma),
|
|
[workspace transcript helper](../../../hub/src/agent/transcript.ts),
|
|
[`pg_dump` scope](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
|
|
|
|
## Current repository facts that make the boundaries material
|
|
|
|
- The systemd unit runs `prisma migrate deploy` as `ExecStartPre=` before every
|
|
service start and uses `Restart=on-failure`.
|
|
([`cph-hub.service`](../../../hub/deploy/cph-hub.service))
|
|
- The deploy script updates the live Hub directory using
|
|
`rsync -az --delete`, builds in that same directory, restarts the unit, and
|
|
accepts a constant liveness endpoint as deployment success.
|
|
([`deploy_platform.sh`](../../../hub/deploy/deploy_platform.sh))
|
|
- `Project.workspaceDir` is database state, but the referenced directory holds
|
|
independently mutable tenant content. A workspace JSONL transcript helper
|
|
exists but is not wired into the production runner; file contents still live
|
|
in the workspace while the database separately carries run/message/file-change
|
|
facts.
|
|
([Hub schema](../../../hub/prisma/schema.prisma),
|
|
[workspace transcript helper](../../../hub/src/agent/transcript.ts))
|
|
|
|
These are project facts, not upstream guarantees. The sections below establish
|
|
what the upstream tools do and, critically, what they do not do.
|
|
|
|
## PostgreSQL logical dump and restore
|
|
|
|
### Snapshot and concurrent-write semantics
|
|
|
|
- `pg_dump` makes a consistent export even while the database is being used;
|
|
it does not block ordinary readers or writers. It dumps only one database.
|
|
Cluster-global objects such as roles and tablespaces require `pg_dumpall` or
|
|
`pg_dumpall --globals-only`.
|
|
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
|
|
[whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
|
|
- `pg_dumpall` invokes `pg_dump` once per database. Each database is internally
|
|
consistent, but the snapshots are not synchronized across databases.
|
|
([whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
|
|
- A parallel dump uses directory format, opens `njobs + 1` connections, and
|
|
uses synchronized snapshots so every worker sees the same data set. The
|
|
connection count and additional server load are part of its capacity cost.
|
|
([`pg_dump --jobs`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
|
|
- Concurrent DDL can still interfere. The parallel-dump leader holds
|
|
`ACCESS SHARE` locks; if a later exclusive-lock request queues ahead of a
|
|
worker, that worker uses `NOWAIT` and aborts the dump rather than deadlocking.
|
|
`--lock-wait-timeout` can bound the initial lock wait.
|
|
([`pg_dump --jobs` and lock behaviour](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
|
|
- The ordinary snapshot is suitable for disaster-recovery dump semantics, but
|
|
it need not correspond to a serial execution of all transactions that later
|
|
commit. `--serializable-deferrable` waits for an anomaly-free snapshot;
|
|
PostgreSQL says that option is not beneficial for a dump intended only for
|
|
disaster recovery, but it can matter for a continuously updated reporting
|
|
copy.
|
|
([`--serializable-deferrable`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
|
|
- The default waits for dump files to be written safely to disk. Production
|
|
backups must not use `--no-sync`, because an immediate OS crash can otherwise
|
|
leave a corrupt dump.
|
|
([`pg_dump --no-sync`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
|
|
|
|
Derived Hub implications:
|
|
|
|
- An online database dump may be internally valid while referring to a
|
|
`workspaceDir` whose files are from another instant. “`pg_dump` succeeded” is
|
|
therefore not a whole-Hub consistency claim.
|
|
- Backup scheduling must either serialize against schema migration/DDL or
|
|
treat any lock wait, abort, non-zero exit, and stderr warning as a failed
|
|
backup. A partially written archive must never be published as restorable.
|
|
- If the deployed cluster contains more than the Hub database, a single Hub
|
|
dump is not a cluster backup. Even `pg_dumpall` does not create a single
|
|
cross-database snapshot.
|
|
|
|
### Restore error and atomicity semantics
|
|
|
|
- `pg_restore` defaults to continuing after SQL errors and reports an error
|
|
count at the end. `--exit-on-error` is required for fail-fast behaviour.
|
|
`--single-transaction` wraps all emitted commands in `BEGIN`/`COMMIT`, makes
|
|
the restore all-or-nothing, and implies `--exit-on-error`.
|
|
([`pg_restore` error and transaction options](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
|
|
- Parallel restore uses separate connections for data loading, index creation,
|
|
and constraint creation. It supports only custom/directory archives and
|
|
cannot be combined with `--single-transaction`; it is not a whole-restore
|
|
atomic operation.
|
|
([`pg_restore --jobs`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
|
|
- A single giant restore transaction can be impractical because it locks every
|
|
restored object and may exhaust the lock table. PostgreSQL 18 offers
|
|
`--transaction-size=N` as a bounded intermediate option; it implies
|
|
`--exit-on-error` but is not all-or-nothing.
|
|
([`pg_restore --transaction-size`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
|
|
- Plain SQL restored by `psql` also continues after errors unless
|
|
`ON_ERROR_STOP` is enabled; a single transaction is separately required for
|
|
all-or-nothing behaviour.
|
|
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
|
|
- PostgreSQL recommends restoring into a truly empty database based on
|
|
`template0`; otherwise local additions in `template1` can cause duplicate
|
|
definition errors. Required roles must exist before owner/ACL restoration.
|
|
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
|
|
- A dump can execute arbitrary code selected by superusers of the source
|
|
database. Partial dump/restore does not remove that trust boundary.
|
|
([`pg_dump` security warning](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
|
|
- A `pg_dump` binary can dump older servers but refuses a server newer than its
|
|
own major version. Output is intended to load into newer PostgreSQL versions,
|
|
but loading into an older major is not guaranteed.
|
|
([`pg_dump` cross-version notes](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-NOTES))
|
|
|
|
Derived Hub implications:
|
|
|
|
- Restore should target a new isolated database/cluster. If a non-atomic or
|
|
parallel restore reports any error, discard that target and start again;
|
|
never promote a partially restored database.
|
|
- The runbook must record the restore atomicity strategy and exact PostgreSQL
|
|
client/server versions. A green process exit without explicit fail-fast
|
|
options is insufficient evidence.
|
|
- A restored database must remain disconnected from real Feishu/provider
|
|
side-effect endpoints until application-level validation completes.
|
|
|
|
## Physical backup, WAL, and PITR
|
|
|
|
### Base backups
|
|
|
|
- `pg_basebackup` backs up a running PostgreSQL cluster without blocking other
|
|
clients. A full backup is an exact copy of the entire cluster and can seed
|
|
PITR or a standby; it cannot select one database or object.
|
|
([`pg_basebackup` description](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
|
|
- It uses the replication protocol. The account needs `REPLICATION` or
|
|
superuser privilege, `pg_hba.conf` must permit it, and `max_wal_senders`
|
|
needs capacity for the backup and, when streaming WAL, another connection.
|
|
([`pg_basebackup` requirements](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
|
|
- Default `-X stream` obtains WAL over a second connection while copying. With
|
|
`-X fetch`, WAL is collected only at the end; if required WAL was recycled,
|
|
the backup fails and is unusable.
|
|
([`pg_basebackup --wal-method`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
|
|
- The default waits for backup files to be durable. `--no-sync` is explicitly
|
|
unsuitable for production because an OS crash can corrupt the backup.
|
|
([`pg_basebackup --no-sync`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
|
|
- A manifest is generated by default, with per-file checksums. A SHA manifest
|
|
can detect malicious modification only if the manifest itself is held in a
|
|
separate trusted location or otherwise authenticated.
|
|
([`pg_basebackup` manifest options](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
|
|
|
|
Derived minimum backup record:
|
|
|
|
- source cluster/system identifier and PostgreSQL version;
|
|
- backup tool version, start/end time, start/end LSN and WAL range;
|
|
- manifest digest and its independently protected location;
|
|
- destination object/version, byte count, exit status, and verification state;
|
|
- an explicit `VALID`, `INVALID`, or `QUARANTINED` state so a failed leftover
|
|
directory cannot enter the retention set merely because files exist.
|
|
|
|
### WAL archival and recovery
|
|
|
|
- PITR combines a physical backup with WAL replay. It can stop at a chosen time
|
|
and reach a consistent database state. Logical dumps do not contain enough
|
|
information for WAL replay.
|
|
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
|
|
- Successful recovery requires a continuous sequence of archived WAL extending
|
|
back at least to the base-backup start. PostgreSQL tells operators to set up
|
|
and test WAL archiving **before** taking the first base backup.
|
|
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
|
|
- The archive command must return zero if and only if durable archival
|
|
succeeded. Non-zero causes retry. Archive implementations should refuse to
|
|
overwrite an existing file; a repeated name may report success only when the
|
|
existing durable content is identical, and differing content must fail.
|
|
([WAL archiving](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
|
|
- Repeated archive failure accumulates WAL in `pg_wal`; filling that filesystem
|
|
causes PostgreSQL to panic and remain offline until space is freed. PostgreSQL
|
|
explicitly advises monitoring archival progress.
|
|
([WAL archiving failure behaviour](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
|
|
- WAL is archived after a segment is completed, so low traffic can make the
|
|
last committed change wait a long time for archival. `archive_timeout` can
|
|
bound that age by forcing segment switches, at the cost of archive growth.
|
|
([WAL archiving latency](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
|
|
- WAL archival does not restore manually edited PostgreSQL configuration files
|
|
(`postgresql.conf`, `pg_hba.conf`, `pg_ident.conf`); those need a separate
|
|
file backup.
|
|
([WAL archiving caveat](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
|
|
- PostgreSQL's recovery procedure keeps clients out, restores the base backup,
|
|
configures `restore_command` and `recovery.signal`, replays WAL, and requires
|
|
inspection of database contents before restoring ordinary access.
|
|
([PITR recovery procedure](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-PITR-RECOVERY))
|
|
- Recovery can target a timestamp, LSN, transaction ID, named restore point, or
|
|
the earliest consistent point. Only one target may be set. Timeline history
|
|
is required to navigate recovery branches, and a configured target not
|
|
reached before WAL exhaustion is a fatal recovery failure.
|
|
([recovery target settings](https://www.postgresql.org/docs/current/runtime-config-wal.html#RUNTIME-CONFIG-WAL-RECOVERY-TARGET),
|
|
[timelines](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-TIMELINES))
|
|
|
|
Derived Hub implications:
|
|
|
|
- The database RPO is the age of the newest **durably archived, continuous**
|
|
WAL position, not the age of the newest base backup and not merely the name
|
|
in `last_archived_wal`.
|
|
- Archive keys need a collision-proof cluster/system-identifier namespace and
|
|
create-if-absent semantics; unrelated clusters must never overwrite a shared
|
|
WAL name.
|
|
- Monitoring must cover archive failures, time since durable success, archive
|
|
lag, WAL continuity, `pg_wal` growth, free space, and base-backup age.
|
|
- PITR restores PostgreSQL only. It does not rewind workspace files. A database
|
|
recovery target has no whole-Hub meaning until paired with a workspace
|
|
recovery point and reconciliation rule.
|
|
|
|
## Verification is a ladder, not a checksum
|
|
|
|
- `pg_verifybackup` validates the backup manifest and system identifier,
|
|
detects missing/extra/changed files, checks file checksums, and parses the
|
|
required WAL with `pg_waldump`. WAL verification is currently supported only
|
|
for plain-format base backups.
|
|
([`pg_verifybackup` checks](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
|
|
- PostgreSQL explicitly says these checks cannot cover everything a running
|
|
server will encounter and still requires test restores plus checks that the
|
|
database works and contains correct data.
|
|
([`pg_verifybackup` limitation](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
|
|
- `pg_amcheck` can check supported heap/index structures, but currently only
|
|
ordinary/TOAST tables, materialized views, sequences, and B-tree indexes are
|
|
supported; other relation types are silently skipped. Stronger B-tree checks
|
|
can block concurrent modifications.
|
|
([`pg_amcheck`](https://www.postgresql.org/docs/current/app-pgamcheck.html#APP-PGAMCHECK))
|
|
|
|
Derived recovery-evidence ladder:
|
|
|
|
1. **Artifact verification:** backup command succeeded; manifest and all
|
|
required WAL validate; any failure marks the artifact unusable.
|
|
2. **Isolated technical restore:** restore to a clean non-production target,
|
|
reach the intended LSN/time/timeline, and prove PostgreSQL starts without
|
|
exposing it to users.
|
|
3. **Database validation:** verify migration history/schema identity, tenant
|
|
counts and relationships, critical constraints/sequences, and appropriate
|
|
corruption checks.
|
|
4. **Workspace validation:** restore the paired snapshot/version, validate file
|
|
hashes/ownership/containment, and reconcile database file-change/run state
|
|
against actual files.
|
|
5. **Application validation:** run read-only tenant-isolation and core workflow
|
|
smoke tests with external side effects disabled.
|
|
6. **Recovery report:** persist the exact backup, WAL range, workspace point,
|
|
release/schema versions, checks, observed RPO/RTO, operator, and failures.
|
|
Only a fully passing report may authorize promotion.
|
|
|
|
## Prisma Migrate production semantics
|
|
|
|
### What `migrate deploy` proves and does not prove
|
|
|
|
- In production, `migrate deploy` compares applied migration checksums with the
|
|
migration directory, warns when an applied migration was modified, and
|
|
applies pending migrations.
|
|
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
|
|
- It does **not** warn when an already-applied migration is missing, detect
|
|
production schema drift, reset the database, generate Prisma Client, or use a
|
|
shadow database.
|
|
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
|
|
- The migration directory is the deployable history and must be committed in
|
|
full, including `migration_lock.toml`. Prisma warns against editing or
|
|
deleting migrations already applied in production; `migrate deploy` executes
|
|
migration SQL rather than deriving a new migration from the Prisma schema.
|
|
([Prisma migration histories](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/migration-histories))
|
|
- Prisma uses a database advisory lock for production migration commands so
|
|
multiple migration commands do not run simultaneously. The documented lock
|
|
timeout is ten seconds and not configurable; after timeout, the caller must
|
|
retry. The lock can be disabled by environment variable.
|
|
([Prisma 6 advisory locking](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#advisory-locking))
|
|
|
|
Derived Hub implication: `migrate deploy` exit zero proves that this invocation
|
|
applied or found no pending migration. It does not prove the live schema equals
|
|
the expected migration end state, that the new and previous application builds
|
|
are both compatible with it, or that the migration is operationally safe under
|
|
production data volume and traffic.
|
|
|
|
The advisory lock is a migration-command concurrency guard, not a substitute
|
|
for the repository's deployment lock or application/schema compatibility. As a
|
|
derived safety rule, production preflight should reject
|
|
`PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK` unless an explicit reviewed mechanism
|
|
provides the same serialization guarantee.
|
|
|
|
### Transactions and failed migrations
|
|
|
|
- Prisma's first-party migration guidance states that PostgreSQL migrations
|
|
are not wrapped in a transaction by default. Operators may opt in by adding
|
|
`BEGIN;`/`COMMIT;` to a migration, but large transactional migrations can hold
|
|
locks longer and consume more database resources.
|
|
([Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic))
|
|
- A failed migration records its error in `_prisma_migrations.logs`; further
|
|
migrations cannot deploy until the failed state is explicitly recovered.
|
|
Recovery is either (a) revert/repair partial steps, mark the migration rolled
|
|
back, and redeploy, or (b) manually complete the exact steps and mark it
|
|
applied.
|
|
([Prisma failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
|
|
- Prisma error codes `P3009` and `P3018` explicitly report that failed
|
|
migrations block new migration application until recovery.
|
|
([Prisma error reference](https://www.prisma.io/docs/orm/reference/error-reference#p3009))
|
|
- `migrate resolve --rolled-back` changes migration-history state so Prisma may
|
|
apply the migration again; it does not itself undo SQL already executed.
|
|
`--applied` records success without executing the migration SQL.
|
|
([failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration),
|
|
[`migrate resolve`](https://www.prisma.io/docs/cli/migrate/resolve))
|
|
- Prisma can generate a down SQL script, but its documentation limits the
|
|
simple rollback workflow to a **failed** up migration. To reverse a successful
|
|
migration, the supported history is a new forward migration generated from a
|
|
reverted Prisma schema. A schema down script also cannot restore lost data,
|
|
application changes, or manually authored SQL automatically.
|
|
([generating down migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/generating-down-migrations#considerations-when-generating-down-migrations))
|
|
|
|
Derived Hub implications:
|
|
|
|
- Every migration needs a declared atomicity choice. If it is intentionally
|
|
non-transactional, the migration must be resumable/idempotent or ship an
|
|
exact, tested partial-failure procedure. Blindly rerunning a partially applied
|
|
script is unsafe.
|
|
- A database backup checkpoint before a destructive or difficult-to-reverse
|
|
migration is necessary, but restoring that checkpoint is a whole-database
|
|
recovery event—not a substitute for a compatible rollout design.
|
|
- Service restart cannot be treated as application rollback after a successful
|
|
schema migration. The previous binary may no longer understand the new
|
|
schema, and a down migration cannot recreate deleted data.
|
|
|
|
### Compatibility and rollout ordering
|
|
|
|
- Prisma documents expand-and-contract as the way to avoid downtime for fields
|
|
in active use: add the new representation, deploy code that writes both while
|
|
reading the old, migrate and verify data, switch reads, stop old writes, then
|
|
remove the old representation.
|
|
([Prisma expand-and-contract workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-use-the-expand-and-contract-pattern-to-evolve-the-schema-without-downtime))
|
|
- Prisma also warns that generated “renames” may be expressed as add/drop and
|
|
lose data unless the SQL is reviewed and customized before application.
|
|
([customizing migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-rename-a-field))
|
|
|
|
Derived deployment contract:
|
|
|
|
1. Migration SQL is immutable after production application and reviewed for
|
|
destructive operations, lock duration, data volume, and transaction choice.
|
|
2. It is rehearsed against a recent, anonymized production-scale restore and
|
|
its runtime/locks/disk growth are recorded.
|
|
3. Each release declares a schema compatibility window. Expand releases remain
|
|
compatible with both old and new application versions; contract/destructive
|
|
releases occur only after old binaries can no longer run.
|
|
4. Migration status/drift is checked before changing the live release. A
|
|
failed migration blocks deployment and enters an operator runbook; it must
|
|
never be hidden by repeated restart attempts.
|
|
5. Rollback means switching to a binary proven compatible with the current
|
|
schema or deploying a new forward migration. Restoring a backup is the last
|
|
resort and must also restore/reconcile the workspace recovery point.
|
|
|
|
## systemd stop, restart, and dependency semantics
|
|
|
|
- When `ExecStartPre=`, `ExecStart=`, or `ExecStartPost=` fails or times out,
|
|
service startup does not continue to the normal running process. `ExecStop=`
|
|
is skipped because startup never succeeded; `ExecStopPost=` is the hook that
|
|
still runs after a failed startup.
|
|
([systemd start/stop command semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStartPre=),
|
|
[`ExecStopPost=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStopPost=),
|
|
[permanent upstream man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L525-L587))
|
|
- `Restart=on-failure` covers non-zero exits and failures/timeouts in service
|
|
control processes, including `ExecStartPre=`. An explicit systemd stop does
|
|
not trigger automatic restart. Restarts are subject to unit start-rate
|
|
limiting.
|
|
([`Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=),
|
|
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L856-L995))
|
|
- With no `ExecStop=`, systemd sends `KillSignal=` (default `SIGTERM`).
|
|
`TimeoutStopSec=` bounds the wait; after it expires, remaining processes can
|
|
be forcibly killed with `SIGKILL`/`FinalKillSignal=`.
|
|
([`ExecStop=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
|
|
[`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=))
|
|
- Default `KillMode=control-group` applies termination to all remaining
|
|
processes in the unit cgroup. systemd explicitly discourages `process` or
|
|
`none` because children can escape the service lifecycle and remain running
|
|
after the service is considered stopped.
|
|
([`KillMode=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html#KillMode=))
|
|
- `systemctl stop` waits for the job unless `--no-block` is used, but the command
|
|
does not fail merely because an `ExecStop=` command failed: the manager still
|
|
forcibly terminates the unit.
|
|
([`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6),
|
|
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L444-L459))
|
|
- `systemctl restart` is a stop followed by a start; `ExecStop=` and
|
|
`ExecStopPost=` participate. It does not necessarily flush every unit resource
|
|
in the same way as separate completed `stop` then `start` operations.
|
|
([restart semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
|
|
[`systemctl restart`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#restart%20PATTERN%E2%80%A6))
|
|
- `After=` is only an ordering relationship; it does not pull in another unit.
|
|
Requirement and ordering dependencies are independent. During shutdown the
|
|
start order is reversed, so a Hub ordered `After=postgresql.service` stops
|
|
before PostgreSQL when both are in the shutdown transaction.
|
|
([`Requires=` and ordering](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Requires=),
|
|
[`Before=`/`After=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Before=))
|
|
- `systemctl freeze` only uses the cgroup freezer to suspend process execution;
|
|
its documented contract contains no filesystem flush or stable-image
|
|
guarantee. It is not a substitute for an application drain or a filesystem
|
|
snapshot barrier.
|
|
([permanent `systemctl freeze` man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L653-L675))
|
|
|
|
Derived assessment of the current unit:
|
|
|
|
- A Prisma migration failure in the current `ExecStartPre=` prevents the Hub
|
|
from starting, then `Restart=on-failure` can retry startup until rate-limited.
|
|
That is fail-closed, but it is not controlled migration recovery and can
|
|
repeatedly hit a database already left in Prisma's failed state.
|
|
- `KillSignal=SIGTERM` plus `TimeoutStopSec=30` is only a deadline. Until the Hub
|
|
installs a real termination handler that makes readiness false, stops ingress,
|
|
drains/checkpoints active work, closes Feishu and database clients, and reports
|
|
success, the unit cannot claim a graceful consistency barrier.
|
|
- A backup runbook must issue a controlled drain, verify durable work reached a
|
|
reconciled terminal/checkpoint state, stop the Hub, verify the unit and its
|
|
cgroup are inactive, and only then snapshot mutable workspace state. A zero
|
|
`systemctl stop` exit by itself is insufficient evidence.
|
|
- Recovery should not start the normal unit until the restored database and
|
|
workspace have been validated. The current unit would run pending migrations
|
|
immediately on first start, mutating the restored database before validation
|
|
and possibly destroying the ability to inspect the original recovered state.
|
|
|
|
## Filesystem and rsync boundaries
|
|
|
|
- PostgreSQL says a plain filesystem copy of a live data directory is unusable:
|
|
the database must be shut down, because tools such as `tar` do not create an
|
|
atomic snapshot and server buffering remains. A correctly implemented frozen
|
|
snapshot can work while PostgreSQL runs, but all volumes containing data,
|
|
WAL, and tablespaces must be captured simultaneously; otherwise shut down or
|
|
use the continuous-archive base-backup protocol.
|
|
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
|
|
- For a PostgreSQL rsync backup, the documented safe pattern is a first pass
|
|
while running, followed by shutdown and a second `rsync --checksum`; the
|
|
second pass is what makes the result consistent.
|
|
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
|
|
- At the filesystem layer, `fsfreeze` is documented to let in-progress
|
|
filesystem operations complete, halt new modifications, and flush dirty data,
|
|
metadata, and logs before returning. That is still only a filesystem-volume
|
|
guarantee; it does not replace PostgreSQL's backup protocol and does not make
|
|
separately frozen volumes simultaneous.
|
|
([util-linux `fsfreeze` manual source](https://github.com/util-linux/util-linux/blob/master/sys-utils/fsfreeze.8.adoc))
|
|
- rsync's `--delete` defaults to deletion during transfer on modern peers, so
|
|
the receiver tree changes incrementally. `--delay-updates` merely holds each
|
|
updated file and renames files into place rapidly at the end; its own manual
|
|
says this only attempts to make updates “a little more atomic” and points to
|
|
a separate parallel-tree/switch approach for stronger atomicity.
|
|
([rsync `--delete`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delete),
|
|
[rsync `--delay-updates`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delay-updates))
|
|
|
|
Derived Hub implications:
|
|
|
|
- The current in-place `rsync --delete` deployment is neither an immutable
|
|
release switch nor a backup snapshot. A failed/interrupted transfer can leave
|
|
a mixed live tree; service stop/restart cannot reconstruct the deleted prior
|
|
artifact.
|
|
- The workspace snapshot mechanism remains an explicit design choice. Whichever
|
|
storage technology is selected must prove atomicity over all workspace
|
|
volumes or use a quiesced/no-writer interval, then publish an immutable
|
|
version/checksum manifest. Merely copying a changing directory is not enough.
|
|
|
|
## Derived minimum whole-Hub recovery contract
|
|
|
|
The primary sources support the following minimum contract; exact RPO, RTO,
|
|
retention, storage vendor, and numeric schedules remain product/operations
|
|
decisions and must not be invented by implementation.
|
|
|
|
### Backup path
|
|
|
|
1. Maintain verified PostgreSQL base backups and a monitored continuous WAL
|
|
archive, or obtain and test an explicitly equivalent managed-PostgreSQL
|
|
guarantee. Keep logical dumps as a separate portability/selective-restore
|
|
layer, not the sole PITR mechanism.
|
|
2. Keep PostgreSQL configuration, deployment/release manifests, migration
|
|
history, and workspace snapshots in the backup inventory; WAL alone does not
|
|
cover them.
|
|
3. Create a whole-Hub checkpoint only after ingress is drained and active
|
|
workspace writers are stopped/reconciled, unless the eventual storage layer
|
|
supplies a proven atomic snapshot/journal spanning the necessary stores.
|
|
4. Persist one signed/immutable checkpoint manifest joining PostgreSQL
|
|
backup/WAL position, workspace snapshot/version, application revision,
|
|
migration digest, tool versions, checksums, timestamps, and validation state.
|
|
5. Treat database/WAL/workspace backups as cross-tenant sensitive data. Apply
|
|
least-privilege access, encryption, audit, deletion/retention, and an
|
|
independently protected integrity manifest; WAL contains effectively all
|
|
database changes and PostgreSQL advises protecting it from unauthorized
|
|
readers.
|
|
([WAL archive confidentiality](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
|
|
|
|
### Migration path
|
|
|
|
1. Serialize deploys and backup checkpoints; run a drift/status gate before
|
|
migration, not just `migrate deploy`.
|
|
2. Rehearse each migration on a recent production-scale restore and record
|
|
runtime, locks, disk/WAL growth, transaction choice, and failure recovery.
|
|
3. Require expand/contract compatibility for online changes. A release must
|
|
declare which old/new binaries can run against each schema phase.
|
|
4. Separate migration orchestration from ordinary service restart so a failed
|
|
migration produces one durable incident state and an operator decision—not
|
|
an opaque restart loop.
|
|
5. Verify a restorable checkpoint before destructive change. Do not describe
|
|
backup restore as a normal “rollback”; it is whole-service disaster recovery.
|
|
|
|
### Restore and promotion path
|
|
|
|
1. Restore into an isolated environment with real external side effects
|
|
disabled.
|
|
2. Restore the selected PostgreSQL base backup/WAL target and the checkpoint's
|
|
exact workspace version; do not run pending Prisma migrations implicitly.
|
|
3. Prove artifact integrity, database startup, recovery target/timeline,
|
|
migration/schema identity, tenant invariants, workspace hashes/containment,
|
|
and read-only application flows.
|
|
4. Record observed recovery point and recovery time. A restore that starts but
|
|
misses its target, loses a tenant workspace, has partial migration state, or
|
|
fails any invariant is not promotable.
|
|
5. Promote explicitly, then enable ingress and external integrations. Preserve
|
|
the pre-recovery cluster/WAL and recovery report until the incident is closed.
|
|
|
|
## Decisions and evidence still required
|
|
|
|
These values are not fixed by the upstream tools and remain `OPEN` for the SaaS
|
|
production contract:
|
|
|
|
- whole-Hub RPO and RTO, separately for ordinary deletion, database corruption,
|
|
host loss, region/provider loss, and migration failure;
|
|
- base-backup, logical-dump, workspace-snapshot, WAL, and restore-drill cadence;
|
|
- immutable/off-host storage, encryption/key ownership, retention and verified
|
|
deletion policy, including org-deletion and legal-retention interactions;
|
|
- exact workspace checkpoint/journal technology and database/workspace
|
|
reconciliation semantics for in-flight or partially written runs;
|
|
- whether restore promotion is whole-platform only or supports a safe
|
|
organization/project export-import path (PostgreSQL physical PITR is
|
|
cluster-wide);
|
|
- migration transaction policy, maximum lock/downtime budget, compatibility
|
|
window, and who can authorize contract/destructive phases;
|
|
- evidence from at least one production-like full restore and one PITR drill,
|
|
including deliberate missing-WAL, corrupted-backup, failed-migration,
|
|
interrupted-workspace-write, and restart-during-recovery probes.
|