Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
267 lines
11 KiB
Go
267 lines
11 KiB
Go
// Package canon — каноническая форма содержимого точки: то, по чему точки
|
||
// сравниваются и хешируются.
|
||
//
|
||
// Пакет общий для разбора и хранения намеренно. Слияние точек живёт в store,
|
||
// хеш пересчитывается там же, а сравнивать приходится то, что приехало из hae.
|
||
// Положи канонизацию в hae — store станет знать про формат HAE; положи в store
|
||
// — импорт родного экспорта Apple потребует второй реализации. Две реализации
|
||
// разошлись бы на дребезге последнего разряда, и хеш-детектор превратился бы в
|
||
// перезапись недели каждым глубоким проходом синхронизации.
|
||
//
|
||
// Каноническая форма существует только в момент сравнения. Хранится всегда
|
||
// исходные байты точки: обход через разобранные значения теряет литерал
|
||
// (`1.0` становится `1`, целые больше 2^53 сдвигаются, невалидный UTF-8
|
||
// заменяется на U+FFFD), и потеря не видна тестам на фикстурах — они
|
||
// сравнивают разобранное с разобранным.
|
||
package canon
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
)
|
||
|
||
// SignificantDigits — до скольки значащих цифр округляется число в
|
||
// канонической форме.
|
||
//
|
||
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
|
||
// точек различались последним разрядом double при одинаковом измерении — 63%
|
||
// повторов выглядели новыми (docs/local-research.md, находка 30). Двенадцать
|
||
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
|
||
// реально измеряет: даже доли процента у walking_asymmetry_percentage не
|
||
// доходят до седьмой значащей цифры.
|
||
const SignificantDigits = 12
|
||
|
||
// Form возвращает каноническую форму значения: ключи объектов отсортированы,
|
||
// числа округлены до SignificantDigits значащих цифр.
|
||
//
|
||
// Форма предназначена для сравнения и хеширования, а не для хранения.
|
||
func Form(raw []byte) ([]byte, error) {
|
||
v, err := decode(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := write(&buf, v); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
|
||
//
|
||
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
|
||
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
|
||
// проход переприсылает неделю, но почти все сравнения сходятся.
|
||
func Hash(raw []byte) (string, error) {
|
||
form, err := Form(raw)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
sum := sha256.Sum256(form)
|
||
return hex.EncodeToString(sum[:]), nil
|
||
}
|
||
|
||
// HashAll возвращает хеш канонической формы последовательности значений —
|
||
// содержимого часового объекта целиком.
|
||
func HashAll(raws [][]byte) (string, error) {
|
||
h := sha256.New()
|
||
for _, raw := range raws {
|
||
form, err := Form(raw)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// Разделитель нужен, чтобы склейка соседних значений не давала тот же
|
||
// хеш, что другое их разбиение.
|
||
_, _ = h.Write(form)
|
||
_, _ = h.Write([]byte{0})
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil)), nil
|
||
}
|
||
|
||
// Equal говорит, одинаковы ли значения с точностью до канонической формы:
|
||
// порядка ключей и дребезга последнего разряда.
|
||
//
|
||
// Именно это, а не побайтовое равенство, является отношением «то же самое» в
|
||
// хранилище. Байты нестабильны: порядок ключей в JSON от HAE меняется между
|
||
// доставками, а числа расходятся последним разрядом double при одинаковом
|
||
// измерении.
|
||
func Equal(a, b []byte) bool {
|
||
if bytes.Equal(a, b) {
|
||
return true
|
||
}
|
||
fa, err := Form(a)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
fb, err := Form(b)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return bytes.Equal(fa, fb)
|
||
}
|
||
|
||
// Completeness — мера полноты точки: сколько значащих полей она несёт.
|
||
//
|
||
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
|
||
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
|
||
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
|
||
// несёт.
|
||
//
|
||
// Поля с пустым значением не считаются: точка с `context: null` не полнее
|
||
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
|
||
// переписывается задним числом, так что его наличие ничего не говорит о
|
||
// полноте измерения.
|
||
func Completeness(raw []byte) int {
|
||
var obj map[string]json.RawMessage
|
||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||
return 0
|
||
}
|
||
|
||
n := 0
|
||
for k, v := range obj {
|
||
if k == "source" {
|
||
continue
|
||
}
|
||
if isEmpty(v) {
|
||
continue
|
||
}
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
// Less задаёт детерминированный порядок на точках равной полноты.
|
||
//
|
||
// Тай-брейк по времени приёма для этого не годится: у сохранённой точки нет
|
||
// провенанса, сравнивать не с чем, а четверть доставок несёт столкновения
|
||
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
|
||
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
|
||
// что приём в реальном времени.
|
||
func Less(a, b []byte) bool {
|
||
fa, err := Form(a)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
fb, err := Form(b)
|
||
if err != nil {
|
||
return true
|
||
}
|
||
return bytes.Compare(fa, fb) < 0
|
||
}
|
||
|
||
func isEmpty(v json.RawMessage) bool {
|
||
t := bytes.TrimSpace(v)
|
||
switch {
|
||
case len(t) == 0, bytes.Equal(t, []byte("null")):
|
||
return true
|
||
case bytes.Equal(t, []byte(`""`)):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// decode разбирает значение с числами в виде json.Number: строковый литерал
|
||
// вместо float64. Без этого округление применялось бы к уже испорченному
|
||
// значению — round-trip через float64 сам по себе меняет литерал.
|
||
func decode(raw []byte) (any, error) {
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
dec.UseNumber()
|
||
|
||
var v any
|
||
if err := dec.Decode(&v); err != nil {
|
||
return nil, fmt.Errorf("canon: разбор значения: %w", err)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// write пишет каноническую форму значения.
|
||
//
|
||
// Сортировку ключей объекта делает encoding/json сам (json.Marshal для map
|
||
// сортирует ключи), но здесь она выполняется явно: значения приходится
|
||
// обходить всё равно — ради чисел, — и второй проход через json.Marshal
|
||
// означал бы round-trip числа через float64.
|
||
func write(buf *bytes.Buffer, v any) error {
|
||
switch t := v.(type) {
|
||
case map[string]any:
|
||
keys := make([]string, 0, len(t))
|
||
for k := range t {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
buf.WriteByte('{')
|
||
for i, k := range keys {
|
||
if i > 0 {
|
||
buf.WriteByte(',')
|
||
}
|
||
key, err := json.Marshal(k)
|
||
if err != nil {
|
||
return fmt.Errorf("canon: ключ %q: %w", k, err)
|
||
}
|
||
buf.Write(key)
|
||
buf.WriteByte(':')
|
||
if err := write(buf, t[k]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
buf.WriteByte('}')
|
||
|
||
case []any:
|
||
buf.WriteByte('[')
|
||
for i, e := range t {
|
||
if i > 0 {
|
||
buf.WriteByte(',')
|
||
}
|
||
if err := write(buf, e); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
buf.WriteByte(']')
|
||
|
||
case json.Number:
|
||
buf.WriteString(roundNumber(t.String()))
|
||
|
||
default:
|
||
// Строки, bool и null: json.Marshal даёт для них ту же форму, что
|
||
// пришла, и своей реализации не требует.
|
||
b, err := json.Marshal(t)
|
||
if err != nil {
|
||
return fmt.Errorf("canon: значение: %w", err)
|
||
}
|
||
buf.Write(b)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// roundNumber округляет числовой литерал до SignificantDigits значащих цифр.
|
||
//
|
||
// Целые остаются как есть: у них дребезга сериализации не бывает, а округление
|
||
// сдвинуло бы большие идентификаторы. Литерал, не разбирающийся как число,
|
||
// возвращается дословно — канонизация не место, где решается судьба
|
||
// непонятного входа.
|
||
func roundNumber(lit string) string {
|
||
if !bytes.ContainsAny([]byte(lit), ".eE") {
|
||
return lit
|
||
}
|
||
|
||
f, err := strconv.ParseFloat(lit, 64)
|
||
if err != nil {
|
||
return lit
|
||
}
|
||
if math.IsInf(f, 0) || math.IsNaN(f) {
|
||
return lit
|
||
}
|
||
|
||
// %g с точностью в значащих цифрах — ровно то, что нужно: экспонента
|
||
// выбирается сама, хвост за пределами точности отбрасывается.
|
||
return strconv.FormatFloat(f, 'g', SignificantDigits, 64)
|
||
}
|