{"record":{"id":"59dee0d36dff6287","repo":"plandex-ai/plandex","slug":"error-scanning-repo-lock-v","errorCode":null,"errorMessage":"error scanning repo lock: %v","messagePattern":"error scanning repo lock: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/server/db/locks.go","lineNumber":212,"sourceCode":"\t\tif forUpdate {\n\t\t\tlog.Printf(\"[Lock][%d] SELECT FOR UPDATE took %v | reason: %s\",\n\t\t\t\tgoroutineID, time.Since(selectStart), params.Reason)\n\t\t} else {\n\t\t\tlog.Printf(\"[Lock][%d] SELECT FOR SHARE took %v | reason: %s\",\n\t\t\t\tgoroutineID, time.Since(selectStart), params.Reason)\n\t\t}\n\t}\n\n\tdefer repoLockRows.Close()\n\n\tvar expiredLockIds []string\n\texpiredLockIdsSet := make(map[string]bool)\n\n\tnow := time.Now()\n\tfor repoLockRows.Next() {\n\t\tvar lock repoLock\n\t\tif err := repoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, &lock.PlanId, &lock.PlanBuildId, &lock.Scope, &lock.Branch, &lock.LastHeartbeatAt, &lock.CreatedAt); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error scanning repo lock: %v\", err)\n\t\t}\n\n\t\t// ensure heartbeat hasn't timed out\n\t\tif now.Sub(lock.LastHeartbeatAt) < lockHeartbeatTimeout {\n\t\t\tlocks = append(locks, &lock)\n\t\t} else {\n\t\t\texpiredLockIds = append(expiredLockIds, lock.Id)\n\t\t\texpiredLockIdsSet[lock.Id] = true\n\t\t}\n\t}\n\n\tif err := repoLockRows.Err(); err != nil {\n\t\tlog.Printf(\"[Lock][%d] error iterating over repo locks: %v | reason: %s\", goroutineID, err, params.Reason)\n\t\treturn \"\", fmt.Errorf(\"error iterating over repo locks: %v\", err)\n\t}\n\n\tlog.Printf(\"[Lock][%d] %d locks found, %d expired | reason: %s\", goroutineID, len(locks), len(expiredLockIds), params.Reason)\n","sourceCodeStart":194,"sourceCodeEnd":230,"githubUrl":"https://github.com/plandex-ai/plandex/blob/e2d772072efadbe41d2946d97d79be55532dbab5/app/server/db/locks.go#L194-L230","documentation":"After querying existing repo_lock rows, lockRepoDB scans each row into a repoLock struct with nine columns (Id, OrgId, UserId, PlanId, PlanBuildId, Scope, Branch, LastHeartbeatAt, CreatedAt). A scan failure — NULL in a non-pointer column or a type mismatch — aborts the lock attempt with \"error scanning repo lock: %v\".","triggerScenarios":"A repo_locks row containing NULL for a column scanned into a non-pointer field (e.g. Branch, PlanBuildId, UserId); schema drift where the SELECT column list/type no longer matches repoLock fields.","commonSituations":"Old rows written by a prior schema version that lacked a column now scanned; manual DB edits or migrations inserting NULLs; mixing up column order after an ALTER TABLE without updating the query.","solutions":["Find the offending row via the wrapped SQL error and fix or delete the NULL/malformed lock row — it will also block acquisition until its heartbeat expires.","Make nullable columns pointer types (or sql.NullString/NullTime) in repoLock and in Scan so NULLs scan cleanly.","Confirm the SELECT column list matches repoLock field order/types after any schema migration.","Wait out the 60s heartbeat timeout — expired locks are deleted automatically, which often clears the bad row."],"exampleFix":"// before\nvar lock repoLock\nrepoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, ..., &lock.Branch, ...)\n// after\nBranch *string // nullable column in repoLock struct\n...repoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, ..., &lock.Branch, ...) // NULL-safe","handlingStrategy":"retry","validationCode":"// detect stale/broken lock rows before locking\nrows, err := db.Conn.Queryx(\"SELECT * FROM repo_locks WHERE plan_id=$1\", planId)\nfor rows.Next() {\n    var l db.RepoLock\n    if err := rows.StructScan(&l); err != nil {\n        log.Printf(\"corrupt lock row for plan %s, will rely on heartbeat expiry\", planId)\n    }\n}","typeGuard":"func lockRowSane(l *repoLock) bool {\n    return l.Id != \"\" && l.OrgId != \"\" && !l.LastHeartbeatAt.IsZero() && !l.CreatedAt.IsZero()\n}","tryCatchPattern":"lockId, err := db.LockRepo(ctx, cancel, params)\nif err != nil && strings.Contains(err.Error(), \"error scanning repo lock\") {\n    // corrupt/expired row — retry after heartbeat timeout (60s) clears it\n    time.Sleep(db.HeartbeatTimeout)\n    lockId, err = db.LockRepo(ctx, cancel, params)\n}","preventionTips":["Declare nullable repo_locks columns as pointer/Null* types in the struct and Scan.","Keep the SELECT column list in lockRepoDB in lockstep with repo_locks migrations.","Never hand-edit lock rows; clean them through code that sets all NOT NULL fields."],"tags":["database","locking","sql-scan","schema"],"backgroundTag":"sql-scan-null-mismatch","analyzedSha":"e2d772072efadbe41d2946d97d79be55532dbab5","analyzedAt":"2026-09-05T20:56:53.631Z","contentChangedAt":"2026-09-05T20:56:53.631Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}