gastownhall/beads · error
ErrVersionMismatch
ErrVersionMismatch
Error message
%w: expected %d, got %d
What it means
An optimistic-concurrency failure on delete: the request carried an ExpectedVersion and the row's current RowVersion does not match, so the delete refuses to proceed rather than silently deleting a row modified by someone else. Applies only to single-id requests (multi-id versioned deletes are refused in validation).
Source
Thrown at internal/storage/uow/deleter.go:132
return publicops.DeleteResult{}, &publicops.NotFoundError{IDs: missing}
}
// The version precondition, between the existence probe and the dependents
// guard exactly as issueops.Deleter.Delete orders them.
//
// This leg compares the row the probe already loaded rather than issuing
// its own guard read, which is what updatePreconditionsHold does for the
// same token on the update path. The row was read inside this unit of work,
// so the comparison and the deletion still see one snapshot; the sentinel
// and the message are the shared ones, because a caller matching
// ErrVersionMismatch must not have to know which backend answered.
//
// req.IDs[0] is the only distinct id: ValidateDeleteRequest refused a
// multi-id request carrying a version and NormalizeDeleteIDs collapsed the
// duplicates before either ran.
if req.ExpectedVersion != nil {
if current := present[req.IDs[0]].RowVersion; current != *req.ExpectedVersion {
return publicops.DeleteResult{}, fmt.Errorf("%w: expected %d, got %d",
publicops.ErrVersionMismatch, *req.ExpectedVersion, current)
}
}
// The guard runs only when the request did not already say what to do
// about dependents. Under Cascade there is nothing outside the set by
// construction.
if !req.Cascade {
idSet := make(map[string]bool, len(req.IDs))
for _, id := range req.IDs {
idSet[id] = true
}
external, err := externalDependentsBySourceInUOW(ctx, uw, req.IDs, idSet)
if err != nil {
return publicops.DeleteResult{}, err
}
if !req.Force {
// Request order, so the id a caller is told about is stableView on GitHub (pinned to 71377f2769)
Solutions
- Re-read the issue to get its current RowVersion, then retry the delete with the fresh version
- If the delete does not need concurrency protection, omit ExpectedVersion
- Use a reconcile flow: detect mismatch, merge/reconcile changes, then delete
Example fix
// before
req := publicops.DeleteRequest{IDs: []string{"bd-1"}, ExpectedVersion: &staleVersion}
// after
issue, _ := store.GetIssue(ctx, "bd-1") // refresh
req := publicops.DeleteRequest{IDs: []string{"bd-1"}, ExpectedVersion: &issue.RowVersion} Defensive patterns
Strategy: retry
Validate before calling
cur, err := store.GetIssue(ctx, id)
if err != nil { return err }
if req.ExpectedVersion != nil && cur.RowVersion != *req.ExpectedVersion {
return fmt.Errorf("stale read: expected %d, current %d", *req.ExpectedVersion, cur.RowVersion)
} Type guard
func isVersionMismatch(err error) bool {
return errors.Is(err, publicops.ErrVersionMismatch)
} Try / catch
res, err := deleteInUOW(ctx, req)
if errors.Is(err, publicops.ErrVersionMismatch) {
fresh, _ := store.GetIssue(ctx, req.IDs[0])
req.ExpectedVersion = &fresh.RowVersion // re-read and retry once
res, err = deleteInUOW(ctx, req)
} Prevention
- Always re-read the row immediately before a versioned delete
- Omit ExpectedVersion when concurrent modification is acceptable
- Serialize writes to the same issue through one actor/queue
- Detect ErrVersionMismatch and refresh rather than loop blindly
When it happens
Trigger: deleteInUOW compares present[req.IDs[0]].RowVersion against *req.ExpectedVersion and they differ — i.e. the issue/wisp was updated (or its version bumped) between the caller reading it and issuing the delete.
Common situations: Two agents/users editing the same issue concurrently; a stale UI or cached copy issuing a versioned delete after another writer updated the issue; automated scripts re-running against already-modified issues.
Related errors
- failed to unclaim issue %s: no matching row
- %w: expected %d, got %d
- lock busy: held by another process
- lock already held by another process
- lock already held by another process
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/d7d28ff7bfe1f09d.
Report an issue: GitHub.