Gate restore on the dump's data revision, not the code head

A restore could load a database dump whose schema was older than the running
code. The manifest stamped schemarev from the code image on disk, so the
compatibility check compared the code against itself and always passed. A set
captured from a database at 7d37_import_batches_and_journal restored under
code at 7d41_drop_guest_tools_secrets, leaving the data behind the code: every
query on a table the newer migrations touched failed with 1054 Unknown column,
which surfaced as empty configuration-policy dropdowns and a failure to load
data on edit PC.

The authoritative signal is the alembic_version of the database that was
dumped, so manifests now carry datarev alongside schemarev, backup.sh reads it
from the database and warns when it has drifted from the image, and the gate
asks whether the running code knows that revision. Data behind the code is
allowed because it can be rolled forward; data ahead of the code is refused
because it cannot.

Rolling it forward has to happen somewhere with an app context. The backup
sidecar is mysql plus restic plus alembic only and has no Flask, so restore.sh
loads the dump and parks the run in an upgrading phase behind an
.upgrade-requested sentinel rather than declaring a success it cannot verify.
The api container owns the Flask CLI and already runs these exact idempotent
commands on every deploy, so a finalize endpoint there runs db upgrade plus
plugin upgrade-all and stamps the terminal phase. The UI drives it on seeing
the upgrading phase, so recovery needs no operator intervention.

The live simulation runs one codebase against itself, so its dump is always at
the same revision as the code restoring it and it can never reproduce this
drift. The gate therefore gets a standalone tester that builds throwaway
migration chains and dumps offline, invoked as a simulation preflight. It
caught a real bug in the legacy dump-parse fallback, where the revision was
matched loosely enough to return the alembic_version table name instead of the
value it holds.
This commit is contained in:
benjamin.baker
2026-09-04 15:38:31 -04:00
parent 3c9321f16c
commit b973561cb7
7 changed files with 450 additions and 28 deletions

View File

@@ -309,6 +309,11 @@ const restoreTarget = ref(null)
const restoreStatus = ref(null)
let statusTimer = null
let restoreStartedAt = 0
// The Flask-less backup sidecar loads the dump and parks the restore in
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
// the browser pokes the finalize endpoint once to roll the restored data up to
// the running code. Guarded so we fire it exactly once per restore run.
let finalizeRequested = false
const backups = ref([])
const restores = ref([])
@@ -320,6 +325,7 @@ const RESTORE_PHASE_LABELS = {
checking: 'Checking compatibility...',
files: 'Restoring files...',
database: 'Loading database...',
upgrading: 'Upgrading schema to code...',
done: 'Restore complete',
failed: 'Restore failed',
}
@@ -476,6 +482,18 @@ async function pollStatus() {
}
restoreStatus.value = status
const phase = status?.phase
if (phase === 'upgrading' && !finalizeRequested) {
// Hand off to the api container, which owns the Flask CLI, to run the
// post-load schema upgrade. Fire once; polling keeps tracking until it
// reports done or failed.
finalizeRequested = true
try {
await api.post('/shopdbbackups/finalize-restore')
} catch (error) {
// The finalize endpoint stamps its own failed status on error, which the
// next poll surfaces; nothing extra to do here.
}
}
if (phase === 'done' || phase === 'failed') {
stopStatusPolling()
if (phase === 'done') {
@@ -492,6 +510,7 @@ async function pollStatus() {
function startStatusPolling() {
stopStatusPolling()
restoreStartedAt = Date.now()
finalizeRequested = false
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
// Poll on an interval only; skip the immediate call so the seeded "queued"
// phase is guaranteed to paint before the first real status can replace it.