хранение точек часовыми объектами

- слияние read-modify-write в одной транзакции, ключ точки — интервал,
  победа более полной точки, тай-брейк по канонической форме
- хеш объекта как детектор изменений: совпал — записи нет
- _txlock=immediate и повтор всей тройки при SQLITE_BUSY/BUSY_SNAPSHOT
- миграции через goose.Provider: пакетные SetBaseFS/SetDialect писали глобалы
  и давали гонку при двух Open
This commit is contained in:
av
2026-08-01 17:37:50 +03:00
parent 01d0de59df
commit c9ddf0b4dd
5 changed files with 1143 additions and 16 deletions
+531
View File
@@ -0,0 +1,531 @@
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))
}
}