gastownhall/beads · error

db: MovePersistence %s: %w

Error message

db: MovePersistence %s: %w

What it means

Wraps failure from issueops.MoveIssuePersistenceInTx after the issue row was successfully read. This is the actual move operation failing (promote/demote between tables, event recording, or related mutations inside the transaction). The 'get issue' stage already succeeded at this point.

Source

Thrown at internal/storage/domain/db/issue.go:130

// the exact issueops implementation the classic (direct/embedded) route runs,
// so the two modes cannot drift. The issueops error is returned unwrapped on
// purpose: the CLI surfaces it verbatim ("wisp <id> not found"), and that
// text is part of the classic error contract.
func (r *issueSQLRepositoryImpl) PromoteFromEphemeral(ctx context.Context, id, actor string) error {
	if id == "" {
		return errors.New("db: PromoteFromEphemeral: id must not be empty")
	}
	return issueops.PromoteFromEphemeralInTx(ctx, r.runner, id, actor)
}

func (r *issueSQLRepositoryImpl) MovePersistence(ctx context.Context, id string, mode types.PersistenceMode, actor string) (bool, error) {
	issue, err := issueops.GetIssueInTx(ctx, r.runner, id)
	if err != nil {
		return false, fmt.Errorf("db: MovePersistence %s: get issue: %w", id, err)
	}
	result, err := issueops.MoveIssuePersistenceInTx(ctx, r.runner, issue, mode, actor)
	if err != nil {
		return false, fmt.Errorf("db: MovePersistence %s: %w", id, err)
	}
	return result.Changed, nil
}

func (r *issueSQLRepositoryImpl) Update(ctx context.Context, id string, updates map[string]any, actor string, opts domain.IssueTableOpts) error {
	if id == "" {
		return errors.New("db: Update: id must not be empty")
	}
	if len(updates) == 0 {
		return nil
	}
	updates = cloneUpdateFields(updates)
	// Pop the close-policy override before anything reads the map as a set of
	// columns, mirroring issueops.updateIssueInTx. The no-op filter below keeps
	// unrecognized keys, so a surviving override would reach the field
	// allowlist and be refused by name.
	forceClosePolicy := issueops.PopForceClosePolicy(updates)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check the wrapped error for constraint violations or lock timeouts
  2. Retry with backoff if it was a lock conflict; use retry-safe logic since the transaction rolled back
  3. Verify both source and target tables exist and are migrated
  4. Reduce concurrency: ensure only one process mutates the issue at a time

Example fix

// before
changed, err := repo.MovePersistence(ctx, id, mode, actor)
if err != nil { panic(err) }
// after
changed, err := repo.MovePersistence(ctx, id, mode, actor)
if err != nil {
    if isLockTimeout(err) { time.Sleep(time.Second); retry() }
    return fmt.Errorf("move %s: %w", id, err)
}
Defensive patterns

Strategy: retry

Validate before calling

if _, err := repo.Get(ctx, id, opts); err != nil {
    return fmt.Errorf("cannot move missing issue %s", id)
}

Type guard

func isRetryableMoveErr(err error) bool {
    return isLockTimeoutErr(err) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, driver.ErrBadConn)
}

Try / catch

var changed bool
err := retry(3, backoff, func() error {
    var err error
    changed, err = repo.MovePersistence(ctx, id, mode, actor)
    if err != nil && !isRetryableMoveErr(err) { return stopRetry(err) }
    return err
})
if err != nil { return fmt.Errorf("move %s failed after retries: %w", id, err) }

Prevention

When it happens

Trigger: Calling MovePersistence when the move transaction fails: constraint violations moving rows between persistent/wisp tables, event-record insert failure, lock conflicts with concurrent writers on the same issue, or connection loss mid-transaction.

Common situations: Two agents moving the same issue concurrently; orphaned references blocking an ephemeral-to-persistent promote; disk full or replication issues during write.

Related errors


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