package store import ( "context" "database/sql" "fmt" "time" ) // CategoryValue — строка реестра категориальных значений: перечислимая строка, // которую приносил поток, и выведенный для неё код HealthKit. // // Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в // отпечаток витрины он не входит (см. fingerprintCategories). type CategoryValue struct { // Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица // хранения. Metric string // Field — имя поля внутри точки или сущности, дословно как у HAE. Field string // Value — строка, как прислал HAE. Value string // Code — канонический код HealthKit; пустой означает «словарь не знает». Code string // FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала. // Заполняются хранилищем, вызывающему при записи не нужны. FirstSeen time.Time FirstDeliveryID string } // mergeCategories записывает наблюдения доставки в реестр. // // Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс — // только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же // доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at, // id)` давало бы не то, что живой приём. // // Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой: // доставки одной секунды в журнале упорядочены идентификатором, и сравнение по // одной метке сделало бы исход зависящим от того, какая из них свернулась // раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в // Go значило бы читать строку, решать и писать, то есть добавлять обращение к // базе на каждое наблюдение внутри транзакции. func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error { if len(values) == 0 { return nil } const q = ` INSERT INTO category_value (metric, field, value, code, first_seen_utc, first_delivery_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (metric, field, value) DO UPDATE SET code = excluded.code, first_seen_utc = CASE WHEN (excluded.first_seen_utc, excluded.first_delivery_id) < (category_value.first_seen_utc, category_value.first_delivery_id) THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END, first_delivery_id = CASE WHEN (excluded.first_seen_utc, excluded.first_delivery_id) < (category_value.first_seen_utc, category_value.first_delivery_id) THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END` stmt, err := tx.PrepareContext(ctx, q) if err != nil { return fmt.Errorf("prepare category upsert: %w", err) } defer func() { _ = stmt.Close() }() at := FormatTime(from.ReceivedAt) for _, v := range values { if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil { // Значение в текст ошибки не попадает: строка категориального // значения — данные о здоровье наравне со значением точки. // // Координаты обрезаются, хотя сегодня они приходят из объявленного // списка полей и произвольной строкой из тела быть не могут. Предел // стоит здесь, а не держится на форме того списка: правило вида // «поле `value` у метрик с таким-то именем» уронило бы имя метрики // из чужого тела прямо в `ERROR`, и заметить это было бы нечем. return fmt.Errorf("upsert category value (%s/%s): %w", clipCoord(v.Metric), clipCoord(v.Field), err) } } return nil } // CategoryValues возвращает реестр целиком в детерминированном порядке. // // Реестр мал по построению — на живом потоке различных значений около // одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем: // границы разбора не дают доставке положить больше 64 значений, а число полей // объявлено списком. func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) { const q = ` SELECT metric, field, value, code, first_seen_utc, first_delivery_id FROM category_value ORDER BY metric, field, value` rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, fmt.Errorf("select category values: %w", err) } defer func() { _ = rows.Close() }() var out []CategoryValue for rows.Next() { var v CategoryValue var firstSeen string if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil { return nil, fmt.Errorf("scan category value: %w", err) } v.FirstSeen, err = ParseTime(firstSeen) if err != nil { return nil, err } out = append(out, v) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("select category values: %w", err) } return out, nil } // CountCategoryValues возвращает число строк реестра. // // Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика // «до и после» расхождение отпечатков по нему безадресно — числа объектов, // тренировок и записей при этом не меняются. Перечислением отвечать нельзя: // сами строки — данные о здоровье, а отчёт печатается человеку в терминал. func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) { var n int64 if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil { return 0, fmt.Errorf("count category values: %w", err) } return n, nil } // maxCoordInLog — предел длины координаты наблюдения в тексте ошибки. // // Та же граница и по той же причине, что у имени метрики в координатах // столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ. const maxCoordInLog = 64 func clipCoord(s string) string { if len(s) <= maxCoordInLog { return s } return s[:maxCoordInLog] + "…" }