Приём отвечает 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
+97
View File
@@ -0,0 +1,97 @@
package fold_test
import (
"bytes"
"context"
"database/sql"
"errors"
"flag"
"log/slog"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite" // тот же чистый Go-драйвер, что и у хранилища
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Прогон под удерживаемой блокировкой намеренно не входит в `task test` и
// `task gate`: `busy_timeout` — пять секунд, повторов транзакции пять, то есть
// один этот тест стоит около двадцати пяти секунд, а гейт гоняет тесты трижды
// (обычно, на флаки и под детектором гонок).
//
// Проверяет он при этом центральное решение задачи «разнести ответ и свёртку»:
// занятость базы — обстоятельство, а не свойство доставки, и доставка обязана
// остаться в очереди. Ошибка здесь означает молчаливую потерю: `failed` фоновая
// свёртка не подбирает никогда, а вернуть доставку может только пересборка с
// остановкой сервиса и ручной подменой базы.
var runBusy = flag.Bool("healthlog.busy", false,
"прогнать свёртку под удерживаемой блокировкой базы (около 25 секунд)")
func TestBusyЗанятаяБазаОставляетДоставкуВОчереди(t *testing.T) {
if !*runBusy {
t.Skip("прогон под блокировкой выключен: задайте -healthlog.busy")
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "healthlog.db")
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("база: %v", err)
}
defer func() { _ = st.Close() }()
var logs bytes.Buffer
f := fold.New(arch, st, 0, slog.New(slog.NewJSONHandler(&logs, nil)))
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
// Второе соединение держит запись, как её держит свёртка широкой доставки:
// измерено 11 секунд на 16 тысячах объектов, то есть окно реальное.
holder, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(100)&_txlock=immediate")
if err != nil {
t.Fatalf("второе соединение: %v", err)
}
defer func() { _ = holder.Close() }()
tx, err := holder.BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("удержание записи: %v", err)
}
if _, err := tx.Exec(`UPDATE delivery SET points = points WHERE id = 'd1'`); err != nil {
t.Fatalf("удержание записи: %v", err)
}
defer func() { _ = tx.Rollback() }()
_, err = f.Fold(context.Background(), "d1")
if !errors.Is(err, store.ErrBusy) {
t.Fatalf("ошибка свёртки = %v, ожидалась %v", err, store.ErrBusy)
}
status, err := st.DeliveryStatus(context.Background(), "d1")
if err != nil {
t.Fatalf("DeliveryStatus: %v", err)
}
if status != store.ParsePending {
t.Errorf("parse_status = %q, ожидался %q: занятость базы вывела доставку из очереди",
status, store.ParsePending)
}
// Статуса мало: пока база занята, запись `failed` тоже не проходит, и
// `pending` получился бы и без правила. Различает их лог — свёртка обязана
// сказать «отложено», а не «отказ».
out := logs.String()
if !strings.Contains(out, "delivery fold deferred") {
t.Errorf("нет записи об отложенной свёртке:\n%s", out)
}
if strings.Contains(out, "delivery fold failed") {
t.Errorf("занятость базы записана отказом доставки:\n%s", out)
}
}
+43 -4
View File
@@ -90,13 +90,33 @@ type Stats struct {
UncoveredDropped int
}
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
// пересборка вернёт её, когда дефект будет исправлен.
var ErrPanicked = errors.New("свёртка паниковала")
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
//
// Это единственный логирующий чекпоинт свёртки: транспорт и приём исход
// разбора не логируют. Значения точек и имена устройств в лог не попадают —
// данные о здоровье чувствительнее токенов.
func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
var stats Stats
//
// Паника перехватывается ЗДЕСЬ, у той же границы, что пишет исход разбора.
// Пока свёртка шла внутри HTTP-обработчика, панику ловил middleware.Recoverer и
// она стоила одного ответа; из фоновой горутины она валит процесс целиком, а
// `restart: unless-stopped` поднимает его снова — и первый же проход берёт ту
// же доставку, то есть дефект превращается в цикл перезапуска, при котором
// приём не работает вовсе. Перехват у этой границы, а не у вызывающего,
// оставляет писателя `parse_status` единственным.
func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err error) {
defer func() {
r := recover()
if r == nil {
return
}
stats = Stats{}
err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления
s.fail(ctx, deliveryID, err, nil)
}()
d, err := s.store.DeliveryForParse(ctx, deliveryID)
if err != nil {
@@ -282,9 +302,28 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse
return nil
}
// fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт
// пересборка — приём при этом не затрагивается: сохранили значит приняли.
// fail записывает исход неудачной свёртки.
//
// Исход отражает ДОСТАВКУ, а не обстоятельства. Отмена снаружи и занятость базы
// работой доставки не являются: они означают «не сделано», а не «не выходит».
// Статус в этих случаях не трогается вовсе — доставка остаётся `pending` и
// подбирается следующим проходом. Иначе конкуренция за базу (после разнесения
// ответа и свёртки она штатная) выводила бы доставку из очереди навсегда:
// `failed` возвращает только пересборка, то есть ручная операция с остановкой
// сервиса.
//
// Всё прочее — непонятое содержимое, невыводимый слой, нечитаемое или слишком
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не
// затрагивается: сохранили значит приняли.
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
if store.Transient(cause) {
// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
// повтор не выглядел беспричинным.
s.log.WarnContext(ctx, "delivery fold deferred", "error", cause, "delivery_id", deliveryID)
return
}
level := slog.LevelError
switch {
case errors.Is(cause, hae.ErrLayerUnknown):
+62
View File
@@ -324,3 +324,65 @@ func TestFoldПересвёрткаОчищаетСписок(t *testing.T) {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
}
}
// Отмена снаружи не превращается в свойство доставки: работа не сделана, но
// доставка остаётся в очереди и будет свёрнута снова. Иначе остановка сервиса в
// неудачный момент выводила бы доставку из очереди навсегда — `failed` фоновая
// свёртка не подбирает никогда, и вернуть её могла бы только пересборка с
// остановкой сервиса и ручной подменой базы.
func TestFoldОтменаОставляетДоставкуВОчереди(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := f.Fold(ctx, "d1"); err == nil {
t.Fatal("свёртка на отменённом контексте прошла успешно")
}
status, err := st.DeliveryStatus(context.Background(), "d1")
if err != nil {
t.Fatalf("DeliveryStatus: %v", err)
}
if status != store.ParsePending {
t.Errorf("parse_status = %q, ожидался %q", status, store.ParsePending)
}
n, err := st.CountBuckets(context.Background())
if err != nil {
t.Fatalf("CountBuckets: %v", err)
}
if n != 0 {
t.Errorf("объектов %d: прерванная свёртка оставила половину", n)
}
}
// Непонятое содержимое, наоборот, свойство самой доставки: повторять её
// бесполезно, и она выводится из очереди.
func TestFoldНепонятоеСодержимоеВыводитИзОчереди(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
// Метрика есть, но слой определить нечем: плотных метрик нет, заголовок
// ничего не означает, наследовать не от чего.
body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` +
`{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`)
deliver(t, arch, st, "d1", "Default", "auto-1", body)
if _, err := f.Fold(ctx, "d1"); err == nil {
t.Fatal("свёртка непонятого содержимого прошла успешно")
}
status, err := st.DeliveryStatus(ctx, "d1")
if err != nil {
t.Fatalf("DeliveryStatus: %v", err)
}
if status != store.ParseFailed {
t.Errorf("parse_status = %q, ожидался %q", status, store.ParseFailed)
}
}