package store_test import ( "context" "errors" "fmt" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) // Очередь свёртки — сама таблица, и порядок её обхода это порядок журнала: // `(received_at, id)`, тот же, в котором проигрывает пересборка. func TestPendingDeliveriesИдётВПорядкеЖурнала(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() at := ts(t, "2026-08-01T12:00:00Z") // Учёт заполняется в порядке, обратном хронологии, и доставки одной секунды // различаются только идентификатором. seedPending(t, st, "d3", at.Add(time.Second)) seedPending(t, st, "d2", at) seedPending(t, st, "d1", at) got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10) if err != nil { t.Fatalf("PendingDeliveries: %v", err) } want := []string{"d1", "d2", "d3"} if len(got) != len(want) { t.Fatalf("выбрано %d доставок, ожидалось %d", len(got), len(want)) } for i, id := range want { if got[i].ID != id { t.Errorf("на месте %d доставка %q, ожидалась %q", i, got[i].ID, id) } } } // Курсор строго возрастает, и обход им конечен: без этого доставка, у которой // не удалось записать даже исход разбора, выбиралась бы бесконечно. func TestPendingDeliveriesКурсорСтрогоВозрастает(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() at := ts(t, "2026-08-01T12:00:00Z") seedPending(t, st, "d1", at) seedPending(t, st, "d2", at) first, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 1) if err != nil { t.Fatalf("PendingDeliveries: %v", err) } if len(first) != 1 || first[0].ID != "d1" { t.Fatalf("первая порция %+v", first) } second, err := st.PendingDeliveries(ctx, first[0], 1) if err != nil { t.Fatalf("PendingDeliveries: %v", err) } if len(second) != 1 || second[0].ID != "d2" { t.Fatalf("вторая порция %+v", second) } // Доставка, оставшаяся `pending`, за курсором больше не выбирается — именно // на этом стоит завершимость прохода воркера. third, err := st.PendingDeliveries(ctx, second[0], 1) if err != nil { t.Fatalf("PendingDeliveries: %v", err) } if len(third) != 0 { t.Errorf("за последней доставкой выбрано %d строк", len(third)) } } // В очередь попадают только неразобранные: свёрнутая доставка из неё выбывает, // иначе воркер сворачивал бы весь журнал на каждом проходе. func TestPendingDeliveriesБерётТолькоНеразобранные(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() at := ts(t, "2026-08-01T12:00:00Z") seedPending(t, st, "d1", at) seedPending(t, st, "d2", at.Add(time.Second)) if err := st.FinishParse(ctx, "d1", store.ParseOutcome{Status: store.ParseDone}); err != nil { t.Fatalf("FinishParse: %v", err) } n, err := st.CountPendingDeliveries(ctx) if err != nil { t.Fatalf("CountPendingDeliveries: %v", err) } if n != 1 { t.Errorf("задолженность %d, ожидалась 1", n) } got, err := st.PendingDeliveries(ctx, store.PendingDelivery{}, 10) if err != nil { t.Fatalf("PendingDeliveries: %v", err) } if len(got) != 1 || got[0].ID != "d2" { t.Errorf("в очереди %+v, ожидалась только d2", got) } } // Правило «отказ обстоятельств, а не данных» живёт в одном месте: его читают и // тот, кто пишет исход разбора, и тот, кто классифицирует этот исход. func TestTransientРазличаетОбстоятельстваИДанные(t *testing.T) { t.Parallel() cases := map[string]struct { err error want bool }{ "работу прекратили снаружи": {context.Canceled, true}, "база занята": {store.ErrBusy, true}, "база занята, обёрнута": {fmt.Errorf("слияние: %w", store.ErrBusy), true}, "не уложились в бюджет": {context.DeadlineExceeded, false}, "записи нет": {store.ErrNotFound, false}, "прочее": {errors.New("диск отвалился"), false}, "ошибки нет": {nil, false}, } for name, c := range cases { t.Run(name, func(t *testing.T) { t.Parallel() if got := store.Transient(c.err); got != c.want { t.Errorf("Transient(%v) = %v, ожидалось %v", c.err, got, c.want) } }) } } func seedPending(t *testing.T, st *store.Store, id string, at time.Time) { t.Helper() err := st.CreateDelivery(context.Background(), store.Delivery{ ID: id, ReceivedAt: at, RawPath: id + ".json.gz", SHA256: "-", ParseStatus: store.ParsePending, }) if err != nil { 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("доставка засчитала расхождением саму себя") } }