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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+109 -7
View File
@@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
byID[w.ID] = w
}
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
// случая: исход у них общий.
if res.SkippedNoID != 3 {
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
// счётчик на четыре случая: исход у них общий, сущность не адресуема.
if res.SkippedNoID != 4 {
t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
}
// Метка не разбирается и метки нет вовсе.
if res.SkippedEntityNoTime != 2 {
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
if res.SkippedEntityNoTime != 3 {
t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
}
// Элемент, не являющийся объектом.
if res.SkippedEntityMalformed != 1 {
@@ -176,6 +176,50 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
}
})
// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
// этом числится разобранной.
t.Run("имя числом не уносит тренировку", func(t *testing.T) {
w, ok := byID["00000000-0000-4000-8000-00000000000c"]
if !ok {
t.Fatal("тренировка с именем-числом потерялась целиком")
}
if w.Name != "" {
t.Errorf("имя %q, ожидалось пустое", w.Name)
}
if !strings.Contains(string(w.Raw), `"lat"`) {
t.Error("маршрут не сохранился дословно")
}
})
t.Run("конец числом не уносит тренировку", func(t *testing.T) {
w, ok := byID["00000000-0000-4000-8000-00000000000d"]
if !ok {
t.Fatal("тренировка с концом-числом потерялась целиком")
}
if !w.End.Equal(w.Start) {
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
}
})
// Фолбэк `start → date` существует для сущностей, у которых `start` не
// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
// другого поля дала бы метку другого момента времени, неотличимую от
// настоящей и ничем не считаемую.
t.Run("начало числом не подменяется полем date", func(t *testing.T) {
if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
t.Error("нестроковое начало молча заменено меткой из date")
}
})
t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
for id := range byID {
if id == "42" {
t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
}
}
})
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
w := byID["00000000-0000-4000-8000-000000000004"]
if w.Duration != nil {
@@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te
t.Errorf("непокрытые %v", res.Uncovered)
}
}
// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.SkippedEntityMalformed != 4 {
t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
}
if res.SkippedNoID != 0 {
t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
res.SkippedNoID)
}
}
// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
t.Parallel()
body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Workouts) != 1 {
t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
}
if res.Workouts[0].Start.IsZero() {
t.Error("метка не разобралась")
}
}
// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
// от настоящей и ничем не считаемую.
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
t.Parallel()
body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Workouts) != 0 {
t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
}
if res.SkippedEntityNoTime != 1 {
t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
}
}
+77 -6
View File
@@ -220,7 +220,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
defer func() {
if r := recover(); r != nil {
res = Result{}
err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r)
err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
}
}()
@@ -422,7 +422,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
var env envelope
fail := func(e error) (envelope, error) {
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
}
dec := json.NewDecoder(bytes.NewReader(body))
@@ -430,6 +430,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
// список не идут — иначе в одном списке смешались бы имена секций и мусор
// конверта, а форму `{"data": …}` проверяет приём.
at := dec.InputOffset()
tok, err := dec.Token()
if err != nil {
return fail(err)
@@ -441,7 +442,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
return envelope{}, nil
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
}
seen := make(map[string]struct{})
for dec.More() {
@@ -486,13 +487,14 @@ type envelope struct {
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
// имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
at := dec.InputOffset()
tok, err := dec.Token()
if err != nil {
return err
}
// data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
}
for dec.More() {
@@ -544,17 +546,85 @@ func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) {
at := dec.InputOffset()
tok, err := dec.Token()
if err != nil {
return "", err
}
name, ok := tok.(string)
if !ok {
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
}
return name, nil
}
// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
//
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
const maxCauseLen = 200
// ClipCause переводит чужую ошибку в ограниченную по длине строку.
//
// Экспортировано ради приёма: он проверяет форму конверта тем же
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
// через соседний пакет.
func ClipCause(err error) string {
if err == nil {
return ""
}
return clip(err.Error())
}
func clip(s string) string {
if len(s) <= maxCauseLen {
return s
}
// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
cut := maxCauseLen
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}
// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
// во входе.
//
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
// живёт в другом месте и о новой ошибке разбора не узнает.
//
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
// он из фиксированного набора и содержимого не раскрывает.
//
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
// последнего возвращённого токена, и снятое после оно указывало бы на конец
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
func tokenDesc(tok json.Token, at int64) string {
kind := "?"
switch v := tok.(type) {
case json.Delim:
kind = fmt.Sprintf("%q", string(v))
case string:
kind = "string"
case json.Number, float64:
kind = "number"
case bool:
kind = "bool"
case nil:
kind = "null"
}
return fmt.Sprintf("%s на смещении %d", kind, at)
}
// swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error {
var skip json.RawMessage
@@ -562,12 +632,13 @@ func swallow(dec *json.Decoder) error {
}
func expectDelim(dec *json.Decoder, want json.Delim) error {
at := dec.InputOffset()
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); !ok || d != want {
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
}
return nil
}
+84
View File
@@ -702,3 +702,87 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
}
})
}
// Инвариант «тела запросов только на DEBUG и с обрезкой» обходился одним `%v`:
// строка в 8 МиБ на месте ожидаемого объекта давала текст ошибки в 8 МиБ, и он
// уезжал атрибутом `error` на уровень WARN — то есть содержимое доставки
// оказывалось в логе целиком и без обрезки.
//
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
// живёт в другом месте и о новой ошибке разбора не узнает.
func TestParseОшибкаНеНесётЗначенийИзТела(t *testing.T) {
t.Parallel()
const secret = "СЕКРЕТНОЕ-ЗНАЧЕНИЕ-ИЗ-ТЕЛА"
cases := map[string]string{
"строка вместо data": `{"data":"` + secret + strings.Repeat("A", 1<<20) + `"}`,
"строка вместо тела": `"` + secret + strings.Repeat("A", 1<<20) + `"`,
"число вместо имени секции": `{"data":{"metrics":[]},"` + secret + `":1}`,
"число вместо содержимого": `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Skip("вход разобрался — проверять нечего")
}
msg := err.Error()
if len(msg) > 512 {
t.Errorf("текст ошибки %d Б: длина зависит от длины значения во входе", len(msg))
}
if strings.Contains(msg, secret) {
t.Errorf("значение из тела доехало до сообщения: %s", msg)
}
})
}
}
// Смещение обязано указывать на место ПЕРЕД виновным токеном, а не за ним.
// Декодер сообщает позицию как конец последнего возвращённого токена, поэтому
// снятая ПОСЛЕ чтения она отличалась бы от начала проблемы ровно на длину
// значения — на восемь мегабайт в том самом случае, ради которого требование и
// написано. Проверяется само свойство: смещение не зависит от длины значения.
func TestParseОшибкаНазываетТипТокенаИНачало(t *testing.T) {
t.Parallel()
short := `{"data":"` + strings.Repeat("A", 16) + `"}`
long := `{"data":"` + strings.Repeat("A", 1<<20) + `"}`
msgs := make([]string, 0, 2)
for _, body := range []string{short, long} {
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Fatal("ожидалась ошибка")
}
msgs = append(msgs, err.Error())
}
if !strings.Contains(msgs[0], "string") {
t.Errorf("тип токена не назван словарём JSON: %s", msgs[0])
}
// `{"data"` — семь байт: разбор виновного значения начинается здесь.
if !strings.Contains(msgs[0], "смещении 7") {
t.Errorf("смещение не указывает на место перед токеном: %s", msgs[0])
}
if msgs[0] != msgs[1] {
t.Errorf("смещение поехало вместе с длиной значения:\n %s\n %s", msgs[0], msgs[1])
}
}
// Ошибка может прийти не только от нашего разбора, но и из encoding/json, а его
// UnmarshalTypeError кладёт в текст ЛИТЕРАЛ значения: тело из миллиона цифр
// давало текст ошибки в мегабайт. Предел держится на границе пакета.
func TestParseЧужаяПричинаОбрезается(t *testing.T) {
t.Parallel()
body := `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Fatal("ожидалась ошибка")
}
if len(err.Error()) > 512 {
t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
}
}
+24
View File
@@ -74,6 +74,30 @@
"end": "2025-06-05 16:01:00 +0300"
},
"не объект вовсе",
{
"id": "00000000-0000-4000-8000-00000000000c",
"name": 5,
"start": "2025-06-05 18:00:00 +0300",
"end": "2025-06-05 18:10:00 +0300",
"route": [{"lat": 1}]
},
{
"id": "00000000-0000-4000-8000-00000000000d",
"name": "Конец числом",
"start": "2025-06-05 19:00:00 +0300",
"end": 0
},
{
"id": 42,
"name": "Идентификатор числом",
"start": "2025-06-05 20:00:00 +0300"
},
{
"id": "00000000-0000-4000-8000-00000000000e",
"name": "Начало числом при живом date",
"start": 1749100000,
"date": "2025-06-05 22:00:00 +0300"
},
{
"id": "00000000-0000-4000-8000-000000000009",
"name": "Незнакомое поле и дословные литералы",