Files
healthlog/internal/store/entity_test.go
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

867 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func fingerprint(t *testing.T, st *store.Store) string {
t.Helper()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// 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},
{from(t, "d4", "2025-06-05T08:15:00Z"), woWithRoute},
}
orders := [][]int{
{0, 1, 2, 3}, {3, 2, 1, 0}, {1, 0, 3, 2},
{2, 3, 0, 1}, {1, 3, 0, 2}, {3, 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.EntitiesDiverging != b.EntitiesDiverging {
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging)
}
if a.EntitiesDiverging == 0 {
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
}
// Удержаний тут нет: победитель лёг в витрину целиком, терять нечего.
// Счётчики разведены именно ради этого различия.
if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 {
t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d",
a.EntitiesHeld, b.EntitiesHeld)
}
}
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
// записи, склеенный до взятия длины, даёт (`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("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
}
}
// Оракулы враждебного прохода ревью: тело контролирует отправитель целиком, и
// «версия той же формы без содержания» проходила все проверки — а маршрут это
// 95% тренировки, которого в экспорте Apple нет вовсе. Восстановить его после
// затирания не может даже пересборка: журнал проиграет то же поражение.
const (
woRealWorkout = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
"maxHeartRate":{"qty":199,"units":"count/min"},
"heartRate":{"max":{"qty":199},"avg":{"qty":47.2},"min":{"qty":41}},
"heartRateData":[{"Max":199,"Avg":86.1,"Min":41},{"Max":150,"Avg":80.0,"Min":44}],
"activeEnergy":[{"qty":49.4,"units":"kJ"},{"qty":12.1,"units":"kJ"}],
"totalEnergy":{"qty":66.4,"units":"kJ"},"duration":11.1}`
woSkeleton = `{"id":"w7","name":"x","isIndoor":false,
"maxHeartRate":1,"heartRate":1,
"heartRateData":[null,null],"activeEnergy":[null,null],
"totalEnergy":1,"duration":1}`
woNulledRoute = `{"id":"w1","name":"На улице Ходьба","route":[null,null,null],
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
woEmptyRoute = `{"id":"w1","name":"На улице Ходьба","route":[{},{},{}],
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
// Тот же смысл, другой порядок ключей и другая запись числа: каноническая
// форма совпадает, байты — нет.
woSameFormOtherBytes = `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
)
func TestMergeСкелетНеВытесняетТренировку(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w7", woRealWorkout))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w7", woSkeleton))
if got := storedRaw(t, st, "w7"); !strings.Contains(got, "199") {
t.Error("тренировка заменена скелетом из скаляров и null")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1: событие обязано быть видно", stats.EntitiesHeld)
}
}
func TestMergeМаршрутНеЗатираетсяПустышкамиТойЖеДлины(t *testing.T) {
t.Parallel()
for name, poor := range map[string]string{"null": woNulledRoute, "пустые объекты": woEmptyRoute} {
t.Run(name, func(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", poor))
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
t.Error("маршрут затёрт рядом той же длины без содержания")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
}
})
}
}
// Ключ с пустым значением исчезал бы по жребию тай-брейка. Второй разряд
// сравнения записан для точек и здесь применяется к сущностям.
func TestMergeКлючСПустымЗначениемНеИсчезает(t *testing.T) {
t.Parallel()
const (
withEmpty = `{"id":"w1","qty":10,"context":null}`
noEmpty = `{"id":"w1","qty":11}`
)
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", withEmpty))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", noEmpty))
if got := storedRaw(t, st, "w1"); !strings.Contains(got, "context") {
t.Error("ключ с пустым значением исчез по жребию")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
}
}
// Две версии одного `id` в ОДНОМ теле с разным содержанием: счётчик не имеет
// права молчать. До правки он давал ноль — то есть событие проходило бы как
// штатное INFO.
func TestMergeДвеВерсииВОдномТелеСчитаются(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
st := open(t)
stats := mergeWorkouts(t, st, d, workout(t, "w1", woWithRoute), workout(t, "w1", woNulledRoute))
if stats.EntitiesDiverging == 0 {
t.Error("две версии одного id в одном теле — счётчик молчит")
}
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
t.Error("в теле победила версия без содержания")
}
}
// Побайтовое различие при совпавшей канонической форме событием не является:
// порядок ключей у HAE нестабилен и дребезг последнего разряда тоже. Но байты в
// витрине обязаны быть одни при любой перестановке — иначе исход зависит от
// порядка элементов на проводе.
func TestMergeРавнаяФормаРазныеБайтыНеСобытие(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
прямой := open(t)
s1 := mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameFormOtherBytes))
обратный := open(t)
s2 := mergeWorkouts(t, обратный, d, workout(t, "w1", woSameFormOtherBytes), workout(t, "w1", woWithRoute))
if s1.EntitiesDiverging != 0 || s2.EntitiesDiverging != 0 {
t.Errorf("счётчик сработал на дребезге записи: %d и %d",
s1.EntitiesDiverging, s2.EntitiesDiverging)
}
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("в витрине разные байты при одинаковом содержимом: победитель зависит от порядка")
}
}
// Победитель внутри доставки — функция МНОЖЕСТВА версий. Попарная свёртка
// частичного порядка с тотальным тай-брейком нетранзитивна: [A,B,C] давало C,
// [B,C,A] давало A.
func TestMergeПерестановкаТрёхВерсийВОдномТеле(t *testing.T) {
t.Parallel()
const (
vA = `{"a":[9,9],"b":2,"id":"k"}`
vB = `{"a":[1,2],"id":"k"}`
vC = `{"a":[1],"c":3,"id":"k"}`
)
orders := [][]string{
{vA, vB, vC}, {vA, vC, vB}, {vB, vA, vC},
{vB, vC, vA}, {vC, vA, vB}, {vC, vB, vA},
}
var want string
for i, order := range orders {
st := open(t)
ws := make([]store.IncomingEntity, 0, len(order))
for _, raw := range order {
ws = append(ws, workout(t, "k", raw))
}
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
got := storedRaw(t, st, "k")
if i == 0 {
want = got
continue
}
if got != want {
t.Errorf("порядок %d дал другого победителя:\n %s\n %s", i, got, want)
}
}
}
// Провенанс обязан отражать победителя ЖУРНАЛА, а не первую свёрнутую копию.
// Иначе доставка, свёрнутая с опозданием, вернёт витрину к прежнему содержимому,
// и живая витрина разойдётся с пересборкой молча — в содержимом тренировки.
func TestMergeОтложеннаяДоставкаНеВозвращаетПрежнееСодержимое(t *testing.T) {
t.Parallel()
journal := open(t)
mergeWorkouts(t, journal, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, journal, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
mergeWorkouts(t, journal, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
deferred := open(t)
mergeWorkouts(t, deferred, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, deferred, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, deferred, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
if a, b := storedRaw(t, journal, "w1"), storedRaw(t, deferred, "w1"); a != b {
t.Errorf("состояние зависит от порядка свёртки:\n %s\n %s", a, b)
}
if a, b := fingerprint(t, journal), fingerprint(t, deferred); a != b {
t.Errorf("отпечатки разошлись: %s против %s", a, b)
}
}
// Провенанс поднимается до более поздней доставки журнала даже при совпавшем
// хеше — иначе тай-брейк «содержание равно» решает по устаревшей позиции.
func TestMergeПовторОбновляетПровенанс(t *testing.T) {
t.Parallel()
ctx := context.Background()
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", woWithRoute))
if stats.WorkoutsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
}
w, err := st.Workout(ctx, "w1")
if err != nil {
t.Fatalf("чтение тренировки: %v", err)
}
if w.Delivery != "d2" {
t.Errorf("провенанс %q, ожидался d2: победитель журнала — более поздняя доставка", w.Delivery)
}
}
// Провенанс записи обновляется тем же путём, что и у тренировки, но ключ у неё
// СОСТАВНОЙ — а значит текст `WHERE` и хвост аргументов обязаны совпадать. Ветка
// не покрывалась ни одним тестом, притом что промах давал бы `UPDATE` в ноль
// строк, невидимый ни в отпечатке (провенанса там нет), ни в счётчиках.
// Род `record` — единственный, где цена необратима: в экспорте Apple его нет.
func TestMergeПровенансЗаписиОбновляетсяПоСоставномуКлючу(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := open(t)
rec := func() store.IncomingEntity {
return 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}`),
}
}
merge := func(d store.DeliveryRef) store.MergeStats {
t.Helper()
s, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec()}}, d)
if err != nil {
t.Fatalf("слияние записи: %v", err)
}
return s
}
merge(from(t, "d1", "2025-06-05T20:00:00Z"))
stats := merge(from(t, "d2", "2025-06-05T20:05:00Z"))
if stats.RecordsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.RecordsWritten)
}
got, err := st.Record(ctx, "stateOfMind", "e1")
if err != nil {
t.Fatalf("чтение записи: %v", err)
}
if got.Delivery != "d2" {
t.Errorf("провенанс %q, ожидался d2", got.Delivery)
}
}
// Число версий одного ключа приходит из чужого тела, а выбор победителя по ним
// квадратичен. Отмена обязана прерывать отбор: без неё тело с двадцатью
// тысячами версий занимает единственного воркера свёртки дольше, чем длится его
// собственный дедлайн, и очередь встаёт молча при зелёном `/healthz`.
func TestMergeОтменаПрерываетВыборПобедителя(t *testing.T) {
t.Parallel()
st := open(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
ws := make([]store.IncomingEntity, 0, 3)
for i := range 3 {
raw := fmt.Sprintf(`{"id":"w1","qty":%d,"route":[{"lat":%d}]}`, i, i)
ws = append(ws, workout(t, "w1", raw))
}
_, err := st.Merge(ctx, store.Incoming{Workouts: ws}, from(t, "d1", "2025-06-05T08:00:00Z"))
if !errors.Is(err, context.Canceled) {
t.Errorf("слияние дало %v, ожидалась отмена", err)
}
}
// Версии с совпавшей канонической формой схлопываются ДО квадратичного отбора:
// иначе тело, вмещающее сотни тысяч копий одного `id`, стоит часов работы. При
// этом схлопывание не имеет права менять исход — победитель тот же.
func TestMergeРавныеФормыСхлопываютсяДоОтбора(t *testing.T) {
t.Parallel()
st := open(t)
ws := make([]store.IncomingEntity, 0, 2000)
for range 2000 {
ws = append(ws, workout(t, "w1", woWithRoute))
}
ws = append(ws, workout(t, "w1", woRicher))
done := make(chan store.MergeStats, 1)
go func() {
done <- mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
}()
select {
case stats := <-done:
if got := storedRaw(t, st, "w1"); got != woRicher {
t.Errorf("схлопывание изменило победителя:\n%s", got)
}
// Победитель — единственная более полная версия; от неё формой
// отличаются две тысячи копий победнее, и схлопывание их не прячет:
// счётчик считает версии, а не группы.
if stats.EntitiesDiverging != 2000 {
t.Errorf("различающихся версий %d, ожидалось 2000", stats.EntitiesDiverging)
}
case <-time.After(20 * time.Second):
t.Fatal("слияние не уложилось в 20 с: схлопывание не работает")
}
}
// Регрессия, найденная враждебным проходом ревью: безусловный второй разряд
// правила покрытия запирал законный досчёт НАВСЕГДА. Версия с пустым ключом и
// без маршрута оказывалась несравнимой с версией, у которой маршрут приехал, а
// этого ключа нет, — и маршрут не доезжал ни одной доставкой, причём пересборка
// проигрывала то же поражение. Второй разряд разрешает спор равных, а не
// отменяет первый.
func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T) {
t.Parallel()
const (
сПустымКлючом = `{"id":"w2","activeEnergy":[{"qty":10}],"totalEnergy":null}`
сМаршрутом = `{"id":"w2","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
)
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w2", сПустымКлючом))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w2", сМаршрутом))
if got := storedRaw(t, st, "w2"); !strings.Contains(got, `"lat":1`) {
t.Errorf("маршрут не доехал — досчёт заперт пустым ключом:\n%s", got)
}
if stats.WorkoutsWritten != 1 {
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
}
if stats.EntitiesHeld != 0 {
t.Errorf("удержано %d: законный досчёт принят за потерю содержания", stats.EntitiesHeld)
}
}
// Точка, где витрина сущностей НЕ является функцией множества доставок, —
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
// выигрывает» даёт разный итог при разном порядке.
//
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
// перегоняется своими преемницами — проход воркера прекращается на ней.
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
// метки. У точек единственной такой точки давно нет: там от порядка журнала
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
//
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
// текст в обоих местах пора переписать.
func TestMergeНесравнимыеВерсииЗависятОтПорядкаСвёртки(t *testing.T) {
t.Parallel()
const (
база = `{"id":"w3","activeEnergy":[{"qty":10}]}`
сТреком = `{"id":"w3","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2}]}`
сЭтажами = `{"id":"w3","activeEnergy":[{"qty":10}],"flightsClimbed":{"qty":3}}`
)
d1 := from(t, "d1", "2025-06-05T08:00:00Z")
d2 := from(t, "d2", "2025-06-05T08:05:00Z")
d3 := from(t, "d3", "2025-06-05T08:10:00Z")
журнальный := open(t)
mergeWorkouts(t, журнальный, d1, workout(t, "w3", база))
mergeWorkouts(t, журнальный, d2, workout(t, "w3", сТреком))
mergeWorkouts(t, журнальный, d3, workout(t, "w3", сЭтажами))
отложенный := open(t)
mergeWorkouts(t, отложенный, d1, workout(t, "w3", база))
mergeWorkouts(t, отложенный, d3, workout(t, "w3", сЭтажами))
stats := mergeWorkouts(t, отложенный, d2, workout(t, "w3", сТреком))
a, b := storedRaw(t, журнальный, "w3"), storedRaw(t, отложенный, "w3")
if a == b {
t.Fatal("предел закрылся — перепиши текст в pickEntity и architecture.md")
}
// И главное: расхождение не молчит.
if stats.EntitiesHeld == 0 {
t.Error("расхождение по порядку свёртки не отражено счётчиком удержаний")
}
}