charmbracelet/crush · error

failed to begin transaction: %w

Error message

failed to begin transaction: %w

What it means

history.Service.createWithVersion opens a SQLite transaction via db.BeginTx before inserting a file version. This error wraps a BeginTx failure, meaning no transaction could even be started — the retry loop deliberately does not retry this case and returns immediately.

Source

Thrown at internal/history/file.go:95

	// Get the latest version
	latestFile := files[0] // Files are ordered by version DESC, created_at DESC
	nextVersion := latestFile.Version + 1

	return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
}

func (s *service) createWithVersion(ctx context.Context, sessionID, path, content string, version int64) (File, error) {
	// Maximum number of retries for transaction conflicts
	const maxRetries = 3
	var file File
	var err error

	// Retry loop for transaction conflicts
	for attempt := range maxRetries {
		// Start a transaction
		tx, txErr := s.db.BeginTx(ctx, nil)
		if txErr != nil {
			return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
		}

		// Create a new queries instance with the transaction
		qtx := s.q.WithTx(tx)

		// Try to create the file within the transaction
		dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
			ID:        uuid.New().String(),
			SessionID: sessionID,
			Path:      path,
			Content:   content,
			Version:   version,
		})
		if txErr != nil {
			// Rollback the transaction
			tx.Rollback()

			// Check if this is a uniqueness constraint violation

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Check the wrapped error: `sql: database is closed` means DB lifetime management is wrong — keep the DB open until all writers finish
  2. If the context is canceled, propagate a fresh/longer-lived context for history writes
  3. Reduce concurrent long-lived transactions so the pool has a free connection for BeginTx
  4. Verify database connection options (MaxOpenConns, busy_timeout) are adequate for the write load

Example fix

// before
file, err := hist.Create(ctx, sessionID, path, content)
// after — use a detached context for durable history writes
ctx2, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
file, err := hist.Create(ctx2, sessionID, path, content)
Defensive patterns

Strategy: retry

Validate before calling

// Verify DB is usable before history writes
if err := s.db.PingContext(ctx); err != nil {
    return fmt.Errorf("history db not ready: %w", err)
}
if err := ctx.Err(); err != nil {
    return fmt.Errorf("context already done: %w", err)
}

Type guard

func beginTxPossible(db *sql.DB, ctx context.Context) bool {
    return db != nil && ctx.Err() == nil && db.PingContext(ctx) == nil
}

Try / catch

file, err := hist.Create(ctx, sessionID, path, content)
if err != nil {
    var retryable bool
    if strings.Contains(err.Error(), "failed to begin transaction") &&
        (errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "too many clients")) {
        retryable = true
    }
    if retryable {
        file, err = hist.Create(ctx, sessionID, path, content) // single retry
    }
    if err != nil {
        return err
    }
}

Prevention

When it happens

Trigger: Calling Create or CreateVersion while the underlying database connection is closed, the pool is exhausted (all connections busy — e.g. a long-running transaction elsewhere holds the only connection), or the context passed in is already canceled.

Common situations: App shutdown closing the DB while a session still writes file history; too many concurrent writers blocking the single SQLite connection; passing a canceled/expired context to Create during a request timeout.

Related errors


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/06bf2e7a45e61cae. Report an issue: GitHub.