fix(db): restore the PostgreSQL upgrade path #4
Reference in New Issue
Block a user
Delete Branch "benvin/fix-postgres-upgrade"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The v12.0 rebase replaced the PostgreSQL provider initial migration instead of adding to it, so an existing database keeps the pre-12.0 schema, the code migrations run against it and startup aborts on a missing
Users.NormalizedUsername.20260305010333_InitialPostgreSqlas the baseline20260306000000_UpgradeToServer12Schemacarrying it to the 12.0 model20260524120336_AddUniqueNormalizedUsernameIndexafter the code migration that fills the column inReview: restore the PostgreSQL upgrade path
Reviewed as an irreversible change against live data. I rebuilt the branch and ran things rather than reading only.
What I verified by running it
cmpof20260305010333_InitialPostgreSql.csand.Designer.csagainst3bc45ff92d— identical.git log --all --name-statusover the migrations directory shows3bc45ff92dis the only commit that ever wrote those files, so that is what created the live databases and the recorded history id matches.dotnet ef migrations scriptonorigin/main(single regenerated baseline) and on this branch (3-migration chain), applied each to its own database, comparedinformation_schema.columns,pg_indexes,pg_constraint,pg_trigger,pg_sequences: 312 / 95 / 60 / 0 / 12 rows, all identical, and the seeded…0001placeholder row is identical including the correcteddetachedspelling.20260524120336_AddUniqueNormalizedUsernameIndex.Designer.csis identical toJellyfinDbContextModelSnapshot.cs;20260306000000's designer differs only by the missingHasIndex("NormalizedUsername").IsUnique().HasPendingModelChanges()is false.JellyfinMigrationAttribute.cs:37assignsStage = AppInitialisationin the constructor body, which runs after the:52property initialiser and therefore wins — soCoreInitialisationis opt-in, not the default.JellyfinMigrationService.cs:218-223enumerates EF migrations only forCoreInitialisation, and:236merges both into one ordinal sort. Exactly two routines opt in (20260522092304_UpdateNormalizedUsername,20260531160000_DisableLegacyAuthorization); everything else, includingMigrateLinkedChildren,CleanupOrphanedExtrasandFixIncorrectOwnerIdRelationships, runs after the whole EF chain, which is what those need. The merge loop is upstream code, not a fork patch.PostgreSqlMigrationOrderingTestsis a real regression test. Ported ontoorigin/mainand ran it: both facts fail, withSchema migration 20260911134055_InitialPostgreSql has to be ordered before code migration 20260522092304_UpdateNormalizedUsername.START TRANSACTION … COMMIT, and I confirmed a mid-migration failure rolls back completely (column stilltext, only the baseline recorded).20260113233500_DropExtraIdsColumn,20260113233000_AddForeignKeyToOwnerId(same placeholder repoint SQL),20260815063607_RemoveOrphanedUserPermissionsAndPreferences(same two deletes),20260113203012_ChangeOwnerIdToGuid/20260215201634_ChangePrimaryVersionIdToGuid.ExtraIdsandOriginalLanguageare genuinely independent — the old baseline has onlyExtraIds(…InitialPostgreSql.cs:111), the v12 model onlyOriginalLanguage— andMigrateLibraryDb.cs:1213documentsExtraIdsas superseded by theOwnerIdrelation, so the drop+add is right and loses nothing v12 reads.tests/Jellyfin.Database.Tests.PostgreSQL: 2 failed / 7 passed on this branch and onorigin/main, same two tests (PostgreSqlProviderTests.Crud_DisplayPreferences,PurgeDatabase_EmptiesTablesAndResetsFkRole). Not regressions.Jellyfin.Server.Tests.Migrationspass on a real container here.I also seeded nastier legacy databases than the suite does. Results below.
Findings
1 — Medium. Two usernames differing only by case wedge the upgrade past the point of no return.
Migrations/20260524120336_AddUniqueNormalizedUsernameIndex.cs:13, withRoutines/20260522092304_UpdateNormalizedUsername.cs:38.Reproduced: seed
Benandben, run the chain —20260306000000commits, the code migration commits, thenCREATE UNIQUE INDEXfails with SQLSTATE23505and startup aborts on every restart. At that pointBaseItems.OwnerIdis alreadyuuid, so the previous image cannot be rolled back into either.Users.Usernameis unique but case-sensitive in PostgreSQL, so this is possible on a real instance. Upstream has the same hazard; the difference is that these two databases are live.Fix: before deploying, run on both databases
SELECT upper("Username"), count(*) FROM "Users" GROUP BY 1 HAVING count(*) > 1;and rename any collision. Better, make the fill-in disambiguate instead of failing.2 — Medium. The suite that proves this fix does not run in CI.
.woodpecker/ci.yaml:28filtersCategory!=RequiresDocker, andPostgreSqlUpgradeTests.cs:27carries that trait. Green CI here only exercisedPostgreSqlMigrationOrderingTestsand theEncodingOptionstests — the upgrade proof was never executed by the pipeline.Fix: add a step running
--filter "Category=RequiresDocker"on a docker-capable backend (it would also pick up the existingJellyfin.Database.Tests.PostgreSQLsuite).3 — Low/Medium. A 32-hex id with non-standard hyphen placement passes the guard and then aborts the migration.
20260306000000_UpgradeToServer12Schema.cs:127and:138.replace(x,'-','') ~ '^[0-9a-fA-F]{32}$'accepts e.g.1111111-11111-1111-1111-111111111111, which::uuidrejects. Reproduced: the migration throws and rolls back cleanly, so nothing is corrupted, but startup is wedged until someone runs SQL by hand. Everything genuinely junk (empty string, braces, leading space, non-hex, all-zero) is correctly nulled — I checked all of those.Fix: make the
USINGclause total rather than pre-filtering, e.g.USING CASE WHEN "OwnerId" ~ '^[0-9a-fA-F]{32}$' OR "OwnerId" ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN "OwnerId"::uuid END.4 — Low. Divergence from the SQLite chain for
OwnerId = …0001.SQLite
20260113203012_ChangeOwnerIdToGuidnullsOwnerIdwhere it already equals the detached placeholder, before the repoint;20260306000000_UpgradeToServer12Schema.cs:136-143does not. Reproduced: such a row keeps…0001, soRoutines/20260113230000_CleanupOrphanedExtras.cs:57then deletes the item, whereas on SQLite it survives with a null owner. Nothing pre-12 writes that value, so it is unlikely here, but it is a silent delete.Fix: add
UPDATE "BaseItems" SET "OwnerId" = NULL WHERE "OwnerId" IN ('00000000-0000-0000-0000-000000000000','00000000-0000-0000-0000-000000000001');before the cast.5 — Low. The repoint assumes the
…0001row exists.20260306000000_UpgradeToServer12Schema.cs:307-320. I deleted that row and re-ran:AddForeignKeyfails withKey (OwnerId)=(00000000-0000-0000-0000-000000000001) is not present in table "BaseItems"and the migration rolls back, wedging startup. The baseline seeds it so it should be present, but oneINSERT … ON CONFLICT DO NOTHINGbefore the repoint removes the failure mode.6 — Low, deployment. There is no automatic rollback on PostgreSQL.
PostgreSqlDatabaseProvider.cs:71-84:MigrationBackupFastreturns a sentinel andRestoreBackupFastis a no-op, soJellyfinMigrationService's failure path restores nothing. Each EF migration is individually atomic, but the chain plus the ~15AppInitialisationroutines is not, andCleanupOrphanedExtrasdeletes rows. Take a verifiedpg_dumpof both databases immediately before deploying rather than relying on the CronJob schedule.7 — Minor. The
encoding.xmlfix is a separate concern.MediaBrowser.Model/Configuration/EncodingOptions.cs:240-256. The diagnosis is right —BaseConfigurationManager.cs:304-320catches and returns defaults, so one bad element discards every encoding setting — and the XML/JSON split is correct. It is still an unrelated subsystem and belongs in its own PR. Separately,Enum.TryParseaccepts numeric strings, so<EncoderPreset>42</EncoderPreset>yields an undefined enum value instead ofauto; add anEnum.IsDefinedcheck.8 — Nit. Test gaps.
PostgreSqlUpgradeTests.cs:315hand-codes the startup order instead of drivingJellyfinMigrationService— acceptable, since it uses the realUpdateNormalizedUsernameroutine and the ordering is asserted separately.LegacyDatabaseAndFreshInstall_EndUpWithTheSameSchema:137compares chain-against-chain, not chain-against-the-v12-model, so it cannot catch the whole chain drifting together; the stronger comparison against the model-generated baseline is the one that actually proves the claim. It also omitspg_constraint, so an FK or PK difference would slip through.Verdict
The migration logic is correct and the central equivalence claim holds under independent reproduction. Nothing here is a blocker. Before it touches the live databases: run the username-collision query from finding 1, take a fresh
pg_dumpof both, and note that once20260306000000commits there is no going back to the previous image.