feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+173 -24
View File
@@ -72,6 +72,21 @@ type MergeStats struct {
Collisions []Collision
// IncomparableAt — то же для несравнимых наборов.
IncomparableAt []Collision
// Workouts и Records — сколько сущностей пришло с доставкой.
Workouts int
Records int
// WorkoutsWritten и RecordsWritten — сколько из них действительно легло в
// витрину. Разница с пришедшими — работа хеша-детектора: тренировка
// переприсылается каждой доставкой, пока не доедет маршрут.
WorkoutsWritten int
RecordsWritten int
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
// объединять поля: событие считается, а не предотвращается молча.
EntitiesHeld int
// HeldAt — координаты первых таких сущностей, для записи в лог.
HeldAt []EntityRef
}
// Collision — координаты объекта, где столкновение разрешилось перезаписью
@@ -102,35 +117,58 @@ func clipMetric(metric string) string {
return metric[:maxMetricInLog] + "…"
}
// MergePoints раскладывает точки по часовым объектам и сливает их с
// сохранёнными.
// Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым
// объектам, сущности — по своим таблицам.
//
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
// другой выбор сделал бы принадлежность объекту зависящей от длительности.
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект.
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и
// не отдельной транзакцией на сущности.
//
// Транзакция на объект давала недетерминированное частичное состояние: обход
// карты групп рандомизирован, и при отказе посреди доставки набор уже
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
// доставки — семь разных состояний). Это ломает инвариант «состояние
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
// хеш-детектор после расхождения переписывал бы «неизменившееся».
// хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение
// «секции не смешиваются в одной доставке» собрано за двое суток и основанием
// для второй транзакции не является.
//
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
// то есть 8.5 мс на килобайт тела.
func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) {
groups := groupByHour(in)
func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) {
groups := groupByHour(in.Points)
keys := sortedKeys(groups)
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
// входа в транзакцию: канонизация материализует значение целиком, а
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
// кучи умножился бы на число попыток.
workouts, err := prepareEntities(in.Workouts, from)
if err != nil {
return MergeStats{}, err
}
records, err := prepareEntities(in.Records, from)
if err != nil {
return MergeStats{}, err
}
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
var stats MergeStats
err := s.inTx(ctx, func(tx *sql.Tx) error {
err = s.inTx(ctx, func(tx *sql.Tx) error {
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
stats = MergeStats{}
stats = MergeStats{
Workouts: len(in.Workouts),
Records: len(in.Records),
EntitiesHeld: workoutsHeld + recordsHeld,
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
}
for _, key := range keys {
group := groups[key]
res, err := mergeBucket(ctx, tx, key, group, deliveryID)
res, err := mergeBucket(ctx, tx, key, group, from.ID)
if err != nil {
return err
}
@@ -158,6 +196,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
stats.IncomparableAt = append(stats.IncomparableAt, coord)
}
}
now := Now()
written, held, heldAt, err := mergeEntities(ctx, tx, workoutTable, workouts, now)
if err != nil {
return err
}
stats.WorkoutsWritten = written
stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
written, held, heldAt, err = mergeEntities(ctx, tx, recordTable, records, now)
if err != nil {
return err
}
stats.RecordsWritten = written
stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
return nil
})
if err != nil {
@@ -591,27 +646,44 @@ func encodePayload(points []Point) ([]byte, error) {
return nil, fmt.Errorf("сериализация точек: %w", err)
}
return gzipBytes(raw.Bytes())
}
// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины,
// общее для часового объекта и для сущности с собственным `id`. Второй кадр
// упаковки разошёлся бы с первым при первой же правке — например, забытым
// Close, который оставляет усечённый блоб.
func gzipBytes(raw []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(raw.Bytes()); err != nil {
return nil, fmt.Errorf("сжатие точек: %w", err)
if _, err := gz.Write(raw); err != nil {
return nil, fmt.Errorf("сжатие содержимого: %w", err)
}
// Close дописывает хвост gzip; без него блоб читается лишь частично.
if err := gz.Close(); err != nil {
return nil, fmt.Errorf("закрытие gzip: %w", err)
}
return buf.Bytes(), nil
}
func decodePayload(payload []byte) ([]Point, error) {
func gunzipBytes(payload []byte) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("распаковка точек: %w", err)
return nil, fmt.Errorf("распаковка содержимого: %w", err)
}
defer func() { _ = gz.Close() }()
raw, err := io.ReadAll(gz)
if err != nil {
return nil, fmt.Errorf("чтение точек: %w", err)
return nil, fmt.Errorf("чтение содержимого: %w", err)
}
return raw, nil
}
func decodePayload(payload []byte) ([]Point, error) {
raw, err := gunzipBytes(payload)
if err != nil {
return nil, err
}
var sp []storedPoint
@@ -700,7 +772,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
}
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
// и хешам всех объектов в детерминированном порядке.
// и хешам всех её сущностей в детерминированном порядке.
//
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
@@ -708,25 +780,58 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
// отпечаток — нет.
//
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
// данные.
//
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
// и «тренировки после» — ложное расхождение у единственного оракула.
//
// Значений он не раскрывает: содержимое участвует только своим хешем.
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return "", fmt.Errorf("begin read tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
h := sha256.New()
if err := fingerprintBuckets(ctx, tx, h); err != nil {
return "", err
}
if err := fingerprintEntities(ctx, tx, h); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Признак раздела впереди строки: без него строка одного раздела может совпасть
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
const (
fpBucket = "b"
fpWorkout = "w"
fpRecord = "r"
)
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
const q = `
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
ORDER BY metric, layer, hour_utc`
rows, err := s.db.QueryContext(ctx, q)
rows, err := tx.QueryContext(ctx, q)
if err != nil {
return "", fmt.Errorf("select buckets: %w", err)
return fmt.Errorf("select buckets: %w", err)
}
defer func() { _ = rows.Close() }()
h := sha256.New()
for rows.Next() {
var metric, layer, hour, hash, units string
var points int
var sealed bool
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
return "", fmt.Errorf("scan bucket: %w", err)
return fmt.Errorf("scan bucket: %w", err)
}
// Поля переменной длины идут с длиной впереди: разделитель, который
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
@@ -734,13 +839,57 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
// заведён. Тот же приём в canon.HashAll и по той же причине.
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
fpBucket, len(metric), metric, len(layer), layer, hour, hash, points,
len(units), units, sealed)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("select buckets: %w", err)
return fmt.Errorf("select buckets: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
return nil
}
func fingerprintEntities(ctx context.Context, tx *sql.Tx, h io.Writer) error {
queries := []struct {
tag string
sql string
}{
// Род и идентификатор идут ОТДЕЛЬНЫМИ полями, каждое со своей длиной, а
// не склейкой `kind || '/' || id`: склейка выполняется до взятия длины,
// и пара (`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`). Отпечаток —
// единственный оракул сходимости, по нему принимается необратимое
// решение о подмене базы; два разных состояния витрины не имеют права
// дать один отпечаток. У тренировки род один и в строку не идёт.
{fpWorkout, `SELECT '', id, start_utc, content_hash FROM workout ORDER BY id`},
{fpRecord, `SELECT kind, id, ts_utc, content_hash FROM record ORDER BY kind, id`},
}
for _, q := range queries {
if err := fingerprintRows(ctx, tx, h, q.tag, q.sql); err != nil {
return err
}
}
return nil
}
func fingerprintRows(ctx context.Context, tx *sql.Tx, h io.Writer, tag, query string) error {
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return fmt.Errorf("select entities: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var kind, id, ts, hash string
if err := rows.Scan(&kind, &id, &ts, &hash); err != nil {
return fmt.Errorf("scan entity: %w", err)
}
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s\n", tag, len(kind), kind, len(id), id, ts, hash)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("select entities: %w", err)
}
return nil
}
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.