feat(cli): add --lock-timeout flag for SQLite busy_timeout control (#536)

Implements single-shot mode improvements for Windows and Docker scenarios:

- Add --lock-timeout global flag (default 30s, 0 = fail immediately)
- Add config file support: lock-timeout: 100ms
- Parameterize SQLite busy_timeout via NewWithTimeout() function
- In --sandbox mode: default lock-timeout to 100ms
- In --sandbox mode: skip FlushManager creation (no background goroutines)

This addresses bd.exe hanging on Windows and locking conflicts when
using beads across host + Docker containers.

Closes: bd-59er, bd-r4od, bd-dh8a

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-13 18:07:00 -08:00
parent 45328d6bfe
commit fc23dca7fb
4 changed files with 845 additions and 808 deletions

View File

@@ -10,6 +10,7 @@ import (
"runtime"
"strings"
"sync/atomic"
"time"
// Import SQLite driver
sqlite3 "github.com/ncruces/go-sqlite3"
@@ -72,8 +73,17 @@ func init() {
_ = setupWASMCache()
}
// New creates a new SQLite storage backend
// New creates a new SQLite storage backend with default 30s busy timeout
func New(ctx context.Context, path string) (*SQLiteStorage, error) {
return NewWithTimeout(ctx, path, 30*time.Second)
}
// NewWithTimeout creates a new SQLite storage backend with configurable busy timeout.
// A timeout of 0 means fail immediately if the database is locked.
func NewWithTimeout(ctx context.Context, path string, busyTimeout time.Duration) (*SQLiteStorage, error) {
// Convert timeout to milliseconds for SQLite pragma
timeoutMs := int64(busyTimeout / time.Millisecond)
// Build connection string with proper URI syntax
// For :memory: databases, use shared cache so multiple connections see the same data
var connStr string
@@ -81,12 +91,12 @@ func New(ctx context.Context, path string) (*SQLiteStorage, error) {
// Use shared in-memory database with a named identifier
// Note: WAL mode doesn't work with shared in-memory databases, so use DELETE mode
// The name "memdb" is required for cache=shared to work properly across connections
connStr = "file:memdb?mode=memory&cache=shared&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
connStr = fmt.Sprintf("file:memdb?mode=memory&cache=shared&_pragma=journal_mode(DELETE)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", timeoutMs)
} else if strings.HasPrefix(path, "file:") {
// Already a URI - append our pragmas if not present
connStr = path
if !strings.Contains(path, "_pragma=foreign_keys") {
connStr += "&_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
connStr += fmt.Sprintf("&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", timeoutMs)
}
} else {
// Ensure directory exists for file-based databases
@@ -95,7 +105,7 @@ func New(ctx context.Context, path string) (*SQLiteStorage, error) {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
// Use file URI with pragmas
connStr = "file:" + path + "?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)&_time_format=sqlite"
connStr = fmt.Sprintf("file:%s?_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", path, timeoutMs)
}
db, err := sql.Open("sqlite3", connStr)