первая встреча непокрытой секции стала наблюдаемым событием

- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре
  (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат.
  Признак выводится, а не хранится — реестр был бы второй копией факта
- добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение
  только на чтение, экранированные имена и названные границы носителя
- синк документации: ADR о выводе новизны из журнала, две записи в журнал
  дефектов, два правила промоутом в конвенции, терминал оператора назван
  адресатом недоверенного входа
This commit is contained in:
av
2026-08-04 13:39:48 +03:00
parent 2130763d3c
commit bd5d17b079
27 changed files with 3125 additions and 9 deletions
+119 -4
View File
@@ -92,6 +92,17 @@ type Stats struct {
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int
// UncoveredNew — имена непокрытых секций, которых не было ни в одной
// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
// имени: поток дописывает метрики на телефоне молча, и момент появления
// секции наблюдать больше нечем.
UncoveredNew []string
// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
// один раз, промолчавшее событие не восстанавливается ничем.
UncoveredSeenUnknown bool
// UncoveredSeenError — почему не состоялась.
UncoveredSeenError error
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
// CategoricalUnknown — сколько из них словарь не знает;
@@ -158,6 +169,16 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
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 {
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
@@ -166,12 +187,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
// записать этот ноль значило бы объявить доставку проверенной.
s.fail(ctx, deliveryID, err, residueOf(parsed))
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
@@ -196,6 +220,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
s.fail(ctx, deliveryID, err, parseResidue{
uncovered: parsed.Uncovered,
skipped: skippedEntities(parsed),
// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
// как ветвь отказа разбора, — значит и терять событие ей нельзя:
// следующая доставка сочтёт имя виденным, а отказ слияния бывает
// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
novelty: novelty,
})
return stats, err
}
@@ -283,6 +312,18 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped,
// Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень
// поднимается отдельной ветвью ниже. Наблюдаемый признак события это
// он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой
// доставки, которая её принесла, и по нему первую встречу не отличить.
"uncovered_new", st.UncoveredNew,
"uncovered_seen_unknown", st.UncoveredSeenUnknown,
// Причина несостоявшейся сверки — рядом с признаком. Занятость базы
// проходит сама, испорченная колонка не проходит никогда и поднимает
// признак на каждой доставке; по одному булеву это неразличимо.
// Значений точек в ошибке нет: до текста доезжает только имя секции, и
// оно обрезано.
"uncovered_seen_error", st.UncoveredSeenError,
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
@@ -318,6 +359,15 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
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:
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
// за отказ объединять поля: событие обязано быть видно, потому что на
@@ -446,10 +496,61 @@ type parseResidue struct {
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
// ретеншен принимает необратимое решение об удалении тела.
skipped *int64
// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
// не пятым параметром `fail`: параметры путают местами, а поле называет
// себя само.
novelty sectionNovelty
}
func residueOf(parsed hae.Result) parseResidue {
return parseResidue{uncovered: parsed.Uncovered}
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` разбор пропустил.
@@ -469,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
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):
@@ -481,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
// одного класса ошибки давали бы постоянный ERROR-шум.
level = slog.LevelWarn
}
s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID)
s.log.Log(ctx, level, "delivery fold failed", attrs...)
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
// строка здесь оборвала бы цепочку наследования, то есть изменила бы