store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
@@ -474,6 +474,24 @@ func keysOf(m map[string]json.RawMessage) map[string]struct{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// CarriesKeyAbsentIn отвечает, есть ли у f ключ с НЕПУСТЫМ значением, которого
|
||||
// нет у g. Значения при этом не сравниваются вовсе.
|
||||
//
|
||||
// Заведено ради наблюдения, которого у правила слияния не было: разряд полноты
|
||||
// гаснет, когда значения общих содержательных ключей разошлись (см. Relate), и
|
||||
// тогда исход решает тай-брейк — а он может отдать победу точке, у которой
|
||||
// содержательного ключа нет. Событие редкое (на живом корпусе 2 координаты из
|
||||
// 80 129 спорных, обе несравнимые), но это единственное направление, в котором
|
||||
// новое правило способно потерять содержание, и молчать о нём нельзя.
|
||||
//
|
||||
// Отдельным методом, а не через Relate: Relate отвечает про СОДЕРЖАНИЕ (с
|
||||
// условием совпадения значений), здесь же нужен вопрос про имена, и смешение
|
||||
// этих двух вопросов однажды уже дало правило, которое считало пустое поле
|
||||
// содержанием.
|
||||
func (f Fields) CarriesKeyAbsentIn(g Fields) bool {
|
||||
return hasExtra(keysOf(f.full), keysOf(g.full))
|
||||
}
|
||||
|
||||
// relateKeys сравнивает два множества ключей по включению.
|
||||
func relateKeys(a, b map[string]struct{}) Fullness {
|
||||
aExtra := hasExtra(a, b)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,18 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
|
||||
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
|
||||
first.FailedOther, first.Incomparable)
|
||||
// Удержания печатаются, а не утверждаются: число производно от размера
|
||||
// корпуса, а прогон живого архива в гейт не входит — константа покраснела бы
|
||||
// молча (docs/review.md, 2026-08-02). Утверждается инвариант: удержание
|
||||
// возможно только там, где сохранённая точка строго полнее, то есть их не
|
||||
// может быть больше, чем столкновений вообще.
|
||||
t.Logf("удержано точек правилом полноты %d, содержание унесено пришедшей %d",
|
||||
first.PointsHeld, first.PointsErased)
|
||||
// Посылка равенства «пересборка = приём» называется рядом с ним: отказавшие
|
||||
// доставки живой путь в витрину не записал, а пересборка запишет, и
|
||||
// расхождение отпечатков было бы отказом не правила, а посылки.
|
||||
t.Logf("посылка сходимости: отказавших доставок %d",
|
||||
first.FailedLayer+first.FailedMalformed+first.FailedOther)
|
||||
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
|
||||
first.Partial, first.Adopted, first.Orphans)
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package replay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite" // тот же чистый Go-драйвер, что и у хранилища
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/replay"
|
||||
)
|
||||
|
||||
// Прогон под удерживаемой блокировкой намеренно не входит в `task test` и
|
||||
// `task gate`: `busy_timeout` — пять секунд, повторов транзакции пять, то есть
|
||||
// один этот тест стоит около двадцати пяти секунд, а гейт гоняет тесты трижды.
|
||||
// Запускается командой `task verify:busy`.
|
||||
var runBusy = flag.Bool("healthlog.busy", false,
|
||||
"прогнать свёртку под удерживаемой блокировкой базы (около 25 секунд)")
|
||||
|
||||
// Композиция, ради которой заведён барьер: занятость базы откладывает доставку,
|
||||
// проход прекращается на ней, и живая витрина всё равно совпадает с пересборкой.
|
||||
//
|
||||
// Порознь это уже проверено — барьер в `worker_internal_test.go` на подменённой
|
||||
// свёртке, сходимость в `order_test.go` на журнале без отложенной доставки. Но
|
||||
// свойство, ради которого изменение существует, — именно композиция: тай-брейк
|
||||
// «побеждает пришедшая» сделал содержимое витрины функцией порядка свёртки, и
|
||||
// занятость базы это ровно тот случай, который порядок разводит. Здесь она
|
||||
// НАСТОЯЩАЯ, а не подменённая: второе соединение держит запись.
|
||||
func TestBusyОтложеннаяДоставкаНеРазводитПриёмИПересборку(t *testing.T) {
|
||||
if !*runBusy {
|
||||
t.Skip("прогон под блокировкой выключен: задайте -healthlog.busy")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "live.db")
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
src := openStore(t, dbPath)
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
items := collidingJournal(t, 3)
|
||||
for i := range items {
|
||||
writeBody(t, arch, src, items[i], bumped(t, items[i].fixture, i))
|
||||
}
|
||||
|
||||
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
|
||||
|
||||
// Второе соединение держит запись, как её держит свёртка широкой доставки:
|
||||
// измерено 11 секунд на 16 тысячах объектов, то есть окно реальное.
|
||||
holder, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(100)&_txlock=immediate")
|
||||
if err != nil {
|
||||
t.Fatalf("второе соединение: %v", err)
|
||||
}
|
||||
defer func() { _ = holder.Close() }()
|
||||
|
||||
tx, err := holder.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("удержание записи: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE delivery SET points = points`); err != nil {
|
||||
t.Fatalf("удержание записи: %v", err)
|
||||
}
|
||||
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход под блокировкой: %v", err)
|
||||
}
|
||||
if out.Deferred != 1 {
|
||||
t.Fatalf("отложенных %d, ожидалась 1: занятость не дошла до классификации, %+v", out.Deferred, out)
|
||||
}
|
||||
if out.Folded != 0 {
|
||||
t.Fatalf("свёрнуто %d при отложенной ГОЛОВЕ очереди: барьер не сработал, %+v", out.Folded, out)
|
||||
}
|
||||
|
||||
_ = tx.Rollback()
|
||||
|
||||
// Очередь пошла: те же доставки, тот же порядок журнала.
|
||||
out, err = w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход после разблокировки: %v", err)
|
||||
}
|
||||
if out.Folded != len(items) {
|
||||
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
|
||||
}
|
||||
|
||||
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
|
||||
if got, want := rep.Fingerprint, fingerprint(t, src); got != want {
|
||||
t.Errorf("отложенная занятостью доставка развела приём и пересборку:\n приём %s\n пересборка %s",
|
||||
want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package replay_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
"git.vakhrushev.me/av/healthlog/internal/replay"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// qtyPattern находит числовые значения точек в теле HAE.
|
||||
var qtyPattern = regexp.MustCompile(`("(?:qty|Avg|Min|Max)"\s*:\s*)(\d+(?:\.\d+)?)`)
|
||||
|
||||
// bumped берёт РЕАЛЬНЫЙ пакет HAE и сдвигает в нём числовые значения, оставляя
|
||||
// координаты нетронутыми. Получается доставка, сталкивающаяся с исходной по
|
||||
// всем координатам и отличающаяся от неё только значениями — то есть ровно та
|
||||
// форма столкновения, ради которой изменение и делается (равные наборы полей,
|
||||
// разные значения; на живом корпусе это 98,8% спорных координат).
|
||||
//
|
||||
// Синтетическое тело писать нельзя: разбор формата HAE проверяется на реальных
|
||||
// пакетах — документация формата тонка и местами расходится с потоком. Здесь
|
||||
// сохраняется и то и другое: пакет настоящий, меняются только числа.
|
||||
func bumped(t *testing.T, name string, delta int) []byte {
|
||||
t.Helper()
|
||||
|
||||
body := fixture(t, name)
|
||||
out := qtyPattern.ReplaceAllFunc(body, func(m []byte) []byte {
|
||||
g := qtyPattern.FindSubmatch(m)
|
||||
v, err := strconv.ParseFloat(string(g[2]), 64)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
return append(append([]byte{}, g[1]...), strconv.FormatFloat(v+float64(delta), 'f', -1, 64)...)
|
||||
})
|
||||
if string(out) == string(body) {
|
||||
t.Fatalf("фикстура %s не содержит числовых значений — столкновение не построить", name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collidingJournal собирает журнал, где каждая следующая доставка приносит те
|
||||
// же координаты с другими значениями.
|
||||
func collidingJournal(t *testing.T, n int) []item {
|
||||
t.Helper()
|
||||
|
||||
ids := make([]string, 0, n)
|
||||
for range n {
|
||||
ids = append(ids, ident.NewID())
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
out := make([]item, 0, n)
|
||||
for i, id := range ids {
|
||||
out = append(out, item{
|
||||
id: id,
|
||||
at: base.Add(time.Duration(i+1) * time.Second),
|
||||
automationID: "auto-1",
|
||||
aggregation: "Minutes",
|
||||
fixture: "minute.json",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Главный оракул задачи: тай-брейк «побеждает пришедшая» сделал содержимое
|
||||
// витрины функцией ПОРЯДКА свёртки, и витрина остаётся свёрткой журнала только
|
||||
// потому, что порядок свёртки приведён к порядку журнала.
|
||||
//
|
||||
// Проверяется на входе, воспроизводящем настоящую гонку приёма: строки учёта
|
||||
// вставляются в ОБРАТНОМ хронологии порядке — так выглядит конкурентный приём,
|
||||
// где поздняя доставка закоммитила строку первой. Воркер обязан всё равно
|
||||
// свернуть их в порядке `(received_at, id)`, и отпечаток обязан совпасть с
|
||||
// пересборкой.
|
||||
//
|
||||
// Проверка снабжена отрицательным контролем: те же тела, свёрнутые в обратном
|
||||
// порядке, обязаны дать ДРУГОЙ отпечаток. Без него тест зеленел бы и на
|
||||
// правиле, которое к порядку безразлично, — то есть не проверял бы ничего.
|
||||
func TestЖиваяСвёрткаРавнаПересборкеНаСтолкновениях(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
src := openStore(t, filepath.Join(dir, "live.db"))
|
||||
sink := newLogSink()
|
||||
log := slog.New(slog.NewJSONHandler(sink, nil))
|
||||
|
||||
items := collidingJournal(t, 4)
|
||||
bodies := make([][]byte, len(items))
|
||||
for i := range items {
|
||||
bodies[i] = bumped(t, items[i].fixture, i)
|
||||
}
|
||||
|
||||
// Учёт заполняется от последней доставки к первой.
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
writeBody(t, arch, src, items[i], bodies[i])
|
||||
}
|
||||
|
||||
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход воркера: %v", err)
|
||||
}
|
||||
if out.Folded != len(items) {
|
||||
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
|
||||
}
|
||||
// Порядок журнала соблюдён, значит жаловаться не на что.
|
||||
if n := sink.count("delivery folded out of journal order"); n != 0 {
|
||||
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
|
||||
}
|
||||
|
||||
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
|
||||
if got, want := rep.Fingerprint, fingerprint(t, src); got != want {
|
||||
t.Errorf("живая свёртка и пересборка разошлись:\n приём %s\n пересборка %s", want, got)
|
||||
}
|
||||
|
||||
// Отрицательный контроль: тот же журнал, свёрнутый задом наперёд.
|
||||
rev := openArchive(t, filepath.Join(dir, "rev-raw"))
|
||||
revStore := openStore(t, filepath.Join(dir, "rev.db"))
|
||||
f := fold.New(rev, revStore, 0, slog.New(slog.DiscardHandler))
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
writeBody(t, rev, revStore, items[i], bodies[i])
|
||||
if _, err := f.Fold(ctx, items[i].id); err != nil {
|
||||
t.Fatalf("свёртка %s: %v", items[i].id, err)
|
||||
}
|
||||
}
|
||||
if fingerprint(t, revStore) == rep.Fingerprint {
|
||||
t.Error("обратный порядок свёртки дал тот же отпечаток: тай-брейк не зависит от порядка, то есть правило не сработало")
|
||||
}
|
||||
}
|
||||
|
||||
// Остаточное окно не молчит: доставка, ставшая видимой после того, как её
|
||||
// преемница по журналу уже свёрнута, обязана оставить `WARN`.
|
||||
//
|
||||
// Закрыть окно здесь нечем — метка `received_at` фиксируется при выпуске ULID,
|
||||
// а строка учёта появляется только после записи тела. Наблюдение и есть всё,
|
||||
// что можно сделать без изменения приёма; лечится расхождение пересборкой.
|
||||
func TestСвёрткаВнеПорядкаЖурналаНеМолчит(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
st := openStore(t, filepath.Join(dir, "live.db"))
|
||||
sink := newLogSink()
|
||||
log := slog.New(slog.NewJSONHandler(sink, nil))
|
||||
|
||||
items := collidingJournal(t, 2)
|
||||
|
||||
// Поздняя доставка доехала и свернулась первой — её строка учёта успела
|
||||
// закоммититься, пока ранняя писала тело.
|
||||
writeBody(t, arch, st, items[1], bumped(t, items[1].fixture, 1))
|
||||
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
|
||||
if _, err := f.Fold(ctx, items[1].id); err != nil {
|
||||
t.Fatalf("свёртка поздней доставки: %v", err)
|
||||
}
|
||||
|
||||
// Теперь появляется ранняя.
|
||||
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
|
||||
|
||||
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("проход воркера: %v", err)
|
||||
}
|
||||
|
||||
if n := sink.count("delivery folded out of journal order"); n != 1 {
|
||||
t.Fatalf("записей о свёртке вне порядка журнала %d, ожидалась 1:\n%s", n, sink.dump())
|
||||
}
|
||||
// Значений точек в записи быть не должно — данные о здоровье чувствительнее
|
||||
// токенов. Проверяется по разобранной записи, а не по сырому буферу: метка
|
||||
// времени содержит цифры и совпала бы с любым коротким числом.
|
||||
assertNoPointValues(t, sink, "delivery folded out of journal order")
|
||||
}
|
||||
|
||||
// Доставка в статусе `failed` преемницей для этого наблюдения не считается: она
|
||||
// в витрину ничего не записала, перестановка относительно неё содержимого не
|
||||
// разводит, а исход этот штатный — невыводимый слой. Учитывай его предикат,
|
||||
// `WARN` стал бы шумом, на который перестают смотреть.
|
||||
func TestОтказавшаяПреемницаНеСчитаетсяРасхождением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
st := openStore(t, filepath.Join(dir, "live.db"))
|
||||
sink := newLogSink()
|
||||
log := slog.New(slog.NewJSONHandler(sink, nil))
|
||||
|
||||
items := collidingJournal(t, 2)
|
||||
// Поздняя доставка приезжает без плотных метрик и без предшественника —
|
||||
// слой выводить не из чего, исход `failed`.
|
||||
late := items[1]
|
||||
late.aggregation = "Default"
|
||||
late.automationID = "auto-lonely"
|
||||
late.fixture = "sparse_sleep.json"
|
||||
writeBody(t, arch, st, late, fixture(t, late.fixture))
|
||||
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
|
||||
if _, err := f.Fold(ctx, late.id); err == nil {
|
||||
t.Fatal("поздняя доставка свернулась: слой не должен был вывестись")
|
||||
}
|
||||
if status, err := st.DeliveryStatus(ctx, late.id); err != nil || status != store.ParseFailed {
|
||||
t.Fatalf("статус поздней доставки %q (%v), ожидался %q", status, err, store.ParseFailed)
|
||||
}
|
||||
|
||||
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
|
||||
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("проход воркера: %v", err)
|
||||
}
|
||||
if n := sink.count("delivery folded out of journal order"); n != 0 {
|
||||
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoPointValues убеждается, что запись лога не несёт значений точек.
|
||||
//
|
||||
// Разбирает запись и выбрасывает служебные поля, а не ищет подстроку в сыром
|
||||
// буфере: метка времени содержит цифры, и проверка по буферу была бы флаки по
|
||||
// построению (docs/conventions/testing.md, запись 2026-08-02).
|
||||
func assertNoPointValues(t *testing.T, sink *logSink, msg string) {
|
||||
t.Helper()
|
||||
|
||||
for _, line := range sink.linesOf(msg) {
|
||||
for _, k := range []string{"qty", "Avg", "Min", "Max", "value", "points"} {
|
||||
if _, ok := line[k]; ok {
|
||||
t.Errorf("запись %q несёт поле %q: значения точек в лог не попадают", msg, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,19 @@ type Outcome struct {
|
||||
// ретеншену трогать нельзя.
|
||||
Partial int
|
||||
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
|
||||
// их не было ни разу, и на этом стоит отказ от объединения полей.
|
||||
// они единичны, и на этом стоит отказ от объединения полей.
|
||||
Incomparable int
|
||||
// PointsHeld — точек, удержанных правилом полноты против пришедшей
|
||||
// доставки. Здесь по той же причине, что EntitiesHeld: сходимость
|
||||
// отпечатка удержание не проверяет ПО ПОСТРОЕНИЮ — живой приём и пересборка
|
||||
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
|
||||
// точке. То есть слишком строгое правило полноты выглядело бы идеальной
|
||||
// сходимостью.
|
||||
PointsHeld int
|
||||
// PointsErased — точек, чьё содержание унесла пришедшая победительница.
|
||||
// Здесь по той же причине, что PointsHeld, и с большим весом: это
|
||||
// единственное направление, в котором правило слияния теряет содержание.
|
||||
PointsErased int
|
||||
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
|
||||
// содержания». Без него правило слияния сущностей проверить нечем:
|
||||
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
|
||||
@@ -54,6 +65,8 @@ func (o *Outcome) Add(other Outcome) {
|
||||
o.FailedOther += other.FailedOther
|
||||
o.Partial += other.Partial
|
||||
o.Incomparable += other.Incomparable
|
||||
o.PointsHeld += other.PointsHeld
|
||||
o.PointsErased += other.PointsErased
|
||||
o.EntitiesHeld += other.EntitiesHeld
|
||||
o.EntitiesDiverging += other.EntitiesDiverging
|
||||
}
|
||||
@@ -118,6 +131,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
|
||||
out.Partial++
|
||||
}
|
||||
out.Incomparable += st.Incomparable
|
||||
out.PointsHeld += st.PointsHeld
|
||||
out.PointsErased += st.PointsErased
|
||||
out.EntitiesHeld += st.EntitiesHeld
|
||||
out.EntitiesDiverging += st.EntitiesDiverging
|
||||
}
|
||||
|
||||
@@ -211,6 +211,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
||||
"failed_other", rep.FailedOther,
|
||||
"partial", rep.Partial,
|
||||
"incomparable", rep.Incomparable,
|
||||
"points_held", rep.PointsHeld,
|
||||
"points_erased", rep.PointsErased,
|
||||
"entities_held", rep.EntitiesHeld,
|
||||
"entities_diverging", rep.EntitiesDiverging,
|
||||
"buckets", rep.Buckets,
|
||||
|
||||
@@ -170,8 +170,11 @@ func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
|
||||
want, rep.Fingerprint)
|
||||
}
|
||||
|
||||
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
|
||||
// координаты — функция множества кандидатов, а не порядка прихода.
|
||||
// Повторный прогон в ещё одну базу обязан дать то же самое: журнал тот же и
|
||||
// проигрывается в том же порядке, а исход слияния — функция множества
|
||||
// кандидатов вместе с их происхождением, то есть от прогона к прогону не
|
||||
// плавает. От ПОРЯДКА журнала он зависит намеренно; что порядок этот
|
||||
// соблюдён и живым путём, проверяет order_test.go.
|
||||
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
|
||||
if again.Fingerprint != rep.Fingerprint {
|
||||
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
|
||||
|
||||
@@ -60,16 +60,28 @@ type Worker struct {
|
||||
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
|
||||
// запуске обесценила бы уровень.
|
||||
startupDone bool
|
||||
|
||||
// fold — свёртка одной доставки. Полем, а не прямым вызовом, ради ОДНОГО
|
||||
// шва: исход `Deferred` наступает только от занятости базы, а удержать её
|
||||
// по-настоящему стоит двадцать пять секунд (`busy_timeout` 5 с × 5 повторов
|
||||
// транзакции) — столько же, сколько стоит `task verify:busy`, который в
|
||||
// гейт намеренно не входит. Барьер журнального порядка при этом и есть
|
||||
// механизм, на котором держится равенство «пересборка = приём», и оставить
|
||||
// его без дешёвой проверки значило бы проверять его только тем прогоном,
|
||||
// который никто не гоняет по расписанию.
|
||||
fold func(ctx context.Context, deliveryID string) Outcome
|
||||
}
|
||||
|
||||
// NewWorker собирает воркер над рабочей базой.
|
||||
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
|
||||
return &Worker{
|
||||
w := &Worker{
|
||||
store: st,
|
||||
player: Player{Fold: f},
|
||||
log: log.With("capability", "fold-worker"),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
w.fold = w.foldOne
|
||||
return w
|
||||
}
|
||||
|
||||
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
|
||||
@@ -169,7 +181,45 @@ func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
|
||||
return total, nil
|
||||
}
|
||||
lag.add(d)
|
||||
total.Add(w.foldOne(ctx, d.ID))
|
||||
// Порядок спрашивается ДО свёртки, а говорится ПОСЛЕ и только если
|
||||
// свёртка состоялась: отложенная доставка витрину не трогала, и
|
||||
// запись о свёртке вне порядка утверждала бы событие, которого не
|
||||
// было, — да ещё повторялась бы каждым проходом, пока голова очереди
|
||||
// занята. Спросить надо всё же до: после свёртки предикат уже видит
|
||||
// саму эту доставку разобранной.
|
||||
outOfOrder := w.outOfOrder(ctx, d)
|
||||
out := w.fold(ctx, d.ID)
|
||||
total.Add(out)
|
||||
if outOfOrder && out.Deferred == 0 {
|
||||
w.warnOutOfOrder(ctx, d)
|
||||
}
|
||||
if out.Deferred > 0 {
|
||||
// Отложенная доставка ДЕРЖИТ очередь: перешагнув её, проход
|
||||
// свернул бы её преемниц раньше неё, а тай-брейк равной полноты
|
||||
// разрешается в пользу пришедшей доставки — то есть исход
|
||||
// слияния стал бы функцией порядка свёртки, отличного от
|
||||
// порядка журнала, и живая витрина разошлась бы с пересборкой
|
||||
// молча, в значениях точек.
|
||||
//
|
||||
// Очередь от этого не встаёт: отложенным считается ТОЛЬКО
|
||||
// занятость базы и отмена снаружи (store.Transient), а
|
||||
// собственный дедлайн свёртки в него намеренно не входит —
|
||||
// доставка, не уложившаяся в бюджет, получает `failed` и
|
||||
// очередь освобождает. Занятость же блокирует запись всем
|
||||
// одинаково: проход, перешагнувший занятую доставку, упёрся бы
|
||||
// в ту же занятость на следующей.
|
||||
//
|
||||
// Метка отставания пишется ЗДЕСЬ, а не только на пустой
|
||||
// выборке. Иначе барьер отменял бы её ровно в том состоянии,
|
||||
// ради которого она заведена: занятая голова очереди не
|
||||
// пропускает проход до пустой выборки никогда, и «очередь стоит
|
||||
// два часа» стало бы неотличимо от «споткнулась один раз».
|
||||
//
|
||||
// Флаг первого прохода при этом НЕ взводится: проход до конца
|
||||
// очереди не дошёл, и объявлять задолженность разобранной рано.
|
||||
w.warnLag(ctx, lag)
|
||||
return total, nil
|
||||
}
|
||||
cursor = d
|
||||
}
|
||||
}
|
||||
@@ -216,6 +266,38 @@ func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
|
||||
return out
|
||||
}
|
||||
|
||||
// warnOutOfOrder называет свёртку, идущую вне порядка журнала.
|
||||
//
|
||||
// Остаточное окно закрыть здесь нечем: метка `received_at` фиксируется при
|
||||
// выпуске ULID, а строка учёта становится видимой только после записи тела
|
||||
// (измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
|
||||
// ранней меткой появляется после того, как её преемница уже свёрнута. Барьер
|
||||
// выше этого не ловит — отставшей доставки в момент прохода не существует.
|
||||
//
|
||||
// Молчать об этом нельзя: правило слияния точек стало функцией порядка свёртки,
|
||||
// и перестановка оставляет в витрине версию не той доставки, что стоит в
|
||||
// журнале последней. Лечится `healthlog reindex`; без этой строки узнать о
|
||||
// необходимости неоткуда.
|
||||
//
|
||||
// Одна запись на доставку: событие редкое и адресное, шквала здесь не бывает.
|
||||
// Отказ запроса свёртку НЕ прекращает — наблюдение не может стоить доставки.
|
||||
func (w *Worker) outOfOrder(ctx context.Context, d store.PendingDelivery) bool {
|
||||
later, err := w.store.ParsedAfter(ctx, d.ReceivedAt, d.ID)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
w.log.ErrorContext(ctx, "journal order check failed", "error", err, "delivery_id", d.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return later
|
||||
}
|
||||
|
||||
func (w *Worker) warnOutOfOrder(ctx context.Context, d store.PendingDelivery) {
|
||||
w.log.WarnContext(ctx, "delivery folded out of journal order",
|
||||
"delivery_id", d.ID,
|
||||
"waited_sec", int64(store.Now().Sub(d.ReceivedAt).Seconds()))
|
||||
}
|
||||
|
||||
// warnLag называет отставание одной строкой на проход.
|
||||
//
|
||||
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Отложенная доставка ДЕРЖИТ очередь: проход обязан прекратиться на ней, а не
|
||||
// свернуть её преемниц раньше неё.
|
||||
//
|
||||
// Свойство это — единственное, на чём держится равенство «пересборка = приём»
|
||||
// после смены тай-брейка: при равной полноте побеждает пришедшая доставка, то
|
||||
// есть исход слияния стал функцией порядка свёртки. Перешагнув отложенную
|
||||
// доставку, воркер оставил бы в витрине версию не той доставки, что стоит в
|
||||
// журнале последней, — и расхождение было бы молчаливым.
|
||||
//
|
||||
// Свёртка подменяется полем, а не удерживается настоящая блокировка базы:
|
||||
// `Deferred` наступает от занятости, а удержать её по-настоящему стоит
|
||||
// двадцать пять секунд (`busy_timeout` 5 с × 5 повторов транзакции). Столько
|
||||
// стоит `task verify:busy`, который в гейт намеренно не входит, — то есть
|
||||
// проверка барьера жила бы только в прогоне, который никто не гоняет по
|
||||
// расписанию.
|
||||
func TestПроходПрекращаетсяНаОтложеннойДоставке(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
ids := []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB", "01CCCCCCCCCCCCCCCCCCCCCCCC"}
|
||||
for i, id := range ids {
|
||||
d := store.Delivery{
|
||||
ID: id,
|
||||
ReceivedAt: base.Add(time.Duration(i) * time.Second),
|
||||
Bytes: 1,
|
||||
SHA256: "-",
|
||||
RawPath: "x",
|
||||
ParseStatus: store.ParsePending,
|
||||
}
|
||||
if err := st.CreateDelivery(ctx, d); err != nil {
|
||||
t.Fatalf("учёт доставки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w := &Worker{
|
||||
store: st,
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
var seen []string
|
||||
w.fold = func(_ context.Context, id string) Outcome {
|
||||
seen = append(seen, id)
|
||||
if id == ids[1] {
|
||||
return Outcome{Deferred: 1}
|
||||
}
|
||||
return Outcome{Folded: 1}
|
||||
}
|
||||
|
||||
out, err := w.Pass(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("проход: %v", err)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != ids[0] || seen[1] != ids[1] {
|
||||
t.Fatalf("проход тронул %v: он обязан остановиться на второй доставке", seen)
|
||||
}
|
||||
if out.Folded != 1 || out.Deferred != 1 {
|
||||
t.Errorf("исход прохода %+v: ожидались одна свёрнутая и одна отложенная", out)
|
||||
}
|
||||
|
||||
// Следующий проход начинает с начала очереди и доходит до преемниц: барьер
|
||||
// откладывает работу, а не отменяет её. Порядок остаётся журнальным.
|
||||
//
|
||||
// Первая доставка появляется в списке снова потому, что подменённая свёртка
|
||||
// исхода разбора не пишет и очередь не покидает: проверяется здесь порядок,
|
||||
// а не учёт.
|
||||
seen = nil
|
||||
w.fold = func(_ context.Context, id string) Outcome {
|
||||
seen = append(seen, id)
|
||||
return Outcome{Folded: 1}
|
||||
}
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("второй проход: %v", err)
|
||||
}
|
||||
if len(seen) != 3 || seen[0] != ids[0] || seen[1] != ids[1] || seen[2] != ids[2] {
|
||||
t.Fatalf("второй проход тронул %v, ожидался порядок журнала %v", seen, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// Метка отставания обязана срабатывать и на выходе по барьеру.
|
||||
//
|
||||
// Иначе барьер отменял бы её ровно в том состоянии, ради которого она заведена:
|
||||
// занятая голова очереди не пропускает проход до пустой выборки НИКОГДА, и
|
||||
// «очередь стоит два часа» стало бы неотличимо от «споткнулась один раз».
|
||||
// Дельта capability приёма обещает это дословно: «Стоящая голова очереди
|
||||
// молчать MUST NOT».
|
||||
func TestМеткаОтставанияСрабатываетНаБарьере(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
// Доставка ждёт заведомо дольше порога задержки.
|
||||
at := store.Now().Add(-time.Hour)
|
||||
for i, id := range []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB"} {
|
||||
d := store.Delivery{
|
||||
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
|
||||
Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending,
|
||||
}
|
||||
if err := st.CreateDelivery(ctx, d); err != nil {
|
||||
t.Fatalf("учёт доставки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := &Worker{
|
||||
store: st,
|
||||
log: slog.New(slog.NewJSONHandler(&buf, nil)),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
// Первый проход уже был: метка задолженности старта подавляется намеренно.
|
||||
w.startupDone = true
|
||||
w.fold = func(_ context.Context, _ string) Outcome { return Outcome{Deferred: 1} }
|
||||
|
||||
if _, err := w.Pass(ctx); err != nil {
|
||||
t.Fatalf("проход: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "deliveries waited for fold") {
|
||||
t.Errorf("метка отставания молчит на выходе по барьеру:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,31 @@ func (s *logSink) count(msg string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// linesOf отдаёт записи с данным msg РАЗОБРАННЫМИ.
|
||||
//
|
||||
// Разобранными, а не строками: проверка «в логе нет значения точки» обязана
|
||||
// смотреть поля записи, а не сырой буфер — служебное поле `time` содержит доли
|
||||
// секунды, и подстрока вроде «5.1» находится в нём примерно в одном проценте
|
||||
// прогонов (docs/review.md, 2026-08-02).
|
||||
func (s *logSink) linesOf(msg string) []map[string]any {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var out []map[string]any
|
||||
for _, line := range s.lines {
|
||||
if msgOf(line) != msg {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
delete(rec, "time")
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *logSink) dump() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
+144
-25
@@ -60,6 +60,38 @@ type MergeStats struct {
|
||||
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
|
||||
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
|
||||
UnitsConflicts int
|
||||
// PointsHeld — координаты, где ПРИШЕДШАЯ точка проиграла сохранённой.
|
||||
//
|
||||
// Под тай-брейком «побеждает пришедшая» это возможно ровно тогда, когда
|
||||
// сохранённая строго полнее, — то есть счётчик меряет единственное
|
||||
// направление, в котором правило полноты спорит с журналом. Обратный случай
|
||||
// (пришедшая строго полнее) сюда не входит: там полнота и журнал согласны.
|
||||
//
|
||||
// Отдельно от Overwrites, и это не педантизм: тот считает столкновение в обе
|
||||
// стороны — на живом корпусе сработал бы около 80 000 раз, — и отличить
|
||||
// «оставили пришедшую» от «выбросили пришедшую» по нему нельзя. То есть
|
||||
// единственное событие, ради наблюдения за которым правило переписано,
|
||||
// осталось бы невидимым. Тот же довод уже записан для EntitiesHeld.
|
||||
PointsHeld int
|
||||
// PointsHeldAt — координаты первых таких объектов, для записи в лог.
|
||||
PointsHeldAt []Collision
|
||||
// PointsErased — координаты, где победила ПРИШЕДШАЯ точка, а у проигравшей
|
||||
// был содержательный ключ, которого у победительницы нет.
|
||||
//
|
||||
// Единственное направление, в котором новое правило способно потерять
|
||||
// содержание. Разряд полноты там погашен по построению: значения общих
|
||||
// содержательных ключей разошлись, и надмножество имён о полноте не говорит
|
||||
// ничего. До смены тай-брейка исход решал байтовый порядок — то есть тот же
|
||||
// исход случался по жребию и так же молча; разница в том, что теперь он
|
||||
// детерминирован, а значит либо не случается вовсе, либо случается всегда.
|
||||
// Замер на живом корпусе: 2 координаты из 80 129 спорных, обе — те же, что
|
||||
// дают несравнимые наборы.
|
||||
//
|
||||
// Обратимо пересборкой, пока жив архив. Поэтому счётчик, а не запрет:
|
||||
// объединение полей отложено тем же решением и по той же причине.
|
||||
PointsErased int
|
||||
// PointsErasedAt — координаты первых таких объектов, для записи в лог.
|
||||
PointsErasedAt []Collision
|
||||
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
|
||||
// есть содержательный ключ, которого нет у другой. Правило полноты тут
|
||||
// бессильно, и вместо объединения полей — которого на живом потоке не
|
||||
@@ -209,6 +241,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
stats.Stored += res.stored
|
||||
stats.Overwrites += res.overwrites
|
||||
stats.Incomparable += res.incomparable
|
||||
stats.PointsHeld += res.held
|
||||
stats.PointsErased += res.erased
|
||||
if res.unchanged {
|
||||
stats.Unchanged++
|
||||
}
|
||||
@@ -228,6 +262,12 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
|
||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
||||
}
|
||||
if res.held > 0 && len(stats.PointsHeldAt) < maxCollisionsReported {
|
||||
stats.PointsHeldAt = append(stats.PointsHeldAt, coord)
|
||||
}
|
||||
if res.erased > 0 && len(stats.PointsErasedAt) < maxCollisionsReported {
|
||||
stats.PointsErasedAt = append(stats.PointsErasedAt, coord)
|
||||
}
|
||||
}
|
||||
|
||||
now := Now()
|
||||
@@ -315,6 +355,8 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
|
||||
type mergeResult struct {
|
||||
overwrites int
|
||||
incomparable int
|
||||
held int
|
||||
erased int
|
||||
stored int
|
||||
unchanged bool
|
||||
sealed bool
|
||||
@@ -334,12 +376,14 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
return res, err
|
||||
}
|
||||
|
||||
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
|
||||
merged, overwrites, incomparable, held, erased, err := mergePoints(ctx, stored.Points, group.points)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.overwrites = overwrites
|
||||
res.incomparable = incomparable
|
||||
res.held = held
|
||||
res.erased = erased
|
||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
|
||||
@@ -383,17 +427,28 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
|
||||
// победила» стирало бы у сохранённой точки поля, которых новая не несёт.
|
||||
//
|
||||
// При равной полноте исход определяется порядком канонических форм, а не
|
||||
// порядком доставок: у сохранённой точки нет провенанса, а четверть доставок
|
||||
// несёт столкновения внутри себя, где время приёма общее. Свёртка по журналу
|
||||
// обязана давать то же состояние, что приём в реальном времени.
|
||||
// При равной полноте побеждает ПРИШЕДШАЯ точка, а не сохранённая: байтовый
|
||||
// порядок канонических форм системно брал меньшее значение (1 847 из 1 912,
|
||||
// находка 49), то есть хранил версию, которую источник уже пересчитал. Ценой
|
||||
// этого час step_count терял род, и прогон живого архива краснел.
|
||||
//
|
||||
// Байтовый порядок остался вторым разрядом и решает столкновения ВНУТРИ одной
|
||||
// доставки, где провенанс общий, а порядок элементов в JSON-массиве от HAE
|
||||
// нестабилен.
|
||||
//
|
||||
// Плата названа вслух: правило перестало быть коммутативным, и содержимое
|
||||
// витрины стало функцией ПОРЯДКА свёртки. Витрина остаётся свёрткой журнала
|
||||
// ровно потому, что порядок свёртки приведён к порядку журнала — барьер на
|
||||
// отложенной доставке в replay.Worker. Восстановление коммутативности «для
|
||||
// чистоты» откатит починку молча.
|
||||
//
|
||||
// Второй счётчик — несравнимые наборы полей. Они и есть та часть правила,
|
||||
// которую заменили наблюдением: объединять поля никто не будет, пока счётчик
|
||||
// не заговорит.
|
||||
// не заговорит. Третий — удержания: сколько раз сохранённая точка оказалась
|
||||
// строго полнее пришедшей.
|
||||
//
|
||||
// Точки из объекта не удаляются никогда.
|
||||
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
|
||||
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable, held, erased int, err error) {
|
||||
type coord struct {
|
||||
start int64
|
||||
end int64
|
||||
@@ -409,13 +464,13 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
||||
// чего исход зависит от того, что уже лежало в объекте: одна и та же
|
||||
// доставка, свёрнутая дважды, давала два разных состояния витрины
|
||||
// поочерёдно. Это ломало главный инвариант — «состояние пересобираемо».
|
||||
add := func(p Point) {
|
||||
add := func(p Point, fromDelivery bool) {
|
||||
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
|
||||
cands, seen := byCoord[c]
|
||||
if !seen {
|
||||
order = append(order, c)
|
||||
}
|
||||
cand := newCandidate(p)
|
||||
cand := newCandidate(p, fromDelivery)
|
||||
// Побайтовое равенство — только быстрый путь. Тем же самым считается
|
||||
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
|
||||
// байты нестабильны. Из 81952 повторно приехавших точек 67534
|
||||
@@ -423,8 +478,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
||||
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
|
||||
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
|
||||
// правила слияния стал бы неотличим от нормы.
|
||||
for _, q := range cands {
|
||||
for i, q := range cands {
|
||||
if bytes.Equal(q.key, cand.key) {
|
||||
// Схлопывание ПОДНИМАЕТ флаг: доставка, приславшая то же
|
||||
// содержимое, сказала о нём своё слово, и сохранённый
|
||||
// кандидат теперь представляет её. Без этого он проигрывал бы
|
||||
// соседу по доставке, которого обязан обойти по байтам, — то
|
||||
// есть «внутри доставки решают байты» ломалось бы ровно
|
||||
// тогда, когда доставка ничего не изменила. Байты остаются
|
||||
// встреченные первыми: победитель хранится дословно, а разница
|
||||
// при совпавшей канонической форме ненаблюдаема — хеш объекта
|
||||
// считается по канонической форме.
|
||||
cands[i].incoming = q.incoming || fromDelivery
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -432,18 +497,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
||||
}
|
||||
|
||||
for _, p := range stored {
|
||||
add(p)
|
||||
add(p, false)
|
||||
}
|
||||
for _, p := range incoming {
|
||||
add(p)
|
||||
add(p, true)
|
||||
}
|
||||
|
||||
out := make([]Point, 0, len(order))
|
||||
for _, c := range order {
|
||||
cands := byCoord[c]
|
||||
winner, unrelated, err := resolve(ctx, cands)
|
||||
winner, unrelated, keptStored, lostKey, err := resolve(ctx, cands)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
return nil, 0, 0, 0, 0, err
|
||||
}
|
||||
// Перезаписей столько, сколько точек уступило: при двух кандидатах
|
||||
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
|
||||
@@ -452,6 +517,12 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
||||
if unrelated {
|
||||
incomparable++
|
||||
}
|
||||
if keptStored {
|
||||
held++
|
||||
}
|
||||
if lostKey {
|
||||
erased++
|
||||
}
|
||||
out = append(out, winner)
|
||||
}
|
||||
|
||||
@@ -465,7 +536,7 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
|
||||
}
|
||||
return out[i].End.Before(out[j].End)
|
||||
})
|
||||
return out, overwrites, incomparable, nil
|
||||
return out, overwrites, incomparable, held, erased, nil
|
||||
}
|
||||
|
||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||
@@ -476,10 +547,19 @@ type candidate struct {
|
||||
pt Point
|
||||
key []byte // каноническая форма: она же ключ дедупликации и порядок
|
||||
fields canon.Fields
|
||||
// incoming — точка приехала РАЗБИРАЕМОЙ доставкой, а не лежала в объекте.
|
||||
//
|
||||
// Это второй и старший разряд тотального порядка: при равной полноте
|
||||
// побеждает пришедшая. Провенанса у точки нет и не будет (колонка означала
|
||||
// бы смену формата содержимого объекта), поэтому «позже по журналу»
|
||||
// выражается единственным доступным способом — происхождением кандидата. И
|
||||
// ровно поэтому порядок свёртки обязан равняться порядку журнала: правило
|
||||
// перестало быть коммутативным осознанно, см. openspec storage.
|
||||
incoming bool
|
||||
}
|
||||
|
||||
func newCandidate(p Point) candidate {
|
||||
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
|
||||
func newCandidate(p Point, incoming bool) candidate {
|
||||
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw), incoming: incoming}
|
||||
}
|
||||
|
||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||
@@ -495,20 +575,49 @@ func newCandidate(p Point) candidate {
|
||||
// вернуть её значило бы сохранить округлённое число вместо присланного.
|
||||
//
|
||||
// Второй возврат — остались ли непревзойдёнными несколько точек с
|
||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
||||
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
|
||||
// реализовано: вместо него счётчик, который скажет, если событие наступит.
|
||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
|
||||
// несравнимыми наборами содержательных полей. На живом потоке событие
|
||||
// единично (2 пары на 155 доставок), поэтому объединение полей не
|
||||
// реализовано: вместо него счётчик, который скажет, если оно станет массовым.
|
||||
//
|
||||
// Третий возврат — победила ли СОХРАНЁННАЯ точка при наличии пришедших. Под
|
||||
// новым тай-брейком это возможно ровно тогда, когда сохранённая строго полнее,
|
||||
// то есть счётчик меряет единственное направление, в котором полнота спорит с
|
||||
// журналом. Отдельно от Overwrites: тот считает столкновение в обе стороны и
|
||||
// «оставили пришедшую» от «выбросили пришедшую» не отличает.
|
||||
//
|
||||
// Четвёртый — обратное направление, и оно опаснее: победила пришедшая, а у
|
||||
// проигравшей был содержательный ключ, которого у победительницы нет. Разряд
|
||||
// полноты в этом случае погашен по построению — значения общих содержательных
|
||||
// ключей разошлись, и Relate объявляет пару равнополной, — так что защитить
|
||||
// содержание некому. До смены тай-брейка исход здесь решал байтовый порядок,
|
||||
// то есть жребий; теперь он детерминирован в пользу пришедшей. Событие редкое
|
||||
// (на живом корпусе 2 координаты из 80 129 спорных, обе несравнимые), но это
|
||||
// единственное направление, в котором правило теряет содержание, и оно обязано
|
||||
// быть видно — иначе «ничего не теряем молча» держится на слове.
|
||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, bool, bool, error) {
|
||||
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
|
||||
if err != nil {
|
||||
return Point{}, false, err
|
||||
return Point{}, false, false, false, err
|
||||
}
|
||||
|
||||
held, erased := false, false
|
||||
for i, c := range cands {
|
||||
if i == winner {
|
||||
continue
|
||||
}
|
||||
if !cands[winner].incoming && c.incoming {
|
||||
held = true
|
||||
}
|
||||
if cands[winner].incoming && c.fields.CarriesKeyAbsentIn(cands[winner].fields) {
|
||||
erased = true
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
|
||||
// наборами полей и разными значениями тоже остаются обе, и это рядовой
|
||||
// тай-брейк. Считается только то, ради чего отложено объединение полей:
|
||||
// у каждой из двух есть содержательный ключ, которого нет у другой.
|
||||
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
|
||||
return cands[winner].pt, hasIncomparablePair(cands, maximal), held, erased, nil
|
||||
}
|
||||
|
||||
// pointDominates — строгое превосходство по полноте. Relate возвращает
|
||||
@@ -518,9 +627,19 @@ func pointDominates(a, b candidate) bool {
|
||||
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
||||
}
|
||||
|
||||
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
|
||||
// кандидаты с равной формой схлопываются ещё при сборе множества.
|
||||
// pointLess — тотальный порядок: сперва происхождение, затем каноническая
|
||||
// форма. Минимум единствен: кандидаты с равной формой схлопываются ещё при
|
||||
// сборе множества, значит ключи различны и лексикографическая пара
|
||||
// `(не incoming, ключ)` трихотомична.
|
||||
//
|
||||
// Пришедшая идёт РАНЬШЕ сохранённой, потому что pickBest берёт минимум. Это и
|
||||
// есть тай-брейк «побеждает пришедшая»: байтовый порядок остаётся вторым
|
||||
// разрядом и работает там, где происхождение одинаково, — то есть внутри одной
|
||||
// доставки, где провенанс общий и различать нечем.
|
||||
func pointLess(a, b candidate) bool {
|
||||
if a.incoming != b.incoming {
|
||||
return a.incoming
|
||||
}
|
||||
return bytes.Compare(a.key, b.key) < 0
|
||||
}
|
||||
|
||||
|
||||
+331
-30
@@ -351,10 +351,16 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Значение общего содержательного ключа СОВПАДАЕТ — таково условие второго
|
||||
// разряда в спеке: он сравнивает все ключи только там, где содержание
|
||||
// сошлось. Прежняя редакция теста давала `qty` разные, второй разряд при
|
||||
// этом не запускался вовсе, и точка с `Min`/`Max` выигрывала по случайности
|
||||
// байтового порядка (`M` сортируется раньше `d`). Зелёный тест проверял не
|
||||
// то требование, которое называл.
|
||||
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":10,"Min":0,"Max":0}`)
|
||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":12}`)
|
||||
`{"date":"d","qty":10}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
@@ -372,6 +378,86 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// Обратная сторона того же разряда, и она названа ценой изменения: когда
|
||||
// значения общих содержательных ключей РАЗОШЛИСЬ, полнота ответа не даёт по
|
||||
// построению («надмножество имён о полноте не говорит ничего»), и пришедшая
|
||||
// точка побеждает вместе с потерей пустых ключей сохранённой. Прежде исход
|
||||
// здесь решался байтовым порядком, то есть был случайным.
|
||||
func TestMergeРазошедшиесяЗначенияНеУдерживаютсяПустымиКлючами(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":10,"Min":0,"Max":0}`)
|
||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":12}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(narrow.Raw) {
|
||||
t.Errorf("в витрине %s, ожидалась пришедшая точка %s", got, narrow.Raw)
|
||||
}
|
||||
if stats.Overwrites != 1 {
|
||||
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
|
||||
}
|
||||
if stats.PointsHeld != 0 {
|
||||
t.Errorf("удержаний %d: пришедшая победила, удерживать было нечего", stats.PointsHeld)
|
||||
}
|
||||
}
|
||||
|
||||
// Полнота сильнее происхождения: пришедшая точка, теряющая содержательный ключ
|
||||
// сохранённой при совпадающих значениях, проигрывает — и это единственный
|
||||
// случай, когда пришедшая вообще проигрывает. Счётчик удержаний считает ровно
|
||||
// его и отдельно от перезаписей.
|
||||
func TestMergeПолнотаСильнееПроисхождения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rich := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":72,"Min":60,"Max":90,"context":"rest"}`)
|
||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"Avg":72,"Min":60,"Max":90}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(rich.Raw) {
|
||||
t.Errorf("в витрине %s: пришедшая бедная точка стёрла содержательный ключ", got)
|
||||
}
|
||||
if stats.PointsHeld != 1 {
|
||||
t.Errorf("удержаний %d, ожидалось 1", stats.PointsHeld)
|
||||
}
|
||||
if len(stats.PointsHeldAt) != 1 {
|
||||
t.Fatalf("координат удержания %d, ожидалась 1", len(stats.PointsHeldAt))
|
||||
}
|
||||
if stats.PointsHeldAt[0].Metric != "heart_rate" || stats.PointsHeldAt[0].Layer != "minute" {
|
||||
t.Errorf("координаты удержания %+v: не те", stats.PointsHeldAt[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||
@@ -480,10 +566,10 @@ func TestMergeСменаИсточникаНеСоздаётВторуюТочк
|
||||
}
|
||||
}
|
||||
|
||||
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
||||
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
||||
// времени, а внутри одной доставки время приёма общее.
|
||||
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||
// Внутри ОДНОЙ доставки провенанс общий, различать точки нечем, а порядок
|
||||
// элементов в JSON-массиве от HAE нестабилен — поэтому здесь исход обязан
|
||||
// остаться функцией множества и решаться порядком канонических форм.
|
||||
func TestMergeРавнаяПолнотаВнутриДоставкиНеЗависитОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -491,12 +577,10 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
|
||||
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
|
||||
|
||||
winner := func(order []store.IncomingPoint) string {
|
||||
winner := func(batch []store.IncomingPoint) string {
|
||||
st := open(t)
|
||||
for _, p := range order {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
@@ -508,7 +592,119 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
|
||||
forward := winner([]store.IncomingPoint{a, b})
|
||||
backward := winner([]store.IncomingPoint{b, a})
|
||||
if forward != backward {
|
||||
t.Errorf("исход зависит от порядка доставок: %s против %s", forward, backward)
|
||||
t.Errorf("исход внутри доставки зависит от порядка элементов: %s против %s", forward, backward)
|
||||
}
|
||||
if forward != string(a.Raw) {
|
||||
t.Errorf("внутри доставки победила %s, ожидался минимум канонической формы %s", forward, a.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// А МЕЖДУ доставками побеждает пришедшая, и это осознанная потеря
|
||||
// коммутативности: байтовый порядок системно хранил меньшее значение и
|
||||
// стоил `step_count` его рода. Витрина остаётся свёрткой журнала потому, что
|
||||
// порядок свёртки приведён к порядку журнала, а не потому, что правило
|
||||
// безразлично к порядку.
|
||||
func TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
|
||||
|
||||
last := func(order []store.IncomingPoint) string {
|
||||
st := open(t)
|
||||
for i, p := range order {
|
||||
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", i)}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, ref); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
return string(got.Points[0].Raw)
|
||||
}
|
||||
|
||||
if got := last([]store.IncomingPoint{a, b}); got != string(b.Raw) {
|
||||
t.Errorf("после доставок a,b в витрине %s, ожидалась пришедшая последней %s", got, b.Raw)
|
||||
}
|
||||
// Обратный порядок даёт обратный исход — ровно то свойство, ради которого
|
||||
// заведён барьер журнального порядка в воркере.
|
||||
if got := last([]store.IncomingPoint{b, a}); got != string(a.Raw) {
|
||||
t.Errorf("после доставок b,a в витрине %s, ожидалась пришедшая последней %s", got, a.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторная присылка того же содержимого другими байтами ничего не переписывает:
|
||||
// схлопывание поднимает флаг происхождения, но байты в витрине остаются
|
||||
// сохранённые, и хеш-детектор гасит запись.
|
||||
func TestMergeПовторКаноническиТойЖеТочкиНеПереписывает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":10}`)
|
||||
// Тот же смысл, другие байты: порядок ключей у HAE нестабилен.
|
||||
again := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"qty":10,"date":"d"}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{again}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
if stats.Overwrites != 0 {
|
||||
t.Errorf("перезаписей %d: дребезг порядка ключей посчитан столкновением", stats.Overwrites)
|
||||
}
|
||||
if stats.Unchanged != 1 {
|
||||
t.Errorf("неизменившихся объектов %d, ожидался 1: объект переписан зря", stats.Unchanged)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(first.Raw) {
|
||||
t.Errorf("в витрине %s, ожидались сохранённые байты %s", got, first.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Точка, канонически совпавшая с сохранённой, представляет доставку наравне с
|
||||
// остальными её точками: иначе она проигрывала бы соседу по телу, которого
|
||||
// обязана обойти по байтам, — то есть «внутри доставки решают байты» ломалось
|
||||
// бы ровно тогда, когда доставка ничего не изменила.
|
||||
func TestMergeСхлопываниеПоднимаетПроисхождение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
stored := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
same := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||
other := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
|
||||
|
||||
st := open(t)
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
// Вторая доставка несёт обе формы: совпавшую с сохранённой и другую.
|
||||
// Победить обязана меньшая каноническая форма, то есть совпавшая, — а не
|
||||
// вторая только потому, что у первой не поднялся флаг.
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{same, other}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(stored.Raw) {
|
||||
t.Errorf("в витрине %s, ожидался минимум канонической формы доставки %s", got, stored.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,24 +951,57 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
|
||||
ctx := context.Background()
|
||||
in := cycleTriple(t)
|
||||
|
||||
// Все шесть перестановок тройки, и вдобавок разбиение на разные доставки:
|
||||
// в живом приёме точки приходят порознь, в пересборке — вместе.
|
||||
// Все шесть перестановок тройки ВНУТРИ одной доставки. Разбиение на разные
|
||||
// доставки сюда больше не входит намеренно: между доставками правило
|
||||
// перестало быть коммутативным — при равной полноте побеждает пришедшая, —
|
||||
// и требовать здесь независимости от порядка значило бы требовать отката
|
||||
// починки. Что осталось верным между доставками, проверяют
|
||||
// `TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую` (исход = последняя
|
||||
// доставка) и прогон сходимости живого пути с пересборкой в
|
||||
// `internal/replay`.
|
||||
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||
|
||||
winner := func(order []int, split bool) string {
|
||||
winner := func(order []int) string {
|
||||
st := open(t)
|
||||
if split {
|
||||
for _, i := range order {
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
batch := make([]store.IncomingPoint, 0, len(order))
|
||||
for _, i := range order {
|
||||
batch = append(batch, in[i])
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
batch := make([]store.IncomingPoint, 0, len(order))
|
||||
for _, i := range order {
|
||||
batch = append(batch, in[i])
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
return string(b.Points[0].Raw)
|
||||
}
|
||||
|
||||
want := winner(perms[0])
|
||||
for _, p := range perms {
|
||||
if got := winner(p); got != want {
|
||||
t.Errorf("перестановка %v дала %s, ожидалось %s", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Та же тройка, разложенная по РАЗНЫМ доставкам: исход теперь зависит от
|
||||
// порядка, и это не дефект, а объявленная цена. Проверяется другое и не менее
|
||||
// важное — что зависимость ДЕТЕРМИНИРОВАНА: один и тот же порядок журнала
|
||||
// обязан давать один и тот же исход, иначе витрина перестала бы быть свёрткой
|
||||
// журнала вовсе, а не только функцией его порядка.
|
||||
func TestMergeПорядокДоставокДаётВоспроизводимыйИсход(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
in := cycleTriple(t)
|
||||
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||
|
||||
winner := func(order []int) string {
|
||||
st := open(t)
|
||||
for n, i := range order {
|
||||
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", n)}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, ref); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -783,11 +1012,11 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
|
||||
return string(b.Points[0].Raw)
|
||||
}
|
||||
|
||||
want := winner(perms[0], false)
|
||||
for _, p := range perms {
|
||||
for _, split := range []bool{false, true} {
|
||||
if got := winner(p, split); got != want {
|
||||
t.Errorf("перестановка %v (порознь=%v) дала %s, ожидалось %s", p, split, got, want)
|
||||
first := winner(p)
|
||||
for pass := 2; pass <= 3; pass++ {
|
||||
if got := winner(p); got != first {
|
||||
t.Errorf("порядок %v дал %s, а прежде %s: исход невоспроизводим", p, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,3 +1086,75 @@ func TestMergeИмяМетрикиВКоординатеОбрезано(t *test
|
||||
t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Единственное направление, в котором новое правило теряет содержание:
|
||||
// значения общих содержательных ключей разошлись, разряд полноты погас, и
|
||||
// победила пришедшая точка, у которой содержательного ключа нет.
|
||||
//
|
||||
// До смены тай-брейка исход здесь решал байтовый порядок — то есть та же
|
||||
// потеря случалась по жребию и так же молча. Разница в том, что теперь она
|
||||
// детерминирована и СЧИТАЕТСЯ.
|
||||
func TestMergeПришедшаяУнесшаяСодержаниеСчитаетсяОтдельно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// `rem` — содержательный ключ сохранённой; у пришедшей его нет, а значение
|
||||
// общего ключа `asleep` разошлось, поэтому Relate объявляет пару равнополной.
|
||||
stored := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
|
||||
`{"date":"d","asleep":7.5,"rem":1.2}`)
|
||||
arriving := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
|
||||
`{"date":"d","asleep":7.4}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{arriving}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "sleep_analysis_summary", "day", ts(t, "2025-06-05T00:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(arriving.Raw) {
|
||||
t.Fatalf("в витрине %s, ожидалась пришедшая точка %s", got, arriving.Raw)
|
||||
}
|
||||
if stats.PointsErased != 1 {
|
||||
t.Errorf("потерь содержания %d, ожидалась 1: направление, в котором правило теряет, осталось невидимым", stats.PointsErased)
|
||||
}
|
||||
if len(stats.PointsErasedAt) != 1 {
|
||||
t.Errorf("координат потери %d, ожидалась 1", len(stats.PointsErasedAt))
|
||||
}
|
||||
if stats.PointsHeld != 0 {
|
||||
t.Errorf("удержаний %d: победила пришедшая, удерживать было нечего", stats.PointsHeld)
|
||||
}
|
||||
}
|
||||
|
||||
// Обратное: пришедшая победила, ничего не унеся, — счётчик молчит. Без этой
|
||||
// пары счётчик потерь был бы неотличим от счётчика перезаписей.
|
||||
func TestMergeПришедшаяБезПотериСодержанияНеСчитается(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":1}`)
|
||||
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":2}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
if stats.PointsErased != 0 {
|
||||
t.Errorf("потерь содержания %d, ожидалось 0: наборы ключей совпадают, терять нечего", stats.PointsErased)
|
||||
}
|
||||
if stats.Overwrites != 1 {
|
||||
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,38 @@ func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, li
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParsedAfter отвечает, есть ли в журнале доставка ПОЗЖЕ указанной позиции,
|
||||
// уже записавшая свой разбор в витрину.
|
||||
//
|
||||
// Нужен ровно одному наблюдению: свёртка вне порядка журнала. Правило слияния
|
||||
// точек разрешает равную полноту в пользу пришедшей доставки, то есть исход
|
||||
// есть функция порядка свёртки; доставка, свёрнутая после своей преемницы,
|
||||
// возвращает координату к версии, которую источник уже пересчитал, и живая
|
||||
// витрина расходится с пересборкой. Барьер воркера этого не ловит: отставшей
|
||||
// доставки в момент прохода просто не существует — строка учёта становится
|
||||
// видимой только после записи тела.
|
||||
//
|
||||
// `failed` в предикат НЕ входит: такая доставка в витрину ничего не записала,
|
||||
// перестановка относительно неё содержимого не разводит, а исход этот штатный
|
||||
// (невыводимый слой), и его учёт превратил бы WARN в шум.
|
||||
//
|
||||
// Это страж окна, а не постоянная часть свёртки: закрыв порядок на самом
|
||||
// приёме, метод и его вызов сносят вместе с окном.
|
||||
func (s *Store) ParsedAfter(ctx context.Context, at time.Time, id string) (bool, error) {
|
||||
// Кортежное сравнение, а не через OR: развёрнутая форма даёт SCAN по
|
||||
// индексу вместо SEARCH — тот же приём, что в PendingDeliveries.
|
||||
const q = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM delivery
|
||||
WHERE parse_status IN (?, ?) AND (received_at, id) > (?, ?))`
|
||||
|
||||
var found bool
|
||||
if err := s.db.GetContext(ctx, &found, q, ParseDone, ParsePartial, FormatTime(at), id); err != nil {
|
||||
return false, fmt.Errorf("select parsed after: %w", err)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
|
||||
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
|
||||
// разобрать, прежде чем витрина станет полной.
|
||||
|
||||
+19
-15
@@ -197,12 +197,16 @@ func (v *entityVersion) sortKey() []byte {
|
||||
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
||||
// 4. наборы несравнимы → сохранённая
|
||||
//
|
||||
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
|
||||
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
|
||||
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
|
||||
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
|
||||
// кроме отпечатка.
|
||||
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
|
||||
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
|
||||
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
|
||||
// становится видимой только после записи тела, и при конкурентном приёме
|
||||
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
|
||||
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
|
||||
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
|
||||
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
|
||||
// незачем. Критерий выбора записан в architecture.md, «Разрешение
|
||||
// столкновений».
|
||||
//
|
||||
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
||||
// решает минимум канонической формы, потому что порядок элементов в
|
||||
@@ -220,15 +224,15 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
||||
// версий остаётся сохранённая: правило называется «не теряет
|
||||
// содержания», и приехавшая его теряет.
|
||||
//
|
||||
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
|
||||
// журнала: в витрине лежит победитель прошлых слияний, а не все
|
||||
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
|
||||
// при разном порядке. Порядок свёртки журналу не равен — доставка,
|
||||
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
|
||||
// проходом, — так что живой приём и пересборка на несравнимых версиях
|
||||
// законно расходятся. Это единственная точка, где витрина не является
|
||||
// функцией множества доставок; она названа вслух в architecture.md, и
|
||||
// счётчик удержаний ниже — единственное, что о ней сообщает.
|
||||
// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
|
||||
// лежит победитель прошлых слияний, а не все кандидаты истории, и
|
||||
// «сохранённая выигрывает» означает разный итог при разном порядке.
|
||||
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
|
||||
// не перегоняется своими преемницами — проход воркера прекращается на
|
||||
// ней. Осталось окно конкурентного приёма, где строка учёта становится
|
||||
// видимой позже метки; там живой приём и пересборка на несравнимых
|
||||
// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
|
||||
// удержаний ниже — второе, что о расхождении сообщает.
|
||||
return false, true
|
||||
default:
|
||||
return laterInJournal(stored, incoming), false
|
||||
|
||||
@@ -819,12 +819,16 @@ func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Единственная точка, где витрина НЕ является функцией множества доставок, —
|
||||
// Точка, где витрина сущностей НЕ является функцией множества доставок, —
|
||||
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
|
||||
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
|
||||
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
|
||||
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
|
||||
// следующим проходом.
|
||||
// выигрывает» даёт разный итог при разном порядке.
|
||||
//
|
||||
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
|
||||
// перегоняется своими преемницами — проход воркера прекращается на ней.
|
||||
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
|
||||
// метки. У точек единственной такой точки давно нет: там от порядка журнала
|
||||
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
|
||||
//
|
||||
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
|
||||
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
|
||||
|
||||
@@ -151,3 +151,63 @@ func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
|
||||
t.Fatalf("запись доставки %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Свёртка вне порядка журнала обнаруживается запросом «есть ли позже меня уже
|
||||
// разобранная доставка». Это единственное наблюдение, по которому расхождение
|
||||
// живой витрины с пересборкой видно до сверки отпечатков.
|
||||
func TestParsedAfterВидитТолькоЗаписавшихВВитрину(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
at := ts(t, "2026-08-01T12:00:00Z")
|
||||
|
||||
early := at
|
||||
late := at.Add(time.Second)
|
||||
seedPending(t, st, "d-early", early)
|
||||
seedPending(t, st, "d-late", late)
|
||||
|
||||
// Пока преемница в очереди, расхождения нет.
|
||||
found, err := st.ParsedAfter(ctx, early, "d-early")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsedAfter: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("неразобранная преемница засчитана расхождением")
|
||||
}
|
||||
|
||||
// `failed` преемницей не считается: в витрину она ничего не записала, и
|
||||
// перестановка относительно неё содержимого не разводит. Исход этот
|
||||
// штатный — невыводимый слой, — и его учёт превратил бы WARN в шум.
|
||||
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseFailed}); err != nil {
|
||||
t.Fatalf("FinishParse: %v", err)
|
||||
}
|
||||
found, err = st.ParsedAfter(ctx, early, "d-early")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsedAfter: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("отказавшая преемница засчитана расхождением")
|
||||
}
|
||||
|
||||
// А разобранная — считается.
|
||||
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseDone}); err != nil {
|
||||
t.Fatalf("FinishParse: %v", err)
|
||||
}
|
||||
found, err = st.ParsedAfter(ctx, early, "d-early")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsedAfter: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Error("разобранная преемница не замечена: свёртка вне порядка журнала осталась бы молчащей")
|
||||
}
|
||||
|
||||
// Сама преемница расхождением не является: предикат строго «позже».
|
||||
found, err = st.ParsedAfter(ctx, late, "d-late")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsedAfter: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("доставка засчитала расхождением саму себя")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import "context"
|
||||
// сущностях, где ту же ошибку повторили молча.
|
||||
//
|
||||
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
|
||||
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
|
||||
// род метрики будет измерен, — то есть правка одного экземпляра при живом
|
||||
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
|
||||
// свёртки молча.
|
||||
// отношения разные. Правка одного экземпляра при живом втором уже случилась:
|
||||
// тай-брейк точек сменён с байтового порядка на «побеждает пришедшая», а
|
||||
// тай-брейк сущностей остался позицией журнала — потому что у сущности есть
|
||||
// колонка провенанса, а у точки нет. Механизм при этом не разъехался ровно
|
||||
// благодаря общему помощнику; критерий выбора между двумя правилами записан в
|
||||
// `architecture.md`, раздел «Разрешение столкновений».
|
||||
//
|
||||
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
|
||||
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
|
||||
|
||||
Reference in New Issue
Block a user