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
+14 -3
View File
@@ -174,9 +174,14 @@ type report struct {
dbPath string
sourcePrint string
sourceBuckets int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
// sourceWorkouts и sourceRecords — то же «было» для остальных единиц
// хранения витрины. Отпечаток отвечает «да/нет» за витрину целиком, поэтому
// единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64
sourceRecords int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
}
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
@@ -226,6 +231,12 @@ func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceWorkouts, err = src.CountWorkouts(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
+7 -2
View File
@@ -58,6 +58,8 @@ func writeReport(w io.Writer, r report) {
if r.sourceMissing {
p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
@@ -66,6 +68,8 @@ func writeReport(w io.Writer, r report) {
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -76,8 +80,9 @@ func writeReport(w io.Writer, r report) {
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
default:
p(" отпечатки РАЗОШЛИСЬ")
p(" ожидаемые причины: исправленный разбор; признак sealed не")
p(" переносится (правила его выставления ещё нет)")
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)")
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
+20 -8
View File
@@ -88,14 +88,16 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes
replay: replay.Report{
Bodies: 116,
Outcome: replay.Outcome{Folded: 116, Partial: 53},
Buckets: 2049, Fingerprint: "aaaa",
Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2040,
sourceBefore: 116,
sourceAfter: 116,
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2040,
sourceWorkouts: 0,
sourceRecords: 0,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
@@ -113,7 +115,17 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes
// Расхождение отпечатков названо, и рядом — направление: «было/стало».
// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
for _, want := range []string{"РАЗОШЛИСЬ", "было 2040, стало 2049", "task down", "mv "} {
//
// «Было/стало» обязано покрывать КАЖДУЮ единицу хранения: единица, которой
// нет в счётчиках, делает расхождение безадресным — человек видит «не
// совпало» при неизменившемся числе объектов. Класс «покрыта новая секция»
// назван отдельно потому, что первый прогон после такого изменения
// расходится гарантированно и штатно.
for _, want := range []string{
"РАЗОШЛИСЬ", "было 2040, стало 2049",
"тренировок: было 0, стало 2", "записей: было 0, стало 2",
"покрытая разбором новая", "task down", "mv ",
} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q", want)
}