{"record":{"id":"02307f427c741286","repo":"juanfont/headscale","slug":"saving-user-w","errorCode":null,"errorMessage":"saving user: %w","messagePattern":"saving user: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"hscontrol/db/db.go","lineNumber":233,"sourceCode":"\t\t\t\t},\n\t\t\t\tRollback: func(db *gorm.DB) error { return nil },\n\t\t\t},\n\t\t\t// Fix the provider identifier for users that have a double slash in the\n\t\t\t// provider identifier.\n\t\t\t{\n\t\t\t\tID: \"202505141324\",\n\t\t\t\tMigrate: func(tx *gorm.DB) error {\n\t\t\t\t\tusers, err := ListUsers(tx, nil)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"listing users: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\tfor _, user := range users {\n\t\t\t\t\t\tuser.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String)\n\n\t\t\t\t\t\terr := tx.Save(user).Error\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn fmt.Errorf(\"saving user: %w\", err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t\tRollback: func(db *gorm.DB) error { return nil },\n\t\t\t},\n\t\t\t// v0.27.0\n\t\t\t// Schema migration to ensure all tables match the expected schema.\n\t\t\t// This migration recreates all tables to match the exact structure in schema.sql,\n\t\t\t// preserving all data during the process.\n\t\t\t// Only SQLite will be migrated for consistency.\n\t\t\t{\n\t\t\t\tID: \"202507021200\",\n\t\t\t\tMigrate: func(tx *gorm.DB) error {\n\t\t\t\t\t// Only run on SQLite\n\t\t\t\t\tif cfg.Database.Type != types.DatabaseSqlite {\n\t\t\t\t\t\tlog.Info().Msg(\"skipping schema migration on non-SQLite database\")","sourceCodeStart":215,"sourceCodeEnd":251,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/db.go#L215-L251","documentation":"Migration 202505141324 failed saving a user after applying CleanIdentifier to the provider identifier. tx.Save(user) rewrites the full row, so failure can be the save itself (lock, connection) or a constraint violation: the unique partial index on provider_identifier rejects the cleaned value because another user already owns it.","triggerScenarios":"Two users whose identifiers differ only by duplicate slashes (e.g. 'https://idp//a' and 'https://idp/a') - cleaning both produces identical values, violating idx_provider_identifier. Also plain connection/lock failures mid-loop.","commonSituations":"OIDC providers that changed their identifier format over time, creating near-duplicate accounts that collapse after cleaning.","solutions":["Check the wrapped error for a unique-constraint violation on provider_identifier.","If duplicate users result from the cleanup, merge or delete the redundant user in the source IdP / database, then re-run.","For transient errors, retry the migration - each Save is independent within the transaction.","Always back up the DB before upgrading past v0.25 so this migration can be re-attempted."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"-- Pre-upgrade check on a backup copy: will cleaning collide?\nSELECT provider_identifier, COUNT(*) FROM users\nWHERE provider_identifier LIKE '%//%'\nGROUP BY provider_identifier;","typeGuard":null,"tryCatchPattern":"if _, err := db.NewHeadscaleDatabase(cfg); err != nil {\n    if strings.Contains(err.Error(), \"saving user\") && strings.Contains(err.Error(), \"UNIQUE\") {\n        // identifier collision after cleaning: restore backup, merge duplicate IdP accounts, retry\n    }\n}","preventionTips":["Deduplicate OIDC accounts that differ only by duplicate slashes before upgrading.","Always snapshot the database before migrations that rewrite user rows.","Watch server logs during first startup after upgrade; intervene on constraint errors."],"tags":["database","migration","user","unique-constraint","oidc"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}