отработаны находки ревью кода

Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

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

Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
av
2026-08-01 19:02:05 +03:00
parent 32f21044e4
commit cd7a4c1493
23 changed files with 1340 additions and 175 deletions
+68 -68
View File
@@ -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
}
}