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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+17 -2
View File
@@ -14,6 +14,7 @@ import (
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -158,7 +159,17 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
if err != nil {
// Тело уже на диске — данные не потеряны, но учёта нет. Такое тело
// подберёт пересборка (`healthlog reindex`), заведя запись заново.
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath)
//
// Занятость базы называется отдельно. Уровень от этого не меняется:
// тело осиротело в любом случае, и вернуть его в журнал может только
// пересборка. Но лечится занятость не тем, чем сбой диска или испорченная
// база, — это конкуренция за запись, и она будет повторяться. Признак
// снимается с доменной ошибки, а не с предиката «обстоятельства вообще»:
// тот включает ещё и отмену снаружи, а здесь она невозможна по
// построению — учёт ведётся на контексте, переживающем обрыв соединения.
s.log.ErrorContext(ctx, "delivery failed", "error", err,
"delivery_id", res.DeliveryID, "raw_path", rawPath,
"db_busy", errors.Is(err, store.ErrBusy))
return Result{}, fmt.Errorf("record delivery: %w", err)
}
@@ -227,7 +238,11 @@ func checkEnvelope(body []byte) error {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причину наружу не раскрываем, она уходит в лог
// Причина обрезается тем же пределом, что и в разборе: UnmarshalTypeError
// кладёт в текст ЛИТЕРАЛ значения, и тело из миллиона цифр давало
// мегабайт содержимого доставки в логе. Инвариант «тела только на DEBUG
// и с обрезкой» относится и к DEBUG.
return fmt.Errorf("%w: %s", ErrMalformed, hae.ClipCause(err))
}
if len(env.Data) == 0 {
return fmt.Errorf("%w: нет объекта data", ErrMalformed)
+70
View File
@@ -1,14 +1,17 @@
package ingest_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/archive"
@@ -261,3 +264,70 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store)
st, arch := newDeps(t, t.TempDir())
return ingest.New(arch, st, nil, slog.New(slog.DiscardHandler)), arch, st
}
// Отказ учёта после того, как тело легло в архив, обязан называть класс
// причины: занятость базы — конкуренция за запись, которая будет повторяться, и
// лечится она не тем же, чем сбой диска. Уровень при этом остаётся ERROR: тело
// осиротело в любом случае, и вернуть его в журнал может только пересборка.
func TestAcceptОтказУчётаНазываетКлассПричины(t *testing.T) {
dir := t.TempDir()
st, arch := newDeps(t, dir)
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
svc := ingest.New(arch, st, nil, log)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err == nil {
t.Fatal("приём не заметил, что доставка не учтена")
}
var rec map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
var v map[string]any
if err := json.Unmarshal([]byte(line), &v); err != nil {
t.Fatalf("строка лога не JSON: %v", err)
}
if v["msg"] == "delivery failed" {
rec = v
}
}
if rec == nil {
t.Fatal("отказ учёта не залогирован")
}
if rec["level"] != "ERROR" {
t.Errorf("уровень %v, ожидался ERROR: тело осиротело", rec["level"])
}
busy, ok := rec["db_busy"].(bool)
if !ok {
t.Fatalf("класс причины не назван: %v", rec)
}
// Закрытая база — не занятость: признак обязан различать, а не стоять всегда.
if busy {
t.Error("закрытая база названа занятой — признак не различает причины")
}
}
// Инвариант «тела запросов только на DEBUG и с обрезкой» относится и к DEBUG:
// проверка формы конверта идёт через encoding/json, чей UnmarshalTypeError
// кладёт в текст литерал значения.
func TestAcceptОтказФормыНеНесётТелаВЛог(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
// Ни архив, ни база не нужны: тело неверной формы отвергается проверкой
// конверта до всякой записи.
svc := ingest.New(nil, nil, nil, log)
body := []byte(`{"data":` + strings.Repeat("9", 1<<20) + `}`)
if _, err := svc.Accept(context.Background(), body, ingest.Meta{}); err == nil {
t.Fatal("тело неверной формы принято")
}
if buf.Len() > 4096 {
t.Errorf("строка лога %d Б: содержимое тела уехало в лог", buf.Len())
}
if strings.Contains(buf.String(), strings.Repeat("9", 256)) {
t.Error("литерал из тела виден в логе")
}
}