{"record":{"id":"abc8c88f08ca5672","repo":"juanfont/headscale","slug":"clearing-user-id-on-tagged-nodes-w","errorCode":null,"errorMessage":"clearing user_id on tagged nodes: %w","messagePattern":"clearing user_id on tagged nodes: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"hscontrol/db/db.go","lineNumber":725,"sourceCode":"\t\t\t\t// tagged, and the ON DELETE CASCADE FK would destroy the\n\t\t\t\t// tagged nodes if the user were deleted.\n\t\t\t\t//\n\t\t\t\t// A nil tags slice marshals to the JSON literal 'null', so\n\t\t\t\t// untagged nodes can carry tags='null'. That spelling must be\n\t\t\t\t// excluded alongside '[]' and '' or untagged nodes lose their\n\t\t\t\t// user. Nodes already detached by the earlier version of this\n\t\t\t\t// migration are repaired by the recovery migration below.\n\t\t\t\t// Fixes: https://github.com/juanfont/headscale/issues/3077\n\t\t\t\t// Fixes: https://github.com/juanfont/headscale/issues/3323\n\t\t\t\tID: \"202602201200-clear-tagged-node-user-id\",\n\t\t\t\tMigrate: func(tx *gorm.DB) error {\n\t\t\t\t\terr := tx.Exec(`\nUPDATE nodes\nSET user_id = NULL\nWHERE tags IS NOT NULL AND tags != '[]' AND tags != '' AND tags != 'null';\n\t\t\t\t\t\t`).Error\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"clearing user_id on tagged nodes: %w\", err)\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{\n\t\t\t\t// Clear zero-time node expiry values to NULL.\n\t\t\t\t// Versions before 0.28 persisted a pointer to a zero\n\t\t\t\t// time.Time as '0001-01-01 00:00:00+00:00' rather than\n\t\t\t\t// NULL, which 0.29 reports as an expired node. This\n\t\t\t\t// normalises the existing rows so the column once\n\t\t\t\t// again means \"no expiry\" when unset.\n\t\t\t\tID: \"202605221435-clear-zero-time-node-expiry\",\n\t\t\t\tMigrate: func(tx *gorm.DB) error {\n\t\t\t\t\terr := tx.Exec(`\nUPDATE nodes\nSET expiry = NULL","sourceCodeStart":707,"sourceCodeEnd":743,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/db.go#L707-L743","documentation":"Migration '202602201200-clear-tagged-node-user-id' fails its bulk UPDATE setting user_id = NULL on nodes that carry tags, enforcing the tags-XOR-user ownership rule (fixes issues #3077 and #3323). Failure modes: user_id has a NOT NULL constraint in drifted schemas or a foreign key blocking the NULL write; locks; permissions. On Postgres a NOT NULL user_id makes SET NULL impossible.","triggerScenarios":"Upgrading a database where nodes.user_id is NOT NULL (older AutoMigrate variants) or where a foreign key constraint rejects NULL; concurrent access holding locks on nodes during the bulk UPDATE.","commonSituations":"Pre-0.29 databases with user_id NOT NULL on nodes; running the upgrade while tailnet peers are actively polling and writing rows; least-privilege DB roles.","solutions":["Check the wrapped error; if NOT NULL constraint: pre-apply ALTER TABLE nodes ALTER COLUMN user_id DROP NOT NULL (Postgres) or rebuild the column nullable (SQLite), then restart so the migration completes","Run the upgrade with clients disconnected/quiesced to avoid lock contention on the bulk UPDATE","Verify afterwards that tagged nodes have NULL user_id: SELECT count(*) FROM nodes WHERE user_id IS NOT NULL AND tags IS NOT NULL AND tags NOT IN ('[]','','null'); should be 0","Back up before upgrading - this migration intentionally severs user ownership of tagged nodes"],"exampleFix":"-- before: NOT NULL user_id blocks the migration\nALTER TABLE nodes ALTER COLUMN user_id DROP NOT NULL;\n-- after: restart headscale; migration then completes, then verify\nSELECT count(*) FROM nodes WHERE user_id IS NOT NULL AND tags IS NOT NULL AND tags != '[]'; -- expect 0","handlingStrategy":"validation","validationCode":"// Pre-flight: user_id must be nullable for SET NULL to succeed\nvar nullable bool\ndb.QueryRow(`SELECT is_nullable = 'YES' FROM information_schema.columns\n\tWHERE table_name = 'nodes' AND column_name = 'user_id'`).Scan(&nullable) // Postgres\nif !nullable {\n\tlog.Fatal(\"nodes.user_id is NOT NULL; run ALTER TABLE nodes ALTER COLUMN user_id DROP NOT NULL before upgrading\")\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Audit nullable-ness of user_id/expiry on long-lived databases before major upgrades","Back up before crossing the 0.28 to 0.29 boundary; this migration intentionally detaches tagged nodes from users","Document which nodes are tagged so ownership changes are expected, not surprises"],"tags":["database","migration","tags","nullable","bulk-update"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}