gastownhall/beads · error
db: IssueSQLRepository.UnclaimIssueIfAssignee: %w
Error message
db: IssueSQLRepository.UnclaimIssueIfAssignee: %w
What it means
Wraps a failure from issueops.UnclaimIssueIfAssigneeInTx, the compare-and-swap claim release that only succeeds when the current assignee matches expectedAssignee. A mismatch is returned as storage.ErrAssigneeMismatch by the shared helper (wrapped here); empty expectedAssignee is rejected inside the helper. This method intentionally routes wisps to the wisp tables.
Source
Thrown at internal/storage/domain/db/issue.go:1229
return out, nil
}
func (r *issueSQLRepositoryImpl) UnclaimIssue(ctx context.Context, id, actor string, force bool) error {
if err := issueops.UnclaimIssueInTx(ctx, r.runner, id, actor, force); err != nil {
return fmt.Errorf("db: IssueSQLRepository.UnclaimIssue: %w", err)
}
return nil
}
// UnclaimIssueIfAssignee runs the classic compare-and-swap release against this
// runner. Like UnclaimIssue it takes no IssueTableOpts: issueops routes the
// write to the issues or wisps tables from the row itself, so a wisp's claim is
// released against the wisp tables on both backends. The mismatch verdict
// (storage.ErrAssigneeMismatch, nothing written) is produced by the shared
// helper, not restated here.
func (r *issueSQLRepositoryImpl) UnclaimIssueIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {
if err := issueops.UnclaimIssueIfAssigneeInTx(ctx, r.runner, id, actor, expectedAssignee); err != nil {
return fmt.Errorf("db: IssueSQLRepository.UnclaimIssueIfAssignee: %w", err)
}
return nil
}
// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,
// mirroring DoltStore.HeartbeatIssue: wisps are ephemeral and never leased,
// and the SQL work is the classic issueops.HeartbeatIssueInTx — same clock
// (time.Now().UTC()), same TTL resolution (issueops.LeaseTTL), and the same
// only-current-owner classification (storage.ErrAlreadyClaimed /
// ErrNotClaimable) — so classic `bd reclaim` staleness semantics see proxied
// heartbeats identically. Deliberately NO Dolt commit: the leases table is
// dolt_ignored (bd-lrgn1), and the cmd layer commits this transaction with
// uow.RunTxEphemeral (plain SQL COMMIT, nothing in dolt_log).
func (r *issueSQLRepositoryImpl) HeartbeatIssue(ctx context.Context, id, actor string) error {
if issueops.IsActiveWispInTx(ctx, r.runner, id) {
return fmt.Errorf("db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral", storage.ErrNotClaimable, id)
}
if err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {View on GitHub (pinned to 71377f2769)
Solutions
- Check errors.Is(err, storage.ErrAssigneeMismatch): re-fetch the issue to see the current assignee and retry with the correct expected value.
- Never pass an empty expectedAssignee — use UnclaimIssue for unconditional release.
- If the CAS keeps failing, serialize retries (retry loop with fresh read of current assignee).
- For driver-level causes, check connectivity and schema as with other DB errors.
Example fix
// before
err := repo.UnclaimIssueIfAssignee(ctx, id, actor, oldAssignee) // stale value
// after
issue, _ := repo.Get(ctx, id)
if err := repo.UnclaimIssueIfAssignee(ctx, id, actor, issue.Assignee); err != nil {
if errors.Is(err, storage.ErrAssigneeMismatch) { return nil } // lost race, someone else holds it
return err
} Defensive patterns
Strategy: type-guard
Validate before calling
// precondition checks before the CAS
if expectedAssignee == "" { return errors.New("expectedAssignee required; use UnclaimIssue") }
issue, err := repo.Get(ctx, id)
if err != nil { return err }
if issue.Assignee != expectedAssignee { return storage.ErrAssigneeMismatch } Type guard
func isAssigneeMismatch(err error) bool {
return errors.Is(err, storage.ErrAssigneeMismatch)
} Try / catch
err := repo.UnclaimIssueIfAssignee(ctx, id, actor, expected)
switch {
case err == nil:
return nil
case isAssigneeMismatch(err):
return nil // lost the CAS race; current holder keeps it
default:
return fmt.Errorf("conditional unclaim %s: %w", id, err)
} Prevention
- Always errors.Is-check storage.ErrAssigneeMismatch and treat it as an expected outcome.
- Never pass an empty expectedAssignee.
- Re-read the assignee immediately before the CAS to minimize race windows.
- Add a bounded retry loop for high-contention IDs.
When it happens
Trigger: Calling UnclaimIssueIfAssignee(ctx, id, actor, expectedAssignee) when: expectedAssignee is empty, the issue/wisp lookup fails, the current assignee differs from expectedAssignee (storage.ErrAssigneeMismatch), or the conditional UPDATE affects 0 rows / errors at the driver level.
Common situations: Another actor claimed or reassigned the issue between read and release (lost the CAS race), caller passing the wrong expected assignee value, or attempting conditional release on a deleted issue.
Related errors
- db: Claim %s: read old issue: %w
- db: Claim %s: resolve claim pools: %w
- db: Claim %s: resolve claimable statuses: %w
- db: Claim %s: %w
- db: Claim %s: read current state: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/87d5f8f1f2480ebd.
Report an issue: GitHub.