feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+173
-24
@@ -72,6 +72,21 @@ type MergeStats struct {
|
||||
Collisions []Collision
|
||||
// IncomparableAt — то же для несравнимых наборов.
|
||||
IncomparableAt []Collision
|
||||
|
||||
// Workouts и Records — сколько сущностей пришло с доставкой.
|
||||
Workouts int
|
||||
Records int
|
||||
// WorkoutsWritten и RecordsWritten — сколько из них действительно легло в
|
||||
// витрину. Разница с пришедшими — работа хеша-детектора: тренировка
|
||||
// переприсылается каждой доставкой, пока не доедет маршрут.
|
||||
WorkoutsWritten int
|
||||
RecordsWritten int
|
||||
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
|
||||
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
|
||||
// объединять поля: событие считается, а не предотвращается молча.
|
||||
EntitiesHeld int
|
||||
// HeldAt — координаты первых таких сущностей, для записи в лог.
|
||||
HeldAt []EntityRef
|
||||
}
|
||||
|
||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||
@@ -102,35 +117,58 @@ func clipMetric(metric string) string {
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// MergePoints раскладывает точки по часовым объектам и сливает их с
|
||||
// сохранёнными.
|
||||
// Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым
|
||||
// объектам, сущности — по своим таблицам.
|
||||
//
|
||||
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
|
||||
// другой выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект.
|
||||
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и
|
||||
// не отдельной транзакцией на сущности.
|
||||
//
|
||||
// Транзакция на объект давала недетерминированное частичное состояние: обход
|
||||
// карты групп рандомизирован, и при отказе посреди доставки набор уже
|
||||
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
|
||||
// доставки — семь разных состояний). Это ломает инвариант «состояние
|
||||
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
|
||||
// хеш-детектор после расхождения переписывал бы «неизменившееся».
|
||||
// хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение
|
||||
// «секции не смешиваются в одной доставке» собрано за двое суток и основанием
|
||||
// для второй транзакции не является.
|
||||
//
|
||||
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
|
||||
// то есть 8.5 мс на килобайт тела.
|
||||
func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) {
|
||||
groups := groupByHour(in)
|
||||
func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) {
|
||||
groups := groupByHour(in.Points)
|
||||
keys := sortedKeys(groups)
|
||||
|
||||
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
|
||||
// входа в транзакцию: канонизация материализует значение целиком, а
|
||||
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
|
||||
// кучи умножился бы на число попыток.
|
||||
workouts, err := prepareEntities(in.Workouts, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
records, err := prepareEntities(in.Records, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
|
||||
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
|
||||
|
||||
var stats MergeStats
|
||||
err := s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
err = s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
||||
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
||||
stats = MergeStats{}
|
||||
stats = MergeStats{
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesHeld: workoutsHeld + recordsHeld,
|
||||
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
res, err := mergeBucket(ctx, tx, key, group, deliveryID)
|
||||
res, err := mergeBucket(ctx, tx, key, group, from.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -158,6 +196,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
|
||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
||||
}
|
||||
}
|
||||
|
||||
now := Now()
|
||||
written, held, heldAt, err := mergeEntities(ctx, tx, workoutTable, workouts, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats.WorkoutsWritten = written
|
||||
stats.EntitiesHeld += held
|
||||
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||
|
||||
written, held, heldAt, err = mergeEntities(ctx, tx, recordTable, records, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats.RecordsWritten = written
|
||||
stats.EntitiesHeld += held
|
||||
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -591,27 +646,44 @@ func encodePayload(points []Point) ([]byte, error) {
|
||||
return nil, fmt.Errorf("сериализация точек: %w", err)
|
||||
}
|
||||
|
||||
return gzipBytes(raw.Bytes())
|
||||
}
|
||||
|
||||
// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины,
|
||||
// общее для часового объекта и для сущности с собственным `id`. Второй кадр
|
||||
// упаковки разошёлся бы с первым при первой же правке — например, забытым
|
||||
// Close, который оставляет усечённый блоб.
|
||||
func gzipBytes(raw []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(raw.Bytes()); err != nil {
|
||||
return nil, fmt.Errorf("сжатие точек: %w", err)
|
||||
if _, err := gz.Write(raw); err != nil {
|
||||
return nil, fmt.Errorf("сжатие содержимого: %w", err)
|
||||
}
|
||||
// Close дописывает хвост gzip; без него блоб читается лишь частично.
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("закрытие gzip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodePayload(payload []byte) ([]Point, error) {
|
||||
func gunzipBytes(payload []byte) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("распаковка точек: %w", err)
|
||||
return nil, fmt.Errorf("распаковка содержимого: %w", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение точек: %w", err)
|
||||
return nil, fmt.Errorf("чтение содержимого: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodePayload(payload []byte) ([]Point, error) {
|
||||
raw, err := gunzipBytes(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sp []storedPoint
|
||||
@@ -700,7 +772,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
|
||||
// и хешам всех объектов в детерминированном порядке.
|
||||
// и хешам всех её сущностей в детерминированном порядке.
|
||||
//
|
||||
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
|
||||
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
|
||||
@@ -708,25 +780,58 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
||||
// отпечаток — нет.
|
||||
//
|
||||
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
|
||||
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
|
||||
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
|
||||
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
|
||||
// данные.
|
||||
//
|
||||
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
|
||||
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
|
||||
// и «тренировки после» — ложное расхождение у единственного оракула.
|
||||
//
|
||||
// Значений он не раскрывает: содержимое участвует только своим хешем.
|
||||
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("begin read tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
h := sha256.New()
|
||||
if err := fingerprintBuckets(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fingerprintEntities(ctx, tx, h); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Признак раздела впереди строки: без него строка одного раздела может совпасть
|
||||
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
|
||||
const (
|
||||
fpBucket = "b"
|
||||
fpWorkout = "w"
|
||||
fpRecord = "r"
|
||||
)
|
||||
|
||||
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
|
||||
ORDER BY metric, layer, hour_utc`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
rows, err := tx.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
return fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
for rows.Next() {
|
||||
var metric, layer, hour, hash, units string
|
||||
var points int
|
||||
var sealed bool
|
||||
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
|
||||
return "", fmt.Errorf("scan bucket: %w", err)
|
||||
return fmt.Errorf("scan bucket: %w", err)
|
||||
}
|
||||
// Поля переменной длины идут с длиной впереди: разделитель, который
|
||||
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
|
||||
@@ -734,13 +839,57 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
|
||||
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
|
||||
// заведён. Тот же приём в canon.HashAll и по той же причине.
|
||||
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
|
||||
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||
fpBucket, len(metric), metric, len(layer), layer, hour, hash, points,
|
||||
len(units), units, sealed)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
return fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintEntities(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||
queries := []struct {
|
||||
tag string
|
||||
sql string
|
||||
}{
|
||||
// Род и идентификатор идут ОТДЕЛЬНЫМИ полями, каждое со своей длиной, а
|
||||
// не склейкой `kind || '/' || id`: склейка выполняется до взятия длины,
|
||||
// и пара (`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`). Отпечаток —
|
||||
// единственный оракул сходимости, по нему принимается необратимое
|
||||
// решение о подмене базы; два разных состояния витрины не имеют права
|
||||
// дать один отпечаток. У тренировки род один и в строку не идёт.
|
||||
{fpWorkout, `SELECT '', id, start_utc, content_hash FROM workout ORDER BY id`},
|
||||
{fpRecord, `SELECT kind, id, ts_utc, content_hash FROM record ORDER BY kind, id`},
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if err := fingerprintRows(ctx, tx, h, q.tag, q.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprintRows(ctx context.Context, tx *sql.Tx, h io.Writer, tag, query string) error {
|
||||
rows, err := tx.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select entities: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var kind, id, ts, hash string
|
||||
if err := rows.Scan(&kind, &id, &ts, &hash); err != nil {
|
||||
return fmt.Errorf("scan entity: %w", err)
|
||||
}
|
||||
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s\n", tag, len(kind), kind, len(id), id, ts, hash)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("select entities: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
|
||||
|
||||
@@ -51,7 +51,7 @@ func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPo
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
func TestMergeКладётЧасОднимОбъектом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -63,7 +63,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, in, "delivery-1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "delivery-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
||||
|
||||
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
|
||||
// сохранённые остаются. Точки из объекта не удаляются никогда.
|
||||
func TestMergePointsДозаписьНеТеряетСохранённое(t *testing.T) {
|
||||
func TestMergeДозаписьНеТеряетСохранённое(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -104,10 +104,10 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, second, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
||||
|
||||
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
|
||||
// делает широкие проходы синхронизации дешёвыми.
|
||||
func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
func TestMergeПовторНеПишет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -136,7 +136,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -144,7 +144,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, in, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повторное слияние: %v", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
|
||||
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
|
||||
// имеет права от него зависеть.
|
||||
func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) {
|
||||
func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -185,7 +185,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
}
|
||||
reversed := []store.IncomingPoint{in[2], in[1], in[0]}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -193,7 +193,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
|
||||
stats, err := st.MergePoints(ctx, reversed, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние в обратном порядке: %v", err)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
||||
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
|
||||
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
|
||||
// по времени приёма неприменим в принципе.
|
||||
func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
||||
func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -225,7 +225,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
|
||||
// Повтор той же тройки не задваивает: координата включает интервал, и он
|
||||
// совпадает.
|
||||
stats, err := st.MergePoints(ctx, in, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повтор: %v", err)
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
||||
|
||||
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
|
||||
// выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||
func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
func TestMergeЧасПоНачалуИнтервала(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -259,7 +259,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
in := []store.IncomingPoint{
|
||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
||||
// Правило разрешения столкновений: выигрывает более полная точка, а не
|
||||
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
|
||||
// сама не несёт.
|
||||
func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
||||
func TestMergeБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -285,10 +285,10 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":60,"Min":55,"Max":70}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -311,7 +311,7 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
||||
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
|
||||
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
|
||||
// восстановить его можно было бы только из сырого архива, пока он жив.
|
||||
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||
func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -322,10 +322,10 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
||||
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{hollow}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{measured}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
||||
|
||||
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
|
||||
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
|
||||
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||
func TestMergeРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -356,10 +356,10 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":12}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
||||
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||
func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -386,10 +386,10 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
||||
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"mealTime":"До еды"}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -420,7 +420,7 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
||||
|
||||
// Список координат упирается в потолок, счётчик — нет: обрезанный список
|
||||
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
|
||||
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||
func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -434,10 +434,10 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
||||
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, second, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -453,7 +453,7 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
||||
|
||||
// source нестабилен: то же измерение приезжает то с одним именем устройства,
|
||||
// то с другим. Он не входит в ключ и не считается полнотой.
|
||||
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||
func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -464,10 +464,10 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
||||
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"qty":1,"source":"Apple Watch Ultra 3"}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
||||
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
||||
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
||||
// времени, а внутри одной доставки время приёма общее.
|
||||
func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -494,7 +494,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
||||
winner := func(order []store.IncomingPoint) string {
|
||||
st := open(t)
|
||||
for _, p := range order {
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -515,7 +515,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
||||
// Содержимое точки хранится исходными байтами: пересборка повторной
|
||||
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
|
||||
// разобранное с разобранным.
|
||||
func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
func TestMergeХранитТочкуДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -525,7 +525,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
in := []store.IncomingPoint{
|
||||
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
||||
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
|
||||
// Конкурентное слияние того же часа не имеет права терять точки: между
|
||||
// чтением и записью может вклиниться другая доставка.
|
||||
func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
||||
func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -568,7 +568,7 @@ func TestMergePointsКонкурентноеСлияниеНеТеряетТоч
|
||||
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
|
||||
},
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
@@ -607,7 +607,7 @@ func itoa(n int) string {
|
||||
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
|
||||
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
|
||||
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
|
||||
func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) {
|
||||
func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -617,7 +617,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
first := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
|
||||
@@ -627,7 +627,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
late := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, late, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: late}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("досчёт запечатанного часа: %v", err)
|
||||
}
|
||||
@@ -649,7 +649,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
||||
|
||||
// Отмена посреди слияния не имеет права оставить половину: объект либо
|
||||
// прежний, либо полный.
|
||||
func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) {
|
||||
func TestMergeОтменаНеОставляетПоловины(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -657,19 +657,30 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
|
||||
base := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
}
|
||||
if _, err := st.MergePoints(context.Background(), base, "d1"); err != nil {
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: base}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
more := []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
// Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на
|
||||
// той половине, где отмена вероятнее. Вынесение их во вторую транзакцию —
|
||||
// напрашивающаяся правка при жалобе на длину транзакции с маршрутом, и она
|
||||
// прошла бы зелёной, сломав «состояние пересобираемо»: доставка получила бы
|
||||
// failed при записанной тренировке.
|
||||
more := store.Incoming{
|
||||
Points: []store.IncomingPoint{
|
||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||
},
|
||||
Workouts: []store.IncomingEntity{workout(t, "w-отменённая", `{"id":"w-отменённая","qty":1}`)},
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, more, "d2"); err == nil {
|
||||
if _, err := st.Merge(ctx, more, store.DeliveryRef{ID: "d2"}); err == nil {
|
||||
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||
}
|
||||
if _, err := st.Workout(context.Background(), "w-отменённая"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("тренировка отменённой доставки осталась в витрине: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
@@ -704,7 +715,7 @@ func cycleTriple(t *testing.T) []store.IncomingPoint {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||
func TestMergeПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -714,7 +725,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
||||
state := func() (string, string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||
@@ -738,7 +749,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||
func TestMergeИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -752,7 +763,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
st := open(t)
|
||||
if split {
|
||||
for _, i := range order {
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -761,7 +772,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
for _, i := range order {
|
||||
batch = append(batch, in[i])
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, batch, "d"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -785,7 +796,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
||||
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
|
||||
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
|
||||
// равны, и решал второй разряд — по ключам, а не по содержанию.
|
||||
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) {
|
||||
func TestMergeПадингНеВытесняетИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -808,7 +819,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -824,7 +835,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
||||
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
|
||||
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
|
||||
// вытесняет из ротации логов всю недавнюю историю.
|
||||
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||
func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -835,7 +846,7 @@ func TestMergePointsИмяМетрикиВКоординатеОбрезано(t
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/canon"
|
||||
)
|
||||
|
||||
// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
|
||||
// заголовок, по которому идёт выборка, а у записи его нет.
|
||||
const (
|
||||
workoutTable = "workout"
|
||||
recordTable = "record"
|
||||
)
|
||||
|
||||
// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
|
||||
//
|
||||
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
|
||||
// сериализацией, теряет литерал ровно так же, как точка.
|
||||
type IncomingEntity struct {
|
||||
ID string
|
||||
Kind string
|
||||
Name string
|
||||
|
||||
Start time.Time
|
||||
End time.Time
|
||||
// OffsetSeconds — смещение зоны начала.
|
||||
OffsetSeconds int
|
||||
// Duration — длительность тренировки в секундах. nil означает «источник не
|
||||
// прислал» и отличим от нуля: ноль — законная длительность.
|
||||
Duration *float64
|
||||
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
|
||||
// не точка и не сущность: частичное состояние ломает инвариант «состояние
|
||||
// пересобираемо».
|
||||
type Incoming struct {
|
||||
Points []IncomingPoint
|
||||
Workouts []IncomingEntity
|
||||
Records []IncomingEntity
|
||||
}
|
||||
|
||||
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
|
||||
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
|
||||
// журнала задан парой `(received_at, id)`.
|
||||
type DeliveryRef struct {
|
||||
ID string
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (d DeliveryRef) before(other DeliveryRef) bool {
|
||||
if !d.ReceivedAt.Equal(other.ReceivedAt) {
|
||||
return d.ReceivedAt.Before(other.ReceivedAt)
|
||||
}
|
||||
return d.ID < other.ID
|
||||
}
|
||||
|
||||
// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
|
||||
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
|
||||
// измерение душевного состояния.
|
||||
type EntityRef struct {
|
||||
Kind string
|
||||
ID string
|
||||
}
|
||||
|
||||
// maxEntityRefsReported — сколько координат сущностей попадает в лог.
|
||||
const maxEntityRefsReported = 5
|
||||
|
||||
// prepareEntities считает хеш каждой сущности.
|
||||
//
|
||||
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
|
||||
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
|
||||
// базы.
|
||||
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]entityVersion, 0, len(in))
|
||||
for _, e := range in {
|
||||
v, err := newEntityVersion(e, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// clipRefs держит список координат в потолке: он зацепка для разбора, а не
|
||||
// отчёт; масштаб события считает счётчик.
|
||||
func clipRefs(refs []EntityRef) []EntityRef {
|
||||
if len(refs) <= maxEntityRefsReported {
|
||||
return refs
|
||||
}
|
||||
return refs[:maxEntityRefsReported]
|
||||
}
|
||||
|
||||
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
|
||||
// победителя.
|
||||
//
|
||||
// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с
|
||||
// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу
|
||||
// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево
|
||||
// значений на каждой копии — ровно та форма, от которой разбор тела отказался
|
||||
// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается
|
||||
// сразу и один раз на доставку: он и есть быстрый путь.
|
||||
type entityVersion struct {
|
||||
raw json.RawMessage
|
||||
hash string
|
||||
from DeliveryRef
|
||||
|
||||
// key и fields заполняются лениво, методом analyze().
|
||||
key []byte
|
||||
fields canon.Fields
|
||||
parsed bool
|
||||
|
||||
// head — заголовок, который пишется колонками. У сохранённой версии он не
|
||||
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
|
||||
head IncomingEntity
|
||||
}
|
||||
|
||||
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
|
||||
h, err := canon.Hash(e.Raw)
|
||||
if err != nil {
|
||||
return entityVersion{}, fmt.Errorf("хеш сущности: %w", err)
|
||||
}
|
||||
return entityVersion{raw: e.Raw, hash: h, from: from, head: e}, nil
|
||||
}
|
||||
|
||||
// analyze разбирает версию, если этого ещё не делали.
|
||||
func (v *entityVersion) analyze() {
|
||||
if v.parsed {
|
||||
return
|
||||
}
|
||||
v.key = canon.SortKey(v.raw)
|
||||
v.fields = canon.Analyze(v.raw)
|
||||
v.parsed = true
|
||||
}
|
||||
|
||||
// pickEntity выбирает между сохранённой и приехавшей версией.
|
||||
//
|
||||
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
|
||||
// есть плата за отказ объединять поля: событие не предотвращается молча, а
|
||||
// считается и уходит в WARN.
|
||||
//
|
||||
// 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
|
||||
// 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
|
||||
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
||||
// 4. наборы несравнимы → сохранённая
|
||||
//
|
||||
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
|
||||
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
|
||||
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
|
||||
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
|
||||
// кроме отпечатка.
|
||||
//
|
||||
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
||||
// решает минимум канонической формы, потому что порядок элементов в
|
||||
// JSON-массиве нестабилен.
|
||||
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
||||
switch v := compareEntities(stored, incoming); v {
|
||||
case entityIncomingRicher:
|
||||
return true, false
|
||||
case entityStoredRicher:
|
||||
return false, true
|
||||
case entityIncomparable:
|
||||
// Несравнимы: у каждой версии есть содержание, которого нет у другой.
|
||||
// Объединение полей отвергнуто там же и по той же причине, что для
|
||||
// точек, — на живом потоке событие не наступало ни разу, — а из двух
|
||||
// версий остаётся сохранённая: правило называется «не теряет
|
||||
// содержания», и приехавшая его теряет. Исход при этом остаётся
|
||||
// функцией журнала: доставки проигрываются в его порядке.
|
||||
return false, true
|
||||
default:
|
||||
return laterInJournal(stored, incoming), false
|
||||
}
|
||||
}
|
||||
|
||||
// pickWithinDelivery выбирает между двумя версиями одного ключа ВНУТРИ одной
|
||||
// доставки. Второй возврат — различается ли их содержание вообще.
|
||||
//
|
||||
// Отдельно от pickEntity, и не ради симметрии: «сохранённой» версии здесь нет,
|
||||
// есть только порядок элементов в JSON-массиве, а он нестабилен. Правило
|
||||
// «остаётся первая встреченная» сделало бы исход функцией порядка на проводе,
|
||||
// поэтому при равном и при несравнимом содержании решает тотальный порядок
|
||||
// канонических форм.
|
||||
func pickWithinDelivery(a, b *entityVersion) (takeB, differs bool) {
|
||||
switch v := compareEntities(a, b); v {
|
||||
case entityIncomingRicher:
|
||||
return true, true
|
||||
case entityStoredRicher:
|
||||
return false, true
|
||||
case entityIncomparable:
|
||||
return laterInJournal(a, b), true
|
||||
default:
|
||||
return laterInJournal(a, b), false
|
||||
}
|
||||
}
|
||||
|
||||
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
|
||||
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
|
||||
type entityVerdict int
|
||||
|
||||
const (
|
||||
// entityEqualContent — множества содержательных ключей совпадают, длины
|
||||
// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
|
||||
// сравнение здесь неприменимо (см. canon.Fields.Covers).
|
||||
entityEqualContent entityVerdict = iota + 1
|
||||
entityIncomingRicher
|
||||
entityStoredRicher
|
||||
entityIncomparable
|
||||
)
|
||||
|
||||
func compareEntities(stored, incoming *entityVersion) entityVerdict {
|
||||
stored.analyze()
|
||||
incoming.analyze()
|
||||
|
||||
storedCovers := stored.fields.Covers(incoming.fields)
|
||||
incomingCovers := incoming.fields.Covers(stored.fields)
|
||||
|
||||
switch {
|
||||
case incomingCovers && storedCovers:
|
||||
return entityEqualContent
|
||||
case incomingCovers:
|
||||
return entityIncomingRicher
|
||||
case storedCovers:
|
||||
return entityStoredRicher
|
||||
default:
|
||||
return entityIncomparable
|
||||
}
|
||||
}
|
||||
|
||||
// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
|
||||
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
|
||||
// там решает минимум канонической формы — порядок тотальный и от порядка
|
||||
// элементов в массиве не зависит.
|
||||
func laterInJournal(stored, incoming *entityVersion) bool {
|
||||
if stored.from.before(incoming.from) {
|
||||
return true
|
||||
}
|
||||
if incoming.from.before(stored.from) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(incoming.key, stored.key) < 0
|
||||
}
|
||||
|
||||
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же
|
||||
// правилом — до сравнения с сохранённой.
|
||||
//
|
||||
// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы
|
||||
// победу последнему элементу массива мимо правила полноты, а порядок элементов
|
||||
// в JSON-массиве нестабилен.
|
||||
//
|
||||
// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного
|
||||
// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки
|
||||
// «сохранённой» версии не существует, есть только порядок элементов массива, и
|
||||
// счётчик, зависящий от него, наблюдал бы событие через раз.
|
||||
func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) {
|
||||
type slot struct {
|
||||
v entityVersion
|
||||
pos int
|
||||
}
|
||||
|
||||
byKey := make(map[EntityRef]slot, len(versions))
|
||||
order := make([]EntityRef, 0, len(versions))
|
||||
held := 0
|
||||
var heldAt []EntityRef
|
||||
|
||||
for _, v := range versions {
|
||||
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
|
||||
prev, seen := byKey[ref]
|
||||
if !seen {
|
||||
byKey[ref] = slot{v: v, pos: len(order)}
|
||||
order = append(order, ref)
|
||||
continue
|
||||
}
|
||||
takeB, differs := pickWithinDelivery(&prev.v, &v)
|
||||
if differs {
|
||||
held++
|
||||
if len(heldAt) < maxEntityRefsReported {
|
||||
heldAt = append(heldAt, ref)
|
||||
}
|
||||
}
|
||||
winner := prev.v
|
||||
if takeB {
|
||||
winner = v
|
||||
}
|
||||
byKey[ref] = slot{v: winner, pos: prev.pos}
|
||||
}
|
||||
|
||||
out := make([]entityVersion, 0, len(order))
|
||||
for _, ref := range order {
|
||||
out = append(out, byKey[ref].v)
|
||||
}
|
||||
return out, held, heldAt
|
||||
}
|
||||
|
||||
// mergeEntities сливает сущности одной секции с сохранёнными.
|
||||
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
|
||||
for _, v := range versions {
|
||||
stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
|
||||
if !found {
|
||||
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
written++
|
||||
continue
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: совпал, значит писать нечего, и содержимое
|
||||
// сохранённой сущности читать не приходится вовсе. Тренировка
|
||||
// переприсылается каждой доставкой, пока не доедет маршрут, — на живом
|
||||
// архиве 44 копии дают три различных содержимых.
|
||||
if stored.hash == v.hash {
|
||||
continue
|
||||
}
|
||||
|
||||
storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
prev := entityVersion{raw: storedRaw, hash: stored.hash, from: stored.from}
|
||||
|
||||
takeIncoming, lost := pickEntity(&prev, &v)
|
||||
if lost {
|
||||
held++
|
||||
if len(heldAt) < maxEntityRefsReported {
|
||||
heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
|
||||
}
|
||||
}
|
||||
if !takeIncoming {
|
||||
continue
|
||||
}
|
||||
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, held, heldAt, nil
|
||||
}
|
||||
|
||||
type storedEntityHead struct {
|
||||
hash string
|
||||
from DeliveryRef
|
||||
}
|
||||
|
||||
func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
|
||||
q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)
|
||||
|
||||
var (
|
||||
head storedEntityHead
|
||||
receivedAt string
|
||||
)
|
||||
row := queryEntity(ctx, tx, q, table, kind, id)
|
||||
err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedEntityHead{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
|
||||
}
|
||||
// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
|
||||
// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
|
||||
// сохранённую версию «самой ранней в журнале», и её затирала бы любая
|
||||
// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
|
||||
head.from.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
return storedEntityHead{}, false, err
|
||||
}
|
||||
return head, true, nil
|
||||
}
|
||||
|
||||
func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
|
||||
q := `SELECT payload FROM ` + table + entityWhere(table)
|
||||
|
||||
var payload []byte
|
||||
if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
|
||||
return nil, fmt.Errorf("select %s payload: %w", table, err)
|
||||
}
|
||||
raw, err := gunzipBytes(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// entityWhere и queryEntity держат разницу между таблицами в одном месте:
|
||||
// у тренировки ключ — `id`, у записи — пара `kind + id`.
|
||||
func entityWhere(table string) string {
|
||||
if table == recordTable {
|
||||
return ` WHERE kind = ? AND id = ?`
|
||||
}
|
||||
return ` WHERE id = ?`
|
||||
}
|
||||
|
||||
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
|
||||
if table == recordTable {
|
||||
return tx.QueryRowContext(ctx, q, kind, id)
|
||||
}
|
||||
return tx.QueryRowContext(ctx, q, id)
|
||||
}
|
||||
|
||||
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
|
||||
payload, err := gzipBytes(v.raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := FormatTime(now)
|
||||
received := FormatTime(v.from.ReceivedAt)
|
||||
|
||||
if table == recordTable {
|
||||
const q = `
|
||||
INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
|
||||
delivery_id, delivery_received_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (kind, id) DO UPDATE SET
|
||||
ts_utc = excluded.ts_utc,
|
||||
tz_offset = excluded.tz_offset,
|
||||
payload = excluded.payload,
|
||||
content_hash = excluded.content_hash,
|
||||
delivery_id = excluded.delivery_id,
|
||||
delivery_received_at = excluded.delivery_received_at,
|
||||
updated_at = excluded.updated_at`
|
||||
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
|
||||
payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
|
||||
return fmt.Errorf("upsert record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
|
||||
payload, content_hash, delivery_id, delivery_received_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
start_utc = excluded.start_utc,
|
||||
end_utc = excluded.end_utc,
|
||||
tz_offset = excluded.tz_offset,
|
||||
duration_sec = excluded.duration_sec,
|
||||
payload = excluded.payload,
|
||||
content_hash = excluded.content_hash,
|
||||
delivery_id = excluded.delivery_id,
|
||||
delivery_received_at = excluded.delivery_received_at,
|
||||
updated_at = excluded.updated_at`
|
||||
|
||||
var duration any
|
||||
if v.head.Duration != nil {
|
||||
duration = *v.head.Duration
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
|
||||
v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
|
||||
stamp, stamp); err != nil {
|
||||
return fmt.Errorf("upsert workout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
|
||||
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
|
||||
type Workout struct {
|
||||
ID string
|
||||
Name string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
OffsetSeconds int
|
||||
Duration *float64
|
||||
Raw json.RawMessage
|
||||
Delivery string
|
||||
}
|
||||
|
||||
// Workout читает тренировку по идентификатору.
|
||||
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
|
||||
const q = `
|
||||
SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
|
||||
FROM workout WHERE id = ?`
|
||||
|
||||
var (
|
||||
w Workout
|
||||
start, end string
|
||||
duration sql.NullFloat64
|
||||
payload []byte
|
||||
deliveryFrom string
|
||||
)
|
||||
err := s.db.QueryRowxContext(ctx, q, id).
|
||||
Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Workout{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Workout{}, fmt.Errorf("select workout: %w", err)
|
||||
}
|
||||
|
||||
if w.Start, err = ParseTime(start); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
if w.End, err = ParseTime(end); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
if duration.Valid {
|
||||
v := duration.Float64
|
||||
w.Duration = &v
|
||||
}
|
||||
if w.Raw, err = gunzipBytes(payload); err != nil {
|
||||
return Workout{}, err
|
||||
}
|
||||
w.Delivery = deliveryFrom
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Record — запись секции с собственным идентификатором.
|
||||
type Record struct {
|
||||
Kind string
|
||||
ID string
|
||||
TS time.Time
|
||||
OffsetSeconds int
|
||||
Raw json.RawMessage
|
||||
Delivery string
|
||||
}
|
||||
|
||||
// Record читает запись по роду и идентификатору.
|
||||
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
|
||||
const q = `
|
||||
SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
|
||||
FROM record WHERE kind = ? AND id = ?`
|
||||
|
||||
var (
|
||||
r Record
|
||||
ts string
|
||||
payload []byte
|
||||
deliveryFrom string
|
||||
)
|
||||
err := s.db.QueryRowxContext(ctx, q, kind, id).
|
||||
Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Record{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("select record: %w", err)
|
||||
}
|
||||
|
||||
if r.TS, err = ParseTime(ts); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if r.Raw, err = gunzipBytes(payload); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
r.Delivery = deliveryFrom
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
|
||||
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
|
||||
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
|
||||
return 0, fmt.Errorf("count workouts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountRecords возвращает число записей секций с собственным `id`.
|
||||
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
|
||||
return 0, fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
|
||||
// вторичен: правило замены смотрит на содержание, а не на колонки.
|
||||
func workout(t *testing.T, id, raw string) store.IncomingEntity {
|
||||
t.Helper()
|
||||
|
||||
return store.IncomingEntity{
|
||||
ID: id,
|
||||
Kind: "workouts",
|
||||
Name: "На улице Ходьба",
|
||||
Start: ts(t, "2025-06-05T07:00:00Z"),
|
||||
End: ts(t, "2025-06-05T07:10:00Z"),
|
||||
OffsetSeconds: 3 * 3600,
|
||||
Raw: json.RawMessage(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
|
||||
t.Helper()
|
||||
|
||||
return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
|
||||
}
|
||||
|
||||
func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
|
||||
t.Helper()
|
||||
|
||||
stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
|
||||
if err != nil {
|
||||
t.Fatalf("слияние сущностей: %v", err)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const (
|
||||
// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
|
||||
// полей: именно так тренировка и меняется между версиями (досчёт энергии),
|
||||
// и именно на этом ломается правило полноты, написанное для точек.
|
||||
woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||
woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
|
||||
woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woIncomparable = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
|
||||
)
|
||||
|
||||
func storedRaw(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
|
||||
w, err := st.Workout(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
return string(w.Raw)
|
||||
}
|
||||
|
||||
// Единственная причина повторной присылки тренировки — доезжающий маршрут.
|
||||
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 0 {
|
||||
t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("в витрине не версия с маршрутом")
|
||||
}
|
||||
}
|
||||
|
||||
// Тренировка досчитывается задним числом ровно так же, как минутное ведро:
|
||||
// набор полей тот же, значения новые. Тай-брейк по канонической форме
|
||||
// заморозил бы её на произвольной версии навсегда.
|
||||
func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
|
||||
t.Error("досчитанная версия не легла в витрину")
|
||||
}
|
||||
}
|
||||
|
||||
// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
|
||||
// в порядке журнала только среди видимых ему доставок, и доставка с более
|
||||
// ранней меткой может свернуться позже.
|
||||
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
early := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
late := from(t, "d2", "2025-06-05T08:05:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
|
||||
mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))
|
||||
|
||||
a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
|
||||
if a != b {
|
||||
t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
|
||||
}
|
||||
if a != woSameShapeNewValues {
|
||||
t.Error("победила версия не из более поздней доставки журнала")
|
||||
}
|
||||
}
|
||||
|
||||
// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
|
||||
// всего журнала. Событие делается наблюдаемым, а не необратимым.
|
||||
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
|
||||
}
|
||||
if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
|
||||
t.Errorf("координаты удержанной версии %v", stats.HeldAt)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("маршрут пропал из витрины")
|
||||
}
|
||||
}
|
||||
|
||||
// Усечённый маршрут ключа не теряет — множеств ключей мало.
|
||||
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("полный маршрут вытеснен усечённым")
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы: у каждой версии есть содержание, которого нет у другой.
|
||||
// Поля не объединяются, вместо этого — счётчик.
|
||||
func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("несравнимая версия заместила сохранённую")
|
||||
}
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
|
||||
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
|
||||
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
|
||||
// форма обязана совпасть.
|
||||
same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
|
||||
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
|
||||
}
|
||||
}
|
||||
|
||||
// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка
|
||||
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
|
||||
// а не пары: попарная свёртка уже давала нетранзитивную победу на точках.
|
||||
func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type step struct {
|
||||
d store.DeliveryRef
|
||||
raw string
|
||||
}
|
||||
steps := []step{
|
||||
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
|
||||
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
|
||||
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
|
||||
}
|
||||
orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}}
|
||||
|
||||
var want string
|
||||
for i, order := range orders {
|
||||
st := open(t)
|
||||
for _, idx := range order {
|
||||
mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
|
||||
}
|
||||
got := storedRaw(t, st, "w1")
|
||||
if i == 0 {
|
||||
want = got
|
||||
if want != woRicher {
|
||||
t.Fatalf("победила не самая полная версия")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("порядок %v дал другое состояние", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
|
||||
// внутри одной доставки не имеют права разрешаться «последним в массиве».
|
||||
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
}
|
||||
|
||||
// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
|
||||
// один id.
|
||||
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mind := store.IncomingEntity{
|
||||
ID: "e1", Kind: "stateOfMind",
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
|
||||
}
|
||||
ecg := store.IncomingEntity{
|
||||
ID: "e1", Kind: "ecg",
|
||||
Start: ts(t, "2025-06-05T19:00:00Z"),
|
||||
End: ts(t, "2025-06-05T19:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
|
||||
from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние записей: %v", err)
|
||||
}
|
||||
|
||||
for _, kind := range []string{"stateOfMind", "ecg"} {
|
||||
if _, err := st.Record(ctx, kind, "e1"); err != nil {
|
||||
t.Errorf("запись рода %q не найдена: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
|
||||
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
|
||||
func TestFingerprintРазличаетТренировки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(raw string) string {
|
||||
st := open(t)
|
||||
in := store.Incoming{
|
||||
Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
|
||||
Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
|
||||
}
|
||||
if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
full, changed := build(woWithRoute), build(woSameShapeNewValues)
|
||||
if full == changed {
|
||||
t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
|
||||
}
|
||||
if again := build(woWithRoute); again != full {
|
||||
t.Error("отпечаток не воспроизводится на одном содержимом")
|
||||
}
|
||||
}
|
||||
|
||||
// Длительность: ноль — законное измерение, а «не прислали» обязано быть
|
||||
// отличимо от него.
|
||||
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zero := 0.0
|
||||
withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
|
||||
withZero.Duration = &zero
|
||||
withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
w, err := st.Workout(ctx, "w-zero")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration == nil || *w.Duration != 0 {
|
||||
t.Errorf("нулевая длительность потерялась: %v", w.Duration)
|
||||
}
|
||||
w, err = st.Workout(ctx, "w-none")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration != nil {
|
||||
t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
|
||||
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
// Литералы, которые теряет любая пересборка через разобранные значения:
|
||||
// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
|
||||
// символы, которые json.Marshal экранирует по умолчанию.
|
||||
const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
|
||||
"note":"a<b&c>d","isIndoor":false}`
|
||||
|
||||
if _, err := st.Merge(context.Background(),
|
||||
store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
if got := storedRaw(t, st, "w9"); got != raw {
|
||||
t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него
|
||||
// запись WARN об удержанной версии не связать с телом в архиве.
|
||||
func TestWorkoutНесётПровенанс(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
w, err := st.Workout(context.Background(), "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Delivery != "d7" {
|
||||
t.Errorf("провенанс %q, ожидался d7", w.Delivery)
|
||||
}
|
||||
if w.Start.IsZero() || w.End.Before(w.Start) {
|
||||
t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
|
||||
}
|
||||
if w.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Внутри одной доставки «сохранённой» версии не существует — есть только
|
||||
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
|
||||
// имеют права от него зависеть.
|
||||
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
|
||||
const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`
|
||||
|
||||
прямой := open(t)
|
||||
a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))
|
||||
|
||||
обратный := open(t)
|
||||
b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
if a.EntitiesHeld != b.EntitiesHeld {
|
||||
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld)
|
||||
}
|
||||
if a.EntitiesHeld == 0 {
|
||||
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
|
||||
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
|
||||
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(kind, id string) string {
|
||||
st := open(t)
|
||||
rec := store.IncomingEntity{
|
||||
ID: id, Kind: kind,
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"x","valence":0.5}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
if build("a", "b/c") == build("a/b", "c") {
|
||||
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
|
||||
}
|
||||
}
|
||||
@@ -88,3 +88,77 @@ func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
|
||||
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
|
||||
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
|
||||
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
|
||||
//
|
||||
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
|
||||
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
|
||||
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("миграции: %v", err)
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
if err != nil {
|
||||
t.Fatalf("провайдер: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := p.UpTo(ctx, 6); err != nil {
|
||||
t.Fatalf("миграция до 6: %v", err)
|
||||
}
|
||||
|
||||
const insert = `
|
||||
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
||||
aggregation, period, session_id, bytes, sha256, raw_path,
|
||||
parse_status, points, uncovered_sections)
|
||||
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
|
||||
cases := []struct {
|
||||
id string
|
||||
status string
|
||||
uncovered string
|
||||
want string
|
||||
}{
|
||||
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
|
||||
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
|
||||
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
|
||||
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
|
||||
// Ничего из ставшего покрытым: трогать нечего.
|
||||
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
|
||||
// Подстрока имени секции — не имя секции: отбор идёт по элементу
|
||||
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
|
||||
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
|
||||
// Статус `failed` возвращает только пересборка, а `parsed` этой
|
||||
// миграцией не трогается: у него пустой список непокрытых.
|
||||
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
|
||||
{"d-parsed", ParseDone, `[]`, ParseDone},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
|
||||
t.Fatalf("вставка %s: %v", c.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := p.UpTo(ctx, 7); err != nil {
|
||||
t.Fatalf("миграция до 7: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
var got string
|
||||
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
|
||||
t.Fatalf("чтение %s: %v", c.id, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
-- +goose Up
|
||||
-- Вторая единица хранения витрины: сущность с собственным идентификатором.
|
||||
-- Часовой объект ей не подходит — у неё есть естественный ключ, она редка (за
|
||||
-- двое суток потока две тренировки и две записи состояния разума при 44 и 52
|
||||
-- доставленных копиях), и группировать её по часам незачем.
|
||||
--
|
||||
-- Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||
-- которому идёт выборка (имя, интервал, длительность), а у записи его нет.
|
||||
-- Общая таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти
|
||||
-- родов из шести.
|
||||
CREATE TABLE workout (
|
||||
-- Идентификатор из HealthKit. Приходит из тела и ограничен по длине
|
||||
-- разбором: уезжает и в ключ, и в записи лога.
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
-- Имя как прислал HAE, локализованное («В помещении Ходьба» — машинная
|
||||
-- калька с Indoor Walk). Хранится дословно; стабильный код HealthKit
|
||||
-- припишет задача словаря категориальных значений.
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Интервал в UTC, RFC 3339. Конец, которого нет или который не читается,
|
||||
-- равен началу: ключ — id, схлопывать координаты нечем, а истина остаётся
|
||||
-- в payload.
|
||||
start_utc TEXT NOT NULL,
|
||||
end_utc TEXT NOT NULL,
|
||||
-- Смещение зоны НАЧАЛА: колонка одна, а тренировка через смену зоны дала
|
||||
-- бы два разных.
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Длительность в секундах, как прислал HAE. NULL означает «источник не
|
||||
-- прислал»: ноль — законная длительность, и потребитель, сложивший
|
||||
-- столбец, иначе не отличил бы одно от другого. Не вычисляется из
|
||||
-- интервала — HAE шлёт 91.746 при интервале в 91 секунду.
|
||||
duration_sec REAL,
|
||||
|
||||
-- Тренировка целиком исходными байтами, сжатая gzip: заголовок, маршрут,
|
||||
-- внутренние ряды и сводки. Маршрут — 95% веса (190 КБ из 199,6 у
|
||||
-- десятиминутной прогулки), а такой JSON жмётся примерно в 25 раз.
|
||||
-- Внутрь средствами SQL не заглянуть — та же плата, что у bucket.payload.
|
||||
payload BLOB NOT NULL,
|
||||
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
|
||||
-- Тренировка переприсылается каждой доставкой, пока не доедет маршрут: 44
|
||||
-- копии на живом архиве дают три различных содержимых.
|
||||
content_hash TEXT NOT NULL,
|
||||
|
||||
-- Провенанс: доставка, ЧЬЯ ВЕРСИЯ лежит сейчас, и её метка приёма. Не
|
||||
-- отчётность: по паре (received_at, id) разрешается тай-брейк между
|
||||
-- версиями равной полноты. «Побеждает приехавшая» было бы функцией порядка
|
||||
-- свёртки, а он порядку журнала не равен — доставка с более ранней меткой,
|
||||
-- свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка
|
||||
-- разошлась бы с живым приёмом молча.
|
||||
-- Без DEFAULT намеренно: единственный писатель заполняет обе колонки
|
||||
-- всегда, а умолчание превратило бы его дефект из отказа вставки в тихо
|
||||
-- неверный исход — строка с пустой меткой оказалась бы «самой ранней в
|
||||
-- журнале», и её затирала бы любая приехавшая версия.
|
||||
delivery_id TEXT NOT NULL,
|
||||
delivery_received_at TEXT NOT NULL,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Основной запрос трекера — «заголовки тренировок за период».
|
||||
CREATE INDEX workout_start_utc ON workout (start_utc);
|
||||
|
||||
CREATE TABLE record (
|
||||
-- Род — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||||
-- `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||||
-- именам секций.
|
||||
kind TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
|
||||
-- Метка события в UTC и смещение исходной зоны. У stateOfMind HAE шлёт
|
||||
-- RFC 3339 в UTC, поэтому смещение там всегда 0 — это значит «источник
|
||||
-- прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в
|
||||
-- потоке нет вовсе.
|
||||
ts_utc TEXT NOT NULL,
|
||||
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
payload BLOB NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
|
||||
delivery_id TEXT NOT NULL,
|
||||
delivery_received_at TEXT NOT NULL,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
-- Ключ — ПАРА, а не один id. Собственный id наблюдался живьём только у
|
||||
-- stateOfMind, где он UUID HealthKit; форма идентификатора остальных пяти
|
||||
-- секций не наблюдалась никем, и короткий несквозной id в двух разных
|
||||
-- секциях затёр бы одну запись другой молча. Пара стоит ноль: запросы к
|
||||
-- записям всегда идут с родом.
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
|
||||
-- «Записи такого-то рода за период» — единственная форма запроса к таблице.
|
||||
CREATE INDEX record_kind_ts ON record (kind, ts_utc);
|
||||
|
||||
-- Покрыли секцию — пересверните. Список непокрытых ключей это снимок покрытия
|
||||
-- на момент свёртки: доставки, свёрнутые до того, как workouts и stateOfMind
|
||||
-- стали покрытыми, остались бы partial со старым списком, и ретеншен вечно
|
||||
-- щадил бы тела, которые больше ничего не хранят сверх витрины.
|
||||
--
|
||||
-- Отбор по ЭЛЕМЕНТУ массива, а не по подстроке тела: имя секции приходит из
|
||||
-- чужого тела, и LIKE '%workouts%' поймал бы ключ, лишь содержащий эту
|
||||
-- подстроку. Перевод точечный, а не «все partial»: доставка с непокрытой ecg
|
||||
-- пересворачивать нечего.
|
||||
UPDATE delivery
|
||||
SET parse_status = 'pending'
|
||||
WHERE parse_status = 'partial'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||
WHERE json_each.value IN ('workouts', 'stateOfMind')
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
-- Строки, переведённые в pending, Down обратно не возвращает: какими они были,
|
||||
-- восстановить неоткуда, а pending консервативен — ретеншен его не трогает.
|
||||
DROP INDEX record_kind_ts;
|
||||
DROP TABLE record;
|
||||
DROP INDEX workout_start_utc;
|
||||
DROP TABLE workout;
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// каждом глубоком проходе, и настоящий отказ правила слияния становился
|
||||
// неотличим от нормы — при том что счётчик перезаписей объявлен единственным
|
||||
// наблюдением за этим правилом.
|
||||
func TestMergePointsДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
||||
func TestMergeДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string][2]string{
|
||||
@@ -41,10 +41,10 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
||||
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0])
|
||||
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1])
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{first}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{second}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
||||
// Настоящее столкновение обязано оставить след с координатами объекта: одно
|
||||
// число `overwrites` не говорит, какая метрика и какой час пострадали, и
|
||||
// расследовать перезапись по нему нечем.
|
||||
func TestMergePointsСтолкновениеОставляетКоординаты(t *testing.T) {
|
||||
func TestMergeСтолкновениеОставляетКоординаты(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -74,10 +74,10 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":61}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
||||
// экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но
|
||||
// обещание перестаёт быть правдой, а сравнение байтов при следующей доставке
|
||||
// той же точки начинает промахиваться навсегда.
|
||||
func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
||||
func TestMergeХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -107,7 +107,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
||||
raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}`
|
||||
in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
||||
// Смена единиц не имеет права молча переподписать уже сохранённые точки:
|
||||
// внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их
|
||||
// единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик.
|
||||
func TestMergePointsСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
||||
func TestMergeСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -148,10 +148,10 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
||||
mi.End = mi.Start
|
||||
mi.Raw = []byte(`{"qty":2}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{km}, "d1"); err != nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{km}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{mi}, "d2")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{mi}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
@@ -173,14 +173,14 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо
|
||||
// ровно тогда, когда точки начнут теряться по-настоящему.
|
||||
func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
func TestMergeСчитаетСохранённые(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{p, p, p}, "d1")
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p, p, p}}, store.DeliveryRef{ID: "d1"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored)
|
||||
}
|
||||
|
||||
stats, err = st.MergePoints(ctx, []store.IncomingPoint{p}, "d2")
|
||||
stats, err = st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("повтор: %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
||||
// оставляет частичного состояния — а значит и не зависит от порядка обхода.
|
||||
// Раньше транзакция была на объект, и восемь прогонов одной доставки давали
|
||||
// семь разных наборов записанных объектов.
|
||||
func TestMergePointsОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
||||
func TestMergeОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
@@ -218,7 +218,7 @@ func TestMergePointsОтказНеОставляетЧастиОбъектов(t
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err == nil {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err == nil {
|
||||
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user