// Package fold — свёртка доставки в часовые объекты. // // Хранилище — свёртка по журналу: состояние пересобирается как // `import(экспорт) + replay(доставки по received_at)`. Поэтому свёртка // принимает идентификатор доставки, а тело читает из архива — тем же кодом, // каким его прочитает пересборка. Разбор «из памяти, раз тело всё равно в // руках» дал бы второй путь, который разошёлся бы с первым молча. package fold import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "strings" "time" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/healthkit" "git.vakhrushev.me/av/healthlog/internal/store" ) // defaultMaxBodyBytes — граница размера тела при чтении из архива, когда // вызывающий свою не задал. // // Наблюдалось 42 МиБ. Граница явная, потому что молчаливое «сколько дадут» — // это отказ, который проявится только на пике потока: битый или враждебный // файл архива выест память процесса. const defaultMaxBodyBytes = 64 << 20 // Service сворачивает доставки в часовые объекты. type Service struct { arch *archive.Archive store *store.Store log *slog.Logger // maxBody — та же граница, что у приёма, и это существенно. Тело между // границами приёма и свёртки было бы принято со статусом 200, легло бы в // архив и потом вечно валилось бы при каждой пересборке. maxBody int64 } // New собирает свёртку. maxBody — граница размера РАСПАКОВАННОГО тела; ноль // означает умолчание. func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger) *Service { if maxBody <= 0 { maxBody = defaultMaxBodyBytes } return &Service{ arch: arch, store: st, maxBody: maxBody, log: log.With("capability", "fold"), } } // finishTimeout — сколько отводится записи исхода свёртки. // // Исход пишется на контексте, ПЕРЕЖИВАЮЩЕМ отмену исходного: иначе при // срабатывании дедлайна свёртки запись статуса гарантированно провалится, и // доставка навсегда останется `pending` — притом что часть объектов уже // записана. То есть ровно в том случае, ради которого дедлайн и заведён, // учёт разошёлся бы с содержимым витрины. const finishTimeout = 10 * time.Second // Stats — итог свёртки одной доставки. // // Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование // молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия // сущности) принято решение не объединять поля — забытая строка присваивания // отменила бы наблюдение при зелёных тестах хранилища. type Stats struct { store.MergeStats Metrics int Points int SkippedNoTime int SkippedMalformed int SkippedBadEnd int // Счётчики пропуска сущностей: у каждого класса свой, потому что тело в // архиве остаётся, а вернуть сущность может только пересборка. SkippedNoID int SkippedEntityNoTime int SkippedEntityMalformed int Layer string LayerMismatch bool // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает. // Это ответ на вопрос «что останется потерянным, если тело удалить». Uncovered []string // UncoveredDropped — сколько имён отброшено границей списка. UncoveredDropped int // UncoveredNew — имена непокрытых секций, которых не было ни в одной // доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь // имени: поток дописывает метрики на телефоне молча, и момент появления // секции наблюдать больше нечем. UncoveredNew []string // UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все // непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания // один раз, промолчавшее событие не восстанавливается ничем. UncoveredSeenUnknown bool // UncoveredSeenError — почему не состоялась. UncoveredSeenError error // Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось; // CategoricalUnknown — сколько из них словарь не знает; // CategoricalDropped — сколько отброшено границами разбора. // // Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна // описывают человека не меньше, чем число. Categoricals int CategoricalUnknown int CategoricalDropped int } // ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и // пересборка вернёт её, когда дефект будет исправлен. var ErrPanicked = errors.New("свёртка паниковала") // Fold разбирает тело доставки и раскладывает точки по часовым объектам. // // Это единственный логирующий чекпоинт свёртки: транспорт и приём исход // разбора не логируют. Значения точек и имена устройств в лог не попадают — // данные о здоровье чувствительнее токенов. // // Паника перехватывается ЗДЕСЬ, у той же границы, что пишет исход разбора. // Пока свёртка шла внутри HTTP-обработчика, панику ловил middleware.Recoverer и // она стоила одного ответа; из фоновой горутины она валит процесс целиком, а // `restart: unless-stopped` поднимает его снова — и первый же проход берёт ту // же доставку, то есть дефект превращается в цикл перезапуска, при котором // приём не работает вовсе. Перехват у этой границы, а не у вызывающего, // оставляет писателя `parse_status` единственным. func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err error) { defer func() { r := recover() if r == nil { return } stats = Stats{} err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления s.fail(ctx, deliveryID, err, parseResidue{}) }() d, err := s.store.DeliveryForParse(ctx, deliveryID) if err != nil { // Ни один выход с ошибкой не молчит: вызывающий эту ошибку сознательно // отбрасывает, и молчащий путь означал бы доставку вообще без событий // в журнале — её не найти ни по какому запросу. s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID) return stats, err } body, err := s.readBody(d.RawPath) if err != nil { s.fail(ctx, deliveryID, err, parseResidue{}) return stats, err } fallback, err := s.store.LastDerivedLayer(ctx, d.AutomationID, d.ReceivedAt, d.ID) if err != nil { s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID) return stats, err } parsed, err := hae.Parse(body, hae.Meta{ Aggregation: d.Aggregation, FallbackLayer: hae.Layer(fallback), Locale: localeOf(d.Headers), }) // Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых // секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи // здесь — и следующая доставка сочтёт его виденным, а событие не вернётся // ничем, кроме ручного запроса в базу. novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{ ID: d.ID, ReceivedAt: d.ReceivedAt, }) if err != nil { // Список непокрытых секций переживает отказ: доставка, у которой не // определился слой, обязана остаться записью о том, что в теле есть // невосстановимая секция. // // А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший // ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и // записать этот ноль значило бы объявить доставку проверенной. s.fail(ctx, deliveryID, err, residueOf(parsed, novelty)) return stats, err } stats.Uncovered = parsed.Uncovered stats.UncoveredDropped = parsed.UncoveredDropped stats.UncoveredNew = novelty.fresh stats.UncoveredSeenUnknown = novelty.unknown stats.UncoveredSeenError = novelty.cause stats.Metrics = parsed.Metrics stats.Points = len(parsed.Points) 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 stats.Categoricals = len(parsed.Categoricals) stats.CategoricalUnknown = parsed.CategoricalUnknown stats.CategoricalDropped = parsed.CategoricalDropped merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{ ID: d.ID, ReceivedAt: d.ReceivedAt, }) if err != nil { // Здесь разбор досчитал: отказало слияние. Значит счётчик пропусков // измерен и обязан дойти до учёта — в отличие от ветки выше. s.fail(ctx, deliveryID, err, parseResidue{ uncovered: parsed.Uncovered, skipped: skippedEntities(parsed), // Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же, // как ветвь отказа разбора, — значит и терять событие ей нельзя: // следующая доставка сочтёт имя виденным, а отказ слияния бывает // нетранзиентным (исчерпанный дедлайн свёртки под большим телом). novelty: novelty, }) return stats, err } stats.MergeStats = merge // Источник истины — список; статус производен от него и от факта отказа. // Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats) // разойдутся: один спросит parse_status, другой — непустоту списка. status := store.ParseDone if len(parsed.Uncovered) > 0 { status = store.ParsePartial } out := store.ParseOutcome{ Status: status, Points: int64(stats.Points), Layer: stats.Layer, Uncovered: parsed.Uncovered, SkippedEntities: skippedEntities(parsed), } if err := s.finish(ctx, deliveryID, out); err != nil { s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID) return stats, err } s.logResult(ctx, deliveryID, stats) return stats, nil } // logResult — единственный логирующий чекпоинт свёртки. // // Все признаки идут АТРИБУТАМИ всегда, а уровень выбирается отдельно. Раньше // признаки жили только в тексте сообщения, и `switch` их терял: доставка, // одновременно задевшая запечатанный час и разошедшаяся с заголовком, не // оставляла следа о слое вовсе — а это единственный индикатор того, что слой // выводится неправильно. // // Значений точек и имён устройств здесь нет и быть не может: данные о здоровье // чувствительнее токенов. Координаты столкновений — метрика, слой, час — не // значения. 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, "metrics", st.Metrics, "points", st.Points, "stored", st.Stored, "buckets", st.Buckets, "unchanged", st.Unchanged, "overwrites", st.Overwrites, // Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым // тай-брейком это правило полноты, работающее штатно (на живом корпусе // 981 координата из 80 129 спорных), и эскалация обесценила бы уровень. // Наблюдаемым событие делает то, что оно посчитано отдельно от // перезаписей и печатается прогоном пересборки. "points_held", st.PointsHeld, // Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже: // на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет, // а событие означает потерю содержания — обратимую пересборкой, пока // жив архив. "points_erased", st.PointsErased, "incomparable", st.Incomparable, "units_conflicts", st.UnitsConflicts, "sealed_hits", st.SealedHits, "skipped", skipped, "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, "entities_diverging", st.EntitiesDiverging, "skipped_entities", skippedEntities, "layer", st.Layer, "layer_mismatch", st.LayerMismatch, // Структурным []string, а не склейкой: JSON-кодировщик slog экранирует // управляющие символы, поэтому имя секции из чужого тела не разрывает // построчный разбор логов. Содержимого секций здесь нет. "uncovered", st.Uncovered, "uncovered_dropped", st.UncoveredDropped, // Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень // поднимается отдельной ветвью ниже. Наблюдаемый признак события это // он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой // доставки, которая её принесла, и по нему первую встречу не отличить. "uncovered_new", st.UncoveredNew, "uncovered_seen_unknown", st.UncoveredSeenUnknown, // Причина несостоявшейся сверки — рядом с признаком. Занятость базы // проходит сама, испорченная колонка не проходит никогда и поднимает // признак на каждой доставке; по одному булеву это неразличимо. // Значений точек в ошибке нет: до текста доезжает только имя секции, и // оно обрезано. "uncovered_seen_error", st.UncoveredSeenError, // Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов: // «Сидячий образ жизни» — это контекст пульса, то есть данные о // здоровье. Какие именно строки ждут словаря, отвечает реестр в базе. "categoricals", st.Categoricals, "categoricals_unknown", st.CategoricalUnknown, "categoricals_dropped", st.CategoricalDropped, } if len(st.Collisions) > 0 { attrs = append(attrs, "collisions", formatCollisions(st.Collisions)) } if len(st.IncomparableAt) > 0 { attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt)) } if len(st.PointsHeldAt) > 0 { attrs = append(attrs, "points_held_at", formatCollisions(st.PointsHeldAt)) } if len(st.PointsErasedAt) > 0 { attrs = append(attrs, "points_erased_at", formatCollisions(st.PointsErasedAt)) } if len(st.HeldAt) > 0 { attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt)) } if len(st.DivergingAt) > 0 { attrs = append(attrs, "diverging_at", formatEntityRefs(st.DivergingAt)) } // Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не // штатная работа. Без этого условия смена формата метки выглядела бы как // здоровый поток: 200, parsed, INFO, points=0. allSkipped := st.Points == 0 && skipped > 0 // То же для сущностей: доставка из одних тренировок, у которой не осталось // ни одной, — это сменившийся формат, а не пустая секция. allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0 switch { case len(st.UncoveredNew) > 0: // ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях, // повторяющихся на живом потоке; это — однократное за всю жизнь имени, и // замаскировать его перезаписью точек значило бы потерять ровно то, ради // чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой // секции — штатное событие внешнего мира, «посмотри», а не «разбери // сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а // не измерение. s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...) case st.EntitiesHeld > 0: // Приехавшая версия сущности отклонена как теряющая содержание. Плата // за отказ объединять поля: событие обязано быть видно, потому что на // живом потоке оно не наступало ни разу и правило держится на этом. s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...) case st.EntitiesDiverging > 0: // Событие другого рода и с другим лечением: в одном теле приехали // версии одного ключа с разным содержанием. Победитель лёг в витрину // целиком, терять нечего — но корпус такого не производил, и молчать // об этом нельзя. Отдельной ветвью, а не общей с удержанием: сообщение // «удержана обеднённая версия» отправляло бы владельца искать то, чего // не случилось. s.log.WarnContext(ctx, "delivery folded, entity versions diverge in one body", attrs...) case allEntitiesSkipped: s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...) case st.UncoveredDropped > 0: // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // а границу выбило больше тридцати двух. s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...) case st.CategoricalDropped > 0: // Тот же класс события и та же причина эскалации, что у списка секций: // границы подобраны по измерению (на живом потоке около одиннадцати // различных значений, самое длинное 36 байт), поэтому попадание в них — // уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять // минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт // только здесь: лог ротируется, и к следующей сверке отпечатков причину // уже не восстановить. // // `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой — // словарь покрывает только фазы сна, — и постоянный WARN обесценил бы // уровень. s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...) case st.Incomparable > 0: // Выше перезаписей намеренно: несравнимый набор полей — событие реже и // информативнее, на живом потоке не случавшееся ни разу. Признаки при // этом идут атрибутами всегда, так что выбор ветви ничего не прячет. s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...) case st.PointsErased > 0: // НИЖЕ несравнимости, хотя событие тяжелее: на живом корпусе эти два // множества совпадают (2 координаты и там, и там), и несравнимость // сообщает больше — она называет ещё и то, что объединять поля было бы // что. Ветвь эта говорит про случай, которого несравнимость не // покрывает: сохранённая была надмножеством по именам, но значения // общих ключей разошлись, разряд полноты погас, и содержание унесла // пришедшая. Счётчики обеих ветвей идут атрибутами всегда, так что // порядок ветвей ничего не прячет. s.log.WarnContext(ctx, "delivery folded, arriving point dropped stored field", attrs...) case st.Overwrites > 0: // Единственное наблюдение, по которому проверяется правило слияния. // В INFO оно тонуло: поток идёт раз в пять минут. s.log.WarnContext(ctx, "delivery folded, points overwritten", attrs...) case st.UnitsConflicts > 0: s.log.WarnContext(ctx, "delivery folded, units differ from stored", attrs...) case st.SealedHits > 0: // Досчёт часа, в который его уже не ждали: единственное наблюдение, по // которому вообще можно судить о глубине досчёта. s.log.WarnContext(ctx, "delivery folded, sealed hour changed", attrs...) case allSkipped: s.log.WarnContext(ctx, "delivery folded, all points skipped", attrs...) case st.LayerMismatch: // Расхождение сверяется только с надёжным заголовком: `Default` не // означает режима, и сравнение с ним давало бы WARN на каждой доставке. s.log.WarnContext(ctx, "delivery folded, layer differs from header", attrs...) default: s.log.InfoContext(ctx, "delivery folded", attrs...) } } // formatCollisions превращает координаты столкновений в строку для лога. func formatCollisions(cs []store.Collision) string { parts := make([]string, 0, len(cs)) for _, c := range cs { parts = append(parts, c.Metric+"/"+c.Layer+"@"+store.FormatTime(c.HourUTC)) } 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 = "" // finish записывает исход разбора на контексте, переживающем отмену исходного. func (s *Service) finish(ctx context.Context, deliveryID string, out store.ParseOutcome) error { ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finishTimeout) defer cancel() if err := s.store.FinishParse(ctx, deliveryID, out); err != nil { return fmt.Errorf("запись исхода разбора: %w", err) } return nil } // fail записывает исход неудачной свёртки. // // Исход отражает ДОСТАВКУ, а не обстоятельства. Отмена снаружи и занятость базы // работой доставки не являются: они означают «не сделано», а не «не выходит». // Статус в этих случаях не трогается вовсе — доставка остаётся `pending` и // подбирается следующим проходом. Иначе конкуренция за базу (после разнесения // ответа и свёртки она штатная) выводила бы доставку из очереди навсегда: // `failed` возвращает только пересборка, то есть ручная операция с остановкой // сервиса. // // Всё прочее — непонятое содержимое, невыводимый слой, нечитаемое или слишком // большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их // бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не // затрагивается: сохранили значит приняли. // parseResidue — то, что разбор успел узнать о доставке до отказа и что обязано // пережить его в учёте: список непокрытых секций и число пропущенных сущностей. // // Структурой, а не двумя параметрами: у `fail` их стало бы четыре, и следующий // счётчик неизбежно перепутали бы местами с предыдущим. Пустое значение — // «разбор до этого не дошёл», и оно честно: отказ на чтении тела ничего о // содержимом не знает. type parseResidue struct { uncovered []string // skipped — nil означает «разбор до конца не дошёл, пропусков никто не // считал». Ноль означал бы «проверено, терять нечего», а по этому числу // ретеншен принимает необратимое решение об удалении тела. skipped *int64 // novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а // не пятым параметром `fail`: параметры путают местами, а поле называет // себя само. novelty sectionNovelty } func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue { return parseResidue{uncovered: parsed.Uncovered, novelty: novelty} } // sectionNovelty — исход сверки имён непокрытых секций с журналом. type sectionNovelty struct { // fresh — имена, которых не было ни в одной доставке раньше этой. fresh []string // unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена // доставки. unknown bool // cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и // испорченное содержимое колонки (не пройдёт никогда, и признак будет // подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем // самым ручным запросом в базу, от которого задача избавляет. cause error } // novelty спрашивает журнал, какие из непокрытых имён встречаются впервые. // // Отказ запроса свёртку не роняет и исходом доставки не становится: правила // классификации исходов наблюдение не трогает, занятая база и отменённый // контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя, // о котором смолчали, уже записано в учёт, — поэтому при отказе новыми // объявляются все имена, а признак несостоявшейся сверки идёт в запись. // // Запрос берётся только при непустом списке: на живом потоке все три // приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего. func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty { if len(uncovered) == 0 { return sectionNovelty{} } seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at) if err != nil { return sectionNovelty{fresh: uncovered, unknown: true, cause: err} } fresh := make([]string, 0, len(uncovered)) for _, name := range uncovered { if _, ok := seen[name]; ok { continue } fresh = append(fresh, name) } if len(fresh) == 0 { return sectionNovelty{} } return sectionNovelty{fresh: fresh} } // skippedEntities — сколько сущностей с собственным `id` разбор пропустил. // Сумма трёх классов, а не три колонки: ретеншен спрашивает «есть ли что // терять», а не «почему», а разбор класса живёт в логе свёртки, где все три // счётчика идут атрибутами. func skippedEntities(parsed hae.Result) *int64 { n := int64(parsed.SkippedNoID + parsed.SkippedEntityNoTime + parsed.SkippedEntityMalformed) return &n } func (s *Service) fail(ctx context.Context, deliveryID string, cause error, residue parseResidue) { if store.Transient(cause) { // WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы // повтор не выглядел беспричинным. s.log.WarnContext(ctx, "delivery fold deferred", "error", cause, "delivery_id", deliveryID) return } // Новые имена доезжают до записи об отказе: она уже выше рутинного уровня, // а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно // — там учётная запись не меняется, доставка вернётся следующим проходом, и // повторение признака на каждом проходе занятой базы превратило бы // однократное событие в дребезг. attrs := []any{"error", cause, "delivery_id", deliveryID} if len(residue.novelty.fresh) > 0 { attrs = append(attrs, "uncovered_new", residue.novelty.fresh) } if residue.novelty.unknown { attrs = append(attrs, "uncovered_seen_unknown", true, "uncovered_seen_error", residue.novelty.cause) } level := slog.LevelError switch { case errors.Is(cause, hae.ErrLayerUnknown): // Слой не определился — это не поломка, а ожидаемый исход для доставки // без плотных метрик. Тело ждёт пересборки. level = slog.LevelWarn case errors.Is(cause, hae.ErrMalformed): // Непонятое содержимое от отправителя — норма жизни, разбирать нечего. // На границе приёма такой же отказ уходит в DEBUG; два разных уровня у // одного класса ошибки давали бы постоянный ERROR-шум. level = slog.LevelWarn } s.log.Log(ctx, level, "delivery fold failed", attrs...) // Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая // строка здесь оборвала бы цепочку наследования, то есть изменила бы // результат пересборки журнала. out := store.ParseOutcome{ Status: store.ParseFailed, Layer: keepLayer, Uncovered: residue.uncovered, SkippedEntities: residue.skipped, } if err := s.finish(ctx, deliveryID, out); err != nil { s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID) } } // ReadBody читает тело из архива с той же границей размера, что и свёртка. // // Экспортировано ради пересборки: ей нужно прочесть тело, у которого ещё нет // учётной записи, чтобы посчитать размер и хеш. Своей копией чтения это делать // нельзя — граница обязана быть общей, иначе тело, принятое приёмом со `200`, // начнёт вечно отказывать на каждой пересборке, и договорённость «предел тот // же» ничем не проверяется. func (s *Service) ReadBody(rawPath string) ([]byte, error) { return s.readBody(rawPath) } func (s *Service) readBody(rawPath string) ([]byte, error) { r, err := s.arch.Open(rawPath) if err != nil { return nil, err } defer func() { _ = r.Close() }() body, err := io.ReadAll(io.LimitReader(r, s.maxBody+1)) if err != nil { return nil, fmt.Errorf("чтение тела из архива: %w", err) } if int64(len(body)) > s.maxBody { return nil, fmt.Errorf("тело больше %d байт", s.maxBody) } return body, nil } 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, Point: store.Point{ Start: p.Start, End: p.End, OffsetSeconds: p.OffsetSeconds, Raw: p.Raw, }, }) } return store.Incoming{ Points: points, Workouts: toEntities(parsed.Workouts), Records: toEntities(parsed.Records), Categories: toCategories(parsed.Categoricals), } } func toCategories(in []hae.Categorical) []store.CategoryValue { if len(in) == 0 { return nil } out := make([]store.CategoryValue, 0, len(in)) for _, c := range in { out = append(out, store.CategoryValue{ Metric: c.Metric, Field: c.Field, Value: c.Value, Code: c.Code, }) } return out } // localeOf достаёт язык доставки из сохранённых заголовков запроса. // // Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и // вторая копия того же факта разошлась бы с первой при первой же правке приёма. // // Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают // пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет: // заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из // осиротевшего тела, приезжает без них — и обязана дать то же состояние. // // Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при // приёме, но в базе лежит то, что было записано, и правило регистра не наше. func localeOf(headers string) string { if headers == "" { return "" } var h map[string][]string if err := json.Unmarshal([]byte(headers), &h); err != nil { return "" } for name, values := range h { if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 { continue } return healthkit.Locale(values[0]) } return "" } const acceptLanguageHeader = "Accept-Language" 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 }