{"record":{"id":"87d5f8f1f2480ebd","repo":"gastownhall/beads","slug":"db-issuesqlrepository-unclaimissueifassignee-w","errorCode":null,"errorMessage":"db: IssueSQLRepository.UnclaimIssueIfAssignee: %w","messagePattern":"db: IssueSQLRepository\\.UnclaimIssueIfAssignee: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/domain/db/issue.go","lineNumber":1229,"sourceCode":"\treturn out, nil\n}\n\nfunc (r *issueSQLRepositoryImpl) UnclaimIssue(ctx context.Context, id, actor string, force bool) error {\n\tif err := issueops.UnclaimIssueInTx(ctx, r.runner, id, actor, force); err != nil {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.UnclaimIssue: %w\", err)\n\t}\n\treturn nil\n}\n\n// UnclaimIssueIfAssignee runs the classic compare-and-swap release against this\n// runner. Like UnclaimIssue it takes no IssueTableOpts: issueops routes the\n// write to the issues or wisps tables from the row itself, so a wisp's claim is\n// released against the wisp tables on both backends. The mismatch verdict\n// (storage.ErrAssigneeMismatch, nothing written) is produced by the shared\n// helper, not restated here.\nfunc (r *issueSQLRepositoryImpl) UnclaimIssueIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {\n\tif err := issueops.UnclaimIssueIfAssigneeInTx(ctx, r.runner, id, actor, expectedAssignee); err != nil {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.UnclaimIssueIfAssignee: %w\", err)\n\t}\n\treturn nil\n}\n\n// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,\n// mirroring DoltStore.HeartbeatIssue: wisps are ephemeral and never leased,\n// and the SQL work is the classic issueops.HeartbeatIssueInTx — same clock\n// (time.Now().UTC()), same TTL resolution (issueops.LeaseTTL), and the same\n// only-current-owner classification (storage.ErrAlreadyClaimed /\n// ErrNotClaimable) — so classic `bd reclaim` staleness semantics see proxied\n// heartbeats identically. Deliberately NO Dolt commit: the leases table is\n// dolt_ignored (bd-lrgn1), and the cmd layer commits this transaction with\n// uow.RunTxEphemeral (plain SQL COMMIT, nothing in dolt_log).\nfunc (r *issueSQLRepositoryImpl) HeartbeatIssue(ctx context.Context, id, actor string) error {\n\tif issueops.IsActiveWispInTx(ctx, r.runner, id) {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral\", storage.ErrNotClaimable, id)\n\t}\n\tif err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {","sourceCodeStart":1211,"sourceCodeEnd":1247,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/domain/db/issue.go#L1211-L1247","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nerr := repo.UnclaimIssueIfAssignee(ctx, id, actor, oldAssignee) // stale value\n// after\nissue, _ := repo.Get(ctx, id)\nif err := repo.UnclaimIssueIfAssignee(ctx, id, actor, issue.Assignee); err != nil {\n    if errors.Is(err, storage.ErrAssigneeMismatch) { return nil } // lost race, someone else holds it\n    return err\n}","handlingStrategy":"type-guard","validationCode":"// precondition checks before the CAS\nif expectedAssignee == \"\" { return errors.New(\"expectedAssignee required; use UnclaimIssue\") }\nissue, err := repo.Get(ctx, id)\nif err != nil { return err }\nif issue.Assignee != expectedAssignee { return storage.ErrAssigneeMismatch }","typeGuard":"func isAssigneeMismatch(err error) bool {\n    return errors.Is(err, storage.ErrAssigneeMismatch)\n}","tryCatchPattern":"err := repo.UnclaimIssueIfAssignee(ctx, id, actor, expected)\nswitch {\ncase err == nil:\n    return nil\ncase isAssigneeMismatch(err):\n    return nil // lost the CAS race; current holder keeps it\ndefault:\n    return fmt.Errorf(\"conditional unclaim %s: %w\", id, err)\n}","preventionTips":["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."],"tags":["database","claim","optimistic-concurrency"],"backgroundTag":"assignee-mismatch","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}