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

- половина потока (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)
}
+90
View File
@@ -0,0 +1,90 @@
package store
import (
"context"
"io/fs"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
)
// Миграция 00005 переводит существующие `parsed` в `pending`. Статус, который
// поставил код, не различавший частичного разбора, ничего не доказывает: под
// ним лежат и полностью разобранные доставки, и доставки из одних тренировок с
// нулём точек (48 из 99 на живом архиве). Ретеншен, ради которого признак и
// заводится, поверил бы им и срезал тела — а для stateOfMind это необратимо.
func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T) {
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
sub, err := fs.Sub(migrationsFS, "migrations")
if err != nil {
t.Fatalf("миграции: %v", err)
}
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
if err != nil {
t.Fatalf("провайдер: %v", err)
}
ctx := context.Background()
// Состояние ДО этой миграции: схема четвёртой версии.
if _, err := p.UpTo(ctx, 4); err != nil {
t.Fatalf("миграция до 4: %v", err)
}
const insert = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
aggregation, period, session_id, bytes, sha256, raw_path,
parse_status, points)
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, ?)`
for _, c := range []struct {
id string
status string
points int
}{
{"d-parsed-points", "parsed", 12},
{"d-parsed-empty", "parsed", 0},
{"d-failed", "failed", 0},
{"d-pending", "pending", 0},
} {
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.points); err != nil {
t.Fatalf("вставка %s: %v", c.id, err)
}
}
if _, err := p.Up(ctx); err != nil {
t.Fatalf("миграция до последней: %v", err)
}
want := map[string]string{
// Переводятся ОБА варианта parsed, а не только пустой: наблюдение
// «секции не смешиваются» собрано за двое суток потока, и ставить на
// него необратимое удаление тел значит повторять ошибку, ради которой
// задача и заведена.
"d-parsed-points": "pending",
"d-parsed-empty": "pending",
"d-failed": "failed",
"d-pending": "pending",
}
for id, wantStatus := range want {
var status, sections string
err := db.QueryRowContext(ctx,
`SELECT parse_status, uncovered_sections FROM delivery WHERE id = ?`, id).
Scan(&status, &sections)
if err != nil {
t.Fatalf("чтение %s: %v", id, err)
}
if status != wantStatus {
t.Errorf("%s: статус %q, ожидался %q", id, status, wantStatus)
}
if sections != "[]" {
t.Errorf("%s: список %q, ожидался `[]`", id, sections)
}
}
}
@@ -0,0 +1,35 @@
-- +goose Up
-- Секции тела, которых разбор не покрыл. JSON-массив имён (`["stateOfMind"]`),
-- пустой список — `[]`.
--
-- Массивом, а не строкой с разделителем: имя ключа приходит из чужого тела и
-- может содержать что угодно, включая пробел и запятую. JSON снимает вопрос
-- разделителя, согласуется с колонкой headers и читается из SQLite через
-- json_each, если ретеншену это понадобится.
--
-- Колонка — ответ на вопрос «что останется потерянным, если тело удалить».
-- Для stateOfMind ответ необратим: в экспорте Apple его нет (находка 46),
-- доставки HAE для него единственный источник.
ALTER TABLE delivery ADD COLUMN uncovered_sections TEXT NOT NULL DEFAULT '[]';
-- Статус parsed, поставленный кодом, который частичного разбора не различал,
-- ничего не доказывает: под ним лежат и полностью разобранные доставки, и
-- доставки из одних тренировок с нулём точек (48 из 99 на живом архиве).
-- Ретеншен, ради которого признак и заводится, поверил бы им и срезал тела.
--
-- pending — консервативный статус: ретеншен не трогает его никогда, а подбор
-- pending пересвернёт доставки из архива. Свёртка идемпотентна, повторный
-- прогон журнала состояния не меняет.
--
-- Целевой перевод только строк с points = 0 рассматривался и отвергнут: он
-- опирается на наблюдение «секции не смешиваются», собранное за двое суток
-- потока, а ставить на такое наблюдение необратимое удаление тел значит
-- повторять ошибку, ради которой задача и заведена.
UPDATE delivery SET parse_status = 'pending' WHERE parse_status = 'parsed';
-- +goose Down
-- ВНИМАНИЕ: миграция односторонняя ПО ДАННЫМ. Down снимает колонку, но какие
-- доставки были parsed, восстановить неоткуда — состояние пересобирается из
-- архива, а не откатом. До появления подбора pending строки останутся в этом
-- статусе.
ALTER TABLE delivery DROP COLUMN uncovered_sections;