отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
+68
-68
@@ -85,6 +85,12 @@ type Point struct {
|
||||
// сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на
|
||||
// фикстурах: они сравнивают разобранное с разобранным.
|
||||
Raw json.RawMessage
|
||||
|
||||
// local — метка в исходной зоне. Наружу не отдаётся: хранится всегда UTC
|
||||
// плюс офсет. Нужна выводу слоя — HAE строит сетку по МЕСТНОМУ времени, и
|
||||
// выравнивание, посчитанное по UTC, объявляет часовую выгрузку минутной в
|
||||
// зонах с получасовым смещением (+0530, +0545, +0930).
|
||||
local time.Time
|
||||
}
|
||||
|
||||
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
|
||||
@@ -99,6 +105,10 @@ type Result struct {
|
||||
SkippedNoTime int
|
||||
// SkippedMalformed — точки, не разобравшиеся как объект JSON.
|
||||
SkippedMalformed int
|
||||
// SkippedBadEnd — точки, у которых есть `end`, но он не разбирается.
|
||||
// Вырождать такую точку в мгновенную нельзя: она схлопнулась бы с соседней
|
||||
// по координате.
|
||||
SkippedBadEnd int
|
||||
|
||||
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
|
||||
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
|
||||
@@ -158,9 +168,15 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
groups := make([]group, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
g := decodeGroup(m, &res)
|
||||
if g.spillover != nil {
|
||||
groups = append(groups, *g.spillover)
|
||||
g.spillover = nil
|
||||
if len(g.summaries) > 0 {
|
||||
groups = append(groups, group{
|
||||
metric: sleepSummaryMetric,
|
||||
units: g.units,
|
||||
points: g.summaries,
|
||||
layer: LayerDay,
|
||||
fixed: true,
|
||||
})
|
||||
g.summaries = nil
|
||||
}
|
||||
if len(g.points) > 0 {
|
||||
groups = append(groups, g)
|
||||
@@ -208,9 +224,11 @@ type group struct {
|
||||
alignment Layer
|
||||
dense bool
|
||||
|
||||
// spillover — вторая группа, отделившаяся от этой при разделении схем под
|
||||
// одним именем метрики.
|
||||
spillover *group
|
||||
// summaries — точки, схема которых опознана прямо при разборе и слой
|
||||
// которым назначен, а не выведен (суточная сводка сна). Держатся отдельно,
|
||||
// потому что в определении слоя доставки не участвуют: сводок бывает больше
|
||||
// порога плотности, и их полуночные метки назначили бы всей доставке `hour`.
|
||||
summaries []Point
|
||||
}
|
||||
|
||||
// envelope — форма тела, ровно настолько подробная, насколько нужно разбору.
|
||||
@@ -254,6 +272,12 @@ func decodeMetrics(body []byte) ([]metricEnvelope, error) {
|
||||
|
||||
// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки
|
||||
// пропускается со счётчиком — ронять из-за неё остальную доставку незачем.
|
||||
//
|
||||
// Схема точки определяется ЗДЕСЬ ЖЕ, в том же проходе, где точка разобрана.
|
||||
// Второй проход по исходному массиву был бы неверен: список разобранных точек
|
||||
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
|
||||
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
|
||||
// корреляции между двумя списками здесь не существует по построению.
|
||||
func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
|
||||
|
||||
@@ -272,30 +296,42 @@ func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
|
||||
end := start
|
||||
if head.End != "" {
|
||||
if e, err := time.Parse(timeLayout, head.End); err == nil {
|
||||
end = e
|
||||
e, err := time.Parse(timeLayout, head.End)
|
||||
if err != nil {
|
||||
// Интервал, конец которого не читается, вырождать в точку
|
||||
// нельзя: две записи с общим началом получили бы одну
|
||||
// координату и одна из них исчезла бы. Пропускаем со
|
||||
// счётчиком — тело остаётся в архиве.
|
||||
res.SkippedBadEnd++
|
||||
continue
|
||||
}
|
||||
end = e
|
||||
}
|
||||
|
||||
_, offset := start.Zone()
|
||||
g.points = append(g.points, Point{
|
||||
p := Point{
|
||||
Metric: m.Name,
|
||||
Units: m.Units,
|
||||
Start: start.UTC(),
|
||||
End: end.UTC(),
|
||||
OffsetSeconds: offset,
|
||||
Raw: raw,
|
||||
})
|
||||
}
|
||||
local: start,
|
||||
}
|
||||
|
||||
if len(g.points) == 0 {
|
||||
return g
|
||||
}
|
||||
|
||||
// Разделение схем выполняется ДО вывода слоя: у суточной сводки слой
|
||||
// назначен, и её полуночные метки не должны участвовать в голосовании.
|
||||
if m.Name == "sleep_analysis" {
|
||||
splitSleep(&g, m.Data)
|
||||
// Под именем sleep_analysis HAE шлёт две несовместимые схемы:
|
||||
// поэпизодную (start/end/value/qty) и суточную сводку
|
||||
// (totalSleep/core/rem/deep/awake с меткой на местной полуночи). Имя
|
||||
// sleep_analysis_summary — наше; инвариант «форма Apple не
|
||||
// транслируется» это не нарушает: поля внутри точки не
|
||||
// переименовываются, разделяются только имена метрик, под которыми
|
||||
// HAE смешал две схемы.
|
||||
if m.Name == sleepMetric && head.TotalSleep != nil {
|
||||
p.Metric = sleepSummaryMetric
|
||||
g.summaries = append(g.summaries, p)
|
||||
continue
|
||||
}
|
||||
g.points = append(g.points, p)
|
||||
}
|
||||
|
||||
g.dense = len(g.points) >= denseThreshold
|
||||
@@ -303,53 +339,11 @@ func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
return g
|
||||
}
|
||||
|
||||
// splitSleep разводит суточную сводку сна на собственное имя метрики.
|
||||
//
|
||||
// Под именем sleep_analysis HAE шлёт две несовместимые схемы: поэпизодную
|
||||
// (start/end/value/qty) и суточную сводку (totalSleep/core/rem/deep/awake с
|
||||
// меткой на местной полуночи). Имя sleep_analysis_summary — наше; инвариант
|
||||
// «форма Apple не транслируется» это не нарушает, потому что поля внутри точки
|
||||
// не переименовываются, разделяются только имена метрик, под которыми HAE
|
||||
// смешал две схемы.
|
||||
//
|
||||
// Если в группе оказались обе схемы, сводки переезжают в отдельную группу,
|
||||
// возвращаемую через g.spillover. Живьём смеси не наблюдалось, но полагаться
|
||||
// на это нельзя: HAE меняется между версиями приложения.
|
||||
func splitSleep(g *group, raws []json.RawMessage) {
|
||||
summaries := make([]Point, 0)
|
||||
episodes := make([]Point, 0, len(g.points))
|
||||
|
||||
for i, p := range g.points {
|
||||
if i < len(raws) && isSleepSummary(raws[i]) {
|
||||
p.Metric = sleepSummaryMetric
|
||||
summaries = append(summaries, p)
|
||||
continue
|
||||
}
|
||||
episodes = append(episodes, p)
|
||||
}
|
||||
|
||||
if len(summaries) == 0 {
|
||||
return
|
||||
}
|
||||
g.spillover = &group{
|
||||
metric: sleepSummaryMetric,
|
||||
units: g.units,
|
||||
points: summaries,
|
||||
layer: LayerDay,
|
||||
fixed: true,
|
||||
}
|
||||
g.points = episodes
|
||||
}
|
||||
|
||||
const sleepSummaryMetric = "sleep_analysis_summary"
|
||||
|
||||
func isSleepSummary(raw json.RawMessage) bool {
|
||||
var head pointHead
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return false
|
||||
}
|
||||
return head.TotalSleep != nil
|
||||
}
|
||||
// Имена метрик сна: пришедшее от HAE и наше для суточной сводки.
|
||||
const (
|
||||
sleepMetric = "sleep_analysis"
|
||||
sleepSummaryMetric = "sleep_analysis_summary"
|
||||
)
|
||||
|
||||
// parseTime разбирает метку точки. Начало берётся из start, а при его
|
||||
// отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с
|
||||
@@ -380,10 +374,16 @@ func parseTime(date, start string) (time.Time, bool) {
|
||||
func finestAlignment(points []Point) Layer {
|
||||
finest := LayerHour
|
||||
for _, p := range points {
|
||||
// По МЕСТНОЙ метке, а не по UTC: HAE строит сетку в зоне телефона.
|
||||
// В зонах с получасовым смещением (+0530 Индия, +0545 Непал, +0930
|
||||
// Аделаида) ровный местный час превращается в UTC-метку на половине,
|
||||
// и вся часовая выгрузка уехала бы в слой `minute` — а там столкнулась
|
||||
// бы с настоящей минутной автоматизацией на координате hh:30 и завысила
|
||||
// сумму минутного слоя вдвое.
|
||||
switch {
|
||||
case p.Start.Second() != 0 || p.Start.Nanosecond() != 0:
|
||||
case p.local.Second() != 0 || p.local.Nanosecond() != 0:
|
||||
return LayerRaw
|
||||
case p.Start.Minute() != 0:
|
||||
case p.local.Minute() != 0:
|
||||
finest = LayerMinute
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -31,7 +31,7 @@ func assignLayers(groups []group, meta Meta, res *Result) error {
|
||||
if delivery == "" {
|
||||
delivery = res.HeaderLayer
|
||||
}
|
||||
if delivery == "" {
|
||||
if delivery == "" && needsDerivedLayer(groups) {
|
||||
return ErrLayerUnknown
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,22 @@ func assignLayers(groups []group, meta Meta, res *Result) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// needsDerivedLayer говорит, осталась ли в доставке хоть одна группа, которой
|
||||
// слой действительно надо вывести.
|
||||
//
|
||||
// Доставка, состоящая из одних суточных сводок сна, слоя не требует — он у них
|
||||
// назначен схемой. Отказ ради неё был бы необратим: исход детерминирован, и
|
||||
// пересборка из архива повторила бы его вечно, то есть автоматизация,
|
||||
// настроенная только на сон, не донесла бы данных никогда.
|
||||
func needsDerivedLayer(groups []group) bool {
|
||||
for _, g := range groups {
|
||||
if !g.fixed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deliveryLayer — самый мелкий слой среди плотных метрик доставки. Пустой,
|
||||
// если плотных метрик нет.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
// Схема точки определяется по ней самой, а не по её месту в исходном массиве.
|
||||
//
|
||||
// Соответствие «разобранная точка ↔ исходный элемент» по индексу было неверным:
|
||||
// список разобранных отфильтрован пропусками, и одна пропущенная точка сдвигала
|
||||
// разметку всех последующих — эпизод сна уезжал под имя суточной сводки со
|
||||
// слоем `day`, сводка оставалась под именем эпизода. Метрика и слой входят в
|
||||
// координату точки, поэтому ошибка необратима: пересборка воспроизвела бы её,
|
||||
// а точки из объекта не удаляются никогда.
|
||||
func TestParseСхемаСнаНеЗависитОтПропущенныхТочек(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
null,
|
||||
{"date":"","qty":1},
|
||||
{"date":"2025-06-06 00:00:00 +0300","totalSleep":7.5,"core":4.0,"deep":1.0,"rem":2.5},
|
||||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"2025-06-05 23:16:00 +0300","value":"Во сне","qty":1.2}
|
||||
]}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Minutes"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]hae.Layer{}
|
||||
for _, p := range res.Points {
|
||||
got[p.Metric] = p.Layer
|
||||
}
|
||||
|
||||
if got["sleep_analysis_summary"] != hae.LayerDay {
|
||||
t.Errorf("сводка получила метрику/слой %v, ожидался sleep_analysis_summary/day", got)
|
||||
}
|
||||
if got["sleep_analysis"] == hae.LayerDay {
|
||||
t.Errorf("эпизод получил слой day — разметка съехала: %v", got)
|
||||
}
|
||||
if res.SkippedNoTime+res.SkippedMalformed != 2 {
|
||||
t.Errorf("пропущено %d+%d точек, ожидалось 2",
|
||||
res.SkippedNoTime, res.SkippedMalformed)
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка, состоящая из одних суточных сводок, слоя не требует — он у них
|
||||
// назначен схемой. Отказ ради неё был бы необратим: исход детерминирован, и
|
||||
// пересборка повторяла бы его вечно, то есть автоматизация, настроенная только
|
||||
// на сон, не донесла бы данных никогда.
|
||||
func TestParseДоставкаИзОднихСводокСохраняется(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[`
|
||||
for i := range 12 {
|
||||
if i > 0 {
|
||||
body += ","
|
||||
}
|
||||
body += `{"date":"2025-06-0` + string(rune('1'+i%9)) + ` 00:00:00 +0300","totalSleep":7.5}`
|
||||
}
|
||||
body += `]}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Points) != 12 {
|
||||
t.Fatalf("точек %d, ожидалось 12", len(res.Points))
|
||||
}
|
||||
for _, p := range res.Points {
|
||||
if p.Metric != "sleep_analysis_summary" || p.Layer != hae.LayerDay {
|
||||
t.Fatalf("точка %s/%s, ожидалось sleep_analysis_summary/day", p.Metric, p.Layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Слой считается по МЕСТНОЙ метке: HAE строит сетку в зоне телефона. По UTC
|
||||
// ровный местный час в зоне со смещением на половину превращается в метку
|
||||
// hh:30, и вся часовая выгрузка уехала бы в слой minute — где столкнулась бы с
|
||||
// настоящей минутной автоматизацией и завысила сумму минутного слоя вдвое.
|
||||
func TestParseСлойВЗонеСПолучасовымСмещением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, offset := range []string{"+0300", "+0530", "+0545", "+0930", "-0330"} {
|
||||
t.Run(offset, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"metrics":[{"name":"step_count","units":"count","data":[`
|
||||
for h := range 12 {
|
||||
if h > 0 {
|
||||
body += ","
|
||||
}
|
||||
body += `{"date":"2025-06-05 ` + string(rune('0'+h/10)) + string(rune('0'+h%10)) +
|
||||
`:00:00 ` + offset + `","qty":1}`
|
||||
}
|
||||
body += `]}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.Layer != hae.LayerHour {
|
||||
t.Errorf("слой %q при смещении %s, ожидался hour", res.Layer, offset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Интервал, конец которого не читается, вырождать в мгновенную точку нельзя:
|
||||
// две записи с общим началом получили бы одну координату, и одна исчезла бы
|
||||
// молча. Пропускаем со счётчиком — тело остаётся в архиве.
|
||||
func TestParseНеразобранныйКонецПропускаетТочку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"сломано","value":"Во сне","qty":1},
|
||||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"2025-06-06 02:21:00 +0300","value":"В кровати","qty":4}
|
||||
]}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Minutes"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.SkippedBadEnd != 1 {
|
||||
t.Errorf("точек с нечитаемым концом %d, ожидалась 1", res.SkippedBadEnd)
|
||||
}
|
||||
if len(res.Points) != 1 {
|
||||
t.Errorf("точек %d, ожидалась 1: вырожденная точка схлопнулась бы с соседней", len(res.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка, что отказ по неопределимому слою остался ровно там, где он нужен:
|
||||
// у доставки с обычными метриками и без всякой опоры.
|
||||
func TestParseОтказПоСлоюОсталсяДляОбычныхМетрик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"metrics":[{"name":"step_count","units":"count","data":[
|
||||
{"date":"2025-06-05 10:00:00 +0300","qty":1}
|
||||
]}]}}`
|
||||
|
||||
if _, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"}); !errors.Is(err, hae.ErrLayerUnknown) {
|
||||
t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user