Skip to content

fix(migration): make option_type migration self-sufficient - #3603

Merged
Chartman123 merged 1 commit into
mainfrom
fix/3562-guard-option-type-migration
Aug 25, 2026
Merged

fix(migration): make option_type migration self-sufficient#3603
Chartman123 merged 1 commit into
mainfrom
fix/3562-guard-option-type-migration

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Aug 25, 2026

Copy link
Copy Markdown
Member

Version050300Date20260716000000 guarded changeSchema() against a missing option_type column but ran an unguarded UPDATE on it in postSchemaChange(), aborting occ upgrade on any instance where the column was absent. Create the column when missing and guard the backfill in both migrations.

Fixes #3562

Assisted-by: Claude Code:claude-opus-5


Forms 5.3.4 introduced a database migration that is internally inconsistent: it explicitly tolerates the option_type column being missing during the schema phase, then unconditionally writes to that same column during the data phase, so any instance lacking the column fails the upgrade outright. Because Nextcloud records a migration as executed only after it completes, the failure repeats on every retry and leaves the instance unreachable until an administrator disables the Forms app by hand — which is exactly the loop three independent reporters describe on both PostgreSQL and MySQL. Investigation ruled out the obvious suspects (missing file in the released tarball, packaging exclusions, migration ordering, branch divergence) and established that affected instances have the column-creating migration recorded in oc_migrations while the column itself is absent, a desynchronisation that Nextcloud's schema-only install path can produce because it marks migrations as executed without per-migration verification. The defect escaped CI because all test workflows install the app from scratch, which never exercises the incremental-upgrade code path where these hooks run. The fix makes the migration self-sufficient — it recreates the column when missing and guards the backfill — and should ship as 5.3.6 with a backport to stable5.3; adding a CI job that performs a real version-to-version upgrade would prevent this whole class of defect from recurring.


Details

Analysis: #3562 — upgrade aborts with "column option_type does not exist"

Date: 2026-08-25
Analysed by: Andy Scherzinger (Assisted by Claude Code)
Affected app versions: Forms 5.3.3 / 5.3.4 / 5.3.5 (migration 050300Date20260716000000 shipped in 5.3.4)
Reported upgrade paths: 5.2.7 → 5.3.5, 5.3.2 → 5.3.5
Affected databases: PostgreSQL (SQLSTATE 42703) and MySQL/MariaDB (1054) — both reported
Severity: High — aborts occ upgrade, leaves the instance unreachable until the app is disabled


1. Symptom

Exception: Database error when running migration 050300Date20260716000000 for app forms
An exception occurred while executing a query:
SQLSTATE[42703]: Undefined column: 7 ERROR: column "option_type" does not exist
LINE 1: ...oc_forms_v2_options" SET "option_type" = $1 WHERE "option_ty...

Reporters describe a loop: the upgrade fails, the reverse proxy returns bad gateway, restarting the
container and re-running occ upgrade reproduces the identical error. Recovery required
occ app:disable forms.

2. Root cause — the migration is self-contradictory

lib/Migration/Version050300Date20260716000000.php:

public function changeSchema(...): ?ISchemaWrapper {
    $table = $schema->getTable('forms_v2_options');
    if ($table->hasColumn('option_type')) {      // tolerates the column being ABSENT
        $column = $table->getColumn('option_type');
        if ($column->getDefault() === null) {
            $column->setDefault('choice');
            $changed = true;
        }
    }
    return $changed ? $schema : null;             // returns null -> no DDL is emitted at all
}

public function postSchemaChange(...): void {
    $qbUpdate->update('forms_v2_options')
        ->set('option_type', $qbUpdate->createNamedParameter('choice'))
        ->where($qbUpdate->expr()->isNull('option_type'))
        ->executeStatement();                     // assumes the column EXISTS — unguarded
}

The schema phase explicitly handles the case where option_type is missing and emits no DDL.
The data phase then unconditionally runs an UPDATE against that same column. On any instance
where the column is absent, this is a guaranteed hard failure.

Two aggravating properties:

  • Not recoverable by retry. MigrationService::executeStep() calls markAsExecuted() last,
    so the version is never recorded and every subsequent occ upgrade re-runs and re-fails.
  • Not self-healing. The migration cannot create the column it depends on, so there is no path
    forward without manual DBA intervention or disabling the app.

3. Key deduction — oc_migrations and the schema are out of sync

option_type is created by Version050300Date20250914000000, added in 5.3.0 by the Grid feature
commit ff31614b and unchanged since.

MigrationService::sortMigrations() orders 050300Date20250914000000 strictly before
050300Date20260716000000. Therefore, if the creating migration had been pending, it would have
run first and created the column.
The crash surfacing in 050300Date20260716000000 proves that on
affected instances:

oc_migrations contains a row forms / 050300Date20250914000000, while
oc_forms_v2_options does not contain the option_type column.

Diagnostic to confirm with reporters

SELECT version FROM oc_migrations WHERE app = 'forms' AND version LIKE '0503%' ORDER BY version;

PostgreSQL: \d oc_forms_v2_options — MySQL/MariaDB: SHOW COLUMNS FROM oc_forms_v2_options;

Expected on an affected instance: the 050300Date20250914000000 row is present, the column is not.

Implication for the "5.2.7 → 5.3.5" report

A clean 5.2.7 instance has no 050300* rows at all (verified: v5.2.7 and v5.2.10 ship 34
migrations, none of them 050300*). Such an instance would run the creating migration normally.
Any instance reporting this failure must therefore have been on 5.3.x at some point, or otherwise
have acquired the oc_migrations row without the schema change.

4. Causes ruled out

Hypothesis Verdict Evidence
Migration file missing from the released tarball Ruled out Downloaded and unpacked forms-v5.3.2.tar.gz and forms-v5.3.5.tar.gz from nextcloud-releases/forms; Version050300Date20250914000000.php is present in both
Packaging excludes lib/Migration Ruled out Makefile appstore target includes lib/ wholesale; no .gitattributes / export-ignore, no krankerl.toml
Migration ordering bug Ruled out sortMigrations() parses (\d+)Date(\d+), compares version as int then date via strnatcmp; 20250914 sorts before 20260716
Branch divergence / renamed or removed migration Ruled out Tag-by-tag diff of lib/Migration/: v5.2.10 → v5.3.0 adds 4 migrations, v5.3.2 → v5.3.5 adds exactly 20260713180000 and 20260716000000; nothing removed or renamed
A migration drops the column or recreates the table Ruled out No dropColumn('option_type') and no dropTable('forms_v2_options') anywhere in lib/Migration/
Corrupt single instance Unlikely Three independent reporters, two different database engines, one claiming a clean-room reproduction

5. How instances reach the desynchronised state

Installer::installAppLastSteps() (Nextcloud 33):

$previousVersion = $this->config->getAppValue($info['id'], 'installed_version', '');
$ms->migrate('latest', $previousVersion === '');   // schemaOnly = true on first install

MigrationService::migrateSchemaOnly():

$toSchema = null;
foreach ($toBeExecuted as $version) {
    $toSchema = $instance->changeSchema($output, function () use ($toSchema): ISchemaWrapper {
        return $toSchema ?: new SchemaWrapper($this->connection);
    }, [...]) ?: $toSchema;
}
if ($toSchema instanceof SchemaWrapper) { /* ... single migrateToSchema() ... */ }
foreach ($toBeExecuted as $version) {
    $this->markAsExecuted($version);               // unconditional
}

Relevant consequences:

  1. Marking is unconditional and unverified. All pending versions are recorded as executed
    regardless of whether the batched migrateToSchema() actually applied their changes. There is no
    per-migration verification.
  2. postSchemaChange never runs on a first install. The backfill inside
    Version050300Date20250914000000 is skipped by design on this path — which is precisely the gap
    Version050300Date20260716000000 was written to close, but it was written assuming the schema
    half had always succeeded.
  3. Latent hazard in the accumulation loop. The closure resolves to
    $toSchema ?: new SchemaWrapper(...). Any migration that mutates the schema and returns null
    before $toSchema has been seeded has its changes silently discarded while still being marked
    executed. Forms does not trip this today (the first migration returns $schema), but it is the
    class of defect that produces exactly this symptom.

Realistic real-world paths to the desynchronised state: a schema-only install or re-enable where the
batched DDL did not materialise; an app remove/re-add or a database restore that leaves
oc_migrations rows out of step with the tables; or a partially applied batch.

6. Why CI did not catch it

The phpunit-mysql / phpunit-pgsql / phpunit-mariadb / phpunit-oci / phpunit-sqlite
workflows install the app fresh, which is the schemaOnly = true path — postSchemaChange is
never executed there. The incremental upgrade path (old version installed → files replaced →
occ upgrade) is the only one that runs these hooks and it is not covered by any workflow.

7. Implemented fix

Version050300Date20260716000000 is made self-sufficient rather than dependent on its predecessor
having succeeded: changeSchema() creates option_type when it is missing, and
postSchemaChange() returns early when the column is still absent. The same postSchemaChange
guard is added to Version050300Date20250914000000, which also gains an explicit 'length' => 255
instead of relying on the DBAL default for a length-less Types::STRING.

Target main, backport to stable5.3, release as 5.3.6.

8. General rules to carry forward

  • A postSchemaChange that touches a column must never be reachable on a code path where its own
    changeSchema permitted that column to be absent.
  • A migration whose job is to repair data written by an earlier migration must be able to recreate
    the schema it depends on. "The earlier migration is recorded in oc_migrations" is not a
    guarantee that its DDL landed.
  • Consider adding a CI job that exercises a real incremental upgrade (install release N-1, swap in
    the working tree, run occ upgrade) so pre-/postSchemaChange hooks are covered.

9. Workaround for affected administrators

Until a fixed release is available, add the column manually and re-run the upgrade (adjust the oc_
prefix to match dbtableprefix):

ALTER TABLE oc_forms_v2_options ADD COLUMN option_type VARCHAR(255) DEFAULT 'choice';
UPDATE oc_forms_v2_options SET option_type = 'choice' WHERE option_type IS NULL;

Then run occ upgrade followed by occ app:enable forms.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

Version050300Date20260716000000 guarded changeSchema() against a missing
option_type column but ran an unguarded UPDATE on it in postSchemaChange(),
aborting occ upgrade on any instance where the column was absent. Create the
column when missing and guard the backfill in both migrations.

Fixes #3562

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger AndyScherzinger added the 2. developing Work in progress label Aug 25, 2026
@Chartman123
Chartman123 marked this pull request as ready for review August 25, 2026 13:14
@Chartman123
Chartman123 enabled auto-merge August 25, 2026 13:14
@Chartman123

Copy link
Copy Markdown
Collaborator

/backport to stable5.3

@backportbot backportbot Bot added the backport-request Pending backport by the backport-bot label Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/Migration/Version050300Date20260716000000.php 0.00% 15 Missing ⚠️
lib/Migration/Version050300Date20250914000000.php 0.00% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Chartman123
Chartman123 merged commit 90199fa into main Aug 25, 2026
53 of 54 checks passed
@Chartman123
Chartman123 deleted the fix/3562-guard-option-type-migration branch August 25, 2026 13:45
@backportbot backportbot Bot removed the backport-request Pending backport by the backport-bot label Aug 25, 2026
@AndyScherzinger AndyScherzinger added 4. to release Ready to be released and/or waiting for tests to finish and removed 2. developing Work in progress labels Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4. to release Ready to be released and/or waiting for tests to finish

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Database error when upgrading from 5.3.2 to 5.3.5: Column not found: 1054 Unknown column 'option_type' in 'SET'

2 participants