Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotFound — записи нет. Граничную ошибку драйвера (sql.ErrNoRows)
|
||||
// транслируем в доменную здесь же, у источника, чтобы выше по коду не торчал
|
||||
// database/sql.
|
||||
var ErrNotFound = errors.New("запись не найдена")
|
||||
|
||||
// ErrBusy — база занята, и повторы транзакции этого не пересидели.
|
||||
//
|
||||
// Доменная ошибка, а не код драйвера: на неё ветвится свёртка. Отказ по
|
||||
// занятости не является свойством доставки — работа просто не сделана, и
|
||||
// доставка обязана остаться в очереди. Без этого различения конкуренция за
|
||||
// базу выводила бы доставку из очереди навсегда.
|
||||
var ErrBusy = errors.New("база занята")
|
||||
|
||||
// Transient отвечает, вызван ли отказ ОБСТОЯТЕЛЬСТВАМИ, а не данными.
|
||||
//
|
||||
// Ровно два случая: работу прекратили снаружи и база оказалась занята дольше,
|
||||
// чем длятся повторы транзакции. Оба означают «не сделано», а не «не выходит»,
|
||||
// поэтому работа обязана остаться к повторению.
|
||||
//
|
||||
// Определение живёт здесь, в одном месте, и его читают двое: тот, кто пишет
|
||||
// исход разбора доставки, и тот, кто классифицирует этот исход в счётчики. Две
|
||||
// копии правила разошлись бы, и доставка одновременно осталась бы в очереди и
|
||||
// числилась отказавшей.
|
||||
//
|
||||
// Дедлайн самой операции сюда НЕ входит: не уложившаяся в бюджет работа не
|
||||
// уложится в него и в следующий раз, а бесконечный повтор заведомо
|
||||
// безнадёжного — это очередь, которая не движется.
|
||||
func Transient(err error) bool {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- Очередью свёртки служит сама таблица: доставка ждёт разбора в статусе
|
||||
-- `pending`, а фоновый воркер выбирает такие строки в порядке журнала. Запрос
|
||||
-- идёт чаще, чем раз в минуту, а `delivery` растёт примерно на 300 строк в
|
||||
-- сутки — без индекса это скан всей таблицы с сортировкой на каждый проход.
|
||||
--
|
||||
-- Индекс ЧАСТИЧНЫЙ, и это не украшение: в установившемся режиме неразобранных
|
||||
-- доставок ноль или одна, поэтому индекс держит ноль-одну строку. Полный
|
||||
-- индекс по `parse_status` хранил бы всю историю (сто тысяч строк в год) ради
|
||||
-- выборки из одной. SQLite применяет частичный индекс, когда условие запроса
|
||||
-- следует из условия индекса — наш случай.
|
||||
--
|
||||
-- Порядок колонок = порядок журнала, тот же, в котором проигрывает пересборка.
|
||||
-- Второй ключ обязателен: `received_at` хранится с секундной точностью, и
|
||||
-- доставки одной секунды без него шли бы в неопределённом порядке.
|
||||
CREATE INDEX delivery_pending ON delivery (received_at, id)
|
||||
WHERE parse_status = 'pending';
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX delivery_pending;
|
||||
@@ -0,0 +1,153 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Очередь свёртки — сама таблица, и порядок её обхода это порядок журнала:
|
||||
// `(received_at, id)`, тот же, в котором проигрывает пересборка.
|
||||
func TestPendingDeliveriesИдётВПорядкеЖурнала(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
// Учёт заполняется в порядке, обратном хронологии, и доставки одной секунды
|
||||
// различаются только идентификатором.
|
||||
seedPending(t, st, "d3", at.Add(time.Second))
|
||||
seedPending(t, st, "d2", at)
|
||||
seedPending(t, st, "d1", at)
|
||||
|
||||
got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
want := []string{"d1", "d2", "d3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("выбрано %d доставок, ожидалось %d", len(got), len(want))
|
||||
}
|
||||
for i, id := range want {
|
||||
if got[i].ID != id {
|
||||
t.Errorf("на месте %d доставка %q, ожидалась %q", i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Курсор строго возрастает, и обход им конечен: без этого доставка, у которой
|
||||
// не удалось записать даже исход разбора, выбиралась бы бесконечно.
|
||||
func TestPendingDeliveriesКурсорСтрогоВозрастает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
seedPending(t, st, "d1", at)
|
||||
seedPending(t, st, "d2", at)
|
||||
|
||||
first, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(first) != 1 || first[0].ID != "d1" {
|
||||
t.Fatalf("первая порция %+v", first)
|
||||
}
|
||||
|
||||
second, err := st.PendingDeliveries(ctx, first[0], 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(second) != 1 || second[0].ID != "d2" {
|
||||
t.Fatalf("вторая порция %+v", second)
|
||||
}
|
||||
|
||||
// Доставка, оставшаяся `pending`, за курсором больше не выбирается — именно
|
||||
// на этом стоит завершимость прохода воркера.
|
||||
third, err := st.PendingDeliveries(ctx, second[0], 1)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(third) != 0 {
|
||||
t.Errorf("за последней доставкой выбрано %d строк", len(third))
|
||||
}
|
||||
}
|
||||
|
||||
// В очередь попадают только неразобранные: свёрнутая доставка из неё выбывает,
|
||||
// иначе воркер сворачивал бы весь журнал на каждом проходе.
|
||||
func TestPendingDeliveriesБерётТолькоНеразобранные(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
seedPending(t, st, "d1", at)
|
||||
seedPending(t, st, "d2", at.Add(time.Second))
|
||||
if err := st.FinishParse(ctx, "d1", store.ParseOutcome{Status: store.ParseDone}); err != nil {
|
||||
t.Fatalf("FinishParse: %v", err)
|
||||
}
|
||||
|
||||
n, err := st.CountPendingDeliveries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountPendingDeliveries: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("задолженность %d, ожидалась 1", n)
|
||||
}
|
||||
|
||||
got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingDeliveries: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "d2" {
|
||||
t.Errorf("в очереди %+v, ожидалась только d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Правило «отказ обстоятельств, а не данных» живёт в одном месте: его читают и
|
||||
// тот, кто пишет исход разбора, и тот, кто классифицирует этот исход.
|
||||
func TestTransientРазличаетОбстоятельстваИДанные(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
"работу прекратили снаружи": {context.Canceled, true},
|
||||
"база занята": {store.ErrBusy, true},
|
||||
"база занята, обёрнута": {fmt.Errorf("слияние: %w", store.ErrBusy), true},
|
||||
"не уложились в бюджет": {context.DeadlineExceeded, false},
|
||||
"записи нет": {store.ErrNotFound, false},
|
||||
"прочее": {errors.New("диск отвалился"), false},
|
||||
"ошибки нет": {nil, false},
|
||||
}
|
||||
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := store.Transient(c.err); got != c.want {
|
||||
t.Errorf("Transient(%v) = %v, ожидалось %v", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
|
||||
t.Helper()
|
||||
|
||||
err := st.CreateDelivery(context.Background(), store.Delivery{
|
||||
ID: id, ReceivedAt: at, RawPath: id + ".json.gz",
|
||||
SHA256: "-", ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("запись доставки %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,10 @@ func (s *Store) inTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return fmt.Errorf("транзакция не прошла за %d попыток: %w", txRetries, lastErr)
|
||||
// Занятость называется доменной ошибкой здесь, у источника: выше по коду
|
||||
// не должно торчать ни `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 {
|
||||
|
||||
Reference in New Issue
Block a user