- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
761 lines
36 KiB
Go
761 lines
36 KiB
Go
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
|
||
}
|