добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и
|
||||
// контекст пульса, которого он не знает.
|
||||
const categoricalBody = `{"data":{"metrics":[` +
|
||||
`{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
|
||||
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` +
|
||||
`{"name":"heart_rate","units":"count/min","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` +
|
||||
`]}}`
|
||||
|
||||
// receivedAt — фиксированная метка приёма.
|
||||
//
|
||||
// Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и
|
||||
// метка от часов сделала бы сравнение отпечатков между подтестами функцией
|
||||
// того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел
|
||||
// на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет.
|
||||
var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC)
|
||||
|
||||
func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
at := receivedAt
|
||||
rawPath, err := arch.Write(id, at, body)
|
||||
if err != nil {
|
||||
t.Fatalf("запись в архив: %v", err)
|
||||
}
|
||||
err = st.CreateDelivery(context.Background(), store.Delivery{
|
||||
ID: id,
|
||||
ReceivedAt: at,
|
||||
AutomationID: "auto-1",
|
||||
Aggregation: "Hours",
|
||||
Bytes: int64(len(body)),
|
||||
SHA256: "-",
|
||||
RawPath: rawPath,
|
||||
Headers: headers,
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("запись доставки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return fold.New(arch, st, 0, log), arch, st
|
||||
}
|
||||
|
||||
func TestFoldКладётНаблюденияВРеестр(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
|
||||
|
||||
stats, err := f.Fold(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
if stats.Categoricals != 2 {
|
||||
t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals)
|
||||
}
|
||||
if stats.CategoricalUnknown != 1 {
|
||||
t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown)
|
||||
}
|
||||
|
||||
got, err := st.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("чтение реестра: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("строк реестра %d: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
||||
t.Errorf("фаза сна в реестре: %+v", got[1])
|
||||
}
|
||||
if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" {
|
||||
t.Errorf("контекст пульса в реестре: %+v", got[0])
|
||||
}
|
||||
if got[1].FirstDeliveryID != "d1" {
|
||||
t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID)
|
||||
}
|
||||
}
|
||||
|
||||
// Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет:
|
||||
// заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего
|
||||
// тела, обязана дать то же состояние.
|
||||
func TestFoldЛокальНеМеняетСостояния(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct{ name, headers string }{
|
||||
{"измеренный заголовок потока", `{"Accept-Language":["ru"]}`},
|
||||
{"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`},
|
||||
{"заголовка нет вовсе", `{}`},
|
||||
{"незнакомая локаль", `{"Accept-Language":["de"]}`},
|
||||
// Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве
|
||||
// нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает
|
||||
// ровно так. Оба обязаны дать пустую локаль и то же состояние.
|
||||
{"заголовков нет вовсе", ``},
|
||||
{"заголовки не разбираются", `не json`},
|
||||
{"заголовок пустым списком", `{"Accept-Language":[]}`},
|
||||
}
|
||||
|
||||
var want string
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
|
||||
deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
got, err := st.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("чтение реестра: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("строк реестра %d: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
||||
t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code)
|
||||
}
|
||||
// Отпечаток не должен зависеть от заголовка вовсе: ключ реестра —
|
||||
// функция одних тел.
|
||||
fp, err := st.Fingerprint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
if want == "" {
|
||||
want = fp
|
||||
} else if fp != want {
|
||||
t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Строки категориальных значений — данные о здоровье наравне со значением
|
||||
// точки: «Сидячий образ жизни» описывает человека. В журнал уходят только
|
||||
// счётчики.
|
||||
//
|
||||
// Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке:
|
||||
// метка времени содержит произвольные цифры, и поиск по сырому буферу делает
|
||||
// такой тест флаки по построению (запись 2026-08-02 в docs/review.md).
|
||||
func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
f, arch, st := foldService(t, log)
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
logged := strings.TrimSpace(buf.String())
|
||||
if logged == "" {
|
||||
t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит")
|
||||
}
|
||||
|
||||
var folded map[string]any
|
||||
for line := range strings.SplitSeq(logged, "\n") {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
delete(rec, "time")
|
||||
clean, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||
}
|
||||
for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
|
||||
if strings.Contains(string(clean), secret) {
|
||||
t.Errorf("в логе оказалось %q:\n%s", secret, clean)
|
||||
}
|
||||
}
|
||||
if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
|
||||
folded = rec
|
||||
}
|
||||
}
|
||||
|
||||
if folded == nil {
|
||||
t.Fatalf("записи об исходе свёртки нет:\n%s", logged)
|
||||
}
|
||||
for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} {
|
||||
if _, ok := folded[attr]; !ok {
|
||||
t.Errorf("в записи нет счётчика %q: %v", attr, folded)
|
||||
}
|
||||
}
|
||||
if folded["categoricals_unknown"] != float64(1) {
|
||||
t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"])
|
||||
}
|
||||
}
|
||||
|
||||
// Срабатывание границы наблюдений обязано подниматься до WARN: границы
|
||||
// подобраны по измерению, поэтому попадание в них — аномалия, а не режим, и в
|
||||
// INFO оно утонуло бы среди штатных доставок раз в пять минут.
|
||||
func TestFoldПревышениеГраницыДаётWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
f, arch, st := foldService(t, log)
|
||||
|
||||
// Одно непомерно длинное значение — граница длины, а не числа: тело
|
||||
// остаётся маленьким, а событие то же.
|
||||
long := strings.Repeat("я", 200)
|
||||
body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
|
||||
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}`
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body))
|
||||
|
||||
stats, err := f.Fold(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
if stats.CategoricalDropped == 0 {
|
||||
t.Fatal("граница не сработала — тест проверяет не то")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
|
||||
var r map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
|
||||
rec = r
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String())
|
||||
}
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
|
||||
}
|
||||
if rec["msg"] != "delivery folded, categorical values dropped by limit" {
|
||||
t.Errorf("сообщение %v не называет событие", rec["msg"])
|
||||
}
|
||||
|
||||
// И ни одного байта самой строки: она из тела доставки.
|
||||
delete(rec, "time")
|
||||
clean, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||
}
|
||||
if strings.Contains(string(clean), "яяя") {
|
||||
t.Errorf("в логе оказалось значение из тела:\n%s", clean)
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -9,6 +9,7 @@ package fold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
@@ -90,6 +92,16 @@ type Stats struct {
|
||||
Uncovered []string
|
||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||
UncoveredDropped int
|
||||
|
||||
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
|
||||
// CategoricalUnknown — сколько из них словарь не знает;
|
||||
// CategoricalDropped — сколько отброшено границами разбора.
|
||||
//
|
||||
// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
|
||||
// описывают человека не меньше, чем число.
|
||||
Categoricals int
|
||||
CategoricalUnknown int
|
||||
CategoricalDropped int
|
||||
}
|
||||
|
||||
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
|
||||
@@ -144,6 +156,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
parsed, err := hae.Parse(body, hae.Meta{
|
||||
Aggregation: d.Aggregation,
|
||||
FallbackLayer: hae.Layer(fallback),
|
||||
Locale: localeOf(d.Headers),
|
||||
})
|
||||
if err != nil {
|
||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||
@@ -169,6 +182,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
|
||||
stats.Layer = string(parsed.Layer)
|
||||
stats.LayerMismatch = parsed.LayerMismatch
|
||||
stats.Categoricals = len(parsed.Categoricals)
|
||||
stats.CategoricalUnknown = parsed.CategoricalUnknown
|
||||
stats.CategoricalDropped = parsed.CategoricalDropped
|
||||
|
||||
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
|
||||
ID: d.ID,
|
||||
@@ -256,6 +272,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// построчный разбор логов. Содержимого секций здесь нет.
|
||||
"uncovered", st.Uncovered,
|
||||
"uncovered_dropped", st.UncoveredDropped,
|
||||
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
|
||||
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
|
||||
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
|
||||
"categoricals", st.Categoricals,
|
||||
"categoricals_unknown", st.CategoricalUnknown,
|
||||
"categoricals_dropped", st.CategoricalDropped,
|
||||
}
|
||||
if len(st.Collisions) > 0 {
|
||||
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
|
||||
@@ -298,6 +320,19 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
|
||||
// а границу выбило больше тридцати двух.
|
||||
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
|
||||
case st.CategoricalDropped > 0:
|
||||
// Тот же класс события и та же причина эскалации, что у списка секций:
|
||||
// границы подобраны по измерению (на живом потоке около одиннадцати
|
||||
// различных значений, самое длинное 36 байт), поэтому попадание в них —
|
||||
// уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять
|
||||
// минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт
|
||||
// только здесь: лог ротируется, и к следующей сверке отпечатков причину
|
||||
// уже не восстановить.
|
||||
//
|
||||
// `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой —
|
||||
// словарь покрывает только фазы сна, — и постоянный WARN обесценил бы
|
||||
// уровень.
|
||||
s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...)
|
||||
case st.Incomparable > 0:
|
||||
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
|
||||
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
|
||||
@@ -479,12 +514,60 @@ func toIncoming(parsed hae.Result) store.Incoming {
|
||||
})
|
||||
}
|
||||
return store.Incoming{
|
||||
Points: points,
|
||||
Workouts: toEntities(parsed.Workouts),
|
||||
Records: toEntities(parsed.Records),
|
||||
Points: points,
|
||||
Workouts: toEntities(parsed.Workouts),
|
||||
Records: toEntities(parsed.Records),
|
||||
Categories: toCategories(parsed.Categoricals),
|
||||
}
|
||||
}
|
||||
|
||||
func toCategories(in []hae.Categorical) []store.CategoryValue {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]store.CategoryValue, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, store.CategoryValue{
|
||||
Metric: c.Metric,
|
||||
Field: c.Field,
|
||||
Value: c.Value,
|
||||
Code: c.Code,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localeOf достаёт язык доставки из сохранённых заголовков запроса.
|
||||
//
|
||||
// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и
|
||||
// вторая копия того же факта разошлась бы с первой при первой же правке приёма.
|
||||
//
|
||||
// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают
|
||||
// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет:
|
||||
// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из
|
||||
// осиротевшего тела, приезжает без них — и обязана дать то же состояние.
|
||||
//
|
||||
// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при
|
||||
// приёме, но в базе лежит то, что было записано, и правило регистра не наше.
|
||||
func localeOf(headers string) string {
|
||||
if headers == "" {
|
||||
return ""
|
||||
}
|
||||
var h map[string][]string
|
||||
if err := json.Unmarshal([]byte(headers), &h); err != nil {
|
||||
return ""
|
||||
}
|
||||
for name, values := range h {
|
||||
if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
return healthkit.Locale(values[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const acceptLanguageHeader = "Accept-Language"
|
||||
|
||||
func toEntities(in []hae.Entity) []store.IncomingEntity {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package hae
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
)
|
||||
|
||||
// Categorical — наблюдённое категориальное значение: строка перечислимого поля
|
||||
// и выведенный для неё код HealthKit.
|
||||
//
|
||||
// Это НАБЛЮДЕНИЕ, а не копия данных: сама строка остаётся в содержимом точки
|
||||
// дословно, а наблюдение говорит «такое значение поток приносил, и вот его
|
||||
// стабильный код». Пустой код означает «словарь этой строки не знает» — и
|
||||
// перечень таких строк есть заявка на пополнение словаря.
|
||||
type Categorical struct {
|
||||
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
|
||||
// хранения (`sleep_analysis_summary` после разделения схем, `workouts` у
|
||||
// тренировок). Второе имя для того же понятия развело бы наблюдение и объект
|
||||
// по разным ключам.
|
||||
Metric string
|
||||
// Field — имя поля внутри точки или сущности, дословно как у HAE.
|
||||
Field string
|
||||
// Value — строка, как прислал HAE.
|
||||
Value string
|
||||
// Code — канонический код HealthKit либо пустая строка.
|
||||
Code string
|
||||
}
|
||||
|
||||
// Имена категориальных полей — дословно как у HAE.
|
||||
const (
|
||||
fieldValue = "value"
|
||||
fieldContext = "context"
|
||||
fieldName = "name"
|
||||
)
|
||||
|
||||
// heartRateMetric — имя метрики пульса у HAE.
|
||||
const heartRateMetric = "heart_rate"
|
||||
|
||||
// pointCategoricalFields — какие поля ТОЧКИ несут перечислимое значение.
|
||||
//
|
||||
// Список объявлен явно, а не выведен из формы значения: строк в точке много
|
||||
// (`date`, `start`, `source`), и правило «всякая строка категориальна» завело бы
|
||||
// в реестр метки времени и имена устройств. Состав измерен на живом потоке
|
||||
// (находка 37); новое поле — одна строка здесь и запись в словаре.
|
||||
var pointCategoricalFields = map[string][]string{
|
||||
sleepMetric: {fieldValue},
|
||||
heartRateMetric: {fieldContext},
|
||||
}
|
||||
|
||||
// Границы наблюдений одной доставки.
|
||||
//
|
||||
// Тело контролирует отправитель целиком: без границы одна доставка кладёт в
|
||||
// витрину сколько угодно строк, а строки эти уезжают в первичный ключ.
|
||||
//
|
||||
// Числа названы измерением, а не аналогией с 32/64 у имён непокрытых секций:
|
||||
// там имена короткие и латинские, здесь — русские фразы в UTF-8. На живом
|
||||
// потоке различных значений по всем трём полям около одиннадцати, самое длинное
|
||||
// — «Сидячий образ жизни», 36 байт (находка 37). Предел в 32 байта отбросил бы
|
||||
// две из трёх измеренных строк контекста пульса.
|
||||
const (
|
||||
maxCategoricalValues = 64
|
||||
maxCategoricalValueLen = 128
|
||||
)
|
||||
|
||||
// categoricalKey — ключ наблюдения. Совпадает с ключом реестра в витрине: два
|
||||
// разных ключа на одно понятие разошлись бы при первом же поле с одинаковым
|
||||
// именем у двух метрик.
|
||||
type categoricalKey struct {
|
||||
metric string
|
||||
field string
|
||||
value string
|
||||
}
|
||||
|
||||
// categoricals — сборщик наблюдений одной доставки.
|
||||
//
|
||||
// Дедупликация обязательна: `context` повторяется в каждой точке пульса, и без
|
||||
// множества доставка на две тысячи точек дала бы две тысячи одинаковых
|
||||
// наблюдений. Локаль хранится здесь, а не в наблюдении: она сужает поиск по
|
||||
// словарю и в ключ не входит — заголовков в сыром архиве нет, и ключ с локалью
|
||||
// сделал бы состояние функцией от того, уцелела ли учётная строка.
|
||||
//
|
||||
// Набор ограничен ПРИ ВСТАВКЕ, а не при выдаче, и это измеренное решение, а не
|
||||
// аккуратность. Накопитель без границы растёт по числу РАЗЛИЧНЫХ строк тела, а
|
||||
// их контролирует отправитель: тело в 60 МиБ из миллиона различных значений
|
||||
// (предел приёма — 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита
|
||||
// памяти у контейнера нет, OOM в фоновой горутине свёртки не перехватывается, а
|
||||
// `restart: unless-stopped` поднимает процесс — и первый же проход берёт ту же
|
||||
// доставку из архива. Приём при этом стоит, а телефон доставку не перешлёт.
|
||||
// Правило то же, что уже действует в этом пакете для имён непокрытых секций.
|
||||
//
|
||||
// Держатся 64 НАИМЕНЬШИХ ключа: усечение остаётся функцией МНОЖЕСТВА, а не
|
||||
// порядка элементов на проводе. Порядок ключей у HAE нестабилен (находка 2), и
|
||||
// «первые 64 по ходу разбора» давали бы разный реестр на переприсланном том же
|
||||
// содержимом — расхождение вышло бы как «пересборка не сошлась», без адреса.
|
||||
//
|
||||
// Срез, а не куча: элементов 64, вставка двоичным поиском стоит дешевле
|
||||
// поддержания инварианта кучи, а отсортированный срез заодно и есть готовый
|
||||
// ответ `result`.
|
||||
type categoricals struct {
|
||||
locale string
|
||||
// seen — членство, kept — те же ключи в порядке возрастания. Две структуры
|
||||
// на одно множество: карта отвечает «видели ли», срез — «кто наибольший»,
|
||||
// и оба вопроса задаются на каждое вхождение.
|
||||
seen map[categoricalKey]struct{}
|
||||
kept []categoricalKey
|
||||
dropped int
|
||||
}
|
||||
|
||||
func newCategoricals(locale string) *categoricals {
|
||||
return &categoricals{
|
||||
locale: locale,
|
||||
seen: make(map[categoricalKey]struct{}, maxCategoricalValues),
|
||||
kept: make([]categoricalKey, 0, maxCategoricalValues),
|
||||
}
|
||||
}
|
||||
|
||||
// less задаёт порядок ключей — он же порядок выдачи и он же правило усечения.
|
||||
func (a categoricalKey) less(b categoricalKey) bool {
|
||||
if a.metric != b.metric {
|
||||
return a.metric < b.metric
|
||||
}
|
||||
if a.field != b.field {
|
||||
return a.field < b.field
|
||||
}
|
||||
return a.value < b.value
|
||||
}
|
||||
|
||||
// add записывает наблюдение, если строка на него годится.
|
||||
//
|
||||
// Пустая строка наблюдением не считается: сказать о данных ей нечего, а в
|
||||
// счётчике строк без кода она сидела бы вечно — тренировка без `name` даёт
|
||||
// ровно её (мягкое чтение заголовка сущности превращает значение не того типа в
|
||||
// пустую строку).
|
||||
//
|
||||
// Слишком длинное значение ОТБРАСЫВАЕТСЯ, а не обрезается: обрезанная строка
|
||||
// неотличима от настоящей и попала бы в ключ реестра самостоятельным значением.
|
||||
// Сама точка при этом хранится целиком — теряется наблюдение, а не данные.
|
||||
func (c *categoricals) add(metric, field, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if len(value) > maxCategoricalValueLen {
|
||||
c.dropped++
|
||||
return
|
||||
}
|
||||
|
||||
key := categoricalKey{metric: metric, field: field, value: value}
|
||||
if _, ok := c.seen[key]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
if len(c.kept) >= maxCategoricalValues {
|
||||
// Набор полон. Ключ больше наибольшего удержанного — он и есть
|
||||
// отброшенный; иначе вытесняем наибольший, а отброшенным становится он.
|
||||
last := c.kept[len(c.kept)-1]
|
||||
if !key.less(last) {
|
||||
c.dropped++
|
||||
return
|
||||
}
|
||||
delete(c.seen, last)
|
||||
c.kept = c.kept[:len(c.kept)-1]
|
||||
c.dropped++
|
||||
}
|
||||
|
||||
at := sort.Search(len(c.kept), func(i int) bool { return key.less(c.kept[i]) })
|
||||
c.kept = append(c.kept, categoricalKey{})
|
||||
copy(c.kept[at+1:], c.kept[at:])
|
||||
c.kept[at] = key
|
||||
c.seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
// addPoint снимает с точки объявленные для её метрики поля.
|
||||
//
|
||||
// Значение читается из уже разобранного заголовка точки и принимается только
|
||||
// как JSON-строка: объяви поле `string` в самом заголовке — и точка, у которой
|
||||
// `value` пришло числом, перестала бы разбираться вовсе. Это был бы новый путь
|
||||
// потери данных ради удобства структуры.
|
||||
func (c *categoricals) addPoint(metric string, head *pointHead) {
|
||||
for _, field := range pointCategoricalFields[metric] {
|
||||
var raw *json.RawMessage
|
||||
switch field {
|
||||
case fieldValue:
|
||||
raw = head.Value
|
||||
case fieldContext:
|
||||
raw = head.Context
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := jsonString(*raw); ok {
|
||||
c.add(metric, field, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// result отдаёт наблюдения доставки: отсортированные, усечённые границей и с
|
||||
// выведенными кодами.
|
||||
//
|
||||
// Порядок и усечение — функция МНОЖЕСТВА, а не порядка элементов на проводе; за
|
||||
// это отвечает add, здесь набор уже готов.
|
||||
//
|
||||
// Код выводится здесь, а не при добавлении: словарь зовётся по разу на
|
||||
// РАЗЛИЧНОЕ удержанное значение, а не по разу на точку. На теле в миллион
|
||||
// точек это 64 обращения к карте вместо миллиона.
|
||||
func (c *categoricals) result() (out []Categorical, unknown, dropped int) {
|
||||
dropped = c.dropped
|
||||
|
||||
out = make([]Categorical, 0, len(c.kept))
|
||||
for _, k := range c.kept {
|
||||
code := healthkit.Code(c.locale, k.value)
|
||||
if code == "" {
|
||||
unknown++
|
||||
}
|
||||
out = append(out, Categorical{Metric: k.metric, Field: k.field, Value: k.value, Code: code})
|
||||
}
|
||||
return out, unknown, dropped
|
||||
}
|
||||
|
||||
// jsonString читает значение как строку JSON, не считая строкой ничего другого.
|
||||
//
|
||||
// Проверка первого байта — та же дисциплина, что в jsonNumber: полагаться на
|
||||
// тип-приёмник значило бы получить разное поведение от невидимой детали, а
|
||||
// молчаливое приведение числа к строке выдумало бы за источник значение,
|
||||
// которого он не присылал.
|
||||
func jsonString(raw json.RawMessage) (string, bool) {
|
||||
if len(raw) == 0 || raw[0] != '"' {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Значение, которое `encoding/json` ЗАМЕНИЛ, наблюдением не считается.
|
||||
//
|
||||
// Ошибки он на этом не даёт: негодную последовательность — сырой байт 0xFF,
|
||||
// одинокий суррогат `\ud800` — он молча меняет на U+FFFD, и строка на выходе
|
||||
// оказывается валидным UTF-8, но уже не равной пришедшим байтам (измерено:
|
||||
// 0xFF даёт "\uFFFDВо сне", `utf8.ValidString` отвечает true). Проверять
|
||||
// поэтому надо не годность результата, а его НЕТРОНУТОСТЬ.
|
||||
//
|
||||
// Реестр требует хранить значение дословно; подменённое осело бы в первичном
|
||||
// ключе таблицы, у которой нет обслуживания, и кода не получило бы никогда.
|
||||
// Точка при этом хранится целиком — теряется наблюдение, а не данные, и это
|
||||
// та же цена, что у непомерной длины.
|
||||
//
|
||||
// Цена правила названа: настоящий U+FFFD в значении тоже не станет
|
||||
// наблюдением. Перечислимые значения HealthKit — слова человеческого языка,
|
||||
// символа замены в них не бывает, а ошибка направлена в безопасную сторону.
|
||||
if strings.ContainsRune(s, utf8.RuneError) {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
// Строки берутся ИЗ пакета testdata, а не из литерала теста: словарь набран
|
||||
// литералами Go, а сверяться ему предстоит с байтами тела. Apple шлёт внутри
|
||||
// своих строк неразрывные пробелы (находка 24), и литерал, набранный руками,
|
||||
// такую подмену не воспроизводит — совпадение теста с реализацией доказывало бы
|
||||
// только согласие автора с самим собой.
|
||||
func TestParseКатегориальныеЗначенияРеальногоПакета(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "sparse_sleep.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
want := []hae.Categorical{
|
||||
{Metric: "heart_rate", Field: "context", Value: "Не задано", Code: ""},
|
||||
{Metric: "sleep_analysis", Field: "value", Value: "В кровати", Code: "HKCategoryValueSleepAnalysisInBed"},
|
||||
{Metric: "sleep_analysis", Field: "value", Value: "Во сне", Code: "HKCategoryValueSleepAnalysisAsleepUnspecified"},
|
||||
}
|
||||
assertCategoricals(t, res.Categoricals, want)
|
||||
|
||||
// Строка, из которой выведен код, обязана остаться в точке ДОСЛОВНО: код
|
||||
// приписывается, а не подменяет.
|
||||
assertRawContains(t, res.Points, "sleep_analysis", `"value": "Во сне"`)
|
||||
|
||||
// Контекст пульса словарём не покрыт намеренно (см. healthkit): его строка
|
||||
// видна реестром с пустым кодом и считается счётчиком.
|
||||
if res.CategoricalUnknown != 1 {
|
||||
t.Errorf("строк без кода %d, ожидалась 1", res.CategoricalUnknown)
|
||||
}
|
||||
if res.CategoricalDropped != 0 {
|
||||
t.Errorf("отброшено %d, ожидался ноль", res.CategoricalDropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Тысяча точек с одним контекстом даёт одно наблюдение: без дедупликации
|
||||
// доставка пульса за сутки положила бы в витрину тысячи одинаковых строк.
|
||||
func TestParseПовторСтрокиНеЗадваиваетНаблюдение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "raw.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
if len(res.Points) < 10 {
|
||||
t.Fatalf("в пакете %d точек — тест потерял смысл", len(res.Points))
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for _, c := range res.Categoricals {
|
||||
seen[c.Metric+"/"+c.Field+"/"+c.Value]++
|
||||
}
|
||||
for key, n := range seen {
|
||||
if n != 1 {
|
||||
t.Errorf("наблюдение %s встретилось %d раз", key, n)
|
||||
}
|
||||
}
|
||||
t.Logf("точек %d, различных наблюдений %d", len(res.Points), len(res.Categoricals))
|
||||
}
|
||||
|
||||
func TestParseИмяТренировкиПопадаетВНаблюдения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "workout_route.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) == 0 {
|
||||
t.Fatal("в пакете нет тренировок — тест потерял смысл")
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, c := range res.Categoricals {
|
||||
if c.Metric == "workouts" && c.Field == "name" {
|
||||
if c.Code != "" {
|
||||
t.Errorf("имя тренировки %q получило код %q — словарь их не покрывает", c.Value, c.Code)
|
||||
}
|
||||
names = append(names, c.Value)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("имена тренировок в наблюдения не попали")
|
||||
}
|
||||
// Содержимое сущности не изменено: имя остаётся в нём дословно.
|
||||
for _, w := range res.Workouts {
|
||||
if !bytes.Contains(w.Raw, []byte(`"name"`)) {
|
||||
t.Errorf("в содержимом тренировки %s нет ключа name", w.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Метрика, не объявленная категориальной, наблюдений не даёт, даже если у её
|
||||
// точек есть поле `value`: список объявлен явно, а не выведен из формы.
|
||||
func TestParseНеобъявленноеПолеНаблюденийНеДаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "handmade_edge.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
for _, c := range res.Categoricals {
|
||||
if c.Metric == "interval_points" {
|
||||
t.Errorf("метрика interval_points дала наблюдение %q, хотя категориальной не объявлена", c.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseФормыКатегориальногоЗначения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
unknown int
|
||||
}{
|
||||
{"известная строка", `"Во сне"`, "Во сне", 0},
|
||||
{"незнакомая строка", `"Полудрёма"`, "Полудрёма", 1},
|
||||
{"число значением не считается", `123`, "", 0},
|
||||
{"null значением не считается", `null`, "", 0},
|
||||
{"объект значением не считается", `{"a":1}`, "", 0},
|
||||
{"массив значением не считается", `[1]`, "", 0},
|
||||
{"пустая строка значением не считается", `""`, "", 0},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := sleepBody(c.value)
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
// Точка живёт при любой форме значения: категориальное поле не
|
||||
// заводит нового пути её потери.
|
||||
if len(res.Points) != 1 {
|
||||
t.Fatalf("точек %d, ожидалась одна (счётчики: malformed %d, no_time %d)",
|
||||
len(res.Points), res.SkippedMalformed, res.SkippedNoTime)
|
||||
}
|
||||
if !bytes.Contains(res.Points[0].Raw, []byte(c.value)) {
|
||||
t.Errorf("содержимое точки %s не несёт исходного значения %s", res.Points[0].Raw, c.value)
|
||||
}
|
||||
|
||||
var got string
|
||||
if len(res.Categoricals) == 1 {
|
||||
got = res.Categoricals[0].Value
|
||||
} else if len(res.Categoricals) > 1 {
|
||||
t.Fatalf("наблюдений %d, ожидалось не больше одного", len(res.Categoricals))
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("наблюдение %q, ожидалось %q", got, c.want)
|
||||
}
|
||||
if res.CategoricalUnknown != c.unknown {
|
||||
t.Errorf("строк без кода %d, ожидалось %d", res.CategoricalUnknown, c.unknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Локаль сужает поиск, но её отсутствие вывода не отменяет: заголовков в сыром
|
||||
// архиве нет, и усыновлённое тело обязано дать то же состояние.
|
||||
func TestParseЛокальНаКлючНаблюденияНеВлияет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "sparse_sleep.json")
|
||||
|
||||
withLocale, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор с локалью: %v", err)
|
||||
}
|
||||
without, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор без локали: %v", err)
|
||||
}
|
||||
|
||||
assertCategoricals(t, without.Categoricals, withLocale.Categoricals)
|
||||
}
|
||||
|
||||
// Уцелевший при переполнении набор — функция МНОЖЕСТВА, а не порядка элементов
|
||||
// на проводе: порядок ключей у HAE нестабилен, и то же содержимое,
|
||||
// переприсланное иначе, не имеет права дать другой реестр.
|
||||
func TestParseГраницаЧислаНаблюденийНеЗависитОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := make([]string, 0, 200)
|
||||
for i := range 200 {
|
||||
values = append(values, fmt.Sprintf("фаза-%03d", i))
|
||||
}
|
||||
forward := hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}
|
||||
|
||||
res, err := hae.Parse(sleepBodyMany(values), forward)
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != 64 {
|
||||
t.Fatalf("наблюдений %d, ожидался предел 64", len(res.Categoricals))
|
||||
}
|
||||
if res.CategoricalDropped != len(values)-64 {
|
||||
t.Errorf("отброшено %d, ожидалось %d", res.CategoricalDropped, len(values)-64)
|
||||
}
|
||||
if len(res.Points) != len(values) {
|
||||
t.Errorf("точек %d, ожидалось %d: граница теряет наблюдение, а не данные", len(res.Points), len(values))
|
||||
}
|
||||
|
||||
reversed := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
reversed[len(values)-1-i] = v
|
||||
}
|
||||
back, err := hae.Parse(sleepBodyMany(reversed), forward)
|
||||
if err != nil {
|
||||
t.Fatalf("разбор в обратном порядке: %v", err)
|
||||
}
|
||||
assertCategoricals(t, back.Categoricals, res.Categoricals)
|
||||
}
|
||||
|
||||
func TestParseНепомерноеЗначениеОтброшеноЦеликом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("я", 200) // 400 байт UTF-8
|
||||
res, err := hae.Parse(sleepBody(`"`+long+`"`), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("наблюдений %d, ожидался ноль", len(res.Categoricals))
|
||||
}
|
||||
if res.CategoricalDropped != 1 {
|
||||
t.Errorf("отброшено %d, ожидалась единица", res.CategoricalDropped)
|
||||
}
|
||||
// Точка хранится целиком: теряется наблюдение, а не данные.
|
||||
if len(res.Points) != 1 || !bytes.Contains(res.Points[0].Raw, []byte(long)) {
|
||||
t.Error("непомерное значение не сохранилось в содержимом точки целиком")
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка, у которой слой не выводится, наблюдений не отдаёт: «всё или ничего»
|
||||
// относится к доставке целиком, иначе реестр пополнился бы при незаписанной
|
||||
// витрине и повторная свёртка перестала бы быть no-op.
|
||||
func TestParseОтказПоСлоюНаблюденийНеОтдаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Одна точка на середине часа: плотных метрик нет, наследовать нечего.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
{"date":"2026-07-30 21:48:17 +0300","value":"Во сне"}]}]}}`)
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru"})
|
||||
if err == nil {
|
||||
t.Skipf("слой выведен, отказа нет — проверять нечего (наблюдений %d)", len(res.Categoricals))
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("при отказе разбора отдано %d наблюдений", len(res.Categoricals))
|
||||
}
|
||||
}
|
||||
|
||||
func sleepBody(value string) []byte {
|
||||
return []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
{"date":"2026-07-30 21:00:00 +0300","start":"2026-07-30 21:00:00 +0300",` +
|
||||
`"end":"2026-07-30 22:00:00 +0300","qty":1,"value":` + value + `}]}]}}`)
|
||||
}
|
||||
|
||||
func sleepBodyMany(values []string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[`)
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
// Метки ровно на часах: слой выводится, отказа не будет.
|
||||
fmt.Fprintf(&b, `{"date":"2026-07-30 %02d:00:00 +0300","qty":1,"value":%q}`, i%24, v)
|
||||
}
|
||||
b.WriteString(`]}]}}`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func assertCategoricals(t *testing.T, got, want []hae.Categorical) {
|
||||
t.Helper()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("наблюдений %d, ожидалось %d:\n получено %v\n ожидалось %v", len(got), len(want), got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("наблюдение %d: %+v, ожидалось %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawContains(t *testing.T, points []hae.Point, metric, want string) {
|
||||
t.Helper()
|
||||
|
||||
for _, p := range points {
|
||||
if p.Metric == metric && bytes.Contains(p.Raw, []byte(want)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("ни одна точка метрики %s не несёт %s дословно", metric, want)
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение %s: %v", name, err)
|
||||
}
|
||||
// Файл обязан быть настоящим пакетом HAE, а не выдумкой: разбор формата
|
||||
// проверяется живыми данными.
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Data) == 0 {
|
||||
t.Fatalf("%s не пакет HAE", name)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Невалидный UTF-8 наблюдением не считается: encoding/json ошибки на нём не
|
||||
// даёт, он ЗАМЕНЯЕТ негодные байты на U+FFFD — строка на выходе уже не равна
|
||||
// пришедшей, а реестр требует хранить значение дословно. Точка при этом живёт.
|
||||
func TestParseНевалидныйUTF8НаблюденияНеДаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Байт 0xFF внутри строкового литерала JSON: сам литерал валиден по
|
||||
// грамматике, а его содержимое — нет.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
"{\"date\":\"2026-07-30 21:00:00 +0300\",\"qty\":1,\"value\":\"\xffВо сне\"}" +
|
||||
`]}]}}`)
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Points) != 1 {
|
||||
t.Fatalf("точек %d, ожидалась одна: невалидный UTF-8 не имеет права уносить точку", len(res.Points))
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("наблюдений %d, ожидался ноль: %+v", len(res.Categoricals), res.Categoricals)
|
||||
}
|
||||
if res.CategoricalUnknown != 0 {
|
||||
t.Errorf("счётчик строк без кода %d, ожидался ноль", res.CategoricalUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// Граница числа применяется при накоплении: удерживаются 64 НАИМЕНЬШИХ ключа, а
|
||||
// не первые попавшиеся. Проверяется на трёх перестановках, а не на паре.
|
||||
func TestParseУдерживаютсяНаименьшиеКлючи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := make([]string, 0, 100)
|
||||
for i := range 100 {
|
||||
values = append(values, fmt.Sprintf("фаза-%03d", i))
|
||||
}
|
||||
|
||||
orders := map[string][]string{
|
||||
"по возрастанию": append([]string(nil), values...),
|
||||
"по убыванию": nil,
|
||||
"вперемешку": nil,
|
||||
}
|
||||
for i := len(values) - 1; i >= 0; i-- {
|
||||
orders["по убыванию"] = append(orders["по убыванию"], values[i])
|
||||
}
|
||||
// Детерминированная перестановка без источника случайности: тест обязан
|
||||
// краснеть воспроизводимо.
|
||||
for i := range values {
|
||||
orders["вперемешку"] = append(orders["вперемешку"], values[(i*37)%len(values)])
|
||||
}
|
||||
|
||||
want := values[:64]
|
||||
for name, order := range orders {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(sleepBodyMany(order), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != len(want) {
|
||||
t.Fatalf("наблюдений %d, ожидалось %d", len(res.Categoricals), len(want))
|
||||
}
|
||||
for i, c := range res.Categoricals {
|
||||
if c.Value != want[i] {
|
||||
t.Fatalf("наблюдение %d: %q, ожидалось %q — удержаны не наименьшие ключи", i, c.Value, want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+66
-2
@@ -179,6 +179,32 @@ type Result struct {
|
||||
// по координате.
|
||||
SkippedBadEnd int
|
||||
|
||||
// Categoricals — различные наблюдённые категориальные значения доставки с
|
||||
// выведенными кодами, в детерминированном порядке. Повтор одной строки в
|
||||
// тысяче точек даёт один элемент.
|
||||
Categoricals []Categorical
|
||||
// CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода.
|
||||
// Считаются различные значения, а не их вхождения: счётчик отвечает на
|
||||
// вопрос «сколько строк ждёт словаря», а не «сколько точек их несло».
|
||||
//
|
||||
// В установившемся режиме он ненулевой — словарь покрывает только фазы сна,
|
||||
// а контекст пульса и имена тренировок объявлены категориальными заранее.
|
||||
// Сигналом «появилось новое» служит поэтому новая строка реестра, а не
|
||||
// ненулевой счётчик.
|
||||
CategoricalUnknown int
|
||||
// CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная
|
||||
// длина значения или переполнение числа различных значений.
|
||||
//
|
||||
// Единица названа вслух и отличается от соседнего счётчика намеренно.
|
||||
// Считать здесь различные значения нечем: набор ограничен при вставке (иначе
|
||||
// накопитель растёт вместе с телом, а тело контролирует отправитель), и
|
||||
// отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть
|
||||
// ровно тот неограниченный рост, ради устранения которого граница и стоит на
|
||||
// вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала
|
||||
// граница», а не «сколько строк потеряно»; на второй отвечает реестр в
|
||||
// витрине.
|
||||
CategoricalDropped int
|
||||
|
||||
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
|
||||
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
|
||||
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
|
||||
@@ -204,6 +230,13 @@ type Meta struct {
|
||||
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
|
||||
// разбор остаётся чистой функцией.
|
||||
FallbackLayer Layer
|
||||
|
||||
// Locale — нормализованный языковой тег доставки из `Accept-Language`
|
||||
// (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не
|
||||
// сохраняется: заголовков в сыром архиве нет, поэтому доставка,
|
||||
// восстановленная из осиротевшего тела, приезжает без локали — и обязана
|
||||
// дать то же состояние. Пустая локаль законна.
|
||||
Locale string
|
||||
}
|
||||
|
||||
// Parse разбирает секцию metrics тела доставки в точки.
|
||||
@@ -231,18 +264,28 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
res.Uncovered = env.uncovered
|
||||
res.UncoveredDropped = env.dropped
|
||||
|
||||
cat := newCategoricals(meta.Locale)
|
||||
|
||||
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
|
||||
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
|
||||
// Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое
|
||||
// чтение превратило значение не того типа в пустую строку, а пустая строка
|
||||
// наблюдением не считается, — то есть «имени не было» и «имя приехало
|
||||
// числом» дают один исход, и он верный.
|
||||
for _, w := range res.Workouts {
|
||||
cat.add(workoutsSection, fieldName, w.Name)
|
||||
}
|
||||
|
||||
metrics := env.metrics
|
||||
res.Metrics = len(metrics)
|
||||
if len(metrics) == 0 {
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
groups := make([]group, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
g := decodeGroup(m, &res)
|
||||
g := decodeGroup(m, &res, cat)
|
||||
if len(g.summaries) > 0 {
|
||||
groups = append(groups, group{
|
||||
metric: sleepSummaryMetric,
|
||||
@@ -258,6 +301,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -280,6 +324,11 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
// Наблюдения отдаются только на успешном исходе: «всё или ничего» относится
|
||||
// к доставке целиком. У отказа по слою (см. ветку выше) сущности не
|
||||
// отдаются по той же причине.
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
|
||||
total := 0
|
||||
for _, g := range groups {
|
||||
total += len(g.points)
|
||||
@@ -400,6 +449,15 @@ type pointHead struct {
|
||||
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
|
||||
// суточную сводку. Общих полей, кроме date и source, у них нет.
|
||||
TotalSleep *json.RawMessage `json:"totalSleep"`
|
||||
|
||||
// Value и Context — категориальные поля точки (фаза сна и контекст пульса,
|
||||
// находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и
|
||||
// точка, у которой поле пришло числом, перестала бы разбираться вовсе —
|
||||
// json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup
|
||||
// считает такую точку не разобравшейся. Новый путь потери точки ради
|
||||
// удобства структуры недопустим.
|
||||
Value *json.RawMessage `json:"value"`
|
||||
Context *json.RawMessage `json:"context"`
|
||||
}
|
||||
|
||||
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
|
||||
@@ -665,7 +723,7 @@ func clipSection(name string) string {
|
||||
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
|
||||
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
|
||||
// корреляции между двумя списками здесь не существует по построению.
|
||||
func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group {
|
||||
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
|
||||
|
||||
for _, raw := range m.Data {
|
||||
@@ -716,8 +774,14 @@ func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
if m.Name == sleepMetric && head.TotalSleep != nil {
|
||||
p.Metric = sleepSummaryMetric
|
||||
g.summaries = append(g.summaries, p)
|
||||
// Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым
|
||||
// адресуется единица хранения. У суточной сводки категориальных
|
||||
// полей нет, так что здесь это ноль работы, — но правило записано
|
||||
// один раз и не разойдётся при следующем разделении схем.
|
||||
cat.addPoint(p.Metric, &head)
|
||||
continue
|
||||
}
|
||||
cat.addPoint(p.Metric, &head)
|
||||
g.points = append(g.points, p)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package healthkit — знание о значениях HealthKit: словарь локализованных
|
||||
// строк и эквивалентность имён кодов между версиями iOS.
|
||||
//
|
||||
// Отдельно от разбора HAE потому, что источников у этого знания будет два.
|
||||
// HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий
|
||||
// образ жизни»), а родной экспорт Apple — кодами (находка 37); словарь нужен
|
||||
// первому, таблица синонимов — обоим. Пакет не зависит ни от чего внутреннего и
|
||||
// ничего не пишет: обе операции — чистые функции.
|
||||
//
|
||||
// Чего здесь нет намеренно: знания о том, КАКИЕ поля HAE несут категориальные
|
||||
// значения. Имена `value`, `context`, `name` принадлежат формату HAE и живут в
|
||||
// internal/hae — иначе импорт родного экспорта потянул бы за собой словарь имён
|
||||
// полей чужого приложения.
|
||||
package healthkit
|
||||
|
||||
import "strings"
|
||||
|
||||
// Префикс кодов фазы сна. Вынесен ради читаемости таблицы: без него шесть строк
|
||||
// словаря отличаются друг от друга последним словом в конце длинной строки.
|
||||
const sleepPrefix = "HKCategoryValueSleepAnalysis"
|
||||
|
||||
// dictionary — локализованная строка → код HealthKit, по локалям.
|
||||
//
|
||||
// Словарь не составлен, а ВЫВЕДЕН: сопоставлением потока HAE с родным экспортом
|
||||
// Apple за тот же период (docs/research/apple-health.md, находка 43). Числа
|
||||
// вхождений на живом корпусе — 692 «Основная», 568 «Бодрствование», 206 «БДГ»,
|
||||
// 171 «Во сне», 94 «Глубокий», 38 «В кровати» — сошлись с фазами экспорта
|
||||
// однозначно.
|
||||
//
|
||||
// Локаль одна, `ru`: другого языка телефон не присылал ни разу. Строка
|
||||
// незнакомой локали получает код вторым разрядом Code, если сама строка
|
||||
// однозначна, — так что вторая локаль добавляется одной записью и ничего не
|
||||
// ломает.
|
||||
//
|
||||
// Контекста пульса и типов тренировок здесь нет, и это не пробел, а отказ
|
||||
// угадывать: экспорт Apple хранит контекст пульса метаданным-числом, а не
|
||||
// `HKCategoryValue*`, и сопоставление «Сидячий образ жизни» с чем бы то ни было
|
||||
// осталось бы догадкой. Их строки видны реестром с пустым кодом — это и есть
|
||||
// заявка на будущий вывод.
|
||||
var dictionary = map[string]map[string]string{
|
||||
"ru": {
|
||||
"Основная": sleepPrefix + "AsleepCore",
|
||||
"Бодрствование": sleepPrefix + "Awake",
|
||||
"БДГ": sleepPrefix + "AsleepREM",
|
||||
"Глубокий": sleepPrefix + "AsleepDeep",
|
||||
"В кровати": sleepPrefix + "InBed",
|
||||
"Во сне": sleepPrefix + "AsleepUnspecified",
|
||||
},
|
||||
}
|
||||
|
||||
// synonyms — устаревшее имя кода → нынешнее.
|
||||
//
|
||||
// Коды HealthKit устойчивее локализованных строк, но не вечны: те же 338
|
||||
// записей сна экспортированы как `…Asleep` в 2021 году и как
|
||||
// `…AsleepUnspecified` в 2026-м, причём счётчики сошлись до единицы — Apple
|
||||
// переименовала значение и переписывает историю при выгрузке (находка 43). Без
|
||||
// этой таблицы история раскололась бы вторично, уже на «стабильной» стороне.
|
||||
//
|
||||
// Таблица ПЛОСКАЯ: ни одно её значение не является ключом, поэтому алиас
|
||||
// разрешается ровно за один шаг. Инвариант держит тест по таблице целиком, а не
|
||||
// обход цепочек в рантайме: у обхода нет ни одного достижимого сценария, зато
|
||||
// есть собственный вырожденный случай — «что вернуть при превышении глубины».
|
||||
var synonyms = map[string]string{
|
||||
sleepPrefix + "Asleep": sleepPrefix + "AsleepUnspecified",
|
||||
}
|
||||
|
||||
// byValue — строка → код, когда локаль неизвестна.
|
||||
//
|
||||
// Пустой код означает «строка встречается в разных локалях с разными кодами» и
|
||||
// от «строки нет вовсе» на выходе Code не отличается: оба исхода означают «не
|
||||
// угадываем». Различать их незачем — решение одно.
|
||||
//
|
||||
// Индекс существует не ради удобства. Заголовки запроса в сыром архиве не
|
||||
// лежат, поэтому доставка, восстановленная из осиротевшего тела, приезжает без
|
||||
// `Accept-Language`; правило «нет локали — нет кода» сделало бы состояние
|
||||
// функцией от того, уцелела ли учётная строка, то есть сломало бы
|
||||
// `import + replay`.
|
||||
var byValue = buildByValue()
|
||||
|
||||
func buildByValue() map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, values := range dictionary {
|
||||
for value, code := range values {
|
||||
code = Canonical(code)
|
||||
if prev, seen := out[value]; seen && prev != code {
|
||||
// Расхождение локалей: выбирать не из чего.
|
||||
out[value] = ""
|
||||
continue
|
||||
}
|
||||
out[value] = code
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Code возвращает канонический код HealthKit для локализованной строки.
|
||||
//
|
||||
// Три разряда, и второй обязателен, а не удобен (см. byValue):
|
||||
//
|
||||
// 1. пара (локаль, строка) есть в словаре — её код;
|
||||
// 2. локали нет либо пары нет, но строка однозначна по всем локалям — её код;
|
||||
// 3. иначе — пустой код.
|
||||
//
|
||||
// Пустой код честнее догадки: по коду сверяются с экспортом Apple, а неверный
|
||||
// код неотличим от верного до тех пор, пока по нему не примут решение.
|
||||
func Code(locale, value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if values, ok := dictionary[locale]; ok {
|
||||
if code, ok := values[value]; ok {
|
||||
return Canonical(code)
|
||||
}
|
||||
}
|
||||
return byValue[value]
|
||||
}
|
||||
|
||||
// Canonical приводит устаревшее имя кода к нынешнему.
|
||||
//
|
||||
// Зовётся и изнутри Code, поэтому «две формы сходятся в один код» верно по
|
||||
// построению, а не по дисциплине того, кто правит словарь.
|
||||
func Canonical(code string) string {
|
||||
if to, ok := synonyms[code]; ok {
|
||||
return to
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// maxLocaleTag — предел длины языкового тега.
|
||||
//
|
||||
// Первичный подтег BCP 47 — от двух до восьми букв; предел стоит на всём теге
|
||||
// до отсечения подтегов, с запасом. Заголовок контролирует отправитель целиком,
|
||||
// а `MaxHeaderBytes` у Go — мегабайт: без предела мегабайтная строка уехала бы
|
||||
// в свёртку регистра и в сравнение со словарём.
|
||||
const maxLocaleTag = 32
|
||||
|
||||
// Locale нормализует заголовок `Accept-Language` до языкового тега.
|
||||
//
|
||||
// Правило lookup RFC 4647: берётся первый тег списка, вес `q` отбрасывается,
|
||||
// подтеги отсекаются, регистр сворачивается — `RU-ru,ru;q=0.9` даёт `ru`.
|
||||
// Свёртка регистра обязательна: теги BCP 47 регистронезависимы, и без неё `RU`
|
||||
// и `ru` были бы разными языками, а локаль сужает поиск по словарю.
|
||||
//
|
||||
// Согласования весов нет намеренно: измеренное значение заголовка — `ru`
|
||||
// (находка 32), одна строка без вариантов. Появятся веса — правило стоит
|
||||
// пересматривать целиком, а не дописывать.
|
||||
//
|
||||
// Не тег — пустая строка: `*`, пустой заголовок, мусор. Пустая локаль законна и
|
||||
// вывода кода не отменяет (см. Code).
|
||||
func Locale(header string) string {
|
||||
tag := header
|
||||
if i := strings.IndexAny(tag, ",;"); i >= 0 {
|
||||
tag = tag[:i]
|
||||
}
|
||||
tag = strings.TrimSpace(tag)
|
||||
if len(tag) > maxLocaleTag {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||
tag = tag[:i]
|
||||
}
|
||||
if !isLanguageTag(tag) {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(tag)
|
||||
}
|
||||
|
||||
// isLanguageTag проверяет первичный подтег: от двух до восьми ASCII-букв.
|
||||
//
|
||||
// Проверка нужна не эстетике. Без неё `*` из `Accept-Language: *` стал бы
|
||||
// полноценной локалью, а мусор из чужого заголовка — ключом поиска по словарю.
|
||||
func isLanguageTag(s string) bool {
|
||||
if len(s) < 2 || len(s) > 8 {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package healthkit_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
)
|
||||
|
||||
// Обе формы имени, которые Apple дала одному значению, обязаны сойтись в один
|
||||
// код: иначе история расколется вторично, уже на «стабильной» стороне
|
||||
// (находка 43).
|
||||
func TestCanonicalОбеФормыСходятся(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
old = "HKCategoryValueSleepAnalysisAsleep"
|
||||
now = "HKCategoryValueSleepAnalysisAsleepUnspecified"
|
||||
)
|
||||
|
||||
if got := healthkit.Canonical(old); got != now {
|
||||
t.Errorf("устаревшее имя %q дало %q, ожидалось %q", old, got, now)
|
||||
}
|
||||
if got := healthkit.Canonical(now); got != now {
|
||||
t.Errorf("нынешнее имя %q дало %q, ожидалось %q", now, got, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalКодБезСинонимаОстаётсяСобой(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const code = "HKCategoryValueSleepAnalysisAsleepREM"
|
||||
if got := healthkit.Canonical(code); got != code {
|
||||
t.Errorf("код без синонима стал %q", got)
|
||||
}
|
||||
if got := healthkit.Canonical(""); got != "" {
|
||||
t.Errorf("пустой код стал %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Плоскость таблицы синонимов — то, чем оправдано отсутствие обхода цепочек в
|
||||
// рантайме. Проверяется по таблице целиком, а не на примере: правило держится
|
||||
// на всей таблице, и первая же добавленная запись может его нарушить.
|
||||
//
|
||||
// Обходим через Canonical, а не через саму карту: наружу она не отдаётся, а
|
||||
// «значение не является ключом» проверяемо снаружи — канонизация значения
|
||||
// обязана быть неподвижной точкой.
|
||||
func TestSynonymsТаблицаПлоская(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Значения таблицы наблюдаемы через Code: словарь уже канонизирован, а
|
||||
// коды, которые он выдаёт, обязаны быть неподвижными точками.
|
||||
for _, value := range []string{"Основная", "Бодрствование", "БДГ", "Глубокий", "В кровати", "Во сне"} {
|
||||
code := healthkit.Code("ru", value)
|
||||
if code == "" {
|
||||
t.Fatalf("измеренная строка %q кода не дала", value)
|
||||
}
|
||||
if again := healthkit.Canonical(code); again != code {
|
||||
t.Errorf("код %q не неподвижная точка: канонизация дала %q — таблица синонимов не плоская", code, again)
|
||||
}
|
||||
}
|
||||
// То же для известного алиаса: его канонизация обязана быть неподвижной с
|
||||
// одного шага.
|
||||
once := healthkit.Canonical("HKCategoryValueSleepAnalysisAsleep")
|
||||
if twice := healthkit.Canonical(once); twice != once {
|
||||
t.Errorf("алиас разрешился не за один шаг: %q → %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeФазыСна(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
locale string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"ru", "Основная", "HKCategoryValueSleepAnalysisAsleepCore"},
|
||||
{"ru", "Бодрствование", "HKCategoryValueSleepAnalysisAwake"},
|
||||
{"ru", "БДГ", "HKCategoryValueSleepAnalysisAsleepREM"},
|
||||
{"ru", "Глубокий", "HKCategoryValueSleepAnalysisAsleepDeep"},
|
||||
{"ru", "В кровати", "HKCategoryValueSleepAnalysisInBed"},
|
||||
{"ru", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"},
|
||||
// Локали нет — код всё равно выводится вторым разрядом: заголовков в
|
||||
// сыром архиве не лежит, и усыновлённое тело обязано дать то же
|
||||
// состояние.
|
||||
{"", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"},
|
||||
// Незнакомая локаль знакомой строки код не отменяет.
|
||||
{"en", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"},
|
||||
// Догадок нет.
|
||||
{"ru", "Полудрёма", ""},
|
||||
{"ru", "", ""},
|
||||
{"", "Сидячий образ жизни", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.locale+"/"+c.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := healthkit.Code(c.locale, c.value); got != c.want {
|
||||
t.Errorf("Code(%q, %q) = %q, ожидалось %q", c.locale, c.value, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleНормализация(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"измеренное значение потока", "ru", "ru"},
|
||||
{"верхний регистр", "RU", "ru"},
|
||||
{"подтег", "ru-RU", "ru"},
|
||||
{"список с весами и регистром", "RU-ru,ru;q=0.9,en;q=0.8", "ru"},
|
||||
{"пробелы вокруг", " ru ", "ru"},
|
||||
{"вес без списка", "ru;q=1", "ru"},
|
||||
{"звёздочка тегом не является", "*", ""},
|
||||
{"пустой заголовок", "", ""},
|
||||
{"мусор", "!!!", ""},
|
||||
{"однобуквенный тег", "r", ""},
|
||||
{"непомерно длинный тег", strings.Repeat("x", 64), ""},
|
||||
{"сто тегов", strings.Repeat("en,", 100) + "ru", "en"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := healthkit.Locale(c.header); got != c.want {
|
||||
t.Errorf("Locale(%q) = %q, ожидалось %q", c.header, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package healthkit
|
||||
|
||||
import "testing"
|
||||
|
||||
// Плоскость таблицы синонимов — то, чем оправдано отсутствие обхода цепочек в
|
||||
// рантайме. Проверять её обязательно ИЗНУТРИ пакета и по самой карте: внешний
|
||||
// тест умеет обойти только записи, достижимые из словаря, а запись, до которой
|
||||
// словарь не дотягивается, под утверждение не попадёт вовсе — при этом
|
||||
// `Canonical` вернёт промежуточный код, и в реестре осядет имя, которого в
|
||||
// экспорте Apple нет. Проверено воспроизведением: с неплоской таблицей внешний
|
||||
// тест остаётся зелёным.
|
||||
func TestSynonymsЗначениеНеЯвляетсяКлючом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for from, to := range synonyms {
|
||||
if _, ok := synonyms[to]; ok {
|
||||
t.Errorf("синоним %q → %q, но %q сам является ключом таблицы: цепочка длиннее одного шага, "+
|
||||
"а разрешения цепочек в рантайме нет намеренно", from, to, to)
|
||||
}
|
||||
if from == to {
|
||||
t.Errorf("синоним %q указывает на самого себя", from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь обязан быть однозначен по строке независимо от локали, и это не
|
||||
// вкусовщина, а условие корректности второго разряда `Code`.
|
||||
//
|
||||
// Ключ реестра локали не содержит, а `mergeCategories` переписывает `code`
|
||||
// безусловно. Пока строка даёт один код во всех локалях, три решения
|
||||
// согласованы. Первая же строка, означающая в двух локалях разное, обнуляет
|
||||
// `byValue` — и тогда код строки становится функцией того, у какой доставки
|
||||
// уцелел `Accept-Language`, то есть какая свернулась последней, а не последней
|
||||
// по журналу. Отпечаток этого не покажет: код в него не входит намеренно.
|
||||
// Оракула у такого расхождения нет вовсе — поэтому страж стоит здесь.
|
||||
func TestDictionaryСтрокаОднозначнаПоВсемЛокалям(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
codes := make(map[string]string)
|
||||
locales := make(map[string]string)
|
||||
for locale, values := range dictionary {
|
||||
for value, code := range values {
|
||||
code = Canonical(code)
|
||||
if prev, seen := codes[value]; seen && prev != code {
|
||||
t.Errorf("строка %q означает %q в локали %q и %q в локали %q: "+
|
||||
"код станет функцией порядка свёртки, а не журнала",
|
||||
value, prev, locales[value], code, locale)
|
||||
continue
|
||||
}
|
||||
codes[value] = code
|
||||
locales[value] = locale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь не должен молча раздваивать код: два разных ключа с одним кодом
|
||||
// законны (синонимы перевода), а вот пустой код в словаре — нет. Пустота
|
||||
// означает «не знаем», и записывать её явно значит выдать незнание за знание.
|
||||
func TestDictionaryПустыхКодовНет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for locale, values := range dictionary {
|
||||
for value, code := range values {
|
||||
if code == "" {
|
||||
t.Errorf("словарь локали %q сопоставляет %q пустому коду", locale, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
"git.vakhrushev.me/av/healthlog/internal/replay"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
@@ -155,6 +157,90 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
coords, labels)
|
||||
}
|
||||
t.Logf("координат сна %d, различных меток %d", coords, labels)
|
||||
|
||||
measureCategories(t, dst, second)
|
||||
}
|
||||
|
||||
// measureCategories проверяет реестр категориальных значений на живом корпусе.
|
||||
//
|
||||
// Здесь он проверяется единственным способом, каким это вообще возможно: против
|
||||
// строк, которые телефон действительно присылал. Заголовков в архиве нет, то
|
||||
// есть локаль у всех доставок пуста — прогон заодно доказывает, что вывод кода
|
||||
// не зависит от уцелевшей учётной строки.
|
||||
//
|
||||
// Утверждаются СВОЙСТВА, а не числа: число строк реестра и число строк без кода
|
||||
// растут вместе с потоком, а прогон живого архива в гейт не входит — константа
|
||||
// покраснела бы молча (docs/review.md, 2026-08-02). Измеренное печатается.
|
||||
func measureCategories(t *testing.T, dst *store.Store, second replay.Report) {
|
||||
t.Helper()
|
||||
|
||||
values, err := dst.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("реестр категориальных значений: %v", err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
t.Fatal("реестр пуст — категориальные значения не извлекаются вовсе")
|
||||
}
|
||||
if int64(len(values)) != second.Categories {
|
||||
t.Errorf("повторное проигрывание изменило число строк реестра: %d → %d",
|
||||
len(values), second.Categories)
|
||||
}
|
||||
|
||||
var withCode, withoutCode int
|
||||
for _, v := range values {
|
||||
if v.Code == "" {
|
||||
withoutCode++
|
||||
continue
|
||||
}
|
||||
withCode++
|
||||
// Всякий выведенный код обязан быть каноническим: обе формы имени,
|
||||
// которые Apple дала одному значению, сходятся в одну (находка 43).
|
||||
// Координаты — метрика и поле; сам код не печатается: он константа
|
||||
// бинаря, но напечатанный рядом с живым архивом сообщает, что эта фаза
|
||||
// у человека была.
|
||||
if healthkit.Canonical(v.Code) != v.Code {
|
||||
t.Errorf("%s/%s: выведенный код не канонический", v.Metric, v.Field)
|
||||
}
|
||||
// Провенанс — доставка журнала, а не пустое место.
|
||||
if v.FirstDeliveryID == "" || v.FirstSeen.IsZero() {
|
||||
t.Errorf("%s/%s: провенанс не заполнен", v.Metric, v.Field)
|
||||
}
|
||||
}
|
||||
|
||||
// Фазы сна — единственное, что словарь покрывает, и покрывать он их обязан:
|
||||
// ради этой сверки задача и существует. Строки берутся из ЖИВОГО архива, а
|
||||
// не из литерала теста.
|
||||
var sleepValues, sleepCoded int
|
||||
for _, v := range values {
|
||||
if v.Metric != "sleep_analysis" || v.Field != "value" {
|
||||
continue
|
||||
}
|
||||
sleepValues++
|
||||
if v.Code != "" {
|
||||
sleepCoded++
|
||||
}
|
||||
}
|
||||
if sleepValues == 0 {
|
||||
t.Fatal("фаз сна в реестре нет — проверять нечего")
|
||||
}
|
||||
if sleepCoded != sleepValues {
|
||||
// Печатается ЧИСЛО, а не перечень. Перечень был бы удобнее, и ровно
|
||||
// поэтому его тут быть не может: прогон идёт против рабочего архива, а
|
||||
// фаза сна — значение точки. `CLAUDE.md`, «Запреты»: ничего из `./data`
|
||||
// не попадает ни в логи выше `DEBUG`, ни в вывод агента. Ветка
|
||||
// срабатывает как раз тогда, когда телефон принёс НОВУЮ фазу, то есть
|
||||
// именно тогда, когда соблазн напечатать её сильнее всего.
|
||||
//
|
||||
// Адрес, по которому строки смотрят, отпечатывать не нужно: они лежат в
|
||||
// пересобранной базе, `SELECT value FROM category_value WHERE code = ''`.
|
||||
t.Errorf("фаз сна без кода %d из %d — словарь неполон; строки смотреть в реестре пересобранной базы",
|
||||
sleepValues-sleepCoded, sleepValues)
|
||||
}
|
||||
|
||||
// Числа печатаются: они растут с корпусом и утверждению не подлежат.
|
||||
// Значения при этом НЕ печатаются — это данные о здоровье.
|
||||
t.Logf("реестр: строк %d, с кодом %d, без кода %d; фаз сна %d, все с кодом",
|
||||
len(values), withCode, withoutCode, sleepValues)
|
||||
}
|
||||
|
||||
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
|
||||
|
||||
@@ -79,8 +79,12 @@ type Report struct {
|
||||
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом
|
||||
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
|
||||
// решение о подмене базы необратимо и требует направления расхождения.
|
||||
Workouts int64
|
||||
Records int64
|
||||
Workouts int64
|
||||
Records int64
|
||||
// Categories — строки реестра категориальных значений: четвёртая единица
|
||||
// хранения витрины. Без счётчика расхождение по ней безадресно — объекты,
|
||||
// тренировки и записи при этом не меняются вовсе.
|
||||
Categories int64
|
||||
Fingerprint string
|
||||
|
||||
// Canceled — проигрывание прервано отменой, а не дошло до конца.
|
||||
@@ -184,6 +188,10 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
||||
if err != nil {
|
||||
return stopOr(rep, err)
|
||||
}
|
||||
rep.Categories, err = o.Target.CountCategoryValues(ctx)
|
||||
if err != nil {
|
||||
return stopOr(rep, err)
|
||||
}
|
||||
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
return stopOr(rep, err)
|
||||
@@ -207,7 +215,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
||||
"entities_diverging", rep.EntitiesDiverging,
|
||||
"buckets", rep.Buckets,
|
||||
"workouts", rep.Workouts,
|
||||
"records", rep.Records)
|
||||
"records", rep.Records,
|
||||
"category_values", rep.Categories)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
stats.RecordsWritten = written
|
||||
stats.EntitiesHeld += held
|
||||
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||
return nil
|
||||
|
||||
return mergeCategories(ctx, tx, in.Categories, from)
|
||||
})
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
@@ -602,6 +603,18 @@ func readBucket(ctx context.Context, tx *sql.Tx, key bucketKey) (Bucket, bool, e
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// writeBucket пишет часовой объект.
|
||||
//
|
||||
// `first_delivery_id` в `DO UPDATE` НЕ входит намеренно: провенанс объекта —
|
||||
// «кто создал строку», то есть функция ПОРЯДКА СВЁРТКИ, а не журнала. Это
|
||||
// допустимо ровно потому, что в отпечаток витрины он не идёт
|
||||
// (см. fingerprintBuckets): расхождение по нему ненаблюдаемо и решений по нему
|
||||
// не принимают.
|
||||
//
|
||||
// Единице хранения, чей провенанс входит в отпечаток, такого правила МАЛО — там
|
||||
// нужен явный минимум по журналу, иначе живой приём и пересборка разойдутся при
|
||||
// одинаковом журнале. Образец — mergeCategories в category.go. Сказано здесь,
|
||||
// потому что копировать будут отсюда: этот upsert старше и проще.
|
||||
func writeBucket(ctx context.Context, tx *sql.Tx, b Bucket, now time.Time) error {
|
||||
payload, err := encodePayload(b.Points)
|
||||
if err != nil {
|
||||
@@ -806,10 +819,12 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
||||
// отпечаток — нет.
|
||||
//
|
||||
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
|
||||
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
|
||||
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
|
||||
// данные.
|
||||
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки, записи и реестр
|
||||
// категориальных значений. Отпечаток одних объектов давал бы «состояние
|
||||
// сошлось» при разъехавшихся тренировках, то есть ломался бы молча тем самым
|
||||
// изменением, которое добавило данные.
|
||||
//
|
||||
// От реестра берётся НАБЛЮДЕНИЕ, но не выведенный код: см. fingerprintCategories.
|
||||
//
|
||||
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
|
||||
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
|
||||
@@ -830,17 +845,65 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
if err := fingerprintEntities(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fingerprintCategories(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Признак раздела впереди строки: без него строка одного раздела может совпасть
|
||||
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
|
||||
const (
|
||||
fpBucket = "b"
|
||||
fpWorkout = "w"
|
||||
fpRecord = "r"
|
||||
fpBucket = "b"
|
||||
fpWorkout = "w"
|
||||
fpRecord = "r"
|
||||
fpCategory = "c"
|
||||
)
|
||||
|
||||
// fingerprintCategories добавляет в отпечаток реестр категориальных значений —
|
||||
// его НАБЛЮДЕНИЕ, но не выведенный код.
|
||||
//
|
||||
// Ключ и провенанс — функция журнала: те же тела в том же порядке дают их
|
||||
// побайтно. Код — функция журнала И версии словаря в бинаре. Включи его сюда, и
|
||||
// отпечаток перестал бы отвечать на свой единственный вопрос («дал ли повтор
|
||||
// журнала то же состояние») ровно тогда, когда его задают: всякое пополнение
|
||||
// словаря давало бы расхождение при побайтно совпавшем журнале, а человек,
|
||||
// принимающий по отпечатку необратимое решение о подмене базы, читал бы это как
|
||||
// дефект. Правильность вывода кода проверяют тесты словаря — это другой вопрос,
|
||||
// и смешение обесценило бы оракул.
|
||||
//
|
||||
// Значений наружу отпечаток не раскрывает: он хеш.
|
||||
func fingerprintCategories(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||
const q = `
|
||||
SELECT metric, field, value, first_seen_utc, first_delivery_id
|
||||
FROM category_value ORDER BY metric, field, value`
|
||||
|
||||
rows, err := tx.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select category values: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var metric, field, value, firstSeen, deliveryID string
|
||||
if err := rows.Scan(&metric, &field, &value, &firstSeen, &deliveryID); err != nil {
|
||||
return fmt.Errorf("scan category value: %w", err)
|
||||
}
|
||||
// Длина впереди каждого поля переменной длины: значение приходит из тела
|
||||
// дословно и может содержать что угодно, включая признак раздела и
|
||||
// разделители. Без длины пара (`a`, `b|c`) дала бы ту же строку, что
|
||||
// (`a|b`, `c`), — то есть два разных состояния витрины сошлись бы
|
||||
// отпечатком. Тот же приём в fingerprintBuckets и по той же причине.
|
||||
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%d:%s|%s|%d:%s\n",
|
||||
fpCategory, len(metric), metric, len(field), field,
|
||||
len(value), value, firstSeen, len(deliveryID), deliveryID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("select category values: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||
const q = `
|
||||
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CategoryValue — строка реестра категориальных значений: перечислимая строка,
|
||||
// которую приносил поток, и выведенный для неё код HealthKit.
|
||||
//
|
||||
// Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в
|
||||
// отпечаток витрины он не входит (см. fingerprintCategories).
|
||||
type CategoryValue struct {
|
||||
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
|
||||
// хранения.
|
||||
Metric string
|
||||
// Field — имя поля внутри точки или сущности, дословно как у HAE.
|
||||
Field string
|
||||
// Value — строка, как прислал HAE.
|
||||
Value string
|
||||
// Code — канонический код HealthKit; пустой означает «словарь не знает».
|
||||
Code string
|
||||
|
||||
// FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала.
|
||||
// Заполняются хранилищем, вызывающему при записи не нужны.
|
||||
FirstSeen time.Time
|
||||
FirstDeliveryID string
|
||||
}
|
||||
|
||||
// mergeCategories записывает наблюдения доставки в реестр.
|
||||
//
|
||||
// Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс —
|
||||
// только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же
|
||||
// доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at,
|
||||
// id)` давало бы не то, что живой приём.
|
||||
//
|
||||
// Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой:
|
||||
// доставки одной секунды в журнале упорядочены идентификатором, и сравнение по
|
||||
// одной метке сделало бы исход зависящим от того, какая из них свернулась
|
||||
// раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в
|
||||
// Go значило бы читать строку, решать и писать, то есть добавлять обращение к
|
||||
// базе на каждое наблюдение внутри транзакции.
|
||||
func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO category_value
|
||||
(metric, field, value, code, first_seen_utc, first_delivery_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (metric, field, value) DO UPDATE SET
|
||||
code = excluded.code,
|
||||
first_seen_utc = CASE
|
||||
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
|
||||
< (category_value.first_seen_utc, category_value.first_delivery_id)
|
||||
THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END,
|
||||
first_delivery_id = CASE
|
||||
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
|
||||
< (category_value.first_seen_utc, category_value.first_delivery_id)
|
||||
THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END`
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare category upsert: %w", err)
|
||||
}
|
||||
defer func() { _ = stmt.Close() }()
|
||||
|
||||
at := FormatTime(from.ReceivedAt)
|
||||
for _, v := range values {
|
||||
if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil {
|
||||
// Значение в текст ошибки не попадает: строка категориального
|
||||
// значения — данные о здоровье наравне со значением точки.
|
||||
//
|
||||
// Координаты обрезаются, хотя сегодня они приходят из объявленного
|
||||
// списка полей и произвольной строкой из тела быть не могут. Предел
|
||||
// стоит здесь, а не держится на форме того списка: правило вида
|
||||
// «поле `value` у метрик с таким-то именем» уронило бы имя метрики
|
||||
// из чужого тела прямо в `ERROR`, и заметить это было бы нечем.
|
||||
return fmt.Errorf("upsert category value (%s/%s): %w",
|
||||
clipCoord(v.Metric), clipCoord(v.Field), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CategoryValues возвращает реестр целиком в детерминированном порядке.
|
||||
//
|
||||
// Реестр мал по построению — на живом потоке различных значений около
|
||||
// одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем:
|
||||
// границы разбора не дают доставке положить больше 64 значений, а число полей
|
||||
// объявлено списком.
|
||||
func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) {
|
||||
const q = `
|
||||
SELECT metric, field, value, code, first_seen_utc, first_delivery_id
|
||||
FROM category_value ORDER BY metric, field, value`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select category values: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []CategoryValue
|
||||
for rows.Next() {
|
||||
var v CategoryValue
|
||||
var firstSeen string
|
||||
if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil {
|
||||
return nil, fmt.Errorf("scan category value: %w", err)
|
||||
}
|
||||
v.FirstSeen, err = ParseTime(firstSeen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("select category values: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountCategoryValues возвращает число строк реестра.
|
||||
//
|
||||
// Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика
|
||||
// «до и после» расхождение отпечатков по нему безадресно — числа объектов,
|
||||
// тренировок и записей при этом не меняются. Перечислением отвечать нельзя:
|
||||
// сами строки — данные о здоровье, а отчёт печатается человеку в терминал.
|
||||
func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil {
|
||||
return 0, fmt.Errorf("count category values: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// maxCoordInLog — предел длины координаты наблюдения в тексте ошибки.
|
||||
//
|
||||
// Та же граница и по той же причине, что у имени метрики в координатах
|
||||
// столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ.
|
||||
const maxCoordInLog = 64
|
||||
|
||||
func clipCoord(s string) string {
|
||||
if len(s) <= maxCoordInLog {
|
||||
return s
|
||||
}
|
||||
return s[:maxCoordInLog] + "…"
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func category(metric, field, value, code string) store.CategoryValue {
|
||||
return store.CategoryValue{Metric: metric, Field: field, Value: value, Code: code}
|
||||
}
|
||||
|
||||
func mergeCategories(t *testing.T, st *store.Store, d store.DeliveryRef, vs ...store.CategoryValue) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Categories: vs}, d); err != nil {
|
||||
t.Fatalf("слияние наблюдений: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func categories(t *testing.T, st *store.Store) []store.CategoryValue {
|
||||
t.Helper()
|
||||
|
||||
got, err := st.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("чтение реестра: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestMergeCategoriesКладётНаблюдение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
||||
mergeCategories(t, st, d,
|
||||
category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"),
|
||||
category("heart_rate", "context", "Не задано", ""))
|
||||
|
||||
got := categories(t, st)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("строк реестра %d, ожидалось 2: %+v", len(got), got)
|
||||
}
|
||||
// Порядок детерминирован ключом.
|
||||
if got[0].Metric != "heart_rate" || got[1].Metric != "sleep_analysis" {
|
||||
t.Errorf("порядок реестра не по ключу: %+v", got)
|
||||
}
|
||||
// Пустой код — законное состояние: словарь этой строки не знает.
|
||||
if got[0].Code != "" {
|
||||
t.Errorf("контекст пульса получил код %q", got[0].Code)
|
||||
}
|
||||
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
||||
t.Errorf("фаза сна получила код %q", got[1].Code)
|
||||
}
|
||||
if got[1].FirstDeliveryID != d.ID || !got[1].FirstSeen.Equal(d.ReceivedAt) {
|
||||
t.Errorf("провенанс %s/%s, ожидался %s/%s",
|
||||
got[1].FirstDeliveryID, got[1].FirstSeen, d.ID, d.ReceivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторная свёртка той же доставки не меняет ни одной колонки: иначе состояние
|
||||
// зависело бы от числа прогонов, а пересборка перестала бы быть no-op.
|
||||
func TestMergeCategoriesПовторНичегоНеМеняет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
||||
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
||||
|
||||
mergeCategories(t, st, d, v)
|
||||
first := fingerprint(t, st)
|
||||
before := categories(t, st)
|
||||
|
||||
mergeCategories(t, st, d, v)
|
||||
if got := fingerprint(t, st); got != first {
|
||||
t.Error("повторная свёртка сдвинула отпечаток")
|
||||
}
|
||||
after := categories(t, st)
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("повтор изменил реестр: было %+v, стало %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс — МИНИМУМ по журналу, а не последняя запись: иначе проигрывание
|
||||
// журнала давало бы не то, что живой приём, и порядок свёртки решал бы исход.
|
||||
//
|
||||
// Три доставки во всех шести порядках, а не пара: пара доказывает
|
||||
// коммутативность и молчит про ассоциативность.
|
||||
func TestMergeCategoriesПровенансНеЗависитОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deliveries := []store.DeliveryRef{
|
||||
from(t, "01AAA", "2026-08-01T10:00:00Z"),
|
||||
from(t, "01BBB", "2026-08-02T10:00:00Z"),
|
||||
// Та же секунда, что у предыдущей: порядок задаёт идентификатор, и
|
||||
// сравнение по одной метке сделало бы исход зависящим от того, какая
|
||||
// свернулась раньше.
|
||||
from(t, "01AAB", "2026-08-02T10:00:00Z"),
|
||||
}
|
||||
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
||||
|
||||
orders := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||
var want string
|
||||
for _, order := range orders {
|
||||
t.Run(fmt.Sprint(order), func(t *testing.T) {
|
||||
st := open(t)
|
||||
for _, i := range order {
|
||||
mergeCategories(t, st, deliveries[i], v)
|
||||
}
|
||||
got := categories(t, st)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("строк реестра %d, ожидалась одна", len(got))
|
||||
}
|
||||
if got[0].FirstDeliveryID != "01AAA" {
|
||||
t.Errorf("провенанс %s, ожидалась самая ранняя доставка 01AAA", got[0].FirstDeliveryID)
|
||||
}
|
||||
// Отпечаток обязан совпасть у всех шести порядков.
|
||||
fp := fingerprint(t, st)
|
||||
if want == "" {
|
||||
want = fp
|
||||
} else if fp != want {
|
||||
t.Errorf("порядок %v дал другой отпечаток", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдение в отпечаток входит, выведенный код — нет. Код производен от
|
||||
// словаря в бинаре, а не от журнала: включённый в отпечаток, он заставил бы
|
||||
// всякое пополнение словаря давать расхождение при совпавшем журнале.
|
||||
func TestFingerprintРеестрБезКода(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
||||
|
||||
withCode := open(t)
|
||||
mergeCategories(t, withCode, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"))
|
||||
|
||||
otherCode := open(t)
|
||||
mergeCategories(t, otherCode, d, category("sleep_analysis", "value", "Во сне", ""))
|
||||
|
||||
if fingerprint(t, withCode) != fingerprint(t, otherCode) {
|
||||
t.Error("расхождение только по коду сдвинуло отпечаток — оракул сходимости стал функцией версии словаря")
|
||||
}
|
||||
|
||||
otherValue := open(t)
|
||||
mergeCategories(t, otherValue, d, category("sleep_analysis", "value", "Бодрствование", "HKCategoryValueSleepAnalysisAwake"))
|
||||
if fingerprint(t, withCode) == fingerprint(t, otherValue) {
|
||||
t.Error("разошедшееся наблюдение отпечаток не сдвинуло")
|
||||
}
|
||||
}
|
||||
|
||||
// Значение приходит из тела дословно и может содержать что угодно, включая
|
||||
// признак раздела и разделители полей. Длина впереди каждого поля — то, что не
|
||||
// даёт двум разным состояниям витрины сойтись отпечатком.
|
||||
func TestFingerprintРазделительВЗначенииГраницуНеПодделывает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
||||
|
||||
a := open(t)
|
||||
mergeCategories(t, a, d, category("sleep", "value|c|9", "x", ""))
|
||||
|
||||
b := open(t)
|
||||
mergeCategories(t, b, d, category("sleep", "value", "|c|9|x", ""))
|
||||
|
||||
if fingerprint(t, a) == fingerprint(t, b) {
|
||||
t.Error("разное разбиение тех же байтов по полям дало один отпечаток")
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой реестр отпечаток не ломает: раздел, у которого нет строк, не пишет в
|
||||
// хеш ничего.
|
||||
func TestFingerprintПустойРеестр(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
fp, err := empty.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток пустой витрины: %v", err)
|
||||
}
|
||||
if fp == "" {
|
||||
t.Error("отпечаток пустой витрины пуст")
|
||||
}
|
||||
n, err := empty.CountCategoryValues(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт реестра: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("строк реестра %d, ожидался ноль", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ слияния не оставляет строк реестра: доставка — единица свёртки, и
|
||||
// частичное состояние повторная свёртка не чинит.
|
||||
func TestMergeCategoriesОтказНеОставляетСтрок(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Точка, содержимое которой не канонизируется, роняет всю транзакцию.
|
||||
in := store.Incoming{
|
||||
Points: []store.IncomingPoint{point(t, "m", "hour", "2026-08-03T10:00:00Z", "2026-08-03T10:00:00Z", `{{{`)},
|
||||
Categories: []store.CategoryValue{category("sleep_analysis", "value", "Во сне", "код")},
|
||||
}
|
||||
if _, err := st.Merge(ctx, in, from(t, "01AAA", "2026-08-03T10:00:00Z")); err == nil {
|
||||
t.Skip("слияние не отказало — проверять нечего")
|
||||
}
|
||||
if got := categories(t, st); len(got) != 0 {
|
||||
t.Errorf("после отказа в реестре %d строк: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Доставки приходят внахлёст, а провенанс реестра выбирается правилом
|
||||
// «минимум по журналу» прямо в SQL. Правило, ни разу не исполненное
|
||||
// конкурентно, проверено ровно наполовину: у точек такой прогон есть с самого
|
||||
// начала (TestMergeКонкурентноеСлияниеНеТеряетТочки), у реестра его не было.
|
||||
//
|
||||
// Порядок горутин недетерминирован намеренно — в этом весь смысл: исход обязан
|
||||
// быть функцией МНОЖЕСТВА доставок, а не того, кто первым добрался до
|
||||
// транзакции.
|
||||
func TestMergeCategoriesКонкурентноНеПортитПровенанс(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const writers = 8
|
||||
const perWriter = 10
|
||||
|
||||
shared := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
|
||||
for w := range writers {
|
||||
wg.Go(func() {
|
||||
for i := range perWriter {
|
||||
// Метка приёма растёт вместе с номером доставки: самая ранняя —
|
||||
// у первой итерации первого писателя, и она обязана победить
|
||||
// независимо от того, кто дошёл до базы раньше.
|
||||
n := w*perWriter + i
|
||||
d := store.DeliveryRef{
|
||||
ID: fmt.Sprintf("d%03d", n),
|
||||
ReceivedAt: ts(t, "2026-08-03T10:00:00Z").Add(time.Duration(n) * time.Second),
|
||||
}
|
||||
// Общий ключ у всех писателей плюс свой собственный: проверяется
|
||||
// и спор за одну строку, и параллельная вставка разных.
|
||||
own := category("heart_rate", "context", fmt.Sprintf("контекст-%03d", n), "")
|
||||
if _, err := st.Merge(ctx, store.Incoming{Categories: []store.CategoryValue{shared, own}}, d); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("конкурентное слияние реестра: %v", err)
|
||||
}
|
||||
|
||||
got := categories(t, st)
|
||||
if len(got) != writers*perWriter+1 {
|
||||
t.Fatalf("строк реестра %d, ожидалось %d", len(got), writers*perWriter+1)
|
||||
}
|
||||
for _, v := range got {
|
||||
if v.Metric != "sleep_analysis" {
|
||||
continue
|
||||
}
|
||||
if v.FirstDeliveryID != "d000" {
|
||||
t.Errorf("провенанс общей строки %q, ожидалась самая ранняя доставка d000", v.FirstDeliveryID)
|
||||
}
|
||||
if !v.FirstSeen.Equal(ts(t, "2026-08-03T10:00:00Z")) {
|
||||
t.Errorf("метка первой встречи %s, ожидалась 2026-08-03T10:00:00Z", v.FirstSeen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,18 +376,23 @@ type DeliveryBody struct {
|
||||
RawPath string
|
||||
AutomationID string
|
||||
Aggregation string
|
||||
// Headers — заголовки запроса JSON-объектом. Разбору нужен ровно один из
|
||||
// них, `Accept-Language`: он задаёт язык, на котором приехали
|
||||
// категориальные строки, и сужает поиск по словарю. Колонки под язык нет
|
||||
// намеренно — она была бы вторым домом факта, который уже лежит здесь.
|
||||
Headers string
|
||||
}
|
||||
|
||||
// DeliveryForParse возвращает сведения о доставке, нужные разбору.
|
||||
func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, raw_path, automation_id, aggregation
|
||||
SELECT id, received_at, raw_path, automation_id, aggregation, headers
|
||||
FROM delivery WHERE id = ?`
|
||||
|
||||
var d DeliveryBody
|
||||
var receivedAt string
|
||||
err := s.db.QueryRowxContext(ctx, q, id).
|
||||
Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation)
|
||||
Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation, &d.Headers)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DeliveryBody{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ type Incoming struct {
|
||||
Points []IncomingPoint
|
||||
Workouts []IncomingEntity
|
||||
Records []IncomingEntity
|
||||
// Categories — наблюдённые категориальные значения доставки. Пишутся той же
|
||||
// транзакцией: состояние «точки легли, реестр нет» повторная свёртка не
|
||||
// чинит — хеш содержимого сойдётся, и объекты переписываться не станут.
|
||||
Categories []CategoryValue
|
||||
}
|
||||
|
||||
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- +goose Up
|
||||
-- Реестр категориальных значений: какие перечислимые строки поток приносил и
|
||||
-- какой у них стабильный код HealthKit.
|
||||
--
|
||||
-- HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ жизни», тип
|
||||
-- тренировки как «В помещении Ходьба» — строками локали телефона, а родной
|
||||
-- экспорт Apple говорит кодами (`HKCategoryValueSleepAnalysisAsleepREM`).
|
||||
-- Источники несопоставимы, и на этой сверке стоит устаревание нижнего слоя.
|
||||
-- Словарь фаз сна выведен сопоставлением потока с экспортом за тот же период
|
||||
-- (docs/research/apple-health.md, находка 43).
|
||||
--
|
||||
-- ПОЧЕМУ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ ПОЛЕ РЯДОМ СО СТРОКОЙ В ТОЧКЕ. Точка хранится
|
||||
-- исходными байтами; дописать в неё ключ можно только пересериализацией, а она
|
||||
-- теряет литерал — ровно то, от чего эти байты и защищают. Параллельный массив
|
||||
-- кодов в `bucket` завёл бы производную величину в путь слияния и хеширования:
|
||||
-- правило полноты, тай-брейк и `content_hash` пришлось бы учить носить код, не
|
||||
-- давая ему влиять на исход. Это правка на поверхности critical-инвариантов
|
||||
-- ради нуля новых сведений — код есть функция от того, что уже лежит. И
|
||||
-- пополнение словаря переписывало бы каждый объект с фазами сна; здесь меняется
|
||||
-- десяток строк.
|
||||
--
|
||||
-- ПОЧЕМУ ЛОКАЛИ НЕТ В КЛЮЧЕ. Она приезжает заголовком `Accept-Language`, а
|
||||
-- заголовков в сыром архиве не лежит: они были заголовками запроса, а не телом.
|
||||
-- Доставка, восстановленная из осиротевшего тела, приходит без локали — и ключ
|
||||
-- с локалью положил бы вторую строку на то же значение, то есть состояние стало
|
||||
-- бы функцией от того, уцелела ли учётная строка. Ключ по трём полям — функция
|
||||
-- одних тел. На каком языке приехала строка, восстанавливается по доставке
|
||||
-- провенанса: `first_delivery_id` → `delivery.headers`.
|
||||
--
|
||||
-- ПОЧЕМУ ПРОВЕНАНС ТОЛЬКО ПЕРВОЙ ВСТРЕЧИ. Минимум по журналу идемпотентен при
|
||||
-- повторной свёртке той же доставки; счётчик встреч не идемпотентен и сделал бы
|
||||
-- состояние зависящим от числа прогонов. «Когда эта строка появилась впервые»
|
||||
-- отвечает на вопрос о смене языка телефона — «сколько раз» не отвечает ни на
|
||||
-- один заданный.
|
||||
--
|
||||
-- `code` — КЭШ. Он производная не от журнала, а от словаря в бинаре, и потому
|
||||
-- в отпечаток витрины не входит (см. Fingerprint): включённый туда, он заставил
|
||||
-- бы всякое пополнение словаря давать расхождение при побайтно совпавшем
|
||||
-- журнале, а человек, принимающий по отпечатку необратимое решение о подмене
|
||||
-- базы, читал бы это как дефект. Строка, переставшая приезжать, держит код
|
||||
-- прежнего словаря до пересборки — осознанная цена, названная вслух.
|
||||
--
|
||||
-- Пустой код — законное состояние: он означает «словарь этой строки не знает».
|
||||
-- Пустая строка, а не NULL: различать «кода нет» и «кода не считали» здесь
|
||||
-- нечем — вывод кода идёт на каждой встрече, и третьего состояния у него не
|
||||
-- бывает.
|
||||
--
|
||||
-- `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
|
||||
-- единицы — на живом потоке различных значений по всем трём полям около
|
||||
-- одиннадцати.
|
||||
--
|
||||
-- Data-миграции нет и быть не может: коды выводятся из ТЕЛ, а тела лежат в
|
||||
-- архиве, а не в базе. Реестр рабочей витрины наполняется по мере свёртки новых
|
||||
-- доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
|
||||
-- выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом.
|
||||
CREATE TABLE category_value (
|
||||
metric TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
first_seen_utc TEXT NOT NULL,
|
||||
first_delivery_id TEXT NOT NULL,
|
||||
PRIMARY KEY (metric, field, value)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE category_value;
|
||||
Reference in New Issue
Block a user