gastownhall/beads · error
timeout waiting for cache lock on
Error message
timeout waiting for cache lock on %s
What it means
acquireLock() polls a non-blocking exclusive flock on the cache entry's .lock file for up to 2 minutes (stale locks older than 5 minutes are removed first). This error means another process held the lock continuously for the whole deadline, so the operation was abandoned rather than mutating the dolt database concurrently.
Solutions
- Identify and finish/kill the other process holding the lock (lsof/fuser on <cache-entry>/.lock).
- If no process is using it and the lock is genuinely stale-but-young, remove the .lock file (or wait out staleLockAge) and retry.
- Serialize usage: avoid running multiple bd commands against the same remote concurrently (scripts, cron overlap).
- Retry after a delay — this is a transient contention error, not data corruption.
Example fix
// before: immediate retry, same contention
if err := cache.Push(ctx, url); err != nil { return err }
// after: detect lock timeout and back off
if err := cache.Push(ctx, url); err != nil {
if strings.Contains(err.Error(), "timeout waiting for cache lock") {
return fmt.Errorf("another bd process is syncing %s; retry later", url)
}
return err
} Defensive patterns
Strategy: retry
Validate before calling
lockPath := filepath.Join(cacheDir, "beads", "remotes", remotecache.CacheKey(remoteURL), ".lock")
if info, err := os.Stat(lockPath); err == nil {
age := time.Since(info.ModTime())
if age < 5*time.Minute {
return fmt.Errorf("cache lock active (%.0fs old); wait or stop the other process", age.Seconds())
}
} Try / catch
err := doSync(ctx) // Ensure/Push inside
if err != nil && strings.Contains(err.Error(), "timeout waiting for cache lock") {
// check no bd process is running, optionally clear a stale .lock, then retry once
select {
case <-time.After(time.Minute):
return doSync(ctx)
case <-ctx.Done():
return ctx.Err()
}
} Prevention
- Avoid overlapping scheduled jobs (cron/CI) that sync the same remote.
- Use a supervisor/mutex in your own tooling to serialize remote operations.
- Inspect `lsof <cache-entry>/.lock` before manually deleting a lock file.
- Keep sync durations short (fresh FreshFor TTL) so locks are released quickly.
When it happens
Trigger: Cache.Ensure() or Cache.Push() on a remoteURL whose .lock is held by another live process (long-running bd sync, stuck dolt command) for >2 minutes; the lock file is younger than staleLockAge so it is not treated as stale.
Common situations: Two shells running `bd sync`/`bd pull` on the same remote simultaneously; a hung dolt operation leaving a live-looking lock; backup/indexing software holding the file; very slow network making a legitimate push exceed 2 minutes.
Understand the failure class
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- lock already held by another process
- lock busy: held by another process
- timeout ( ) waiting for spawn marker ; wait for the…
- acquire gc connection
- acquiring lock (blocking)
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/03abe795e4533992.
Report an issue: GitHub.
Appendix: source
Thrown at internal/remotecache/cache.go:257
f, err := os.OpenFile(lp, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
// Poll with timeout
deadline := time.Now().Add(2 * time.Minute)
for {
err := lockfile.FlockExclusiveNonBlocking(f)
if err == nil {
return f, nil
}
if !lockfile.IsLocked(err) {
_ = f.Close()
return nil, err
}
if time.Now().After(deadline) {
_ = f.Close()
return nil, fmt.Errorf("timeout waiting for cache lock on %s", remoteURL)
}
select {
case <-ctx.Done():
_ = f.Close()
return nil, fmt.Errorf("interrupted waiting for cache lock on %s: %w", remoteURL, ctx.Err())
case <-time.After(100 * time.Millisecond):
}
}
}
// releaseLock releases a cache entry file lock.
// The lock file is intentionally NOT removed: deleting it after unlock creates
// a TOCTOU race where another process's newly-acquired lock gets deleted.
// Stale lock files are cleaned up by acquireLock's age check instead.
func (c *Cache) releaseLock(f *os.File) {
if f != nil {
_ = lockfile.FlockUnlock(f)
_ = f.Close()View on GitHub (pinned to 71377f2769)