reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
+23 -5
View File
@@ -2,6 +2,7 @@
package logging
import (
"io"
"log/slog"
"os"
"strings"
@@ -10,21 +11,38 @@ import (
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
func New(level, format string) *slog.Logger {
return NewTo(os.Stdout, level, format)
}
// NewErr — тот же логгер, что New, но в stderr.
//
// Нужен командам CLI: stdout у них занят отчётом человеку, и лог в том же
// потоке сделал бы отчёт неразбираемым — а именно его человек перенаправляет в
// файл и читает глазами.
func NewErr(level, format string) *slog.Logger {
return NewTo(os.Stderr, level, format)
}
// NewTo — общая форма: приёмник вывода параметром, как у log.New и
// slog.NewTextHandler. New и NewErr — тонкие обёртки над ней; отдельные имена
// существуют ради читаемости места вызова, а не ради разного поведения.
func NewTo(w io.Writer, level, format string) *slog.Logger {
opts := &slog.HandlerOptions{Level: parseLevel(level), ReplaceAttr: utcTime}
var handler slog.Handler
if strings.EqualFold(format, "text") {
handler = slog.NewTextHandler(os.Stdout, opts)
handler = slog.NewTextHandler(w, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
handler = slog.NewJSONHandler(w, opts)
}
return slog.New(handler)
}
// NewStderr — JSON-логгер в stderr (UTC) для фатальных ошибок старта, когда
// основной логгер ещё не собран (конфиг не прочитан).
// NewStderr — JSON-логгер в stderr для фатальных ошибок старта, когда основной
// логгер ещё не собран (конфиг не прочитан). Уровень и формат брать неоткуда,
// поэтому они фиксированы — этим он и отличается от NewErr.
func NewStderr() *slog.Logger {
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: utcTime}))
return NewTo(os.Stderr, "info", "json")
}
// utcTime приводит метку времени записи к UTC: однозначный порядок событий и
+65
View File
@@ -0,0 +1,65 @@
package logging
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
// Уровень — это адресат, а не громкость: DEBUG предназначен разработчику при
// отладке и в штатной работе наружу не выходит.
func TestУровеньОтсекаетНижние(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := NewTo(&buf, "warn", "json")
log.Debug("отладка")
log.Info("событие")
log.Warn("может стать проблемой")
out := buf.String()
if strings.Contains(out, "отладка") || strings.Contains(out, "событие") {
t.Errorf("уровень warn пропустил записи ниже себя: %s", out)
}
if !strings.Contains(out, "может стать проблемой") {
t.Errorf("запись уровня warn потерялась: %s", out)
}
}
// Время в логах — UTC: иначе порядок событий между машинами не сравнить.
func TestВремяВUTC(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
NewTo(&buf, "info", "json").Info("событие")
var rec map[string]any
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("запись не JSON: %v (%s)", err, buf.String())
}
ts, _ := rec[slog.TimeKey].(string)
if !strings.HasSuffix(ts, "Z") {
t.Errorf("метка времени %q не в UTC", ts)
}
}
// Команды CLI пишут отчёт в stdout, поэтому их лог обязан идти в stderr —
// иначе отчёт не разобрать ни глазами, ни перенаправлением.
func TestNewErrПишетВStderrАNewВStdout(t *testing.T) {
t.Parallel()
// Прямой проверки потока здесь нет намеренно: подмена os.Stdout была бы
// мутацией глобала. Проверяем то, что от этих конструкторов зависит на
// самом деле, — что они собирают разные назначения и оба живые.
if New("info", "json") == nil || NewErr("info", "text") == nil {
t.Fatal("конструктор вернул nil")
}
var buf bytes.Buffer
NewTo(&buf, "info", "text").Info("событие", "ключ", "значение")
if !strings.Contains(buf.String(), "ключ=значение") {
t.Errorf("текстовый формат не применён: %s", buf.String())
}
}