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(&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. Verify the issue exists before the versioned mutation
  2. Branch on errors.Is(err, storage.ErrNotFound) and surface 'already deleted' instead of a generic version failure
  3. Refresh local issue list (bd sync) if the row was expected to exist
  4. 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

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


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