store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
@@ -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 оно тонуло: поток идёт раз в пять минут.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user