feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+85
-28
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user