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

  1. Check who holds the lock: SELECT * FROM repo_locks WHERE plan_id = ... and inspect last_heartbeat_at
  2. Delete stale locks whose heartbeat is old, or wait for the holder to finish and release
  3. Reduce contention: serialize or dedupe jobs for the same plan before calling lockRepoDB
  4. Increase maxLockRetries / initialLockRetryDelay if contention is legitimately high
  5. 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

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


AI-assisted analysis of plandex-ai/plandex@e2d772072e (2026-09-05). Data as JSON: /api/errors/001e41c2144e5e1e. Report an issue: GitHub.