JuliusBrussee/caveman · error

close open arm

Error message

close open arm: %w

What it means

SwitchExperimentArm wraps the error from the UPDATE that sets ended_at on the currently open experiment_arms row. This library throws it when closing the running arm fails in SQLite, before any new arm is opened.

Solutions

  1. Inspect the wrapped error via errors.Unwrap for the underlying SQLite cause.
  2. Enable busy_timeout / retry on SQLITE_BUSY and re-run the switch.
  3. Verify the experiment_arms schema includes ended_at (nullable).
  4. Ensure single-writer access or serialize experiment mutations.

Example fix

// before
exp, err := store.SwitchExperimentArm(label, "off")
if err != nil { return err }
// after
exp, err := store.SwitchExperimentArm(label, "off")
if err != nil {
    if errors.Is(err, sqlite.ErrBusy) { /* retry once */ }
    return err
}
Defensive patterns

Strategy: retry

Validate before calling

var one int
_ = s.db.QueryRow(`SELECT 1 FROM experiment_arms LIMIT 1`).Scan(&one) // table exists

Try / catch

for i := 0; i < 3; i++ {
    exp, err := store.SwitchExperimentArm(label, arm)
    if err == nil { break }
    if !isBusy(err) { return err }
    time.Sleep(100*time.Millisecond << i)
}

Prevention

When it happens

Trigger: Calling Store.SwitchExperimentArm(label, arm) when 'UPDATE experiment_arms SET ended_at = ? WHERE experiment_id = ? AND ended_at IS NULL' fails: locked DB, missing table/column, disk I/O error.

Common situations: Concurrent writers hitting SQLITE_BUSY; schema drift after an upgrade; read-only database file.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/a92baf3e79501e80. Report an issue: GitHub.

Appendix: source

Thrown at proxy/internal/store/learn_experiment.go:135

	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)
	}
	for _, existing := range experiment.Arms {
		if existing.EndedAt == "" && existing.Arm == arm {
			return experiment, nil
		}
	}
	now := learnOutcomeClock().UTC().Format(time.RFC3339)
	if _, err := s.db.Exec(`UPDATE experiment_arms SET ended_at = ?
		WHERE experiment_id = ? AND ended_at IS NULL`, now, experiment.id); err != nil {
		return Experiment{}, fmt.Errorf("close open arm: %w", err)
	}
	if _, err := s.db.Exec(`INSERT INTO experiment_arms (experiment_id, arm, started_at)
		VALUES (?, ?, ?)`, experiment.id, arm, now); err != nil {
		return Experiment{}, fmt.Errorf("open arm %q: %w", arm, err)
	}
	return s.LoadExperiment(label)
}

func (s *Store) StopExperiment(label string) (Experiment, error) {
	experiment, err := s.LoadExperiment(label)
	if err != nil {
		return Experiment{}, err
	}
	now := learnOutcomeClock().UTC().Format(time.RFC3339)
	if _, err := s.db.Exec(`UPDATE experiment_arms SET ended_at = ?
		WHERE experiment_id = ? AND ended_at IS NULL`, now, experiment.id); err != nil {
		return Experiment{}, fmt.Errorf("close open arm: %w", err)
	}

View on GitHub (pinned to 3ee70a1026)