- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
128 lines
8.0 KiB
Go
128 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// writeReport печатает итог пересборки человеку.
|
|
//
|
|
// Отдельной функцией с io.Writer, а не печатью в os.Stdout из недр: отчёт —
|
|
// новая поверхность вывода, и единственное, что защищает её от утечки данных о
|
|
// здоровье, — тест. Тест на глобальном os.Stdout был бы тестом на глобальном
|
|
// состоянии, то есть его бы не написали.
|
|
//
|
|
// Ни значений точек, ни имён метрик, ни имён устройств здесь нет и быть не
|
|
// может: содержимое витрины входит в отчёт только отпечатком, а он берёт его
|
|
// хешем.
|
|
func writeReport(w io.Writer, r report) {
|
|
p := func(format string, args ...any) {
|
|
_, _ = fmt.Fprintf(w, format+"\n", args...)
|
|
}
|
|
|
|
p("пересборка витрины из журнала")
|
|
p(" архив: тел %d, пропущено файлов %d, повторов идентификатора %d",
|
|
r.replay.Bodies, r.replay.SkippedFiles, r.replay.Duplicates)
|
|
p(" учёт: подобрано тел без записи %d, не удалось подобрать %d, записей без тела %d",
|
|
r.replay.Adopted, r.replay.AdoptFailed, r.replay.Orphans)
|
|
p(" свёрнуто: %d; отказов: слой не выведен %d, содержимое %d, прочее %d",
|
|
r.replay.Folded, r.replay.FailedLayer, r.replay.FailedMalformed, r.replay.FailedOther)
|
|
p(" слияние: частично разобрано %d, несравнимых наборов %d",
|
|
r.replay.Partial, r.replay.Incomparable)
|
|
|
|
if r.replay.Canceled {
|
|
// Ни отпечаток пересобранной витрины, ни число доставок после прогона при
|
|
// отмене не снимались. Печатать их сравнение значило бы выдать
|
|
// неизмеренное за измеренное — в единственном оракуле задачи.
|
|
p("")
|
|
p("прогон ОТМЕНЁН: сравнение не проводилось, файл назначения не создан")
|
|
return
|
|
}
|
|
|
|
// «Часть журнала не прочитана» — отдельное состояние, и оно обязано быть
|
|
// видно рядом с вердиктом отпечатков. Пропущенный симлинк на каталог уносит
|
|
// из прогона целый месяц одной строкой в счётчике, а вердикт «СОВПАЛИ»
|
|
// выдал бы сертификат воспроизводимости прогону, который этих тел не читал.
|
|
partialJournal := r.replay.SkippedFiles > 0 || r.replay.Orphans > 0 || r.replay.Duplicates > 0
|
|
// Нештатные отказы. Невыведенный слой сюда не входит: он есть в каждом
|
|
// журнале, и предупреждать о нём значило бы отправлять человека искать
|
|
// дефект там, где его нет. А вот «содержимое не разбирается» штатным не
|
|
// является: тело один раз уже прошло проверку формы на приёме.
|
|
badFailures := r.replay.FailedOther > 0 || r.replay.FailedMalformed > 0 || r.replay.AdoptFailed > 0
|
|
|
|
if r.sourceMissing {
|
|
p(" объектов: %d", r.replay.Buckets)
|
|
p("")
|
|
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
|
|
p("не восстанавливаются: в архиве их нет.")
|
|
} else {
|
|
// «Было / стало» — единственное, по чему можно судить о НАПРАВЛЕНИИ
|
|
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
|
|
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
|
|
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
|
|
p("")
|
|
p(" отпечаток рабочей: %s", r.sourcePrint)
|
|
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
|
|
switch {
|
|
case r.sourcePrint == r.replay.Fingerprint && !partialJournal:
|
|
p(" отпечатки СОВПАЛИ — состояние воспроизводимо")
|
|
case r.sourcePrint == r.replay.Fingerprint:
|
|
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
|
|
default:
|
|
p(" отпечатки РАЗОШЛИСЬ")
|
|
p(" ожидаемые причины: исправленный разбор; признак sealed не")
|
|
p(" переносится (правила его выставления ещё нет)")
|
|
if partialJournal {
|
|
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
|
|
p(" объясняться этим, а не разбором")
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.replay.Bodies == 0 || r.replay.Folded == 0 {
|
|
p("")
|
|
p("проигрывать было нечего: файл назначения не создан.")
|
|
p("проверьте storage.archive_dir и каталог запуска — пустая витрина")
|
|
p("совпадает по отпечатку с пустой витриной и выглядит идеальной сверкой")
|
|
return
|
|
}
|
|
|
|
if d := r.sourceAfter - r.sourceBefore; d != 0 {
|
|
// Доставки, приехавшие за время прогона, есть в рабочей базе и в архиве,
|
|
// но не в собранном файле. Подмена стёрла бы их учёт вместе с
|
|
// заголовками, восстановить которые неоткуда, — поэтому процедура здесь
|
|
// не печатается вовсе.
|
|
p("")
|
|
p("за время прогона в рабочую базу приехало доставок: %d.", d)
|
|
p("подменять этим файлом НЕЛЬЗЯ: учёта новых доставок в нём нет, а вместе")
|
|
p("с ним пропали бы их заголовки. Остановите сервис и пересоберите заново.")
|
|
return
|
|
}
|
|
|
|
p("")
|
|
if partialJournal {
|
|
p("ЧАСТЬ ЖУРНАЛА НЕ ПРОЧИТАНА: пропущено файлов %d, записей без тела %d,",
|
|
r.replay.SkippedFiles, r.replay.Orphans)
|
|
p("повторов идентификатора %d. Пересобранная витрина беднее рабочей на",
|
|
r.replay.Duplicates)
|
|
p("объекты этих доставок — и на объекты тех, кто наследовал от них слой.")
|
|
p("Проверьте каталог архива (симлинк на подкаталог обходом не читается)")
|
|
p("по DEBUG-строкам лога, прежде чем подменять базу.")
|
|
p("")
|
|
}
|
|
if badFailures {
|
|
p("отказы, которых быть не должно (%d прочих, %d по содержимому, %d при подборе) —",
|
|
r.replay.FailedOther, r.replay.FailedMalformed, r.replay.AdoptFailed)
|
|
p("разберитесь по логу, прежде чем подменять базу.")
|
|
p("")
|
|
}
|
|
p("собрано в %s", r.target)
|
|
p("подмена — вручную и при ОСТАНОВЛЕННОМ сервисе: он держит файл открытым,")
|
|
p("и переименование поверх живого процесса портит базу молча.")
|
|
p("")
|
|
p(" task down")
|
|
p(" mv %s %s", r.target, r.dbPath)
|
|
p(" rm -f %s-wal %s-shm", r.dbPath, r.dbPath)
|
|
p(" task up")
|
|
}
|