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 stable

View on GitHub (pinned to 71377f2769)

Solutions

  1. Re-read the issue to get its current RowVersion, then retry the delete with the fresh version
  2. If the delete does not need concurrency protection, omit ExpectedVersion
  3. 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

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


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