plandex-ai/plandex · error
failed to acquire lock after %d attempts: %w
Error message
failed to acquire lock after %d attempts: %w
What it means
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.
Source
Thrown at app/server/db/locks.go:573
}
if pqErr, ok := err.(*pq.Error); ok && (pqErr.Code == "40001" || pqErr.Code == "40P01") {
return true
}
return false
}
func retryWithExponentialBackoff(
ctx context.Context,
cause error,
attempt int,
nextCall func(int) (string, error),
) (string, error) {
// If we have retried enough times, bail out.
if attempt >= maxLockRetries {
log.Printf("[Lock][Retry][%d] Failed to acquire lock after %d attempts: %v", getGoroutineID(), attempt, cause)
return "", fmt.Errorf("failed to acquire lock after %d attempts: %w", attempt, cause)
}
// Exponential delay: initialRetryDelay * 2^(attempt)
backoff := time.Duration(float64(initialLockRetryDelay) * math.Pow(backoffFactor, float64(attempt)))
// Add jitter: ± jitterFraction
jitterRange := time.Duration(float64(backoff) * jitterFraction)
jitter := time.Duration(rand.Int63n(int64(jitterRange)*2)) - jitterRange
wait := backoff + jitter
if wait < 0 {
wait = 0
}
log.Printf("[Lock][Retry][%d] Lock/transaction conflict (attempt #%d). Retrying in %s... (cause: %v)", getGoroutineID(), attempt, wait, cause)
select {
case <-ctx.Done():
log.Printf("[Lock][Retry][%d] Context canceled while waiting to retry: %v", getGoroutineID(), ctx.Err())View on GitHub (pinned to e2d772072e)
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
Example fix
// before
id, err := lockRepoDB(ctx, orgId, planId, reason)
if err != nil { return err }
// after
id, err := lockRepoDB(ctx, orgId, planId, reason)
if err != nil {
if strings.Contains(err.Error(), "failed to acquire lock after") {
return fmt.Errorf("plan %s is busy; try again later", planId)
}
return err
} Defensive patterns
Strategy: retry
Validate before calling
var holder struct {
ID string
LastHeartbeatAt time.Time
}
err := Conn.GetContext(ctx, &holder,
`SELECT id, last_heartbeat_at FROM repo_locks WHERE plan_id = $1 AND scope = 'w'`, planId)
if err == nil && time.Since(holder.LastHeartbeatAt) > 2*lockHeartbeatInterval {
// stale lock: safe to delete before attempting acquisition
_, _ = Conn.ExecContext(ctx, `DELETE FROM repo_locks WHERE id = $1`, holder.ID)
} Type guard
func isLockContention(err error) bool {
return err != nil && strings.Contains(err.Error(), "failed to acquire lock after")
} Try / catch
id, err := lockRepoDB(ctx, orgId, planId, reason)
if isLockContention(err) {
// plan busy; surface as retryable, schedule with backoff or requeue the job
return ErrPlanBusy
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- error inserting new lock: %v
- context canceled while waiting to retry: %w
- delete lock failed after 10 attempts: %w
- failed to get DB lock: %w
- Error getting cached map: %v
AI-assisted analysis of plandex-ai/plandex@e2d772072e (2026-09-05).
Data as JSON: /api/errors/001e41c2144e5e1e.
Report an issue: GitHub.