feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+85 -28
View File
@@ -65,26 +65,28 @@ func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger
const finishTimeout = 10 * time.Second
// Stats — итог свёртки одной доставки.
//
// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование
// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия
// сущности) принято решение не объединять поля — забытая строка присваивания
// отменила бы наблюдение при зелёных тестах хранилища.
type Stats struct {
store.MergeStats
Metrics int
Points int
Stored int
Buckets int
Unchanged int
Overwrites int
Incomparable int
SealedHits int
UnitsConflicts int
SkippedNoTime int
SkippedMalformed int
SkippedBadEnd int
Layer string
LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
// Счётчики пропуска сущностей: у каждого класса свой, потому что тело в
// архиве остаётся, а вернуть сущность может только пересборка.
SkippedNoID int
SkippedEntityNoTime int
SkippedEntityMalformed int
Layer string
LayerMismatch bool
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
// Это ответ на вопрос «что останется потерянным, если тело удалить»:
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
// Это ответ на вопрос «что останется потерянным, если тело удалить».
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int
@@ -158,24 +160,21 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
stats.SkippedNoTime = parsed.SkippedNoTime
stats.SkippedMalformed = parsed.SkippedMalformed
stats.SkippedBadEnd = parsed.SkippedBadEnd
stats.SkippedNoID = parsed.SkippedNoID
stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
stats.Layer = string(parsed.Layer)
stats.LayerMismatch = parsed.LayerMismatch
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
})
if err != nil {
s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err
}
stats.Stored = merge.Stored
stats.Buckets = merge.Buckets
stats.Unchanged = merge.Unchanged
stats.Overwrites = merge.Overwrites
stats.Incomparable = merge.Incomparable
stats.SealedHits = merge.SealedHits
stats.UnitsConflicts = merge.UnitsConflicts
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
stats.MergeStats = merge
// Источник истины — список; статус производен от него и от факта отказа.
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
@@ -212,6 +211,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
// значения.
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed
attrs := []any{
"delivery_id", deliveryID,
@@ -228,6 +228,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"skipped_no_time", st.SkippedNoTime,
"skipped_malformed", st.SkippedMalformed,
"skipped_bad_end", st.SkippedBadEnd,
// Сущности с собственным `id`: пришло, легло и удержано. Координаты
// (род и идентификатор) разрешены, содержимое — нет: маршрут
// тренировки это геотрек до дома, а метки состояния разума —
// измерение душевного состояния.
"workouts", st.Workouts,
"workouts_written", st.WorkoutsWritten,
"records", st.Records,
"records_written", st.RecordsWritten,
"entities_held", st.EntitiesHeld,
"skipped_entities", skippedEntities,
"layer", st.Layer,
"layer_mismatch", st.LayerMismatch,
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
@@ -242,13 +252,26 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
}
if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
}
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без этого условия смена формата метки выглядела бы как
// здоровый поток: 200, parsed, INFO, points=0.
allSkipped := st.Points == 0 && skipped > 0
// То же для сущностей: доставка из одних тренировок, у которой не осталось
// ни одной, — это сменившийся формат, а не пустая секция.
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
switch {
case st.EntitiesHeld > 0:
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
// за отказ объединять поля: событие обязано быть видно, потому что на
// живом потоке оно не наступало ни разу и правило держится на этом.
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
case allEntitiesSkipped:
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
case st.UncoveredDropped > 0:
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух.
@@ -288,6 +311,16 @@ func formatCollisions(cs []store.Collision) string {
return strings.Join(parts, " ")
}
// formatEntityRefs превращает координаты сущностей в строку для лога.
// Содержимого не несёт: род и идентификатор — координаты, а не измерение.
func formatEntityRefs(refs []store.EntityRef) string {
parts := make([]string, 0, len(refs))
for _, r := range refs {
parts = append(parts, r.Kind+"/"+r.ID)
}
return strings.Join(parts, " ")
}
// keepLayer — значение слоя, означающее «оставить как было».
const keepLayer = ""
@@ -379,10 +412,10 @@ func (s *Service) readBody(rawPath string) ([]byte, error) {
return body, nil
}
func toIncoming(points []hae.Point) []store.IncomingPoint {
out := make([]store.IncomingPoint, 0, len(points))
for _, p := range points {
out = append(out, store.IncomingPoint{
func toIncoming(parsed hae.Result) store.Incoming {
points := make([]store.IncomingPoint, 0, len(parsed.Points))
for _, p := range parsed.Points {
points = append(points, store.IncomingPoint{
Metric: p.Metric,
Layer: string(p.Layer),
Units: p.Units,
@@ -394,5 +427,29 @@ func toIncoming(points []hae.Point) []store.IncomingPoint {
},
})
}
return store.Incoming{
Points: points,
Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records),
}
}
func toEntities(in []hae.Entity) []store.IncomingEntity {
if len(in) == 0 {
return nil
}
out := make([]store.IncomingEntity, 0, len(in))
for _, e := range in {
out = append(out, store.IncomingEntity{
ID: e.ID,
Kind: e.Kind,
Name: e.Name,
Start: e.Start,
End: e.End,
OffsetSeconds: e.OffsetSeconds,
Duration: e.Duration,
Raw: e.Raw,
})
}
return out
}
+2 -2
View File
@@ -257,8 +257,8 @@ func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T)
if d.ParseStatus != store.ParsePartial {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
}
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
if !strings.Contains(d.UncoveredSections, "ecg") {
t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections)
}
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
if stats.Points == 0 {
+130 -5
View File
@@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
{"date":"2025-06-05 10:07:00 +0300","qty":8},
{"date":"2025-06-05 10:08:00 +0300","qty":9},
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
"ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
if _, err := f.Fold(ctx, "d1"); err != nil {
@@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
}
out := buf.String()
if !strings.Contains(out, "stateOfMind") {
if !strings.Contains(out, "ecg") {
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
}
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
t.Error("содержимое непокрытой секции утекло в лог")
}
@@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
if rec.Level != "INFO" {
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
}
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
}
}
// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
// Разрешены только координаты: род, идентификатор, интервал.
func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
`"totalEnergy":{"qty":404040.4}}],` +
`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`
// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
// пишется WARN об удержанной версии и где велик соблазн приписать «что
// именно потерялось».
const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
`"totalEnergy":{"qty":505050.5}}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка первой доставки: %v", err)
}
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
if _, err := f.Fold(ctx, "d2"); err != nil {
t.Fatalf("свёртка второй доставки: %v", err)
}
logged := buf.String()
for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
if strings.Contains(logged, secret) {
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
}
}
// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
// говорит, какая сущность пострадала.
if !strings.Contains(logged, "w-открытый") {
t.Error("координат удержанной сущности в логе нет")
}
if !strings.Contains(logged, "poorer entity version held") {
t.Error("удержание обеднённой версии не отмечено записью WARN")
}
}
// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
// слияния встроены структурой. Тест закрепляет имена атрибутов.
func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
// элемент, не являющийся объектом: три класса пропуска плюс успех.
const body = `{"data":{"workouts":[
{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
{"start":"2025-06-05 11:00:00 +0300"},
{"id":"w3","start":"позавчера"},
"строка вместо объекта"]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
if _, err := f.Fold(context.Background(), "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
var rec struct {
Workouts int `json:"workouts"`
WorkoutsWritten int `json:"workouts_written"`
Records int `json:"records"`
RecordsWritten int `json:"records_written"`
EntitiesHeld int `json:"entities_held"`
SkippedEntities int `json:"skipped_entities"`
}
line := strings.TrimSpace(buf.String())
if i := strings.LastIndex(line, "\n"); i >= 0 {
line = line[i+1:]
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("запись лога не разбирается: %v", err)
}
if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
}
if rec.SkippedEntities != 3 {
t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
}
if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
}
}