добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -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