Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+95
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user