gastownhall/beads · error · storage.ErrNotFound
%w: issue %s
Error message
%w: issue %s
What it means
CheckVersionInTx implements row-version (row_lock) optimistic concurrency. If the issue id has no row, it returns storage.ErrNotFound wrapped with the id — same sentinel contract as the CAS check — so callers can distinguish 'gone' from 'changed'.
Source
Thrown at internal/storage/issueops/version.go:39
// retry-wrapped permanent close path — a writer that commits DURING the close's
// transaction collides on this same row_lock cell at commit time, which
// withRetryTx replays; the replayed attempt then re-reads the new version here
// and refuses. Together they close the read-then-write window that a bare
// read-then-write would leave open.
//
//nolint:gosec // G201: table name comes from WispTableRouting (hardcoded constants)
func CheckVersionInTx(ctx context.Context, tx DBTX, id string, expected int64) error {
isWisp := IsActiveWispInTx(ctx, tx, id)
issueTable, _, _, _ := WispTableRouting(isWisp)
// row_lock is NOT NULL DEFAULT 0, but scan defensively so a NULL maps to 0
// rather than erroring (mirrors scan.go's RowVersion handling).
var current sql.NullInt64
err := tx.QueryRowContext(ctx,
fmt.Sprintf("SELECT row_lock FROM %s WHERE id = ?", issueTable), id,
).Scan(¤t)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
}
if err != nil {
return fmt.Errorf("failed to read row version for %s: %w", id, err)
}
if current.Int64 != expected {
return fmt.Errorf("%w: expected %d, got %d", storage.ErrVersionMismatch, expected, current.Int64)
}
return nil
}
View on GitHub (pinned to 71377f2769)
Solutions
- Verify the issue exists before the versioned mutation
- Branch on errors.Is(err, storage.ErrNotFound) and surface 'already deleted' instead of a generic version failure
- Refresh local issue list (bd sync) if the row was expected to exist
- Guard scripts against reusing IDs from stale snapshots
Example fix
// before
deleteIssue(id) // opaque error when already gone
// after
if err := CheckVersionInTx(ctx, tx, id, ver); errors.Is(err, storage.ErrNotFound) {
return nil // already deleted; treat as success
} Defensive patterns
Strategy: try-catch
Validate before calling
if _, err := store.GetIssue(ctx, id); err != nil { return fmt.Errorf("missing issue %s before versioned op", id) } Try / catch
if err := CheckVersionInTx(ctx, tx, id, expected); errors.Is(err, storage.ErrNotFound) {
return nil // already deleted; treat delete as idempotent success
} Prevention
- Existence-check before versioned close/delete
- Treat ErrNotFound in versioned deletes as success (idempotent delete)
- Sync frequently so local ID references stay live
When it happens
Trigger: CloseIssueCheckedInTx, DeleteInTx, ExecuteUpdate, or ExecuteReopen called with an ExpectedVersion/row-lock value for an issue id that does not exist in the issues table.
Common situations: Deleting or closing an issue another session already removed; stale IDs cached by a long-running agent; exporting/importing issues where the target DB lacks the row.
Related errors
- %w: issue %s
- %w: expected %d, got %d
- no store is open for this workspace
- ErrLockHeld
- server: ExternalDoltServer.Start: server already started
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/4f45d7ec3dc3521c.
Report an issue: GitHub.