непокрытые секции доставки видны в статусе разбора
- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind, которых в экспорте Apple нет - разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ - статус partial и колонка delivery.uncovered_sections; миграция переводит прежние parsed в pending — им верить нельзя - витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, §ions)
|
||||
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;
|
||||
Reference in New Issue
Block a user