Restore: onto a database that was never migrated it crashes before restoring anything #476

Closed
opened 2026-10-01 10:46:58 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the restore code and restoring a backup onto an unmigrated SQLite file.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

restore_backup decides whether the instance is empty by asking get_user_model().objects.exists(). On a database that has never been migrated there is no accounts_user table, so that query raises OperationalError (SQLite) or ProgrammingError (PostgreSQL). Neither is a BackupError, so manage.py restore ends in a traceback and restores nothing. A database with no tables at all is the emptiest instance there is, and it is what a rebuild after losing a host starts from.

  • Where: core/backup.py:503, restore_backup (the emptiness check); core/management/commands/restore.py:31, which catches only BackupError.
  • What happens: an operator rebuilds on a new data volume and follows the container procedure in the wiki's Backups and your data: docker compose run --rm -e POSTULO_SKIP_MIGRATE=1 postulo python manage.py restore /app/data/backups/....tar.gz. POSTULO_SKIP_MIGRATE=1 stops docker/entrypoint.sh creating the tables, and the rest of the entry point (plugins sync, check --deploy) runs without complaint on the empty file. The command then stops at backup.py:503 with django.db.utils.OperationalError: no such table: accounts_user. Reproduced: a backup taken from a migrated SQLite file, restored with manage.py restore onto a new, unmigrated file.
  • Why it matters: disaster recovery is the moment a restore exists for, and the documented command fails there with a stack trace rather than a sentence. The way out (run migrate first, then restore) is not written anywhere, and restore runs the migrations itself afterwards precisely so that nobody has to.
  • Fix: treat a database without the user table as empty: check get_user_model()._meta.db_table in connection.introspection.table_names() before counting, and count only when it is there. load_database already copes with an empty target (SQLite's backup API replaces the file's contents; pg_restore --clean --if-exists tolerates missing objects), and the migrate at the end brings the result forward.

Done when

  • A test in tests/test_backup.py restores an archive onto a file-backed SQLite database that has never been migrated (the existing restore tests all run on the test runner's already-migrated in-memory database) and finds the users and the manifest's counts; it fails today with OperationalError.

Related to #234 (closed), which asked for a safe restore onto a fresh instance and documented the POSTULO_SKIP_MIGRATE=1 procedure this path fails on.

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the restore code and restoring a backup onto an unmigrated SQLite file. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. `restore_backup` decides whether the instance is empty by asking `get_user_model().objects.exists()`. On a database that has never been migrated there is no `accounts_user` table, so that query raises `OperationalError` (SQLite) or `ProgrammingError` (PostgreSQL). Neither is a `BackupError`, so `manage.py restore` ends in a traceback and restores nothing. A database with no tables at all is the emptiest instance there is, and it is what a rebuild after losing a host starts from. - **Where:** `core/backup.py:503`, `restore_backup` (the emptiness check); `core/management/commands/restore.py:31`, which catches only `BackupError`. - **What happens:** an operator rebuilds on a new data volume and follows the container procedure in the wiki's *Backups and your data*: `docker compose run --rm -e POSTULO_SKIP_MIGRATE=1 postulo python manage.py restore /app/data/backups/....tar.gz`. `POSTULO_SKIP_MIGRATE=1` stops `docker/entrypoint.sh` creating the tables, and the rest of the entry point (`plugins sync`, `check --deploy`) runs without complaint on the empty file. The command then stops at `backup.py:503` with `django.db.utils.OperationalError: no such table: accounts_user`. Reproduced: a backup taken from a migrated SQLite file, restored with `manage.py restore` onto a new, unmigrated file. - **Why it matters:** disaster recovery is the moment a restore exists for, and the documented command fails there with a stack trace rather than a sentence. The way out (run `migrate` first, then restore) is not written anywhere, and `restore` runs the migrations itself afterwards precisely so that nobody has to. - **Fix:** treat a database without the user table as empty: check `get_user_model()._meta.db_table in connection.introspection.table_names()` before counting, and count only when it is there. `load_database` already copes with an empty target (SQLite's backup API replaces the file's contents; `pg_restore --clean --if-exists` tolerates missing objects), and the `migrate` at the end brings the result forward. ## Done when - A test in `tests/test_backup.py` restores an archive onto a file-backed SQLite database that has never been migrated (the existing restore tests all run on the test runner's already-migrated in-memory database) and finds the users and the manifest's counts; it fails today with `OperationalError`. Related to #234 (closed), which asked for a safe restore onto a fresh instance and documented the `POSTULO_SKIP_MIGRATE=1` procedure this path fails on.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:58 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#476
No description provided.