store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+27
View File
@@ -247,6 +247,17 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"buckets", st.Buckets,
"unchanged", st.Unchanged,
"overwrites", st.Overwrites,
// Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым
// тай-брейком это правило полноты, работающее штатно (на живом корпусе
// 981 координата из 80 129 спорных), и эскалация обесценила бы уровень.
// Наблюдаемым событие делает то, что оно посчитано отдельно от
// перезаписей и печатается прогоном пересборки.
"points_held", st.PointsHeld,
// Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже:
// на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет,
// а событие означает потерю содержания — обратимую пересборкой, пока
// жив архив.
"points_erased", st.PointsErased,
"incomparable", st.Incomparable,
"units_conflicts", st.UnitsConflicts,
"sealed_hits", st.SealedHits,
@@ -285,6 +296,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
}
if len(st.PointsHeldAt) > 0 {
attrs = append(attrs, "points_held_at", formatCollisions(st.PointsHeldAt))
}
if len(st.PointsErasedAt) > 0 {
attrs = append(attrs, "points_erased_at", formatCollisions(st.PointsErasedAt))
}
if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
}
@@ -338,6 +355,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
case st.PointsErased > 0:
// НИЖЕ несравнимости, хотя событие тяжелее: на живом корпусе эти два
// множества совпадают (2 координаты и там, и там), и несравнимость
// сообщает больше — она называет ещё и то, что объединять поля было бы
// что. Ветвь эта говорит про случай, которого несравнимость не
// покрывает: сохранённая была надмножеством по именам, но значения
// общих ключей разошлись, разряд полноты погас, и содержание унесла
// пришедшая. Счётчики обеих ветвей идут атрибутами всегда, так что
// порядок ветвей ничего не прячет.
s.log.WarnContext(ctx, "delivery folded, arriving point dropped stored field", attrs...)
case st.Overwrites > 0:
// Единственное наблюдение, по которому проверяется правило слияния.
// В INFO оно тонуло: поток идёт раз в пять минут.
+43
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
@@ -514,3 +515,45 @@ func TestFoldОтказРазбораНеПодделываетСчётчикП
t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities)
}
}
// Удержание точки правилом полноты доезжает до итога свёртки и до лога:
// счётчик отдельный от перезаписей, координаты — без значений.
//
// Тела строятся из РЕАЛЬНОГО пакета: у второй доставки из каждой точки убран
// `qty`. Так получается ровно тот вход, на котором пришедшая точка теряет
// содержание сохранённой, — единственный случай, когда под новым тай-брейком
// она вообще проигрывает.
func TestFoldУдержаниеТочкиСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
rich := fixture(t, "minute.json")
poor := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+\s*,\s*`).ReplaceAll(rich, nil)
if len(poor) == len(rich) {
t.Fatal("фикстура не содержит qty — обеднить тело нечем")
}
deliver(t, arch, st, "d1", "Minutes", "auto-1", rich)
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
deliver(t, arch, st, "d2", "Minutes", "auto-1", poor)
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.PointsHeld == 0 {
t.Fatalf("удержаний 0: обеднённая точка вытеснила измерение, %+v", stats.MergeStats)
}
if len(stats.PointsHeldAt) == 0 {
t.Error("координат удержания нет: счётчик без адреса неразбираем")
}
if stats.PointsHeld > stats.Overwrites {
t.Errorf("удержаний %d при %d перезаписях: удержание — частный случай столкновения",
stats.PointsHeld, stats.Overwrites)
}
}
+78
View File
@@ -389,3 +389,81 @@ func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *t
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
}
}
// Единственное направление, в котором новое правило теряет содержание, не
// молчит: сохранённая была надмножеством по именам, но значения общих ключей
// разошлись — разряд полноты погас, и содержание унесла пришедшая.
//
// От несравнимости этот случай отличается тем, что объединять поля тут было бы
// нечего: у пришедшей своего ключа нет, она просто беднее.
func TestFoldПотеряСодержанияДаётWarn(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
`]}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1,"mealTime":"До еды"`)))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("первая свёртка: %v", err)
}
buf.Reset()
// Беднее по именам И с другим значением общего ключа: Relate объявит пару
// равнополной, и защитить `mealTime` будет некому.
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":6.2`)))
stats, err := f.Fold(ctx, "d2")
if err != nil {
t.Fatalf("вторая свёртка: %v", err)
}
if stats.PointsErased != 1 {
t.Fatalf("потерь содержания %d, ожидалась 1: тест проверяет не то", stats.PointsErased)
}
if stats.Incomparable != 0 {
t.Fatalf("несравнимых %d, ожидалось 0: случай перепутан с несравнимостью", stats.Incomparable)
}
var rec map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
}
if rec["level"] != "WARN" {
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
}
if rec["msg"] != "delivery folded, arriving point dropped stored field" {
t.Errorf("сообщение %v не называет событие", rec["msg"])
}
if rec["points_erased"] != float64(1) {
t.Errorf("счётчик потерь %v, ожидался 1", rec["points_erased"])
}
at, _ := rec["points_erased_at"].(string)
if !strings.Contains(at, "blood_glucose/minute@") {
t.Errorf("координаты объекта в записи не те: %q", at)
}
// Ни значения точки, ни потерянной строки: `mealTime` — контекст измерения,
// то есть данные о здоровье. Проверяется по разобранной записи, а не по
// сырому буферу.
delete(rec, "time")
for k, v := range rec {
if s, ok := v.(string); ok && strings.Contains(s, "До еды") {
t.Errorf("в поле %q оказалось значение точки: %q", k, s)
}
}
}