Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Запросы каталога вынесены константами: по ним же проверяется план выполнения.
|
||||
// Обе выборки обязаны отвечать по покрывающему индексу `bucket_catalog`, не
|
||||
// касаясь строк таблицы — вместе со строкой пришло бы и сжатое содержимое.
|
||||
const (
|
||||
layerRangesQuery = `
|
||||
SELECT metric, layer, units, min(first_ts), max(last_ts), sum(points)
|
||||
FROM bucket
|
||||
GROUP BY metric, layer, units
|
||||
ORDER BY metric, layer, units`
|
||||
|
||||
// Общие часы: самые свежие часы, за которые у метрики есть объекты обоих
|
||||
// слоёв. Возвращаются вместе с числом точек и единицами каждой стороны —
|
||||
// эти колонки лежат в том же покрывающем индексе, а стоят они того, чтобы
|
||||
// решать пригодность часа НЕ разжимая содержимое.
|
||||
//
|
||||
// Горизонт (`f.hour_utc <= ?`) обязателен. Час объекта берётся из метки в
|
||||
// теле доставки, а тело мы не контролируем: без верхней границы одна
|
||||
// доставка с метками в будущем занимает всё окно и подменяет измеренный род
|
||||
// метрики. Свидетельство из будущего свидетельством не является.
|
||||
commonHoursQuery = `
|
||||
SELECT f.hour_utc, f.points, f.units, c.points, c.units
|
||||
FROM bucket f
|
||||
JOIN bucket c ON c.metric = ? AND c.layer = ? AND c.hour_utc = f.hour_utc
|
||||
WHERE f.metric = ? AND f.layer = ? AND f.hour_utc <= ?
|
||||
ORDER BY f.hour_utc DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
|
||||
// hourPairsQuery строит выборку объектов окна: ОДИН запрос на любое число
|
||||
// часов. Отдельной функцией потому, что число подстановок переменное, а форма
|
||||
// «один запрос независимо от размера окна» — проверяемое свойство, а не
|
||||
// намерение.
|
||||
func hourPairsQuery(hours int) string {
|
||||
return `
|
||||
SELECT layer, hour_utc, payload FROM bucket
|
||||
WHERE metric = ? AND layer IN (?, ?) AND hour_utc IN (` +
|
||||
strings.TrimSuffix(strings.Repeat("?,", hours), ",") + `)`
|
||||
}
|
||||
|
||||
// LayerRange — один разрез метрики: слой, единицы, границы данных, число точек.
|
||||
//
|
||||
// Границы — это границы ДАННЫХ, а не обещание покрытия: внутри диапазона законно
|
||||
// есть дыры (часы без доставок, периоды, чьи верхние слои не пережили
|
||||
// пересборку). Единицы входят в разрез потому, что группировка идёт вместе с
|
||||
// ними: расхождение единиц у объектов одной метрики обязано быть видно строкой,
|
||||
// а не выбираться молча.
|
||||
type LayerRange struct {
|
||||
Metric string
|
||||
Layer string
|
||||
Units string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Points int
|
||||
}
|
||||
|
||||
// HourPair — час, за который у метрики есть объекты обоих слоёв сразу.
|
||||
// Именно на таких часах и держится измерение рода агрегации.
|
||||
//
|
||||
// Точки заполнены не всегда: содержимое читается только у часов, прошедших
|
||||
// предварительный отбор по учётным колонкам (см. CatalogWindow). У остальных
|
||||
// известны число точек и единицы — этого хватает, чтобы признать час
|
||||
// непригодным, не разжимая ни байта.
|
||||
type HourPair struct {
|
||||
Hour time.Time
|
||||
FinePoints int
|
||||
FineUnits string
|
||||
Fine []Point
|
||||
CoarsePoints int
|
||||
CoarseUnits string
|
||||
Coarse []Point
|
||||
}
|
||||
|
||||
// CatalogWindow — что считать окном измерения. Все числа задаёт вызывающий:
|
||||
// правило измерения принадлежит домену, хранилище лишь выбирает по нему строки.
|
||||
type CatalogWindow struct {
|
||||
// Fine и Coarse — мелкий и крупный слои сверки.
|
||||
Fine, Coarse string
|
||||
// Hours — сколько самых свежих общих часов брать.
|
||||
Hours int
|
||||
// Horizon — верхняя граница: часы позже неё в окно не входят.
|
||||
Horizon time.Time
|
||||
// CoarsePoints — сколько точек обязан нести объект крупного слоя, чтобы час
|
||||
// стоило читать. MinFinePoints — сколько минимум обязан нести мелкий.
|
||||
// Отбор ПРЕДВАРИТЕЛЬНЫЙ и строго слабее правила вердикта: он экономит
|
||||
// разжатие заведомо непригодных часов, а решение принимает домен.
|
||||
CoarsePoints int
|
||||
MinFinePoints int
|
||||
}
|
||||
|
||||
// CatalogSnapshot — весь вход каталога, снятый ОДНОЙ транзакцией чтения.
|
||||
//
|
||||
// Единый снимок здесь не аккуратность. Приём идёт непрерывно, и фоновая свёртка
|
||||
// пишет в витрину во время запроса: разрезы, снятые до её коммита, и объекты
|
||||
// окна, прочитанные после, дали бы ответ, внутренне противоречивый и
|
||||
// неотличимый от обычного свежего. Тот же довод записан у отпечатка витрины, и
|
||||
// второй его экземпляр разошёлся бы с первым молча.
|
||||
type CatalogSnapshot struct {
|
||||
// Layers — разрезы всех метрик, упорядоченные по метрике, слою и единицам.
|
||||
Layers []LayerRange
|
||||
// Pairs — по метрике её общие часы, от САМЫХ СВЕЖИХ к старым. Метрики, у
|
||||
// которой нет объектов обоих слоёв, в карте нет вовсе.
|
||||
Pairs map[string][]HourPair
|
||||
}
|
||||
|
||||
// ReadCatalog снимает вход каталога: разрезы всех метрик и объекты окна.
|
||||
//
|
||||
// Число обращений к базе — `1 + 2×метрик` и от размера окна НЕ зависит: объекты
|
||||
// окна читаются пакетом, одним запросом на метрику. Чтение по объекту за раз
|
||||
// давало бы под сотню обращений на метрику, каждое своей транзакцией.
|
||||
//
|
||||
// Содержимое разжимается только у часов, прошедших отбор по учётным колонкам.
|
||||
// Разжимать всё подряд означало бы платить памятью за часы, чей вердикт заранее
|
||||
// известен: замер на раздутой витрине давал 109 МиБ аллокаций при нуле
|
||||
// пригодных часов.
|
||||
func (s *Store) ReadCatalog(ctx context.Context, w CatalogWindow) (CatalogSnapshot, error) {
|
||||
out := CatalogSnapshot{Pairs: make(map[string][]HourPair)}
|
||||
switch {
|
||||
case w.Hours <= 0:
|
||||
return CatalogSnapshot{}, fmt.Errorf("окно каталога: часов должно быть больше нуля, задано %d", w.Hours)
|
||||
case w.Fine == w.Coarse:
|
||||
return CatalogSnapshot{}, fmt.Errorf("окно каталога: слои сверки совпадают (%q)", w.Fine)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, fmt.Errorf("begin read tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
out.Layers, err = readLayerRanges(ctx, tx)
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
|
||||
for _, metric := range metricsWithBothLayers(out.Layers, w.Fine, w.Coarse) {
|
||||
pairs, err := commonHours(ctx, tx, metric, w)
|
||||
if err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := readHourPairs(ctx, tx, metric, w, pairs); err != nil {
|
||||
return CatalogSnapshot{}, err
|
||||
}
|
||||
out.Pairs[metric] = pairs
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readLayerRanges отвечает по покрывающему индексу: содержимое объектов ради
|
||||
// границ и счётчиков не разжимается и даже не читается.
|
||||
func readLayerRanges(ctx context.Context, tx *sql.Tx) ([]LayerRange, error) {
|
||||
rows, err := tx.QueryContext(ctx, layerRangesQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select layer ranges: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]LayerRange, 0, 64)
|
||||
for rows.Next() {
|
||||
var r LayerRange
|
||||
var from, to string
|
||||
if err := rows.Scan(&r.Metric, &r.Layer, &r.Units, &from, &to, &r.Points); err != nil {
|
||||
return nil, fmt.Errorf("scan layer range: %w", err)
|
||||
}
|
||||
if r.From, err = ParseTime(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.To, err = ParseTime(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("select layer ranges: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// metricsWithBothLayers отбирает метрики, у которых есть оба слоя. Без отбора
|
||||
// пришлось бы спрашивать общие часы у метрик, где второго слоя заведомо нет, —
|
||||
// два лишних запроса на каждую.
|
||||
func metricsWithBothLayers(layers []LayerRange, fine, coarse string) []string {
|
||||
seen := make(map[string]map[string]bool, len(layers))
|
||||
order := make([]string, 0, len(layers))
|
||||
for _, l := range layers {
|
||||
set, ok := seen[l.Metric]
|
||||
if !ok {
|
||||
set = map[string]bool{}
|
||||
seen[l.Metric] = set
|
||||
order = append(order, l.Metric)
|
||||
}
|
||||
set[l.Layer] = true
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(order))
|
||||
for _, m := range order {
|
||||
if seen[m][fine] && seen[m][coarse] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commonHours возвращает самые свежие часы окна, от свежих к старым, вместе с
|
||||
// учётными колонками обеих сторон. Содержимого не читает.
|
||||
func commonHours(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow) ([]HourPair, error) {
|
||||
rows, err := tx.QueryContext(ctx, commonHoursQuery,
|
||||
metric, w.Coarse, metric, w.Fine, FormatTime(w.Horizon), w.Hours)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select common hours: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]HourPair, 0, w.Hours)
|
||||
for rows.Next() {
|
||||
var p HourPair
|
||||
var raw string
|
||||
if err := rows.Scan(&raw, &p.FinePoints, &p.FineUnits, &p.CoarsePoints, &p.CoarseUnits); err != nil {
|
||||
return nil, fmt.Errorf("scan common hour: %w", err)
|
||||
}
|
||||
if p.Hour, err = ParseTime(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("select common hours: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readHourPairs дочитывает содержимое объектов — ОДНИМ запросом и только у
|
||||
// часов, прошедших отбор по учётным колонкам.
|
||||
func readHourPairs(ctx context.Context, tx *sql.Tx, metric string, w CatalogWindow, pairs []HourPair) error {
|
||||
at := make(map[string]int, len(pairs))
|
||||
args := make([]any, 0, len(pairs)+3)
|
||||
args = append(args, metric, w.Fine, w.Coarse)
|
||||
for i := range pairs {
|
||||
if pairs[i].CoarsePoints != w.CoarsePoints || pairs[i].FinePoints < w.MinFinePoints {
|
||||
continue
|
||||
}
|
||||
key := FormatTime(pairs[i].Hour)
|
||||
at[key] = i
|
||||
args = append(args, key)
|
||||
}
|
||||
if len(at) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, hourPairsQuery(len(at)), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("select hour pairs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var layer, hour string
|
||||
var payload []byte
|
||||
if err := rows.Scan(&layer, &hour, &payload); err != nil {
|
||||
return fmt.Errorf("scan hour pair: %w", err)
|
||||
}
|
||||
i, ok := at[hour]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points, err := decodePayload(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if layer == w.Fine {
|
||||
pairs[i].Fine = points
|
||||
} else {
|
||||
pairs[i].Coarse = points
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("select hour pairs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// План выполнения — единственный оракул требования «каталог не читает
|
||||
// содержимого объектов». `bucket` объявлена WITHOUT ROWID, то есть строка
|
||||
// целиком, вместе со сжатым payload, живёт в дереве первичного ключа: обход по
|
||||
// нему тащил бы страницы содержимого. Проверяется, что обе выборки идут по
|
||||
// ПОКРЫВАЮЩЕМУ индексу — по нему таблица не открывается вовсе.
|
||||
func TestПланЗапросовКаталогаИдётПоИндексу(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{"разрезы метрик", layerRangesQuery, nil},
|
||||
{"общие часы двух слоёв", commonHoursQuery,
|
||||
[]any{"step_count", "hour", "step_count", "minute", "2026-08-02T00:00:00Z", 48}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plan := explain(t, st, c.sql, c.args...)
|
||||
t.Logf("план: %s", plan)
|
||||
if !strings.Contains(plan, "COVERING INDEX bucket_catalog") {
|
||||
t.Errorf("выборка не идёт по покрывающему индексу:\n%s", plan)
|
||||
}
|
||||
// Обращение к самой таблице выглядит в плане как SCAN/SEARCH bucket
|
||||
// без слова INDEX — именно оно и тащило бы страницы содержимого.
|
||||
for _, line := range strings.Split(plan, "\n") {
|
||||
if strings.Contains(line, "bucket") && !strings.Contains(line, "INDEX") {
|
||||
t.Errorf("строка плана читает таблицу, а не индекс: %q", line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func explain(t *testing.T, st *Store, query string, args ...any) string {
|
||||
t.Helper()
|
||||
|
||||
rows, err := st.db.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id, parent, notused int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||
t.Fatalf("разбор плана: %v", err)
|
||||
}
|
||||
out = append(out, detail)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("план пуст — проверять нечего")
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// Свойство, ради которого выборка объектов окна вынесена отдельной функцией:
|
||||
// сколько бы часов ни было в окне, запрос ОДИН. Чтение по объекту за раз давало
|
||||
// бы под сотню обращений на метрику и столько же снимков витрины.
|
||||
func TestВыборкаОкнаОстаётсяОднимЗапросом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, hours := range []int{1, 2, 48, 200} {
|
||||
q := hourPairsQuery(hours)
|
||||
if n := strings.Count(q, ";"); n != 0 {
|
||||
t.Errorf("часов %d: в запросе %d разделителей — это уже не один запрос", hours, n)
|
||||
}
|
||||
// Три подстановки на метрику и слои плюс по одной на час.
|
||||
if got, want := strings.Count(q, "?"), hours+3; got != want {
|
||||
t.Errorf("часов %d: подстановок %d, ждали %d", hours, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func window(hours int, horizon time.Time) store.CatalogWindow {
|
||||
return store.CatalogWindow{
|
||||
Fine: "minute", Coarse: "hour",
|
||||
Hours: hours, Horizon: horizon,
|
||||
CoarsePoints: 1, MinFinePoints: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// hourly кладёт метрику в оба слоя за `hours` часов начиная с `base`.
|
||||
func hourly(t *testing.T, st *store.Store, metric, units string, base time.Time, hours int) {
|
||||
t.Helper()
|
||||
|
||||
var in []store.IncomingPoint
|
||||
for i := range hours {
|
||||
h := base.Add(time.Duration(i) * time.Hour)
|
||||
in = append(in, store.IncomingPoint{
|
||||
Metric: metric, Layer: "hour", Units: units,
|
||||
Point: store.Point{Start: h, End: h, Raw: json.RawMessage(`{"qty":6}`)},
|
||||
})
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
|
||||
at := h.Add(time.Duration(m) * time.Minute)
|
||||
in = append(in, store.IncomingPoint{
|
||||
Metric: metric, Layer: "minute", Units: units,
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: in},
|
||||
store.DeliveryRef{ID: "delivery"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCatalogОтдаётРазрезыИОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 3)
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if len(snap.Layers) != 2 {
|
||||
t.Fatalf("разрезов %d, ждали 2: %+v", len(snap.Layers), snap.Layers)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 3 {
|
||||
t.Fatalf("общих часов %d, ждали 3", len(pairs))
|
||||
}
|
||||
// От свежих к старым — на этот порядок опирается и окно, и границы основания.
|
||||
if !pairs[0].Hour.After(pairs[len(pairs)-1].Hour) {
|
||||
t.Errorf("часы пришли не от свежих к старым: %v", pairs[0].Hour)
|
||||
}
|
||||
if len(pairs[0].Coarse) != 1 || len(pairs[0].Fine) != 3 {
|
||||
t.Errorf("содержимое пары не прочитано: %+v", pairs[0])
|
||||
}
|
||||
if pairs[0].FineUnits != "count" || pairs[0].CoarseUnits != "count" {
|
||||
t.Errorf("единицы сторон не прочитаны: %+v", pairs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Час объекта берётся из метки в теле доставки. Без горизонта одна доставка с
|
||||
// метками в будущем занимает окно целиком и подменяет измеренный род.
|
||||
func TestReadCatalogОтсекаетБудущиеЧасы(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 2)
|
||||
hourly(t, st, "step_count", "count", ts(t, "2099-01-01T00:00:00Z"), 5)
|
||||
|
||||
horizon := base.Add(24 * time.Hour)
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, horizon))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 2 {
|
||||
t.Fatalf("часов в окне %d, ждали 2: будущее не отсечено", len(pairs))
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if p.Hour.After(horizon) {
|
||||
t.Errorf("час %v позже горизонта %v", p.Hour, horizon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Окно ограничено сверху и берёт самые свежие часы.
|
||||
func TestReadCatalogОграничиваетОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
hourly(t, st, "step_count", "count", base, 10)
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(4, base.Add(48*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["step_count"]
|
||||
if len(pairs) != 4 {
|
||||
t.Fatalf("часов %d, ждали 4", len(pairs))
|
||||
}
|
||||
if !pairs[0].Hour.Equal(base.Add(9 * time.Hour)) {
|
||||
t.Errorf("самый свежий час %v, ждали %v", pairs[0].Hour, base.Add(9*time.Hour))
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое читается только у часов, прошедших отбор по учётным колонкам:
|
||||
// разжимать заведомо непригодный час незачем, а знать о нём — обязательно.
|
||||
func TestReadCatalogНеЧитаетСодержимоеНепригодныхЧасов(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
base := ts(t, "2026-06-01T00:00:00Z")
|
||||
in := []store.IncomingPoint{
|
||||
// Часовой объект с двумя точками: час непригоден.
|
||||
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: base, End: base, Raw: json.RawMessage(`{"qty":1}`)}},
|
||||
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: base, End: base.Add(time.Hour), Raw: json.RawMessage(`{"qty":1}`)}},
|
||||
}
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`} {
|
||||
at := base.Add(time.Duration(m) * time.Minute)
|
||||
in = append(in, store.IncomingPoint{Metric: "apple_stand_hour", Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)}})
|
||||
}
|
||||
if _, err := st.Merge(t.Context(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
pairs := snap.Pairs["apple_stand_hour"]
|
||||
if len(pairs) != 1 {
|
||||
t.Fatalf("часов %d, ждали 1", len(pairs))
|
||||
}
|
||||
if pairs[0].CoarsePoints != 2 || pairs[0].FinePoints != 2 {
|
||||
t.Errorf("учётные колонки не прочитаны: %+v", pairs[0])
|
||||
}
|
||||
if pairs[0].Coarse != nil || pairs[0].Fine != nil {
|
||||
t.Errorf("содержимое непригодного часа разжато напрасно: %+v", pairs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCatalogОтвергаетНеверноеОкно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
cases := map[string]store.CatalogWindow{
|
||||
"нулевое окно": {Fine: "minute", Coarse: "hour", Hours: 0},
|
||||
"слои совпадают": {Fine: "minute", Coarse: "minute", Hours: 48},
|
||||
"окно отрицательно": {Fine: "minute", Coarse: "hour", Hours: -1},
|
||||
}
|
||||
for name, w := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := st.ReadCatalog(context.Background(), w); err == nil {
|
||||
t.Error("окно вне контракта принято без ошибки")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- +goose Up
|
||||
-- Каталог разрезов отвечает по учётным колонкам объекта, а не по его
|
||||
-- содержимому. Без индекса это невозможно: `bucket` объявлена `WITHOUT ROWID`,
|
||||
-- то есть строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
|
||||
-- ключа. Агрегат по всем строкам тащил бы за собой страницы содержимого — при
|
||||
-- 260 тысячах объектов за год это сотни мегабайт чтения на каждый запрос
|
||||
-- каталога, притом что сам ответ несёт три десятка строк.
|
||||
--
|
||||
-- Индекс ПОКРЫВАЮЩИЙ: в нём есть всё, что спрашивает каталог, поэтому обращения
|
||||
-- к самой таблице не будет вовсе. Порядок колонок задан двумя запросами:
|
||||
--
|
||||
-- 1. разрезы метрики — GROUP BY metric, layer (+ units, чтобы расхождение
|
||||
-- единиц было видно строкой, а не выбиралось молча);
|
||||
-- 2. общие часы двух слоёв — обход hour_utc по убыванию внутри (metric,
|
||||
-- layer), поэтому hour_utc стоит третьим и до колонок значений.
|
||||
--
|
||||
-- Первичный ключ (metric, layer, hour_utc) в индекс дописывается самим SQLite:
|
||||
-- у таблицы `WITHOUT ROWID` строка адресуется им.
|
||||
--
|
||||
-- Цена — около 60 байт на объект (≈16 МБ за год) и одна вставка в дерево на
|
||||
-- запись объекта. Платит её только настоящее изменение: широкий проход, у
|
||||
-- которого сошёлся хеш содержимого, объект не переписывает вовсе.
|
||||
CREATE INDEX bucket_catalog ON bucket (metric, layer, hour_utc, first_ts, last_ts, points, units);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX bucket_catalog;
|
||||
Reference in New Issue
Block a user