JuliusBrussee/caveman · error
open first arm
Error message
open first arm: %w
What it means
StartExperiment wraps the error from inserting the first experiment arm row (arm 'on') into the experiment_arms table. This library throws it when the SQLite INSERT fails right after the experiment row itself was created successfully. It leaves the experiment record present but without any open arm.
Solutions
- Check the wrapped %w cause with errors.Unwrap to see the underlying SQLite error (e.g. 'no such table: experiment_arms' vs 'database is locked').
- Run schema migrations to ensure experiment_arms exists with columns experiment_id, arm, started_at, ended_at.
- Ensure no other process holds a long write transaction; set a busy_timeout on the SQLite connection.
- Check free disk space and database file permissions, then retry StartExperiment.
Example fix
// before
experiment, err := store.StartExperiment(label)
if err != nil { return err }
// after
experiment, err := store.StartExperiment(label)
if err != nil {
log.Printf("start experiment %q failed: %v", label, err)
return fmt.Errorf("starting experiment: %w", err)
} Defensive patterns
Strategy: try-catch
Validate before calling
var n int _ = s.db.QueryRow(`SELECT COUNT(*) FROM experiment_arms`).Scan(&n) // schema sanity check before calling
Try / catch
experiment, err := store.StartExperiment(label)
if err != nil {
return fmt.Errorf("start experiment: %w", err) // inspect errors.Unwrap for sqlite cause
} Prevention
- Run migrations before starting experiments
- Set sqlite busy_timeout for concurrent writers
- Monitor disk space on the DB volume
When it happens
Trigger: Calling Store.StartExperiment when the INSERT INTO experiment_arms fails: locked database, disk full, schema missing/mismatched (experiment_arms table absent), or a NOT NULL/constraint violation on experiment_id, arm, or started_at.
Common situations: Running against a partially migrated or corrupted SQLite file; two processes writing concurrently and hitting SQLITE_BUSY; deploying a build whose schema setup did not run.
Related errors
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/b2142f78c6f7d922.
Report an issue: GitHub.
Appendix: source
Thrown at proxy/internal/store/learn_experiment.go:107
if label == "" || len(label) > 128 {
return Experiment{}, fmt.Errorf("experiment label must be 1-128 bytes")
}
if len(note) > 4096 {
return Experiment{}, fmt.Errorf("note exceeds 4096 bytes")
}
now := learnOutcomeClock().UTC().Format(time.RFC3339)
result, err := s.db.Exec(`INSERT INTO experiments (label, sink_id, fix_kind, note, created_at)
VALUES (?, ?, ?, ?, ?)`, label, sinkID, fixKind, note, now)
if err != nil {
return Experiment{}, fmt.Errorf("start experiment %q: %w", label, err)
}
id, err := result.LastInsertId()
if err != nil {
return Experiment{}, fmt.Errorf("read experiment id: %w", err)
}
if _, err := s.db.Exec(`INSERT INTO experiment_arms (experiment_id, arm, started_at)
VALUES (?, ?, ?)`, id, experimentArmOn, now); err != nil {
return Experiment{}, fmt.Errorf("open first arm: %w", err)
}
return s.LoadExperiment(label)
}
// SwitchExperimentArm closes the open arm and opens the other one. Switching to
// the arm already running is a no-op rather than an error: it keeps a repeated
// call from fragmenting the interval history.
func (s *Store) SwitchExperimentArm(label, arm string) (Experiment, error) {
arm = strings.ToLower(strings.TrimSpace(arm))
if arm != experimentArmOn && arm != experimentArmOff {
return Experiment{}, fmt.Errorf("arm must be %q or %q", experimentArmOn, experimentArmOff)
}
experiment, err := s.LoadExperiment(label)
if err != nil {
return Experiment{}, err
}
if experiment.StoppedAt != "" {
return Experiment{}, fmt.Errorf("experiment %q is stopped", experiment.Label)View on GitHub (pinned to 3ee70a1026)