Skip to content

Recover a MySQL migration that stopped halfway

DDL that committed, a run nobody can retry, and a repair decided by what the database holds.

What it shows. How to find out what a partial MySQL migration left behind before choosing how to repair it.

A replay of a verified run, not a live cluster. 21 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.

sh · mysql-partial-failure
# Release 1 creates the table on the lab's MySQL, and this resource applies it on its own.$ APPLY=Always demo/bin/lab manifest deliveries "$(cat demo/.lab/deliveries-1-digest)" | kubectl apply -f -ptahmigration.operator.ptah.run/deliveries created # Three deliveries arrive. The next release has to keep them.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -e \  "INSERT INTO deliveries VALUES (1, 'D-100'), (2, 'D-200'), (3, 'D-300'); SELECT * FROM deliveries"id	reference1	D-1002	D-2003	D-300 # Release 2 adds a status, fills it, then constrains it, naming a column that is not there.$ cat demo/mysql/release-2/0000000002_add_status.up.sql-- Adds a status, fills it, and then constrains it. The constraint names a-- column that does not exist, so the third statement fails after the first two-- have committed: MySQL commits each DDL statement on its own.-- +ptah no_transactionALTER TABLE deliveries ADD COLUMN status VARCHAR(16); UPDATE deliveries SET status = 'open'; ALTER TABLE deliveries ADD CONSTRAINT deliveries_status_known CHECK (statuz IN ('open', 'closed')); # Point the resource at release 2, and approve the plan it publishes.$ APPLY=OnApproval demo/bin/lab manifest deliveries "$(cat demo/.lab/deliveries-2-digest)" | kubectl apply -f -ptahmigration.operator.ptah.run/deliveries configured # The plan holds version 2 alone. Approve it by name, UID and fingerprint.$ kubectl -n "$NAMESPACE" get ptahmigration deliveries -o json > demo/.lab/deliveries.jsonkubectl -n "$NAMESPACE" get ptahmigrationplan \  "$(jq -r .status.plan.name demo/.lab/deliveries.json)" -o json > demo/.lab/deliveries-plan.jsonjq -r '.spec.migrations[] | "planned: \(.version) \(.description)"' demo/.lab/deliveries-plan.jsonjq -n --slurpfile migration demo/.lab/deliveries.json --slurpfile plan demo/.lab/deliveries-plan.json '  {    apiVersion: "operator.ptah.run/v1alpha1", kind: "PtahMigrationApproval",    metadata: {name: "deliveries-release-2"},    spec: {      migrationRef: {name: $migration[0].metadata.name, uid: $migration[0].metadata.uid},      planRef:      {name: $plan[0].metadata.name,      uid: $plan[0].metadata.uid},      planFingerprint: $plan[0].spec.fingerprint    }  }' | kubectl -n "$NAMESPACE" create -f -planned: 2 Add Statusptahmigrationapproval.operator.ptah.run/deliveries-release-2 created # The run stopped partway. The resource records it and will not run anything until it is settled.$ kubectl -n "$NAMESPACE" get ptahmigration deliveries -o json \  | jq -r '"last run: \(.status.lastRun.outcome)", "unresolved: \(.status.unresolvedRun.outcome // "none")"'last run: Partialunresolved: Partial # First, what committed. The column exists and every row was filled.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -e \  "SELECT id, reference, status FROM deliveries"id	reference	status1	D-100	open2	D-200	open3	D-300	open # The revision table agrees. Version 2 stopped after two of its three statements.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -N -e \  "SELECT CONCAT('version ', version, ': ', applied, ' of ', total, ' statements, ', state) FROM schema_migrations ORDER BY version"version 1: 1 of 1 statements, appliedversion 2: 2 of 3 statements, failed # This database is disposable, so undo both statements. A real one needs its changes judged first.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -e \  "ALTER TABLE deliveries DROP COLUMN status; DELETE FROM schema_migrations WHERE version = 2" # Point the resource at release 1, which ends before the migration that failed.$ APPLY=OnApproval demo/bin/lab manifest deliveries "$(cat demo/.lab/deliveries-1-digest)" | kubectl apply -f -ptahmigration.operator.ptah.run/deliveries configured # Its next reading finds nothing to apply, and that reading is what clears the record.$ kubectl -n "$NAMESPACE" get ptahmigration deliveries -o json \  | jq -r '"unresolved: \(.status.unresolvedRun.outcome // "none")"'unresolved: none # Release 3 carries the corrected migration. Point the resource at it.$ APPLY=OnApproval demo/bin/lab manifest deliveries "$(cat demo/.lab/deliveries-3-digest)" | kubectl apply -f -ptahmigration.operator.ptah.run/deliveries configured # Its plan holds the corrected version 2. Approve it the same way.$ kubectl -n "$NAMESPACE" get ptahmigration deliveries -o json > demo/.lab/deliveries.jsonkubectl -n "$NAMESPACE" get ptahmigrationplan \  "$(jq -r .status.plan.name demo/.lab/deliveries.json)" -o json > demo/.lab/deliveries-plan.jsonjq -n --slurpfile migration demo/.lab/deliveries.json --slurpfile plan demo/.lab/deliveries-plan.json '  {    apiVersion: "operator.ptah.run/v1alpha1", kind: "PtahMigrationApproval",    metadata: {name: "deliveries-release-3"},    spec: {      migrationRef: {name: $migration[0].metadata.name, uid: $migration[0].metadata.uid},      planRef:      {name: $plan[0].metadata.name,      uid: $plan[0].metadata.uid},      planFingerprint: $plan[0].spec.fingerprint    }  }' | kubectl -n "$NAMESPACE" create -f -ptahmigrationapproval.operator.ptah.run/deliveries-release-3 created # All three statements ran this time, and the three deliveries are still there.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -N -e \  "SELECT CONCAT('version ', version, ': ', applied, ' of ', total, ' statements, ', state) FROM schema_migrations ORDER BY version;   SELECT CONCAT(id, ' ', reference, ' ', status) FROM deliveries ORDER BY id"version 1: 1 of 1 statements, appliedversion 2: 3 of 3 statements, applied1 D-100 open2 D-200 open3 D-300 open

What was checked

Every step states what has to hold before its output may be published. A condition is read as its type, status and reason on the live object, never as a phrase in a message.

StepClaimHeld
1exits 0, prints "created"yes
2ptahmigration/deliveries: Ready=True (HistoryMatched)yes
2exits 0, prints "D-100", prints "D-200", prints "D-300"yes
3exits 0, prints "ADD COLUMN status", prints "UPDATE deliveries", prints "CHECK (statuz"yes
4exits 0, prints "configured"yes
5ptahmigration/deliveries: ApprovalRequired=True (AwaitingApproval)yes
5exits 0, prints "planned: 2 Add Status", prints "created"yes
6exits 0, prints "last run: Partial", prints "unresolved: Partial"yes
6ptahmigration/deliveries: Blocked=True (ApplyOutcomeUnknown)yes
7exits 0, prints "status", prints "open", prints "D-100", prints "D-300"yes
8exits 0, prints "version 1: 1 of 1 statements", prints "version 2: 2 of 3 statements"yes
9exits 0yes
10exits 0, prints "configured"yes
11ptahmigration/deliveries: Ready=True (HistoryMatched)yes
11exits 0, prints "unresolved: none"yes
12exits 0, prints "configured"yes
13ptahmigration/deliveries: ApprovalRequired=True (AwaitingApproval)yes
13exits 0, prints "created"yes
14ptahmigration/deliveries: Ready=True (HistoryMatched)yes
14exits 0, prints "version 2: 3 of 3 statements", prints "1 D-100 open", prints "2 D-200 open", prints "3 D-300 open"yes
14ptahmigration/deliveries: status.lastRun.outcome=Appliedyes

What it ran against

  • Kubernetes1.37.0
  • Operatorc08fc73ebbae
  • Ptahf6e562c
  • Executorsha256:137662e6db45
  • Recorded2026-09-28
  • Scenarios atc08fc73ebbae

What the commands read

A session is repeated, not admired, so every name in it is one your own environment can carry. There is nothing else: no helper of ours stands between a command and the cluster, and the recorder refuses a step that reads a name this list does not hold. The commands arekubectl, ptah and kubectl ptah, each of which you install once; the last one is the operator’s own read-only client.

  • KUBECONFIGthe cluster the commands run against
  • NAMESPACEthe namespace the schema and its database credentials live in
  • OPERATOR_NAMESPACEwhere the chart installed the operator
  • CONTROLLERthe controller Deployment’s name, from that release
  • REGISTRY_IN_CLUSTERthe registry address a Pod in the cluster resolves
  • PTAH_OCI_REGISTRYthe registry address the push goes to
  • PTAH_OCI_USERNAMEthe account that push uses
  • PTAH_OCI_PASSWORDits password, which no command prints

A session also uses demo/bin/lab in two places, and both are the lab handing over what it generated rather than doing the demonstration’s work:lab credentials prints the three registry values above, and lab manifestfills the published template with values the step states. Rundemo/acceptance/reproduce.sh to watch the same scenario repeated from a directory where neither exists, by an account that may not create a Job, with the plan approved by a second account that may create neither a schema nor a Job.

The scenario is demo/scenarios/mysql-partial-failure.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.