непокрытые секции доставки видны в статусе разбора

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
+46 -7
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@@ -11,10 +12,18 @@ import (
// Статусы разбора доставки. Код ответа приёма от них не зависит: сохранили —
// значит приняли.
const (
// ParsePending — тело сохранено, разбора ещё не было.
// ParsePending — этим разбором тело ещё не смотрели.
//
// Смысл именно такой, а не «тела ещё не касались»: миграция 00005 перевела
// сюда доставки, разобранные кодом, который не различал частичный разбор.
// Статус консервативный — ретеншен не трогает pending никогда.
ParsePending = "pending"
// ParseDone — тело разобрано, точки разложены по объектам.
// ParseDone — разобрано всё, что в теле было.
ParseDone = "parsed"
// ParsePartial — разобрано покрытое, но в теле остались секции, которых
// разбор не покрывает. Не отклонение, а установившееся состояние половины
// потока: 48 доставок из 99 несут только workouts или stateOfMind.
ParsePartial = "partial"
// ParseFailed — разобрать не удалось. Тело лежит в архиве, доставку
// подберёт пересборка.
ParseFailed = "failed"
@@ -38,6 +47,10 @@ type Delivery struct {
// значений), уже без секретов. Именованные поля выше дублируют часть из
// них: по ним ходят запросы, а Headers хранит всё остальное на будущее.
Headers string
// UncoveredSections — секции тела, которых разбор не покрыл, JSON-массивом
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
// ретеншен обязан спрашивать его прежде, чем срезать тело.
UncoveredSections string
}
// CreateDelivery записывает факт приёма пакета.
@@ -69,7 +82,7 @@ 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
points, headers, uncovered_sections
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
var d Delivery
@@ -77,7 +90,7 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
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)
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections)
if errors.Is(err, sql.ErrNoRows) {
return Delivery{}, ErrNotFound
}
@@ -101,14 +114,40 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
// Пустой layer означает «не трогать»: у неудачной свёртки слоя нет, а
// затирание оборвало бы цепочку наследования — в том числе у доставки, которая
// раньше свернулась успешно, — и результат пересборки журнала изменился бы.
func (s *Store) FinishParse(ctx context.Context, id, status string, points int64, layer string) error {
// ParseOutcome — исход разбора доставки. Структурой, а не растущим списком
// позиционных параметров: у FinishParse их было уже четыре, и пятый неизбежно
// перепутали бы местами с четвёртым.
type ParseOutcome struct {
Status string
Points int64
// Layer пустой означает «не трогать» — см. FinishParse.
Layer string
// Uncovered замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым:
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
Uncovered []string
}
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error {
const q = `
UPDATE delivery
SET parse_status = ?, points = ?,
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
uncovered_sections = ?
WHERE id = ?`
res, err := s.db.ExecContext(ctx, q, status, points, layer, layer, id)
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как
// null, и в колонке появилось бы второе значение с тем же смыслом.
sections := out.Uncovered
if sections == nil {
sections = []string{}
}
encoded, err := json.Marshal(sections)
if err != nil {
return fmt.Errorf("encode uncovered sections: %w", err)
}
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer, string(encoded), id)
if err != nil {
return fmt.Errorf("update parse status: %w", err)
}