Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
+77 -4
View File
@@ -54,6 +54,13 @@ type Delivery struct {
}
// CreateDelivery записывает факт приёма пакета.
//
// Через ту же транзакцию с повторами, что и слияние точек, и это не симметрия
// ради симметрии. Свёртка держит запись всю доставку целиком — измерено 11
// секунд на 16 тысячах объектов, — а с фоновым воркером конкуренция за базу
// стала штатной. Одиночный `Exec` пересиживал бы только `busy_timeout`, после
// чего приём ответил бы `500` по доставке, тело которой уже на диске: доставка
// исчезла бы из журнала, а телефон её не перешлёт.
func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
const q = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
@@ -66,10 +73,13 @@ func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
headers = "{}"
}
_, err := s.db.ExecContext(ctx, q,
d.ID, FormatTime(d.ReceivedAt), d.AutomationName, d.AutomationID,
d.Aggregation, d.Period, d.SessionID, d.Bytes, d.SHA256,
d.RawPath, d.ParseStatus, d.Points, headers)
err := s.inTx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, q,
d.ID, FormatTime(d.ReceivedAt), d.AutomationName, d.AutomationID,
d.Aggregation, d.Period, d.SessionID, d.Bytes, d.SHA256,
d.RawPath, d.ParseStatus, d.Points, headers)
return err //nolint:wrapcheck // обёртка одна, на выходе
})
if err != nil {
return fmt.Errorf("insert delivery: %w", err)
}
@@ -148,6 +158,69 @@ func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) {
return out, nil
}
// PendingDelivery — доставка, ожидающая свёртки. Она же курсор обхода: место в
// журнале задаётся парой `(received_at, id)`, и вызывающему достаточно передать
// обратно последнюю полученную строку.
//
// Метка приёма отдаётся не для порядка (его держит SQL), а для метки отставания:
// «доставка ждала свёртки дольше N» считается от неё.
type PendingDelivery struct {
ID string
ReceivedAt time.Time
}
// PendingDeliveries возвращает неразобранные доставки в порядке журнала,
// строго после курсора. Нулевой курсор означает «с начала».
//
// Курсор нужен не ради страниц, а ради завершимости обхода: доставка, у которой
// не удалось записать даже исход разбора, остаётся `pending`, и выборка без
// курсора выдавала бы её бесконечно.
func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, limit int) ([]PendingDelivery, error) {
// Сравнение кортежем, а не через OR: развёрнутая форма даёт SCAN по
// индексу вместо SEARCH (проверено EXPLAIN QUERY PLAN). Тот же приём уже
// применён в LastDerivedLayer.
const q = `
SELECT id, received_at FROM delivery
WHERE parse_status = ? AND (received_at, id) > (?, ?)
ORDER BY received_at, id LIMIT ?`
rows, err := s.db.QueryContext(ctx, q, ParsePending, FormatTime(after.ReceivedAt), after.ID, limit)
if err != nil {
return nil, fmt.Errorf("select pending deliveries: %w", err)
}
defer func() { _ = rows.Close() }()
var out []PendingDelivery
for rows.Next() {
var d PendingDelivery
var receivedAt string
if err := rows.Scan(&d.ID, &receivedAt); err != nil {
return nil, fmt.Errorf("scan pending delivery: %w", err)
}
d.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return nil, err
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("select pending deliveries: %w", err)
}
return out, nil
}
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
// разобрать, прежде чем витрина станет полной.
func (s *Store) CountPendingDeliveries(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n,
`SELECT count(*) FROM delivery WHERE parse_status = ?`, ParsePending); err != nil {
return 0, fmt.Errorf("count pending deliveries: %w", err)
}
return n, nil
}
// DeliveryStatus возвращает статус разбора доставки.
func (s *Store) DeliveryStatus(ctx context.Context, id string) (string, error) {
var status string