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(&current)
	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

  1. Re-read the issue to get the fresh row_lock, reapply your changes on top, and retry the mutation with the new version
  2. Check errors.Is(err, storage.ErrVersionMismatch) and implement bounded retry (e.g. 3 attempts) rather than failing outright
  3. Merge changes semantically on retry — some fields may have already been changed by the winner
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/57595613dd38541d. Report an issue: GitHub.