Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
+95 -15
View File
@@ -19,18 +19,67 @@ const maxEntityID = 128
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
const rfc3339Layout = time.RFC3339
// softString — строка заголовка, которая переживает значение не того типа.
//
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
//
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
// encoding/json при несовпадении типа «skips that field and completes the
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
// ключей на проводе, а он у HAE нестабилен.
//
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
// не в «нет id».
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
// существенно: см. фолбэк `start → date` ниже.
//
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
// тренировку на момент времени из другого поля — молча и без счётчика.
type softString struct {
value string
// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
// присутствующий ключ, поэтому признак взводится безусловно.
present bool
}
func (s *softString) UnmarshalJSON(raw []byte) error {
// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
// семантикой «побеждает последнее» — так работает соседний Duration и весь
// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
// тренировку с маршрутом при валидной последней метке.
*s = softString{present: true}
var v string
if err := json.Unmarshal(raw, &v); err != nil {
// Значение не строка — поле считается непрочитанным. Ошибку глушим
// сознательно: это и есть мягкость, ради которой тип заведён.
return nil
}
s.value = v
return nil
}
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно.
//
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
// уводило бы всю тренировку в счётчик «не разобралась как объект».
type entityHead struct {
ID string `json:"id"`
Name string `json:"name"`
Date string `json:"date"`
Start string `json:"start"`
End string `json:"end"`
ID softString `json:"id"`
Name softString `json:"name"`
Date softString `json:"date"`
Start softString `json:"start"`
End softString `json:"end"`
Duration json.RawMessage `json:"duration"`
}
@@ -47,17 +96,41 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
out := make([]Entity, 0, len(raws))
for _, raw := range raws {
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
if !isJSONObject(raw) {
res.SkippedEntityMalformed++
continue
}
var head entityHead
if err := json.Unmarshal(raw, &head); err != nil {
res.SkippedEntityMalformed++
continue
}
if head.ID == "" || len(head.ID) > maxEntityID {
// Идентификатор исключение из мягкости: без строкового `id` сущность не
// адресуема, а приведение чужого нестрокового значения к строке было бы
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
id := head.ID.value
if id == "" || len(id) > maxEntityID {
res.SkippedNoID++
continue
}
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
// Фолбэк `start → date` существует для сущностей, у которых ключа
// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
// (число, объект, `null`), фолбэк не срабатывает: композиция двух
// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
// меткой.
if head.Start.present && head.Start.value == "" {
res.SkippedEntityNoTime++
continue
}
start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
if !ok {
res.SkippedEntityNoTime++
continue
@@ -68,17 +141,17 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
// Истина при этом остаётся в Raw дословно.
end := start
if head.End != "" {
if e, ok := parseEntityTime(head.End); ok {
if head.End.value != "" {
if e, ok := parseEntityTime(head.End.value); ok {
end = e
}
}
_, offset := start.Zone()
e := Entity{
ID: head.ID,
ID: id,
Kind: kind,
Name: head.Name,
Name: head.Name.value,
Start: start.UTC(),
End: end.UTC(),
OffsetSeconds: offset,
@@ -136,6 +209,13 @@ func parseDuration(raw json.RawMessage) *float64 {
return &v
}
// isJSONObject говорит, является ли значение объектом JSON, по первому байту
// литерала — так же, как это делает сканер encoding/json.
func isJSONObject(raw json.RawMessage) bool {
lit := bytes.TrimSpace(raw)
return len(lit) > 0 && lit[0] == '{'
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a