{"record":{"id":"5655747c1e51ddba","repo":"juanfont/headscale","slug":"clearing-zero-time-node-expiry-w","errorCode":null,"errorMessage":"clearing zero-time node expiry: %w","messagePattern":"clearing zero-time node expiry: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"hscontrol/db/db.go","lineNumber":747,"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{\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\nWHERE expiry IS NOT NULL AND expiry < '1900-01-01';\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 zero-time node expiry: %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// Recover user_id on untagged nodes detached by the earlier\n\t\t\t\t// version of 202602201200-clear-tagged-node-user-id, which\n\t\t\t\t// treated tags='null' as tagged and cleared the user. This\n\t\t\t\t// repairs databases that already upgraded to 0.29.0; fresh\n\t\t\t\t// upgrades are protected by the fixed migration above and find\n\t\t\t\t// nothing to repair. Recovery is best-effort: the owner is\n\t\t\t\t// re-derived from the node's pre-auth key, so nodes registered\n\t\t\t\t// via CLI/OIDC (no pre-auth key) cannot be recovered and must\n\t\t\t\t// be reassigned manually.\n\t\t\t\t// Fixes: https://github.com/juanfont/headscale/issues/3323\n\t\t\t\tID: \"202606181200-recover-null-tags-node-user-id\",","sourceCodeStart":729,"sourceCodeEnd":765,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/db.go#L729-L765","documentation":"Migration '202605221435-clear-zero-time-node-expiry' fails its UPDATE setting expiry = NULL where expiry predates 1900, normalising '0001-01-01' zero-time values that 0.29 misreports as expired. Causes: expiry column having a NOT NULL constraint in drifted schemas, lock contention, or permissions - same class as the other bulk UPDATE migrations.","triggerScenarios":"Schema drift leaving nodes.expiry NOT NULL while the migration sets NULL; long-running transactions blocking the range UPDATE; missing UPDATE privilege.","commonSituations":"Databases created by very old headscale versions where expiry was NOT NULL; upgrading under load.","solutions":["From the wrapped error: if NOT NULL violation, pre-apply ALTER TABLE nodes ALTER COLUMN expiry DROP NOT NULL, then restart headscale","Upgrade during a maintenance window to avoid lock timeouts on nodes","Post-migration sanity check: SELECT count(*) FROM nodes WHERE expiry IS NOT NULL AND expiry < '1900-01-01'; should be 0","Take a backup; the NULL rewrite changes semantics (NULL = no expiry) so verify expired-node reporting afterwards"],"exampleFix":"-- before: NOT NULL expiry blocks the NULL write\nALTER TABLE nodes ALTER COLUMN expiry DROP NOT NULL;\n-- after: restart headscale, then confirm zero-time rows are gone\nSELECT count(*) FROM nodes WHERE expiry < '1900-01-01'; -- expect 0","handlingStrategy":"validation","validationCode":"// Pre-flight: expiry must be nullable\nvar nullable bool\ndb.QueryRow(`SELECT is_nullable = 'YES' FROM information_schema.columns\n\tWHERE table_name = 'nodes' AND column_name = 'expiry'`).Scan(&nullable)\nif !nullable {\n\tlog.Fatal(\"nodes.expiry is NOT NULL; make it nullable before upgrading\")\n}\n// Optional: preview affected rows\n// SELECT count(*) FROM nodes WHERE expiry IS NOT NULL AND expiry < '1900-01-01';","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Check for zero-time expiry rows before upgrading and confirm they are truly 'no expiry' nodes","Include column nullability in periodic schema audits","Upgrade in a window where nodes are not checking in"],"tags":["database","migration","expiry","nullable","data-normalization"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}