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
+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)
}
}
}