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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+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("литерал из тела виден в логе")
}
}