- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
154 lines
5.3 KiB
Go
154 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|