Files
healthlog/internal/store/delivery.go
T
av cd7a4c1493 отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

- схема точки сна определяется по самой точке, а не по индексу в исходном
  массиве: одна пропущенная точка меняла эпизод и сводку местами
- доставка сворачивается одной транзакцией: частичное состояние было
  недетерминированным (восемь прогонов — семь состояний)
- граница размера на распакованном теле: 400 КиБ gzip разворачивались в
  400 МиБ мимо max_body_mb
- столкновение — расхождение канонических форм, а не байтов; WARN с
  координатами объекта; payload без HTML-экранирования
- выравнивание по местной метке: получасовые зоны уводили часовую выгрузку
  в minute
- доставка из одних суточных сводок больше не отвергается целиком
- единицы не переписываются молча; счётчик считает сохранённые точки
- все выходы Fold логируются, исход пишется на переживающем отмену контексте

Четыре развилки вынесены блокерами в беклог.
2026-08-01 19:02:05 +03:00

196 lines
7.4 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Статусы разбора доставки. Код ответа приёма от них не зависит: сохранили —
// значит приняли.
const (
// ParsePending — тело сохранено, разбора ещё не было.
ParsePending = "pending"
// ParseDone — тело разобрано, точки разложены по объектам.
ParseDone = "parsed"
// ParseFailed — разобрать не удалось. Тело лежит в архиве, доставку
// подберёт пересборка.
ParseFailed = "failed"
)
// Delivery — учётная запись одного принятого пакета.
type Delivery struct {
ID string
ReceivedAt time.Time
AutomationName string
AutomationID string
Aggregation string
Period string
SessionID string
Bytes int64
SHA256 string
RawPath string
ParseStatus string
Points int64
// Headers — весь набор заголовков запроса как JSON-объект (имя → массив
// значений), уже без секретов. Именованные поля выше дублируют часть из
// них: по ним ходят запросы, а Headers хранит всё остальное на будущее.
Headers string
}
// CreateDelivery записывает факт приёма пакета.
func (s *Store) CreateDelivery(ctx context.Context, d Delivery) error {
const q = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
aggregation, period, session_id, bytes, sha256,
raw_path, parse_status, points, headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
headers := d.Headers
if headers == "" {
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)
if err != nil {
return fmt.Errorf("insert delivery: %w", err)
}
return nil
}
// LastDelivery возвращает последнюю по времени приёма доставку.
// Возвращает ErrNotFound, если доставок ещё не было.
func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
const q = `
SELECT id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status,
points, headers
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
var d Delivery
var receivedAt string
err := s.db.QueryRowxContext(ctx, q).Scan(
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
&d.ParseStatus, &d.Points, &d.Headers)
if errors.Is(err, sql.ErrNoRows) {
return Delivery{}, ErrNotFound
}
if err != nil {
return Delivery{}, fmt.Errorf("select last delivery: %w", err)
}
d.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return Delivery{}, err
}
return d, nil
}
// FinishParse записывает исход разбора доставки.
//
// Слой сохраняется здесь же, потому что он нужен следующей доставке той же
// автоматизации: без плотных метрик выводить его не из чего, и наследовать
// приходится от прошлого раза.
//
// Пустой layer означает «не трогать»: у неудачной свёртки слоя нет, а
// затирание оборвало бы цепочку наследования — в том числе у доставки, которая
// раньше свернулась успешно, — и результат пересборки журнала изменился бы.
func (s *Store) FinishParse(ctx context.Context, id, status string, points int64, layer string) error {
const q = `
UPDATE delivery
SET parse_status = ?, points = ?,
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END
WHERE id = ?`
res, err := s.db.ExecContext(ctx, q, status, points, layer, layer, id)
if err != nil {
return fmt.Errorf("update parse status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update parse status: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// LastDerivedLayer возвращает слой, выведенный для этой автоматизации ПЕРЕД
// указанной доставкой. Пустая строка означает, что наследовать нечего.
//
// «Перед» здесь существенно, а не для красоты: слой обязан быть функцией от
// префикса журнала, иначе повторный прогон даёт другое состояние, чем живой
// приём. Запрос без границы по времени брал бы последний слой вообще — и при
// пересборке доставка наследовала бы слой от будущего. Проверено прогоном
// архива: 1737 объектов превращались в 1742.
func (s *Store) LastDerivedLayer(ctx context.Context, automationID string, before time.Time, beforeID string) (string, error) {
if automationID == "" {
return "", nil
}
const q = `
SELECT derived_layer FROM delivery
WHERE automation_id = ? AND derived_layer != ''
AND (received_at, id) < (?, ?)
ORDER BY received_at DESC, id DESC LIMIT 1`
var layer string
err := s.db.GetContext(ctx, &layer, q, automationID, FormatTime(before), beforeID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("select derived layer: %w", err)
}
return layer, nil
}
// DeliveryBody — что нужно знать о доставке, чтобы разобрать её тело.
type DeliveryBody struct {
ID string
ReceivedAt time.Time
RawPath string
AutomationID string
Aggregation string
}
// DeliveryForParse возвращает сведения о доставке, нужные разбору.
func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) {
const q = `
SELECT id, received_at, raw_path, automation_id, aggregation
FROM delivery WHERE id = ?`
var d DeliveryBody
var receivedAt string
err := s.db.QueryRowxContext(ctx, q, id).
Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation)
if errors.Is(err, sql.ErrNoRows) {
return DeliveryBody{}, ErrNotFound
}
if err != nil {
return DeliveryBody{}, fmt.Errorf("select delivery for parse: %w", err)
}
d.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return DeliveryBody{}, err
}
return d, nil
}
// CountDeliveries возвращает число принятых пакетов. Нужно для healthcheck и
// быстрой проверки «данные вообще идут».
func (s *Store) CountDeliveries(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM delivery`); err != nil {
return 0, fmt.Errorf("count deliveries: %w", err)
}
return n, nil
}