Files
healthlog/internal/store/entity.go
T
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

761 lines
36 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
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.vakhrushev.me/av/healthlog/internal/canon"
)
// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
// заголовок, по которому идёт выборка, а у записи его нет.
const (
workoutTable = "workout"
recordTable = "record"
)
// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
//
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
// сериализацией, теряет литерал ровно так же, как точка.
type IncomingEntity struct {
ID string
Kind string
Name string
Start time.Time
End time.Time
// OffsetSeconds — смещение зоны начала.
OffsetSeconds int
// Duration — длительность тренировки в секундах. nil означает «источник не
// прислал» и отличим от нуля: ноль — законная длительность.
Duration *float64
Raw json.RawMessage
}
// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
// не точка и не сущность: частичное состояние ломает инвариант «состояние
// пересобираемо».
type Incoming struct {
Points []IncomingPoint
Workouts []IncomingEntity
Records []IncomingEntity
// Categories — наблюдённые категориальные значения доставки. Пишутся той же
// транзакцией: состояние «точки легли, реестр нет» повторная свёртка не
// чинит — хеш содержимого сойдётся, и объекты переписываться не станут.
Categories []CategoryValue
}
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
// журнала задан парой `(received_at, id)`.
type DeliveryRef struct {
ID string
ReceivedAt time.Time
}
func (d DeliveryRef) before(other DeliveryRef) bool {
if !d.ReceivedAt.Equal(other.ReceivedAt) {
return d.ReceivedAt.Before(other.ReceivedAt)
}
return d.ID < other.ID
}
// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
// измерение душевного состояния.
type EntityRef struct {
Kind string
ID string
}
// maxEntityRefsReported — сколько координат сущностей попадает в лог.
const maxEntityRefsReported = 5
// prepareEntities считает хеш каждой сущности.
//
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
// базы.
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
if len(in) == 0 {
return nil, nil
}
out := make([]entityVersion, 0, len(in))
for _, e := range in {
v, err := newEntityVersion(e, from)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// clipRefs держит список координат в потолке: он зацепка для разбора, а не
// отчёт; масштаб события считает счётчик.
func clipRefs(refs []EntityRef) []EntityRef {
if len(refs) <= maxEntityRefsReported {
return refs
}
return refs[:maxEntityRefsReported]
}
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
// победителя.
//
// Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость
// здесь была мнимой: хеш всё равно требует полной канонической формы, то есть
// самая дорогая работа платилась на каждой копии и так, а отложенный разбор
// считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая
// открыта `immediate` и повторяется до пяти раз при занятости базы.
//
// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока
// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша
// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без
// разворачивания значений. Внутри транзакции для приехавших версий не остаётся
// ничего.
type entityVersion struct {
raw json.RawMessage
hash string
from DeliveryRef
key []byte
fields canon.Fields
// head — заголовок, который пишется колонками. У сохранённой версии он не
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
head IncomingEntity
}
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
v, err := analyzeVersion(e.Raw, from)
if err != nil {
return entityVersion{}, err
}
v.head = e
return v, nil
}
// newStoredVersion собирает версию, прочитанную из витрины.
//
// Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой
// версии — единственная работа, которая осталась внутри транзакции, открытой
// `immediate`. Замер на тренировке в 168 КБ: полная канонизация с хешем — 4.5 мс
// и 2.3 МБ на 38 тысячах аллокаций, множества ключей — 1.3 мс и 174 КБ на
// тридцати. Хеш сохранённой уже лежит колонкой, а форма нужна ровно в одной
// ветке тай-брейка (равные позиции журнала — та же доставка, свёрнутая
// повторно) и считается там лениво.
func newStoredVersion(raw json.RawMessage, hash string, from DeliveryRef) entityVersion {
return entityVersion{
raw: raw,
hash: hash,
from: from,
fields: canon.Analyze(raw),
}
}
func analyzeVersion(raw json.RawMessage, from DeliveryRef) (entityVersion, error) {
form, hash, err := canon.FormAndHash(raw)
if err != nil {
return entityVersion{}, fmt.Errorf("канонизация сущности: %w", err)
}
return entityVersion{
raw: raw,
hash: hash,
from: from,
key: form,
fields: canon.Analyze(raw),
}, nil
}
// sortKey отдаёт каноническую форму версии, считая её при необходимости.
//
// Ленивость здесь одна на весь файл и нужна ровно сохранённой версии: у неё
// форма требуется только в тай-брейке равных позиций журнала, а стоит она
// втрое дороже разбора и платится под блокировкой записи.
func (v *entityVersion) sortKey() []byte {
if v.key == nil {
v.key = canon.SortKey(v.raw)
}
return v.key
}
// pickEntity выбирает между сохранённой и приехавшей версией.
//
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
// есть плата за отказ объединять поля: событие не предотвращается молча, а
// считается и уходит в WARN.
//
// 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
// 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
// 4. наборы несравнимы → сохранённая
//
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
// становится видимой только после записи тела, и при конкурентном приёме
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
// незачем. Критерий выбора записан в architecture.md, «Разрешение
// столкновений».
//
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
// решает минимум канонической формы, потому что порядок элементов в
// JSON-массиве нестабилен.
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
switch v := compareEntities(stored, incoming); v {
case entityIncomingRicher:
return true, false
case entityStoredRicher:
return false, true
case entityIncomparable:
// Несравнимы: у каждой версии есть содержание, которого нет у другой.
// Объединение полей отвергнуто там же и по той же причине, что для
// точек, — на живом потоке событие не наступало ни разу, — а из двух
// версий остаётся сохранённая: правило называется «не теряет
// содержания», и приехавшая его теряет.
//
// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
// лежит победитель прошлых слияний, а не все кандидаты истории, и
// «сохранённая выигрывает» означает разный итог при разном порядке.
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
// не перегоняется своими преемницами — проход воркера прекращается на
// ней. Осталось окно конкурентного приёма, где строка учёта становится
// видимой позже метки; там живой приём и пересборка на несравнимых
// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
// удержаний ниже — второе, что о расхождении сообщает.
return false, true
default:
return laterInJournal(stored, incoming), false
}
}
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
type entityVerdict int
const (
// entityEqualContent — множества содержательных ключей совпадают, длины
// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
// сравнение здесь неприменимо (см. canon.Fields.Covers).
entityEqualContent entityVerdict = iota + 1
entityIncomingRicher
entityStoredRicher
entityIncomparable
)
func compareEntities(stored, incoming *entityVersion) entityVerdict {
storedCovers := stored.fields.Covers(incoming.fields)
incomingCovers := incoming.fields.Covers(stored.fields)
switch {
case incomingCovers && storedCovers:
return entityEqualContent
case incomingCovers:
return entityIncomingRicher
case storedCovers:
return entityStoredRicher
default:
return entityIncomparable
}
}
// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
// там решает минимум канонической формы — порядок тотальный и от порядка
// элементов в массиве не зависит.
func laterInJournal(stored, incoming *entityVersion) bool {
if stored.from.before(incoming.from) {
return true
}
if incoming.from.before(stored.from) {
return false
}
return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0
}
// entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и
// не покрывается в ответ.
//
// Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии
// могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и
// отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе.
func entityDominates(a, b entityVersion) bool {
return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields)
}
// entityLess — тотальный порядок на версиях равного содержания.
//
// Сперва каноническая форма, потом ИСХОДНЫЕ БАЙТЫ. Второй разряд не украшение:
// у сущностей версии с равной формой не схлопываются (в отличие от точек, где
// это делает дедупликация по ключу), а у HAE порядок ключей в JSON и запись
// числа нестабильны — то есть без него минимум неединствен, и в витрину лёг бы
// тот элемент, что стоял в массиве раньше. Порядок элементов на проводе не
// имеет права решать, какие байты хранятся.
func entityLess(a, b entityVersion) bool {
if c := bytes.Compare(a.key, b.key); c != 0 {
return c < 0
}
return bytes.Compare(a.raw, b.raw) < 0
}
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки — до сравнения
// с сохранённой.
//
// Победитель здесь — функция МНОЖЕСТВА версий, а не порядка элементов массива:
// сперва отбрасываются строго покрытые, среди оставшихся берётся минимум
// тотального порядка. Попарная свёртка была неверна ровно так же, как она была
// неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и
// вместе они дают нетранзитивную победу, при которой [A,B,C] и [B,C,A] дают
// разных победителей.
//
// Версии с СОВПАВШЕЙ канонической формой схлопываются ДО выбора победителя, и
// это не оптимизация ради красоты: выбор квадратичен по числу кандидатов, а их
// число приходит из чужого тела. Точки схлопываются так же и в том же месте
// (см. mergePoints). Внутри схлопнутой группы остаются минимальные байты —
// тот же второй разряд тотального порядка, что и между группами.
//
// Второй возврат — счётчик «в одном теле приехали версии одного ключа с РАЗНЫМ
// содержанием», симметричный по построению: считаются кандидаты, чья форма
// отличается от формы победителя. По форме, а не по байтам: порядок ключей у
// HAE нестабилен и дребезг последнего разряда тоже, так что побайтовый счётчик
// срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
// понадобился бы.
//
// Счётчик отдельный от «удержаний», а не общий с ними. Две версии в одном теле —
// это НЕ потеря содержания: победитель ложится в витрину целиком, и удерживать
// нечего. Смешивать их значило бы отвечать одним числом на два вопроса, которые
// лечатся по-разному, — а на число удержаний опирается единственный контроль
// того, что правило покрытия не стало слишком строгим.
func dedupeEntities(ctx context.Context, versions []entityVersion) ([]entityVersion, int, []EntityRef, error) {
byKey := make(map[EntityRef][]entityVersion, len(versions))
order := make([]EntityRef, 0, len(versions))
for _, v := range versions {
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
if _, seen := byKey[ref]; !seen {
order = append(order, ref)
}
byKey[ref] = append(byKey[ref], v)
}
out := make([]entityVersion, 0, len(order))
diverging := 0
var divergingAt []EntityRef
for _, ref := range order {
cands, dropped := collapseEqualForms(byKey[ref])
winner, _, err := pickBest(ctx, cands, entityDominates, entityLess)
if err != nil {
return nil, 0, nil, err
}
out = append(out, cands[winner])
// Схлопнутые копии победителя различием не считаются: их форма ему
// равна. Считаются все прочие — и оставшиеся кандидаты, и те, что
// схлопнулись в них.
differing := 0
for i := range cands {
if i != winner {
differing += 1 + dropped[i]
}
}
if differing > 0 {
diverging += differing
if len(divergingAt) < maxEntityRefsReported {
divergingAt = append(divergingAt, ref)
}
}
}
return out, diverging, divergingAt, nil
}
// collapseEqualForms схлопывает версии с одинаковой канонической формой в одну,
// оставляя минимальные исходные байты. Второй возврат — сколько копий сложилось
// в каждого оставшегося кандидата (нужно счётчику различий).
//
// Схлопывание обязательно, а не желательно: без него тело с двадцатью тысячами
// повторов одного `id` даёт четыреста миллионов сравнений покрытия, каждое с
// обходом массивов. Тело в пределах приёма такое вмещает.
func collapseEqualForms(versions []entityVersion) ([]entityVersion, []int) {
byForm := make(map[string]int, len(versions))
out := make([]entityVersion, 0, len(versions))
dropped := make([]int, 0, len(versions))
for _, v := range versions {
form := string(v.key)
i, seen := byForm[form]
if !seen {
byForm[form] = len(out)
out = append(out, v)
dropped = append(dropped, 0)
continue
}
dropped[i]++
if bytes.Compare(v.raw, out[i].raw) < 0 {
// Байты решают внутри группы ровно так же, как между группами:
// порядок элементов на проводе не имеет права выбирать содержимое.
// Заголовок едет вместе с байтами — он от них производен.
out[i] = v
}
}
return out, dropped
}
// mergeEntities сливает сущности одной секции с сохранёнными.
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
for _, v := range versions {
stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
if err != nil {
return 0, 0, nil, err
}
if !found {
if err := writeEntity(ctx, tx, table, v, now); err != nil {
return 0, 0, nil, err
}
written++
continue
}
// Хеш — детектор изменений: совпал, значит содержимое то же, и читать
// его не приходится вовсе. Тренировка переприсылается каждой доставкой,
// пока не доедет маршрут, — на живом архиве 44 копии дают три различных
// содержимых.
//
// Но провенанс при этом обновить НАДО. Сохранённая позиция журнала
// участвует в тай-брейке «содержание равно», и если в ней осталась
// первая свёрнутая копия вместо победителя журнала, отложенная доставка
// вернёт витрину к прежнему содержимому — то есть живая витрина
// разойдётся с пересборкой, молча и в содержимом тренировки.
//
// Предел назван вслух: обновляется провенанс, но НЕ байты. При
// совпавшей канонической форме в витрине остаются байты той доставки,
// что свернулась первой, — а порядок ключей у HAE нестабилен, значит у
// живого приёма и пересборки они могут различаться. Отпечаток этого не
// различает (он считает по канонической форме), содержания не теряется
// ничего, а переписывать мегабайтный маршрут на каждой из двадцати
// шести присылок ради выбора между эквивалентными литералами — цена
// несоразмерная.
if stored.hash == v.hash {
if stored.from.before(v.from) {
if err := touchEntityProvenance(ctx, tx, table, v); err != nil {
return 0, 0, nil, err
}
}
continue
}
storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
if err != nil {
return 0, 0, nil, err
}
prev := newStoredVersion(storedRaw, stored.hash, stored.from)
takeIncoming, lost := pickEntity(&prev, &v)
if lost {
held++
if len(heldAt) < maxEntityRefsReported {
heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
}
}
if !takeIncoming {
continue
}
if err := writeEntity(ctx, tx, table, v, now); err != nil {
return 0, 0, nil, err
}
written++
}
return written, held, heldAt, nil
}
// touchEntityProvenance поднимает провенанс сущности до более поздней доставки
// журнала, не трогая содержимое.
//
// `updated_at` НЕ двигается, и это отдельное решение, а не экономия. Тренировка
// приезжает до двадцати шести раз; бамп метки на каждой сделал бы её меткой
// касания строки, а не изменения содержимого, и потребитель запроса «что
// изменилось с момента X» получил бы двадцать шесть ложных изменений,
// неотличимых от настоящего досчёта. Провенанс несёт собственную метку —
// времени приёма своей доставки, — и для тай-брейка её достаточно.
//
// Счётчик записанных сущностей такое обновление тоже не увеличивает: он считает
// СОДЕРЖИМОЕ витрины, и сравнимость его с прежними замерами важнее учёта
// обновлённой ссылки.
func touchEntityProvenance(ctx context.Context, tx *sql.Tx, table string, v entityVersion) error {
q := `UPDATE ` + table + ` SET delivery_id = ?, delivery_received_at = ?` + entityWhere(table)
args := append([]any{v.from.ID, FormatTime(v.from.ReceivedAt)},
entityKeyArgs(table, v.head.Kind, v.head.ID)...)
res, err := tx.ExecContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("update %s provenance: %w", table, err)
}
// Строка гарантированно существует: её заголовок прочитан этой же
// транзакцией десятью строками выше. Ноль означал бы, что ключ собран не
// теми колонками, — а провенанс в отпечаток витрины не входит, значит
// молчаливый промах не поймает ни один оракул сходимости. Соседи по файлу
// (FinishParse, MarkSealed) проверяют по той же причине.
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update %s provenance: %w", table, err)
}
if n == 0 {
return fmt.Errorf("update %s provenance: %w", table, ErrNotFound)
}
return nil
}
type storedEntityHead struct {
hash string
from DeliveryRef
}
func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)
var (
head storedEntityHead
receivedAt string
)
row := queryEntity(ctx, tx, q, table, kind, id)
err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
if errors.Is(err, sql.ErrNoRows) {
return storedEntityHead{}, false, nil
}
if err != nil {
return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
}
// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
// сохранённую версию «самой ранней в журнале», и её затирала бы любая
// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
head.from.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return storedEntityHead{}, false, err
}
return head, true, nil
}
func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
q := `SELECT payload FROM ` + table + entityWhere(table)
var payload []byte
if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
return nil, fmt.Errorf("select %s payload: %w", table, err)
}
raw, err := gunzipBytes(payload)
if err != nil {
return nil, err
}
return raw, nil
}
// entityWhere и queryEntity держат разницу между таблицами в одном месте:
// у тренировки ключ — `id`, у записи — пара `kind + id`.
func entityWhere(table string) string {
if table == recordTable {
return ` WHERE kind = ? AND id = ?`
}
return ` WHERE id = ?`
}
// entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число
// `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их
// соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния,
// то есть на пути, который повторяется до пяти раз и оканчивается `failed` у
// доставки, а не отказом сборки.
func entityKeyArgs(table, kind, id string) []any {
if table == recordTable {
return []any{kind, id}
}
return []any{id}
}
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
return tx.QueryRowContext(ctx, q, entityKeyArgs(table, kind, id)...)
}
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
payload, err := gzipBytes(v.raw)
if err != nil {
return err
}
stamp := FormatTime(now)
received := FormatTime(v.from.ReceivedAt)
if table == recordTable {
const q = `
INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
delivery_id, delivery_received_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (kind, id) DO UPDATE SET
ts_utc = excluded.ts_utc,
tz_offset = excluded.tz_offset,
payload = excluded.payload,
content_hash = excluded.content_hash,
delivery_id = excluded.delivery_id,
delivery_received_at = excluded.delivery_received_at,
updated_at = excluded.updated_at`
if _, err := tx.ExecContext(ctx, q,
v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
return fmt.Errorf("upsert record: %w", err)
}
return nil
}
const q = `
INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
payload, content_hash, delivery_id, delivery_received_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
start_utc = excluded.start_utc,
end_utc = excluded.end_utc,
tz_offset = excluded.tz_offset,
duration_sec = excluded.duration_sec,
payload = excluded.payload,
content_hash = excluded.content_hash,
delivery_id = excluded.delivery_id,
delivery_received_at = excluded.delivery_received_at,
updated_at = excluded.updated_at`
var duration any
if v.head.Duration != nil {
duration = *v.head.Duration
}
if _, err := tx.ExecContext(ctx, q,
v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
stamp, stamp); err != nil {
return fmt.Errorf("upsert workout: %w", err)
}
return nil
}
// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
type Workout struct {
ID string
Name string
Start time.Time
End time.Time
OffsetSeconds int
Duration *float64
Raw json.RawMessage
Delivery string
}
// Workout читает тренировку по идентификатору.
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
const q = `
SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
FROM workout WHERE id = ?`
var (
w Workout
start, end string
duration sql.NullFloat64
payload []byte
deliveryFrom string
)
err := s.db.QueryRowxContext(ctx, q, id).
Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
if errors.Is(err, sql.ErrNoRows) {
return Workout{}, ErrNotFound
}
if err != nil {
return Workout{}, fmt.Errorf("select workout: %w", err)
}
if w.Start, err = ParseTime(start); err != nil {
return Workout{}, err
}
if w.End, err = ParseTime(end); err != nil {
return Workout{}, err
}
if duration.Valid {
v := duration.Float64
w.Duration = &v
}
if w.Raw, err = gunzipBytes(payload); err != nil {
return Workout{}, err
}
w.Delivery = deliveryFrom
return w, nil
}
// Record — запись секции с собственным идентификатором.
type Record struct {
Kind string
ID string
TS time.Time
OffsetSeconds int
Raw json.RawMessage
Delivery string
}
// Record читает запись по роду и идентификатору.
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
const q = `
SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
FROM record WHERE kind = ? AND id = ?`
var (
r Record
ts string
payload []byte
deliveryFrom string
)
err := s.db.QueryRowxContext(ctx, q, kind, id).
Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("select record: %w", err)
}
if r.TS, err = ParseTime(ts); err != nil {
return Record{}, err
}
if r.Raw, err = gunzipBytes(payload); err != nil {
return Record{}, err
}
r.Delivery = deliveryFrom
return r, nil
}
// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
return 0, fmt.Errorf("count workouts: %w", err)
}
return n, nil
}
// CountRecords возвращает число записей секций с собственным `id`.
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
return 0, fmt.Errorf("count records: %w", err)
}
return n, nil
}