plandex-ai/plandex · error

error removing expired locks: %v

Error message

error removing expired locks: %v

What it means

When expired locks (heartbeat older than lockHeartbeatTimeout) are found, lockRepoDB deletes them in bulk with pq.Array(expiredLockIds). Any delete error other than a deadlock aborts the lock attempt with "error removing expired locks: %v". Deadlocks are deliberately swallowed ("won't do anything") so another transaction's cleanup doesn't fail this caller.

Source

Thrown at app/server/db/locks.go:244

		return "", fmt.Errorf("error iterating over repo locks: %v", err)
	}

	log.Printf("[Lock][%d] %d locks found, %d expired | reason: %s", goroutineID, len(locks), len(expiredLockIds), params.Reason)

	if len(expiredLockIds) > 0 {
		log.Printf("[Lock][%d] %d expired locks found, deleting | reason: %s", goroutineID, len(expiredLockIds), params.Reason)
		if locksVerboseLogging {
			log.Printf("deleting expired locks: %v", expiredLockIds)
		}

		query := "DELETE FROM repo_locks WHERE id = ANY($1)"
		_, err := tx.Exec(query, pq.Array(expiredLockIds))
		if err != nil {
			if isDeadlockError(err) {
				log.Println("deadlock clearing expired locks, won't do anything")
			} else {
				log.Printf("[Lock][%d] error removing expired locks: %v | reason: %s", goroutineID, err, params.Reason)
				return "", fmt.Errorf("error removing expired locks: %v", err)
			}
		}
	}

	canAcquire := true

	for _, lock := range locks {
		lockBranch := ""
		if lock.Branch != nil {
			lockBranch = *lock.Branch
		}

		if scope == LockScopeRead {
			// if we're trying to acquire a read lock, we can do so unless there's a conflicting lock
			// a write lock always conflicts with a read lock (regardless of branch)
			// a read lock conflicts if it's for a different branch (since it would need to checkout a different branch in the middle of an already-running read)
			if lock.Scope == LockScopeWrite {
				canAcquire = false

View on GitHub (pinned to e2d772072e)

Solutions

  1. Retry the lock attempt — expired locks will eventually be cleared by another caller once contention subsides; the layer's backoff handles transient conflicts.
  2. Check the wrapped error: serialization failures under REPEATABLE READ can be retried; unique/index errors indicate schema issues.
  3. Reduce delete contention by letting the caller with the oldest view perform cleanup, or delete in smaller batches.
  4. Inspect pg_locks / pg_stat_activity for competing transactions on repo_locks.

Example fix

// before (app code)
lockId, err := db.LockRepo(ctx, cancel, params)
if err != nil { return err }
// after
lockId, err := db.LockRepo(ctx, cancel, params)
if err != nil {
    time.Sleep(500 * time.Millisecond) // expired-lock cleanup contention is transient
    lockId, err = db.LockRepo(ctx, cancel, params)
}
Defensive patterns

Strategy: retry

Validate before calling

// pre-check for expired locks to anticipate contention
var expired int
db.Conn.Get(&expired, "SELECT COUNT(*) FROM repo_locks WHERE plan_id=$1 AND last_heartbeat_at < NOW() - interval '60 seconds'", planId)
if expired > 0 { log.Printf("%d expired locks pending cleanup for plan %s", expired, planId) }

Type guard

func isDeadlockErr(err error) bool { return err != nil && strings.Contains(err.Error(), "deadlock detected") }

Try / catch

lockId, err := db.LockRepo(ctx, cancel, params)
if err != nil && strings.Contains(err.Error(), "error removing expired locks") {
    time.Sleep(time.Second) // cleanup contention is transient
    lockId, err = db.LockRepo(ctx, cancel, params)
}

Prevention

When it happens

Trigger: DELETE ... WHERE id = ANY(...) failing due to lock contention, serialization/repeatable-read conflicts (non-deadlock class), or connection loss during the delete.

Common situations: Many servers concurrently clearing the same expired locks under heavy plan activity; long transactions holding row locks on repo_locks; REPEATABLE READ serialization failures on delete.

Related errors


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