{"record":{"id":"1039e6a60104467c","repo":"juanfont/headscale","slug":"saving-node-d-after-adding-ips-w","errorCode":null,"errorMessage":"saving node(%d) after adding IPs: %w","messagePattern":"saving node\\((.+?)\\) after adding IPs: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/db/ip.go","lineNumber":374,"sourceCode":"\t\t\t\tret = append(ret, fmt.Sprintf(\"removing IPv4 %q from Node(%d) %q\", node.IPv4.String(), node.ID, node.Hostname))\n\t\t\t\tnode.IPv4 = nil\n\t\t\t\tchanged = true\n\t\t\t}\n\n\t\t\t// IPv6 prefix is not set, but node has IP, remove\n\t\t\tif i.prefix6 == nil && node.IPv6 != nil {\n\t\t\t\tret = append(ret, fmt.Sprintf(\"removing IPv6 %q from Node(%d) %q\", node.IPv6.String(), node.ID, node.Hostname))\n\t\t\t\tnode.IPv6 = nil\n\t\t\t\tchanged = true\n\t\t\t}\n\n\t\t\tif changed {\n\t\t\t\t// Use Updates() with Select() to only update IP fields, avoiding overwriting\n\t\t\t\t// other fields like Expiry. We need Select() because Updates() alone skips\n\t\t\t\t// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.\n\t\t\t\terr := tx.Model(node).Select(\"ipv4\", \"ipv6\").Updates(node).Error\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"saving node(%d) after adding IPs: %w\", node.ID, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t})\n\n\treturn ret, err\n}\n\nfunc (i *IPAllocator) FreeIPs(ips []netip.Addr) {\n\ti.mu.Lock()\n\tdefer i.mu.Unlock()\n\n\tfor _, ip := range ips {\n\t\ti.usedIPs.Remove(ip)\n\t}\n}","sourceCodeStart":356,"sourceCodeEnd":392,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/ip.go#L356-L392","documentation":"After computing new or removed IPs for a node, backfill persists only the ipv4/ipv6 columns via Updates(Select(...)). This error is that UPDATE failing at the DB layer — constraint violation, lock, or connectivity. The comment in code notes Select() is deliberate so removing an IP (writing nil) is not skipped as a zero value.","triggerScenarios":"Unique index conflict when another row already holds the allocated IP; SQLite lock timeout; connection loss mid-transaction; a manually inserted row violating assumptions.","commonSituations":"Two nodes assigned the same IP after a partial restore; concurrent registration racing backfill on SQLite; DB volume full.","solutions":["Inspect the wrapped error for the specific constraint or lock message","Query for duplicate ipv4/ipv6 values across nodes and fix the offending rows","If lock-related on SQLite, serialize startup backfill away from heavy registration load","Re-run; backfill re-evaluates each node idempotently"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"if _, err := db.BackfillNodeIPs(ipAlloc); err != nil {\n\tif isTransientDBError(err) {\n\t\t// retry at next startup; backfill is idempotent\n\t}\n\tlog.Error().Err(err).Msg(\"backfill save failed\")\n}","preventionTips":["Keep a unique index on ipv4/ipv6 to catch duplicates early","Avoid restoring partial database dumps that desync IP ownership","Watch the wrapped driver error, not the outer message"],"tags":["go","database","gorm","transaction","ip-allocation"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}