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.

Where it starts. The lab's MySQL, empty, with its URL in the Secret demo-mysql-database. Three releases of one sequence sit in demo/mysql: 1 creates the table, 2 adds a migration whose last statement fails, and 3 carries it corrected. Each becomes a ConfigMap, and a manifest per release selects its files.

A replay of a verified run, not a live cluster. 25 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
# Each release becomes a ConfigMap. The resource reads release 1; every run waits for a person.$ for release in 1 2 3; do  kubectl -n "$NAMESPACE" create configmap "deliveries-release-$release" --from-file="demo/mysql/release-$release"donekubectl -n "$NAMESPACE" apply -f demo/manifests/mysql-partial-failure/deliveries-release-1.yamlconfigmap/deliveries-release-1 createdconfigmap/deliveries-release-2 createdconfigmap/deliveries-release-3 createdptahmigration.operator.ptah.run/deliveries created # The operator reads the history and plans release 1.$ kubectl ptah wait migration/deliveries -n "$NAMESPACE" --for approvalptahmigration/deliveries in namespace demo reached approval.  Phase:      AwaitingApproval  Generation: 1, observed by the operator  Database:   last read at <time>  Plan:       ptah-mplan-0c2a2c68bbb7a5bdca60c22ekubectl ptah approve migration/deliveries -n demo reviews it. # Approve it, and wait for the table.$ kubectl ptah approve migration/deliveries -n "$NAMESPACE" --name deliveries-release-1 --waitPtahMigration: demo/deliveries, UID <uid>Plan:          ptah-mplan-0c2a2c68bbb7a5bdca60c22e, UID <uid>Fingerprint:   sha256:0c2a2c68bbb7a5bdca60c22efe8a55cb15481bde6f4f774a740cc877f2e8756f From version 0, in this order:  1                Create Deliveries                   checksum 82d716605f9c339c1c58a6655b77a5e05833e0bc98d79d7862c1700150ce1b10 A migration plan records versions and checksums. Its SQL is read from thesource the plan binds, and it is not shown here:  Source:          ConfigMap  Revision:        <uid>  Content:         sha256:079a0e40ba36056e6cbb18540f50d266042296c22166f6f0030b39d932cfa61a Approve plan ptah-mplan-0c2a2c68bbb7a5bdca60c22e of migration/deliveries in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/deliveries-release-1, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-0c2a2c68bbb7a5bdca60c22e, fingerprint sha256:0c2a2c68bbb7a5bdca60c22efe8a55cb15481bde6f4f774a740cc877f2e8756f.Waiting up to 5m0s for ptahmigration/deliveries in namespace demo to reach applied.ptahmigration/deliveries in namespace demo reached applied.  Phase:      InSync  Generation: 1, observed by the operator  Database:   last read at <time>  Last run:   Applied, Job ptah-m-apply-deliveries-e59100eb510dddbekubectl ptah migration deliveries -n demo shows where it stands. # 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. The manifest names the next ConfigMap and its new files.$ diff -u demo/manifests/mysql-partial-failure/deliveries-release-1.yaml demo/manifests/mysql-partial-failure/deliveries-release-2.yamlkubectl -n "$NAMESPACE" apply -f demo/manifests/mysql-partial-failure/deliveries-release-2.yaml--- demo/manifests/mysql-partial-failure/deliveries-release-1.yaml	2026-11-02 14:04:31.814354479 +0000+++ demo/manifests/mysql-partial-failure/deliveries-release-2.yaml	2026-11-02 14:04:31.814510611 +0000@@ -20,12 +20,16 @@       key: url   artifact:     configMap:-      name: deliveries-release-1+      name: deliveries-release-2       items:         - key: 0000000001_create_deliveries.up.sql           path: 0000000001_create_deliveries.up.sql         - key: 0000000001_create_deliveries.down.sql           path: 0000000001_create_deliveries.down.sql+        - key: 0000000002_add_status.up.sql+          path: 0000000002_add_status.up.sql+        - key: 0000000002_add_status.down.sql+          path: 0000000002_add_status.down.sql   policy:     # Every run waits for a person.     apply: OnApprovalptahmigration.operator.ptah.run/deliveries configured # The plan holds version 2 alone.$ kubectl ptah wait migration/deliveries -n "$NAMESPACE" --for approvalptahmigration/deliveries in namespace demo reached approval.  Phase:      AwaitingApproval  Generation: 2, observed by the operator  Database:   last read at <time>  Plan:       ptah-mplan-f25698dacfe2dbc5408d38bfkubectl ptah approve migration/deliveries -n demo reviews it. # Approve it. The run stops partway, and the wait says it will not end applied.$ kubectl ptah approve migration/deliveries -n "$NAMESPACE" --name deliveries-release-2 --waitPtahMigration: demo/deliveries, UID <uid>Plan:          ptah-mplan-f25698dacfe2dbc5408d38bf, UID <uid>Fingerprint:   sha256:f25698dacfe2dbc5408d38bfd9f1c7a6c556ee7650d8735e0463ae90c7d38b13 From version 1, in this order:  2                Add Status                   checksum 5dee45a150440c6d26354f1467f448fbcf2a02116cd093828f3497a1b192d46b, transaction none A migration plan records versions and checksums. Its SQL is read from thesource the plan binds, and it is not shown here:  Source:          ConfigMap  Revision:        <uid>  Content:         sha256:7b4b9833ad7bd5a6472fbc28f3ed750889cb49aa4c20ddd7e03986d4fa4549f5 Approve plan ptah-mplan-f25698dacfe2dbc5408d38bf of migration/deliveries in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/deliveries-release-2, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-f25698dacfe2dbc5408d38bf, fingerprint sha256:f25698dacfe2dbc5408d38bfd9f1c7a6c556ee7650d8735e0463ae90c7d38b13.Waiting up to 5m0s for ptahmigration/deliveries in namespace demo to reach applied.kubectl-ptah: ptahmigration/deliveries in namespace demo will not reach applied: OutcomeUnknown.  Phase:       Blocked  Why:         the run of plan ptah-mplan-f25698dacfe2dbc5408d38bf (Job ptah-m-apply-deliveries-64aab31cc110e093) ended Partial: its effect on the database is not established, and nothing runs until a reading or a person accounts for itkubectl ptah migration deliveries -n demo shows where it stands. # The last run ended Partial, and an unresolved run holds the resource. Two different records.$ kubectl ptah migration deliveries -n "$NAMESPACE"Migration:            demo/deliveriesPhase:                BlockedReady:                False, ApplyOutcomeUnknown: The database has to be read by a person before anything else runsBlocked:              True, ApplyOutcomeUnknown: Migration 2 (Add Status) committed 2 of its 3 statements, then failed with SQLSTATE 42S22 (error 1054); retrying the file would run them twicePolicy:               apply OnApprovalSource:               ConfigMap deliveries-release-2Snapshot:             ptah-src-a704e2e5d93c0d3b2400ad66Content:              sha256:7b4b9833ad7bd5a6472fbc28f3ed750889cb49aa4c20ddd7e03986d4fa4549f5History read:         <time>Current version:      1Applied:              1Pending:              1 Last run:             Partial, started <time>, finished <time>  Job:                ptah-m-apply-deliveries-64aab31cc110e093, UID <uid>  Stopped at:         version 2, statement 3 of 3, SQLSTATE 42S22, error 1054  Message:            Migration 2 (Add Status) committed 2 of its 3 statements, then failed with SQLSTATE 42S22 (error 1054); retrying the file would run them twice Unresolved run:       Partial, recorded <time>  Operation:          sha256:151243805e3380a3c3573f4fb4b88cf1e91f19dc5bd6ed16d8ac0fa684cf2659  Job:                ptah-m-apply-deliveries-64aab31cc110e093, UID <uid>  Plan:               ptah-mplan-f25698dacfe2dbc5408d38bf, UID <uid>  Database:           sha256:19b4737d483e05cc9145bd320a83771542b09042d718e21ad66ef5f71dade553 (target identity digest)  While this record stands no plan is published and no Apply is dispatched;  resolve, verify and the history read go on.  The database is the one the run reported reaching or, where it reported  nothing, the one it was dispatched against: where to start looking, not  proof of where the run went. A reader who inspects the wrong database finds  nothing wrong and settles a record that was telling the truth.  Establish what the run did there first. A history read of that database that  finds nothing of the artifact left to apply clears the record by itself. If  the database is accounted for and still has work pending, settle it with a  PtahMigrationRunAcknowledgment naming migration UID  <uid> and the operation above.  The operations guide has the procedure, under "A migration run nobody  accounted for". No plan is published. Approvals, newest first:  deliveries-release-2    approved by kubernetes-admin at <time>    plan ptah-mplan-f25698dacfe2dbc5408d38bf, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched  deliveries-release-1    approved by kubernetes-admin at <time>    plan ptah-mplan-0c2a2c68bbb7a5bdca60c22e, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched Run acknowledgments: none Conditions:  TYPE              STATUS  REASON               SINCE                 MESSAGE  Progressing       False   ApplyOutcomeUnknown  <time>                The run stopped and may not be retried  Blocked           True    ApplyOutcomeUnknown  <time>                Migration 2 (Add Status) committed 2 of its 3 statements, then failed with SQLSTATE 42S22 (error 1054); retrying the file would run them twice  Ready             False   ApplyOutcomeUnknown  <time>                The database has to be read by a person before anything else runs  ApprovalRequired  False   Satisfied            <time>                The plan's approval requirements are satisfied kubectl events -n demo --for ptahmigration/deliveries prints its Events. # 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 ORDER BY id"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 -e \  "SELECT version, applied, total, state FROM schema_migrations ORDER BY version"version	applied	total	state1	1	1	applied2	2	3	failed # Disposable lab only: undo both statements by hand. A real database needs them 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.$ kubectl -n "$NAMESPACE" apply -f demo/manifests/mysql-partial-failure/deliveries-release-1.yamlptahmigration.operator.ptah.run/deliveries configured # Its next reading of the history finds nothing to apply, and that reading clears the record.$ kubectl ptah wait migration/deliveries -n "$NAMESPACE" --for in-syncptahmigration/deliveries in namespace demo reached in-sync.  Phase:      InSync  Generation: 3, observed by the operator  Database:   last read at <time>kubectl ptah migration deliveries -n demo shows where it stands. # The hold is gone, and the resource says what settled it.$ kubectl ptah migration deliveries -n "$NAMESPACE"Migration:            demo/deliveriesPhase:                InSyncReady:                True, HistoryMatched: The database has every migration this artifact carriesPolicy:               apply OnApprovalSource:               ConfigMap deliveries-release-1Snapshot:             ptah-src-a37f5834c12f8c00424f4943Content:              sha256:079a0e40ba36056e6cbb18540f50d266042296c22166f6f0030b39d932cfa61aHistory read:         <time>Current version:      1Applied:              1Pending:              0 Last run:             Partial, started <time>, finished <time>  Job:                ptah-m-apply-deliveries-64aab31cc110e093, UID <uid>  Stopped at:         version 2, statement 3 of 3, SQLSTATE 42S22, error 1054  Message:            Migration 2 (Add Status) committed 2 of its 3 statements, then failed with SQLSTATE 42S22 (error 1054); retrying the file would run them twice Settled run:          Partial run sha256:151243805e3380a3c3573f4fb4b88cf1e91f19dc5bd6ed16d8ac0fa684cf2659, by a history read at <time> No plan is published. Approvals, newest first:  deliveries-release-2    approved by kubernetes-admin at <time>    plan ptah-mplan-f25698dacfe2dbc5408d38bf, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched  deliveries-release-1    approved by kubernetes-admin at <time>    plan ptah-mplan-0c2a2c68bbb7a5bdca60c22e, not the current plan    TYPE      STATUS  REASON             SINCE                 MESSAGE    Consumed  True    DispatchCommitted  <time>                The approved plan was dispatched Run acknowledgments: none Conditions:  TYPE              STATUS  REASON          SINCE                 MESSAGE  Progressing       False   HistoryMatched  <time>                Nothing is pending  Blocked           False   HistoryMatched  <time>                The history continues this artifact  Ready             True    HistoryMatched  <time>                The database has every migration this artifact carries  ApprovalRequired  False   Satisfied       <time>                The plan's approval requirements are satisfied (written for generation 2) kubectl events -n demo --for ptahmigration/deliveries prints its Events. # Release 3 carries the corrected migration. Point the resource at it.$ diff -u demo/manifests/mysql-partial-failure/deliveries-release-2.yaml demo/manifests/mysql-partial-failure/deliveries-release-3.yamlkubectl -n "$NAMESPACE" apply -f demo/manifests/mysql-partial-failure/deliveries-release-3.yaml--- demo/manifests/mysql-partial-failure/deliveries-release-2.yaml	2026-11-02 14:04:31.814510611 +0000+++ demo/manifests/mysql-partial-failure/deliveries-release-3.yaml	2026-11-02 14:04:31.814510611 +0000@@ -20,7 +20,7 @@       key: url   artifact:     configMap:-      name: deliveries-release-2+      name: deliveries-release-3       items:         - key: 0000000001_create_deliveries.up.sql           path: 0000000001_create_deliveries.up.sqlptahmigration.operator.ptah.run/deliveries configured # Its plan holds the corrected version 2.$ kubectl ptah wait migration/deliveries -n "$NAMESPACE" --for approvalptahmigration/deliveries in namespace demo reached approval.  Phase:      AwaitingApproval  Generation: 4, observed by the operator  Database:   last read at <time>  Plan:       ptah-mplan-a09bad360e54ebfce9cb7c6fkubectl ptah approve migration/deliveries -n demo reviews it. # Approve it the same way.$ kubectl ptah approve migration/deliveries -n "$NAMESPACE" --name deliveries-release-3 --waitPtahMigration: demo/deliveries, UID <uid>Plan:          ptah-mplan-a09bad360e54ebfce9cb7c6f, UID <uid>Fingerprint:   sha256:a09bad360e54ebfce9cb7c6fd2ee380b32b011740e220c9307d25c3a5956a2e2 From version 1, in this order:  2                Add Status                   checksum 0f213750642209f38a779a6ef7bac1c8997f339b51db4e59b013c12d5b59e5cb, transaction none A migration plan records versions and checksums. Its SQL is read from thesource the plan binds, and it is not shown here:  Source:          ConfigMap  Revision:        <uid>  Content:         sha256:41016098228262638ebd6b8dedbab65b55a7a979d0bb8c0a30cf218bc686c2e3 Approve plan ptah-mplan-a09bad360e54ebfce9cb7c6f of migration/deliveries in demo? Type yes to approve: yesApproved: PtahMigrationApproval demo/deliveries-release-3, recorded by admission as kubernetes-admin at <time>.It binds plan ptah-mplan-a09bad360e54ebfce9cb7c6f, fingerprint sha256:a09bad360e54ebfce9cb7c6fd2ee380b32b011740e220c9307d25c3a5956a2e2.Waiting up to 5m0s for ptahmigration/deliveries in namespace demo to reach applied.ptahmigration/deliveries in namespace demo reached applied.  Phase:      InSync  Generation: 4, observed by the operator  Database:   last read at <time>  Last run:   Applied, Job ptah-m-apply-deliveries-a90ffd19c655420fkubectl ptah migration deliveries -n demo shows where it stands. # All three statements ran this time, and the three deliveries are still there.$ kubectl -n "$NAMESPACE" exec deploy/demo-mysql-client -- mysql demo -e \  "SELECT version, applied, total, state FROM schema_migrations ORDER BY version;   SELECT id, reference, status FROM deliveries ORDER BY id"version	applied	total	state1	1	1	applied2	3	3	appliedid	reference	status1	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 "configmap/deliveries-release-1 created", prints "configmap/deliveries-release-2 created", prints "configmap/deliveries-release-3 created", prints "deliveries created"yes
2exits 0, prints "reached approval"yes
2ptahmigration/deliveries: ApprovalRequired=True (AwaitingApproval)yes
3exits 0, prints "Create Deliveries", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied"yes
4exits 0, prints "1\tD-100", prints "2\tD-200", prints "3\tD-300"yes
5exits 0, prints "ADD COLUMN status", prints "UPDATE deliveries", prints "CHECK (statuz"yes
6exits 0, prints "+ name: deliveries-release-2", prints "deliveries configured"yes
7exits 0, prints "reached approval"yes
7ptahmigration/deliveries: ApprovalRequired=True (AwaitingApproval)yes
8exits 8, prints "Add Status", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "will not reach applied", prints "Partial"yes
9exits 0, prints "Last run: Partial", prints "Unresolved run: Partial", prints "no Apply is dispatched"yes
9ptahmigration/deliveries: Blocked=True (ApplyOutcomeUnknown)yes
10exits 0, prints "1\tD-100\topen", prints "3\tD-300\topen"yes
11exits 0, prints "version\tapplied\ttotal\tstate", prints "1\t1\t1\tapplied", prints "2\t2\t3\t"yes
12exits 0yes
13exits 0, prints "deliveries configured"yes
14exits 0, prints "reached in-sync"yes
14ptahmigration/deliveries: Ready=True (HistoryMatched)yes
15exits 0, prints "Settled run:", prints "by a history read", prints "Last run: Partial", never prints "Unresolved run:"yes
16exits 0, prints "+ name: deliveries-release-3", prints "deliveries configured"yes
17exits 0, prints "reached approval"yes
17ptahmigration/deliveries: ApprovalRequired=True (AwaitingApproval)yes
18exits 0, prints "Add Status", prints "Type yes to approve: yes", prints "Approved: PtahMigrationApproval", prints "reached applied"yes
19exits 0, prints "2\t3\t3\tapplied", prints "1\tD-100\topen", prints "2\tD-200\topen", prints "3\tD-300\topen"yes
19ptahmigration/deliveries: status.lastRun.outcome=Appliedyes

What it ran against

  • Kubernetes1.37.0
  • Operator42522be3fd5a
  • Ptahv0.17.0
  • Executorsha256:181e27972091
  • Recorded2026-11-02
  • Scenarios at42522be3fd5a

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 client. They run from the root of a clone of this repository, which holds the files they name: the SQL under demo/schemas, demo/migrations,demo/migrations-checkpoint and demo/mysql, the reference data underdemo/reference, and the manifests under demo/manifests.

  • KUBECONFIGthe cluster the commands run against
  • NAMESPACEthe namespace the resources and their database credentials live in
  • OPERATOR_NAMESPACEwhere the chart installed the operator
  • CONTROLLERthe controller Deployment’s name, from that release
  • EXECUTOR_IMAGEthe executor image the chart runs, its executorImage value
  • 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, read by ptah itself
  • PTAH_OCI_PASSWORDits password, which no command prints

The last two are how a CI job hands ptah a registry credential. On a workstation,ptah oci login stores it once instead, reading the password from a prompt or from standard input rather than from the command line.

The registry has two addresses, the way a registry in an ordinary deployment does: a push goes to PTAH_OCI_REGISTRY, and the resource names REGISTRY_IN_CLUSTER with the same repository and digest. The registry Secret a resource names says which address its credential is for, and the operator sends it to no other.

The lab itself appears in one command, demo/bin/lab versions in Stand up the lab, which says what the lab is made of. What a run starts from is its preparation, which is not recorded; Where it starts above says what that is and which run ends there. Rundemo/acceptance/reproduce.sh to watch Approve a plan repeated from a directory where the lab’s scripts do not exist, 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, and then the same schema published with ptah schema push and moved to its next revision withkubectl ptah set-source.

The same changes, as edits

Each kubectl ptah command that changes a resource changes one field of it, so the same change is an ordinary edit, with kubectl edit ptahschema storefront -n "$NAMESPACE"or in a manifest you apply; the runs that keep their resources as files underdemo/manifests make their changes that way. What the commands add is checking: set-source takes only a reference pinned by digest, changes nothing while an Apply may be running, and changes nothing another writer changed since it read the resource.

CommandThe line it changes
kubectl ptah suspendspec.suspend: true
kubectl ptah resumespec.suspend: false
kubectl ptah set-source --ocispec.desired.ociRef of a schema, spec.artifact.ociRef of a migration
kubectl ptah refreshthe annotation operator.ptah.run/read-requested-at, set to the time of the request

An approval is not one of them. It is an object of its own, created once by the person who decided: kubectl ptah approve --dry-run client prints it as YAML for a repository, to be created with kubectl create, and never edited afterwards.

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.