Files
healthlog/internal/store/bucket_test.go
T
av c9ddf0b4dd хранение точек часовыми объектами
- слияние read-modify-write в одной транзакции, ключ точки — интервал,
  победа более полной точки, тай-брейк по канонической форме
- хеш объекта как детектор изменений: совпал — записи нет
- _txlock=immediate и повтор всей тройки при SQLITE_BUSY/BUSY_SNAPSHOT
- миграции через goose.Provider: пакетные SetBaseFS/SetDialect писали глобалы
  и давали гонку при двух Open
2026-08-01 17:37:50 +03:00

532 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store_test
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func open(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func ts(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("метка %q: %v", s, err)
}
return v.UTC()
}
func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPoint {
t.Helper()
return store.IncomingPoint{
Metric: metric,
Layer: layer,
Units: "count",
Point: store.Point{
Start: ts(t, start),
End: ts(t, end),
OffsetSeconds: 3 * 3600,
Raw: json.RawMessage(raw),
},
}
}
func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
}
stats, err := st.MergePoints(ctx, in, "delivery-1")
if err != nil {
t.Fatalf("слияние: %v", err)
}
if stats.Buckets != 2 {
t.Errorf("объектов %d, ожидалось 2 (два разных часа)", stats.Buckets)
}
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение объекта: %v", err)
}
if len(b.Points) != 2 {
t.Fatalf("точек в объекте %d, ожидалось 2", len(b.Points))
}
if b.Units != "count" {
t.Errorf("единицы %q потеряны: внутри точки их нет", b.Units)
}
if b.Delivery != "delivery-1" {
t.Errorf("провенанс %q, ожидался delivery-1", b.Delivery)
}
if !b.FirstTS.Equal(ts(t, "2025-06-05T10:00:00Z")) || !b.LastTS.Equal(ts(t, "2025-06-05T10:30:00Z")) {
t.Errorf("границы содержимого %v..%v", b.FirstTS, b.LastTS)
}
}
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
// сохранённые остаются. Точки из объекта не удаляются никогда.
func TestMergePointsДозаписьНеТеряетСохранённое(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
first := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
}
second := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
}
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
if _, err := st.MergePoints(ctx, second, "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 len(b.Points) != 2 {
t.Fatalf("точек %d, ожидалось 2: дозапись затёрла сохранённое", len(b.Points))
}
if b.Delivery != "d1" {
t.Errorf("провенанс %q: объект создала первая доставка", b.Delivery)
}
}
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
// делает широкие проходы синхронизации дешёвыми.
func TestMergePointsПовторНеПишет(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1,"source":"Device A"}`),
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
stats, err := st.MergePoints(ctx, in, "d2")
if err != nil {
t.Fatalf("повторное слияние: %v", err)
}
if stats.Unchanged != 1 {
t.Errorf("объектов без изменений %d, ожидался 1", stats.Unchanged)
}
after, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение после повтора: %v", err)
}
if before.Hash != after.Hash {
t.Error("хеш изменился при повторе того же содержимого")
}
if len(after.Points) != len(before.Points) {
t.Errorf("точек стало %d вместо %d", len(after.Points), len(before.Points))
}
}
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
// имеет права от него зависеть.
func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2}`),
point(t, "step_count", "minute", "2025-06-05T10:02:00Z", "2025-06-05T10:02:00Z", `{"qty":3}`),
}
reversed := []store.IncomingPoint{in[2], in[1], in[0]}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
stats, err := st.MergePoints(ctx, reversed, "d2")
if err != nil {
t.Fatalf("слияние в обратном порядке: %v", err)
}
if stats.Unchanged != 1 {
t.Error("перестановка точек засчиталась изменением")
}
second, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if first.Hash != second.Hash {
t.Error("хеш зависит от порядка точек в доставке")
}
}
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
// по времени приёма неприменим в принципе.
func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := []store.IncomingPoint{
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-05T19:16:00Z", `{"qty":0.2,"value":"Во сне"}`),
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-05T23:21:00Z", `{"qty":4.28,"value":"В кровати"}`),
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("слияние: %v", err)
}
b, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-05T19:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if len(b.Points) != 3 {
t.Fatalf("точек %d, ожидалось 3: ключ схлопнул записи с одной меткой", len(b.Points))
}
// Повтор той же тройки не задваивает: координата включает интервал, и он
// совпадает.
stats, err := st.MergePoints(ctx, in, "d2")
if err != nil {
t.Fatalf("повтор: %v", err)
}
if stats.Unchanged != 1 {
t.Errorf("повтор записей засчитан изменением: %+v", stats)
}
}
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
// выбор сделал бы принадлежность объекту зависящей от длительности.
func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
in := []store.IncomingPoint{
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("слияние: %v", err)
}
if _, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-05T19:00:00Z")); err != nil {
t.Fatalf("объект часа начала не найден: %v", err)
}
if _, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-06T04:00:00Z")); !errors.Is(err, store.ErrNotFound) {
t.Error("эпизод попал ещё и в объект часа окончания")
}
}
// Правило разрешения столкновений: выигрывает более полная точка, а не
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
// сама не несёт.
func TestMergePointsБеднаяТочкаНеСтираетБогатую(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":60,"Min":55,"Max":70,"context":"Отдых"}`)
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":60,"Min":55,"Max":70}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "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 len(b.Points) != 1 {
t.Fatalf("точек %d, ожидалась 1", len(b.Points))
}
if !strings.Contains(string(b.Points[0].Raw), "context") {
t.Errorf("бедная точка стёрла поле context: %s", b.Points[0].Raw)
}
if stats.Overwrites != 1 {
t.Errorf("столкновений %d, ожидалось 1: различие обязано оставить след", stats.Overwrites)
}
}
// source нестабилен: то же измерение приезжает то с одним именем устройства,
// то с другим. Он не входит в ключ и не считается полнотой.
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":1,"source":"Apple Watch Ultra 3|iPad (Anton)"}`)
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":1,"source":"Apple Watch Ultra 3"}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
if _, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2"); 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)
}
if len(got.Points) != 1 {
t.Fatalf("точек %d, ожидалась 1: смена source задвоила точку", len(got.Points))
}
}
// Исход столкновения точек равной полноты обязан зависеть только от значений:
// свёртка по журналу должна давать то же состояние, что приём в реальном
// времени, а внутри одной доставки время приёма общее.
func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(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}`)
winner := func(order []store.IncomingPoint) string {
st := open(t)
for _, p := range order {
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "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 {
t.Fatalf("чтение: %v", err)
}
return string(got.Points[0].Raw)
}
forward := winner([]store.IncomingPoint{a, b})
backward := winner([]store.IncomingPoint{b, a})
if forward != backward {
t.Errorf("исход зависит от порядка доставок: %s против %s", forward, backward)
}
}
// Содержимое точки хранится исходными байтами: пересборка повторной
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
// разобранное с разобранным.
func TestMergePointsХранитТочкуДословно(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
raw := "{\"exact\":1.0,\"huge\":9007199254740993,\"tail\":0.095231829624713534,\"broken\":\"a\\ufffdb\"}"
in := []store.IncomingPoint{
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
t.Fatalf("слияние: %v", err)
}
b, err := st.Bucket(ctx, "unknown", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != raw {
t.Errorf("точка изменилась при хранении:\n было %s\n стало %s", raw, got)
}
}
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
// Конкурентное слияние того же часа не имеет права терять точки: между
// чтением и записью может вклиниться другая доставка.
func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
const writers = 8
const perWriter = 25
var wg sync.WaitGroup
errs := make(chan error, writers)
for w := range writers {
wg.Go(func() {
for i := range perWriter {
minute := w*perWriter + i
at := ts(t, "2025-06-05T10:00:00Z").Add(time.Duration(minute) * time.Second)
p := store.IncomingPoint{
Metric: "step_count",
Layer: "raw",
Units: "count",
Point: store.Point{
Start: at,
End: at,
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
},
}
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
errs <- err
return
}
}
})
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("конкурентное слияние: %v", err)
}
b, err := st.Bucket(ctx, "step_count", "raw", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if len(b.Points) != writers*perWriter {
t.Errorf("точек %d, ожидалось %d: конкурентная запись потеряла точки",
len(b.Points), writers*perWriter)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
hour := ts(t, "2025-06-05T10:00:00Z")
first := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
}
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
t.Fatalf("пометка sealed: %v", err)
}
late := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
}
stats, err := st.MergePoints(ctx, late, "d2")
if err != nil {
t.Fatalf("досчёт запечатанного часа: %v", err)
}
if stats.SealedHits != 1 {
t.Errorf("изменений запечатанного часа %d, ожидалось 1", stats.SealedHits)
}
b, err := st.Bucket(ctx, "step_count", "minute", hour)
if err != nil {
t.Fatalf("чтение: %v", err)
}
if len(b.Points) != 2 {
t.Errorf("точек %d, ожидалось 2: данные обязаны писаться несмотря на sealed", len(b.Points))
}
if !b.Sealed {
t.Error("признак sealed сброшен дозаписью")
}
}
// Отмена посреди слияния не имеет права оставить половину: объект либо
// прежний, либо полный.
func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) {
t.Parallel()
st := open(t)
base := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
}
if _, err := st.MergePoints(context.Background(), base, "d1"); err != nil {
t.Fatalf("первое слияние: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
more := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
}
if _, err := st.MergePoints(ctx, more, "d2"); err == nil {
t.Fatal("слияние на отменённом контексте прошло успешно")
}
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if len(b.Points) != 1 {
t.Errorf("точек %d, ожидалась 1: отмена оставила половинчатое состояние", len(b.Points))
}
}