{"record":{"id":"001e41c2144e5e1e","repo":"plandex-ai/plandex","slug":"failed-to-acquire-lock-after-d-attempts-w","errorCode":null,"errorMessage":"failed to acquire lock after %d attempts: %w","messagePattern":"failed to acquire lock after (.+?) attempts: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/server/db/locks.go","lineNumber":573,"sourceCode":"\t}\n\n\tif pqErr, ok := err.(*pq.Error); ok && (pqErr.Code == \"40001\" || pqErr.Code == \"40P01\") {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc retryWithExponentialBackoff(\n\tctx context.Context,\n\tcause error,\n\tattempt int,\n\tnextCall func(int) (string, error),\n) (string, error) {\n\t// If we have retried enough times, bail out.\n\tif attempt >= maxLockRetries {\n\t\tlog.Printf(\"[Lock][Retry][%d] Failed to acquire lock after %d attempts: %v\", getGoroutineID(), attempt, cause)\n\t\treturn \"\", fmt.Errorf(\"failed to acquire lock after %d attempts: %w\", attempt, cause)\n\t}\n\n\t// Exponential delay: initialRetryDelay * 2^(attempt)\n\tbackoff := time.Duration(float64(initialLockRetryDelay) * math.Pow(backoffFactor, float64(attempt)))\n\t// Add jitter: ± jitterFraction\n\tjitterRange := time.Duration(float64(backoff) * jitterFraction)\n\tjitter := time.Duration(rand.Int63n(int64(jitterRange)*2)) - jitterRange\n\n\twait := backoff + jitter\n\tif wait < 0 {\n\t\twait = 0\n\t}\n\n\tlog.Printf(\"[Lock][Retry][%d] Lock/transaction conflict (attempt #%d). Retrying in %s... (cause: %v)\", getGoroutineID(), attempt, wait, cause)\n\n\tselect {\n\tcase <-ctx.Done():\n\t\tlog.Printf(\"[Lock][Retry][%d] Context canceled while waiting to retry: %v\", getGoroutineID(), ctx.Err())","sourceCodeStart":555,"sourceCodeEnd":591,"githubUrl":"https://github.com/plandex-ai/plandex/blob/e2d772072efadbe41d2946d97d79be55532dbab5/app/server/db/locks.go#L555-L591","documentation":"retryWithExponentialBackoff aborts with 'failed to acquire lock after %d attempts' once the attempt counter reaches maxLockRetries. Each retry was triggered by a lock conflict (INSERT returned no row because another writer held the plan lock, or a serialization/deadlock error). The root cause is wrapped with %w so errors.Is/As work.","triggerScenarios":"Many concurrent operations locking the same plan_id with scope 'w'; a stale/orphaned lock row that is never deleted because its heartbeat keeps refreshing; heavy contention causing repeated 40001 serialization failures until the retry budget is exhausted.","commonSituations":"Two builds of the same plan triggered simultaneously by a webhook replay; a crashed worker's lock row kept alive by a lingering heartbeat goroutine; maxLockRetries too small for the workload's real contention level.","solutions":["Check who holds the lock: SELECT * FROM repo_locks WHERE plan_id = ... and inspect last_heartbeat_at","Delete stale locks whose heartbeat is old, or wait for the holder to finish and release","Reduce contention: serialize or dedupe jobs for the same plan before calling lockRepoDB","Increase maxLockRetries / initialLockRetryDelay if contention is legitimately high","Wrapped cause is accessible via errors.Unwrap for programmatic handling"],"exampleFix":"// before\nid, err := lockRepoDB(ctx, orgId, planId, reason)\nif err != nil { return err }\n// after\nid, err := lockRepoDB(ctx, orgId, planId, reason)\nif err != nil {\n    if strings.Contains(err.Error(), \"failed to acquire lock after\") {\n        return fmt.Errorf(\"plan %s is busy; try again later\", planId)\n    }\n    return err\n}","handlingStrategy":"retry","validationCode":"var holder struct {\n    ID              string\n    LastHeartbeatAt time.Time\n}\nerr := Conn.GetContext(ctx, &holder,\n    `SELECT id, last_heartbeat_at FROM repo_locks WHERE plan_id = $1 AND scope = 'w'`, planId)\nif err == nil && time.Since(holder.LastHeartbeatAt) > 2*lockHeartbeatInterval {\n    // stale lock: safe to delete before attempting acquisition\n    _, _ = Conn.ExecContext(ctx, `DELETE FROM repo_locks WHERE id = $1`, holder.ID)\n}","typeGuard":"func isLockContention(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"failed to acquire lock after\")\n}","tryCatchPattern":"id, err := lockRepoDB(ctx, orgId, planId, reason)\nif isLockContention(err) {\n    // plan busy; surface as retryable, schedule with backoff or requeue the job\n    return ErrPlanBusy\n}","preventionTips":["Dedupe jobs per plan before attempting to lock","Clean up stale locks whose heartbeat has lapsed","Size maxLockRetries/initialLockRetryDelay to your real contention","Alert on this error — it means sustained contention or an orphaned lock"],"tags":["locking","retry","concurrency","postgres"],"backgroundTag":"lock-acquisition-timeout","analyzedSha":"e2d772072efadbe41d2946d97d79be55532dbab5","analyzedAt":"2026-09-05T20:56:53.631Z","contentChangedAt":"2026-09-05T20:56:53.631Z","schemaVersion":2},"datasetVersion":"2026-09-12T22:17:10.623Z"}