gastownhall/beads · error

db: IssueSQLRepository.WakeExpiredDefers: %w

Error message

db: IssueSQLRepository.WakeExpiredDefers: %w

What it means

Wraps a failure from issueops.WakeExpiredDefersInTx, which scans for issues/wisps whose deferral has expired and moves them back to open, returning per-table counts. Any error in that multi-table sweep is wrapped with repository context. Note that a wisp-only wake still writes wisp rows, so callers must not treat a zero-commit as 'nothing happened'.

Source

Thrown at internal/storage/domain/db/issue.go:1263

		return fmt.Errorf("db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral", storage.ErrNotClaimable, id)
	}
	if err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {
		return fmt.Errorf("db: IssueSQLRepository.HeartbeatIssue: %w", err)
	}
	return nil
}

// WakeExpiredDefers runs the shared lazy defer-wake body against this
// repository's runner (the same DBTX-shaped seam ReclaimExpiredLeases uses)
// and reports how many rows woke per table. The issues count decides whether
// the transaction's owner mints a dolt commit; the wisps count decides
// whether it must still issue a plain SQL commit — wisp tables are
// dolt_ignored, so a wisp-only wake mints no version commit, but a caller
// that treats it as "nothing happened" rolls the wisp writes back.
func (r *issueSQLRepositoryImpl) WakeExpiredDefers(ctx context.Context) (issues, wisps int, err error) {
	out, err := issueops.WakeExpiredDefersInTx(ctx, r.runner)
	if err != nil {
		return 0, 0, fmt.Errorf("db: IssueSQLRepository.WakeExpiredDefers: %w", err)
	}
	return len(out.Issues), len(out.Wisps), nil
}

func (r *issueSQLRepositoryImpl) ReclaimExpiredLeases(ctx context.Context, olderThan time.Duration, filter types.ReclaimFilter, actor string) ([]types.ReclaimedLease, error) {
	cutoff := time.Now().UTC().Add(-olderThan)
	out, err := issueops.ReclaimExpiredLeasesInTx(ctx, r.runner, cutoff, filter, actor)
	if err != nil {
		return nil, fmt.Errorf("db: IssueSQLRepository.ReclaimExpiredLeases: %w", err)
	}
	return out, nil
}

const deleteBatchSize = 200

View on GitHub (pinned to 71377f2769)

Solutions

  1. Read the wrapped cause; if wisp tables are missing, run migrations so optional tables exist.
  2. Serialize wake/reclaim jobs (single background worker or lock) to avoid lock contention.
  3. Retry the sweep on transient errors — it is safe to re-run.
  4. Batch or tune timeouts if large defer backlogs cause slow sweeps.

Example fix

// before
issues, wisps, err := repo.WakeExpiredDefers(ctx)
if err != nil { log.Fatal(err) }
// after
issues, wisps, err := repo.WakeExpiredDefers(ctx)
if err != nil {
    log.Printf("defer wake failed, will retry: %v", err) // idempotent sweep
    return
}
Defensive patterns

Strategy: retry

Validate before calling

// optional: skip wisp traversal on schemas without wisp tables
// (the repo probes tables itself; just ensure migrations have run)
if err := runMigrations(db); err != nil { return err }

Try / catch

issues, wisps, err := repo.WakeExpiredDefers(ctx)
if err != nil {
    if isTransientDBErr(err) { scheduleRetry(err); return }
    log.Printf("wake expired defers failed: %v", err)
    return
}

Prevention

When it happens

Trigger: Calling WakeExpiredDefers(ctx) when the internal SELECT of expired defers or the subsequent UPDATEs (issues and/or wisps tables) fail: driver errors, missing tables, scan errors, or transaction aborts.

Common situations: wisp tables absent on an older schema without optional-table probing succeeding, connection drop mid-sweep, lock contention with concurrent reclaim/wake jobs, or timeout with many expired defers.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/6d12a485a7771cec. Report an issue: GitHub.