{"record":{"id":"48363eeddfad9086","repo":"gastownhall/beads","slug":"failed-to-unclaim-issue-s-no-matching-row","errorCode":null,"errorMessage":"failed to unclaim issue %s: no matching row","messagePattern":"failed to unclaim issue (.+?): no matching row","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/unclaim.go","lineNumber":100,"sourceCode":"\t\t    started_at = NULL, row_lock = ?\n\t\tWHERE id = ? AND status IN ('open', 'in_progress') AND row_lock = ?\n\t`, issueTable), now, freshRowLock(), id, oldIssue.RowVersion)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to unclaim issue: %w\", err)\n\t}\n\n\trowsAffected, err := result.RowsAffected()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get rows affected: %w\", err)\n\t}\n\n\tif rowsAffected == 0 {\n\t\t// The pre-checks passed, so a 0-row result means the row changed\n\t\t// underneath us: re-read to disambiguate an ownership change from a\n\t\t// status change. actorMatches, not verbatim, mirrors the precheck above.\n\t\tcurrent, gerr := GetIssueInTx(ctx, tx, id)\n\t\tif gerr != nil {\n\t\t\treturn fmt.Errorf(\"failed to unclaim issue %s: no matching row\", id)\n\t\t}\n\t\tif !force && !actorMatches(current.Assignee, actor) {\n\t\t\treturn fmt.Errorf(\"%w: %s is held by %s; coordinate with the holder — pass --force only if their claim is abandoned (crashed agent, expired lease)\",\n\t\t\t\tstorage.ErrNotOwner, id, current.Assignee)\n\t\t}\n\t\treturn fmt.Errorf(\"failed to unclaim issue %s: no matching row\", id)\n\t}\n\n\treturn finishUnclaimInTx(ctx, tx, eventTable, id, actor, oldIssue)\n}\n\n// finishUnclaimInTx applies the post-UPDATE half of a release shared by\n// UnclaimIssueInTx and UnclaimIssueIfAssigneeInTx: it drops the lease row (a\n// no-op when none exists, e.g. a wisp or an open-but-assigned issue that was\n// never leased) and records the \"unclaimed\" event. The row mutation\n// (assignee/status/started_at/row_lock) must already have been applied in tx.\nfunc finishUnclaimInTx(ctx context.Context, tx DBTX, eventTable string, id string, actor string, oldIssue *types.Issue) error {\n\tif err := DeleteLeaseInTx(ctx, tx, id); err != nil {","sourceCodeStart":82,"sourceCodeEnd":118,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/unclaim.go#L82-L118","documentation":"When the guarded UPDATE affects 0 rows, the row changed underneath the reader. UnclaimIssueInTx re-reads the issue; if the re-read fails, or the row no longer matches the guarded WHERE (e.g. status moved to closed or the row lock moved on), it returns this error. The optimistic lock lost the race and no unclaim happened — the caller must re-inspect state and decide again.","triggerScenarios":"Concurrent modification between GetIssue and UPDATE: another process closed the issue, changed the assignee, or bumped row_lock/RowVersion so WHERE (status IN ('open','in_progress') AND row_lock = ?) matched 0 rows.","commonSituations":"Two agents racing on the same issue; an external process closing the issue while a cleanup hook releases the claim; a lease expirer resetting rows during release.","solutions":["Re-read the issue and re-evaluate: retry the full claim-check-release sequence with fresh state.","If the issue is now closed or already unassigned, skip the release — the race resolved the claim.","Surface the conflict instead of looping; add small jitter/backoff before retry.","Restrict release to the owning agent to avoid many processes racing on the same claim."],"exampleFix":"// before\nerr := store.ReleaseIssue(ctx, id)\nif err != nil { return err } // lost race, hard fail\n// after\nerr := store.ReleaseIssue(ctx, id)\nif err != nil {\n\tiss, gerr := store.GetIssue(ctx, id)\n\tif gerr == nil && (iss.Assignee == \"\" || iss.Status == types.StatusClosed) {\n\t\treturn nil // race already resolved\n\t}\n\treturn err\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"if err := store.ReleaseIssue(ctx, id); err != nil {\n\tif strings.Contains(err.Error(), \"no matching row\") {\n\t\t// re-read state; retry once with backoff or treat as resolved\n\t}\n}","preventionTips":["Keep claim/release single-owner: only the claiming agent releases.","Add bounded retry with jitter around release in concurrent environments.","Re-read issue state after any 'no matching row' error before deciding next steps.","Reduce cross-process mutation windows by keeping operations short."],"tags":["concurrency","optimistic-locking","race-condition","claim-management"],"backgroundTag":"concurrent-modification","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}