gastownhall/beads · warning · storage.ErrVersionMismatch
%w: expected %d, got %d
Error message
%w: expected %d, got %d
What it means
The core version-conflict branch: the row's current row_lock value differs from the caller's expected version, so the write would clobber another writer's changes. storage.ErrVersionMismatch is returned with expected and actual versions, letting callers implement compare-and-swap retries.
Source
Thrown at internal/storage/issueops/version.go:45
//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
- Re-read the issue to get the fresh row_lock, reapply your changes on top, and retry the mutation with the new version
- Check errors.Is(err, storage.ErrVersionMismatch) and implement bounded retry (e.g. 3 attempts) rather than failing outright
- Merge changes semantically on retry — some fields may have already been changed by the winner
- Serialize conflicting work via assignment/claim CAS instead of raw version retries when contention is high
Example fix
// before
_, err := store.Update(ctx, id, map[string]interface{}{"row_lock": ver, ...})
if err != nil { return err } // gives up on conflict
// after
for i := 0; i < 3; i++ {
iss, _ := store.GetIssue(ctx, id)
_, err := store.Update(ctx, id, map[string]interface{}{"row_lock": iss.RowLock, ...})
if err == nil { return nil }
if !errors.Is(err, storage.ErrVersionMismatch) { return err }
} Defensive patterns
Strategy: retry
Validate before calling
iss, _ := store.GetIssue(ctx, id)
if iss.RowLock != expected { return fmt.Errorf("stale version: have %d, want %d", iss.RowLock, expected) } Try / catch
for attempt := 0; attempt < 3; attempt++ {
err := versionedMutate(ctx, tx, id, expected)
if err == nil { break }
if errors.Is(err, storage.ErrVersionMismatch) {
iss, _ := store.GetIssue(ctx, id)
expected = iss.RowLock // rebase and retry
continue
}
return err
} Prevention
- Always re-fetch row_lock before retrying a conflicted mutation
- Bound retry attempts and merge changes semantically on rebase
- Avoid long-lived snapshots of versions in agents/daemons
- Prefer claim-based coordination to reduce write contention
When it happens
Trigger: Two sessions read the same issue version and both attempt ExecuteUpdate/Close/Delete/Reopen; the loser's expected version no longer matches the row's row_lock after the winner commits.
Common situations: Multiple agents editing the same issue concurrently; retry loops reusing an old version value after a successful first write; daemon and CLI operating on the same issue simultaneously; stale exports replayed against an updated DB.
Related errors
- %w: issue %s
- ErrLockHeld
- server: ExternalDoltServer.Start: server already started
- acquiring lock (blocking): %w
- acquiring lock (non-blocking): %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/57595613dd38541d.
Report an issue: GitHub.