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