gastownhall/beads · critical

release checked close savepoint: %v; rollback to savepoint:

Error message

release checked close savepoint: %v; rollback to savepoint: %w

What it means

The checked close itself succeeded, but RELEASE SAVEPOINT failed, and the fallback ROLLBACK TO SAVEPOINT also failed. The close work may be in an ambiguous savepoint state inside the transaction, so the library returns this compound error (release error as %v context, rollback error wrapped via %w) instead of pretending the transaction is clean.

Source

Thrown at internal/storage/issueops/close.go:101

	// statements cannot retain a savepoint. The shared UOW uses a pinned
	// *sql.Conn after START TRANSACTION, while embedded callers use *sql.Tx.
	if !closeCheckedSavepointEligible(tx) {
		return closeIssueCheckedAfterSavepoint(ctx, tx, id, reason, actor, session, force, closed, targetColumn)
	}
	savepoint, err := createCloseCheckedSavepoint(ctx, tx)
	if err != nil {
		return nil, err
	}
	result, scopedErr := closeIssueCheckedAfterSavepoint(ctx, tx, id, reason, actor, session, force, closed, targetColumn)
	if scopedErr != nil {
		if cleanupErr := rollbackAndReleaseCloseCheckedSavepoint(ctx, tx, savepoint); cleanupErr != nil {
			return nil, fmt.Errorf("discard checked close savepoint after %v: %w", scopedErr, cleanupErr)
		}
		return nil, scopedErr
	}
	if err := releaseCloseCheckedSavepoint(ctx, tx, savepoint); err != nil {
		if rollbackErr := rollbackToCloseCheckedSavepoint(ctx, tx, savepoint); rollbackErr != nil {
			return nil, fmt.Errorf("release checked close savepoint: %v; rollback to savepoint: %w", err, rollbackErr)
		}
		return nil, err
	}
	return result, nil
}

func closeCheckedSavepointEligible(tx DBTX) bool {
	switch tx.(type) {
	case *sql.Tx, *sql.Conn:
		return true
	default:
		return false
	}
}

func closeIssueCheckedAfterSavepoint(ctx context.Context, tx DBTX, id, reason, actor, session string, force, closed bool, targetColumn string) (*CloseResult, error) {
	openChildren, err := enforceClosePolicyForTargetInTx(ctx, tx, id, targetColumn, force, closed)
	if err != nil {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Roll back the entire transaction; do not attempt to commit it
  2. Read the embedded release error for the root cause (connection/context)
  3. Retry the close in a new transaction — the previous one is discarded
  4. If using a custom DBTX wrapper, ensure it is *sql.Tx or *sql.Conn so savepoint logic is eligible, or avoid the savepoint path entirely

Example fix

if err != nil && strings.Contains(err.Error(), "rollback to savepoint") {
	_ = tx.Rollback() // do not commit — savepoint state is unknown
	return retryCloseInFreshTx(ctx, id)
}
Defensive patterns

Strategy: try-catch

Try / catch

result, err := store.CloseIssue(ctx, id, reason, actor)
if err != nil && strings.Contains(err.Error(), "rollback to savepoint") {
	_ = tx.Rollback() // savepoint state unknown — abandon the transaction
	return retryInFreshTransaction(ctx, id)
}

Prevention

When it happens

Trigger: createCloseCheckedSavepoint succeeded and the close completed, but both RELEASE SAVEPOINT and ROLLBACK TO SAVEPOINT error — nearly always a dead connection, canceled context, or a transaction the driver has already aborted.

Common situations: Server restart or network partition right after the close statement; a *sql.DB Runner passed where a savepoint-capable *sql.Tx/*sql.Conn is expected (that path is normally gated by closeCheckedSavepointEligible, but custom DBTX wrappers can evade it).

Related errors


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