package store import ( "context" "database/sql" "errors" "fmt" "time" sqlite "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" ) // Сколько раз повторять транзакцию, отменённую из-за занятости. // // Повтор оборачивает ВСЮ тройку чтение-слияние-запись, а не отдельный запрос: // после отката прочитанное состояние недействительно, и дописывать в него // пришедшие точки значит терять чужие. const ( txRetries = 5 txRetryDelay = 20 * time.Millisecond ) // inTx выполняет работу в транзакции, повторяя её при занятости базы. // // Транзакция открывается сразу на запись (`_txlock=immediate` в DSN). Без // этого read-modify-write ломается измеримо: при восьми писателях и ста // слияниях каждый deferred-транзакция дала 242 успеха из 800, immediate — 800 // из 800. Причина в том, что повышение блокировки с чтения на запись после // того, как снимок уже прочитан, даёт SQLITE_BUSY_SNAPSHOT (код 517), а его // `busy_timeout` не покрывает: ждать бесполезно, снимок уже устарел. func (s *Store) inTx(ctx context.Context, fn func(*sql.Tx) error) error { var lastErr error for attempt := range txRetries { if attempt > 0 { select { case <-ctx.Done(): return fmt.Errorf("ожидание повтора транзакции: %w", ctx.Err()) case <-time.After(txRetryDelay * time.Duration(attempt)): } } err := runTx(ctx, s.db.DB, fn) if err == nil { return nil } if !isBusy(err) { return err } lastErr = err } // Занятость называется доменной ошибкой здесь, у источника: выше по коду // не должно торчать ни `sqlite.Error`, ни его коды, а ветвиться на этот // исход нужно — доставка при нём остаётся в очереди. return fmt.Errorf("%w: транзакция не прошла за %d попыток: %v", ErrBusy, txRetries, lastErr) //nolint:errorlint // раскрываем sentinel, причину — намеренно нет } func runTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } if err := fn(tx); err != nil { _ = tx.Rollback() return err } if err := tx.Commit(); err != nil { _ = tx.Rollback() return fmt.Errorf("commit tx: %w", err) } return nil } // isBusy распознаёт отказ из-за занятости базы. // // Проверка по типу, а не по тексту: сообщения драйвера не контракт. Кодов два // — SQLITE_BUSY и SQLITE_BUSY_SNAPSHOT; второй возвращается, когда снимок // транзакции устарел, и на него `busy_timeout` не действует. func isBusy(err error) bool { var serr *sqlite.Error if !errors.As(err, &serr) { return false } switch serr.Code() { case sqlite3.SQLITE_BUSY, sqlite3.SQLITE_BUSY_SNAPSHOT: return true default: return false } }