| package sql |
|
|
| import ( |
| "context" |
| "database/sql" |
| "fmt" |
| "strings" |
| "time" |
| ) |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func (s *SQLStore) WithTransaction(ctx context.Context, fn func(*sql.Tx) error) error { |
| return withTransaction(ctx, s.db, fn) |
| } |
|
|
| |
| |
| |
| |
| |
| func withTransaction(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const maxRetries = 12 |
| const baseDelay = 25 * time.Millisecond |
|
|
| |
| deadline, hasDeadline := ctx.Deadline() |
|
|
| for attempt := 0; attempt < maxRetries; attempt++ { |
| err := executeSingleTransaction(ctx, db, fn) |
|
|
| |
| if err == nil || !isSQLiteBusyError(err) { |
| return err |
| } |
|
|
| |
| if attempt < maxRetries-1 { |
| |
| nextDelay := calculateBackoffDelay(attempt, baseDelay) |
|
|
| |
| if hasDeadline { |
| |
| if time.Now().Add(nextDelay).After(deadline) { |
| return fmt.Errorf("transaction aborted: context deadline would be exceeded (attempted %d retries): %w", attempt+1, err) |
| } |
| } |
|
|
| |
| timer := time.NewTimer(nextDelay) |
| select { |
| case <-ctx.Done(): |
| if !timer.Stop() { |
| <-timer.C |
| } |
| return fmt.Errorf("transaction cancelled after %d retries: %w", attempt+1, ctx.Err()) |
| case <-timer.C: |
| |
| } |
| continue |
| } |
|
|
| |
| return fmt.Errorf("transaction failed after %d retries: %w", maxRetries, err) |
| } |
|
|
| return fmt.Errorf("unexpected: retry loop exited without result") |
| } |
|
|
| |
| func executeSingleTransaction(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (err error) { |
| |
| tx, err := db.BeginTx(ctx, nil) |
| if err != nil { |
| return fmt.Errorf("begin transaction: %w", err) |
| } |
|
|
| |
| |
| defer func() { |
| if p := recover(); p != nil { |
| |
| _ = tx.Rollback() |
| panic(p) |
| } else if err != nil { |
| |
| _ = tx.Rollback() |
| } |
| }() |
|
|
| |
| if err = fn(tx); err != nil { |
| return err |
| } |
|
|
| |
| if err = tx.Commit(); err != nil { |
| return fmt.Errorf("commit transaction: %w", err) |
| } |
|
|
| return nil |
| } |
|
|
| |
| |
| func isSQLiteBusyError(err error) bool { |
| if err == nil { |
| return false |
| } |
|
|
| errMsg := strings.ToLower(err.Error()) |
|
|
| |
| busyPatterns := []string{ |
| "database is locked", |
| "database is deadlocked", |
| "database table is locked", |
| "sqlite_busy", |
| "sqlite_locked", |
| } |
|
|
| for _, pattern := range busyPatterns { |
| if strings.Contains(errMsg, pattern) { |
| return true |
| } |
| } |
|
|
| return false |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func calculateBackoffDelay(attempt int, baseDelay time.Duration) time.Duration { |
| |
| shift := min(max(attempt, 0), 10) |
| delay := baseDelay * time.Duration(1<<uint(shift)) |
|
|
| |
| |
| randomFactor := float64(time.Now().UnixNano()%100) / 100.0 |
| jitter := time.Duration(float64(delay) * (0.5 + 0.5*randomFactor)) |
|
|
| return jitter |
| } |
|
|