OpenNHP/opennhp · error

keystore: sweep otp

Error message

keystore: sweep otp: %w

What it means

SweepStaleOTPs wraps failures of the DELETE that removes used/expired OTP rows older than the retention cutoff. The deletion predicate itself is fixed (created_at < cutoff AND (used=1 OR expires_at <= now)); only Exec-level DB failures produce this error.

Solutions

  1. Unwrap the error to identify the cause (no such table: otp_records / database is locked).
  2. Run migrations creating otp_records before starting the OTP sweeper.
  3. Enable WAL mode and busy_timeout to coexist with OTP traffic.
  4. Retry the sweep with backoff if the error is transient lock contention.
  5. Verify the process can write to the SQLite file and its containing directory.

Example fix

null
Defensive patterns

Strategy: retry

Validate before calling

var tables int
db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='otp_records'").Scan(&tables)
if tables == 0 { return errors.New("otp_records missing; migrate first") }

Type guard

n, err := store.SweepStaleOTPs(retention)
if err != nil && isBusyOrLocked(err) {
    time.Sleep(time.Second)
    n, err = store.SweepStaleOTPs(retention)
}

Try / catch

n, err := store.SweepStaleOTPs(86400)
if err != nil {
    if strings.Contains(err.Error(), "no such table") {
        return 0, errors.New("otp schema not migrated")
    }
    return 0, err
}

Prevention

When it happens

Trigger: Calling SweepStaleOTPs(retentionSeconds) when otp_records does not exist (missing migration), the DB is locked by another writer, the handle is closed, or the filesystem is full/read-only.

Common situations: Retention sweeper running before the OTP schema migration; lock contention with OTP creation/verification traffic; container data mount being read-only.

Related errors


AI-assisted analysis of OpenNHP/opennhp@6e04ca5ff0 (2026-09-07). Data as JSON: /api/errors/d8671fe8a0a1e1a8. Report an issue: GitHub.

Appendix: source

Thrown at endpoints/server/keystore.go:562

// SweepStaleOTPs deletes OTP rows that are already used or expired and
// were created more than retentionSeconds ago. Returns the number of rows
// deleted. Unused, non-expired OTPs are never swept. Retention defaults
// to 86400s (24 hours) when passed a negative value. Pass 0 to delete all
// used or expired OTPs regardless of age.
func (s *AgentKeyStore) SweepStaleOTPs(retentionSeconds int64) (int64, error) {
	if retentionSeconds < 0 {
		retentionSeconds = 86400
	}
	cutoff := time.Now().Unix() - retentionSeconds
	res, err := s.db.Exec(
		`DELETE FROM otp_records
		 WHERE created_at < ?
		   AND (used = 1 OR expires_at <= ?)`,
		cutoff, time.Now().Unix(),
	)
	if err != nil {
		return 0, fmt.Errorf("keystore: sweep otp: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return 0, fmt.Errorf("keystore: sweep otp rows affected: %w", err)
	}
	return n, nil
}

// ── Helpers ───────────────────────────────────────────────────────────────

func randomDigits(n int) (string, error) {
	if n <= 0 {
		return "", fmt.Errorf("invalid digit count: %d", n)
	}

	buf := make([]byte, n)
	for i := range buf {
		digit, err := rand.Int(rand.Reader, big.NewInt(10))

View on GitHub (pinned to 6e04ca5ff0)