Files
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

94 lines
3.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}