первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
|
||||
// healthlog reindex --config <path> пересобрать витрину из журнала
|
||||
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
|
||||
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||
package main
|
||||
|
||||
@@ -30,6 +31,8 @@ func main() {
|
||||
err = runServe(args)
|
||||
case "reindex":
|
||||
err = runReindex(args)
|
||||
case "uncovered":
|
||||
err = runUncovered(args)
|
||||
case "healthcheck":
|
||||
err = runHealthcheck(args)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/config"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// uncoveredLimit — сколько строк перечня печатается по умолчанию.
|
||||
//
|
||||
// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на
|
||||
// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно
|
||||
// версии HAE, кладущей в ключ переменную часть. Двести взято с запасом: секций у
|
||||
// HAE восемь, и перечень длиннее сотни означает не рост потока, а смену формы
|
||||
// ключей — про неё скажет строка остатка.
|
||||
const uncoveredLimit = 200
|
||||
|
||||
func runUncovered(args []string) error {
|
||||
fs := flag.NewFlagSet("uncovered", flag.ContinueOnError)
|
||||
cfgPath := fs.String("config", config.DefaultPath, "путь к config.toml")
|
||||
limit := fs.Int("limit", uncoveredLimit, "сколько строк перечня печатать")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
// Справка — не отказ: иначе `uncovered -h` печатает usage и выходит
|
||||
// со словом «fatal» и кодом 1.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Только на чтение и без наката миграций: команда диагностическая, и запуск
|
||||
// её при живом сервисе не имеет права ни мигрировать схему, ни писать.
|
||||
// Расхождение версий — отказ с указанием обеих, и он доезжает до кода
|
||||
// возврата: молчаливый пустой перечень неотличим от «ничего не приезжало».
|
||||
st, err := store.OpenForRead(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
sections, total, err := st.UncoveredSections(context.Background(), *limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeUncovered(os.Stdout, sections, total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUncovered печатает перечень человеку.
|
||||
//
|
||||
// Имя секции идёт ЭКРАНИРОВАННЫМ (`%q`): оно приходит верхнеуровневым ключом
|
||||
// чужого тела, обрезано по длине на разборе, но по содержимому не ограничено
|
||||
// ничем — сырая печать впустила бы в терминал управляющие последовательности.
|
||||
//
|
||||
// Данных о здоровье здесь нет: имя секции — структурный ключ, а не измерение.
|
||||
// Идентификатор доставки печатается затем, чтобы по нему достать тело из архива
|
||||
// и посмотреть форму секции глазами.
|
||||
func writeUncovered(w io.Writer, sections []store.UncoveredSection, total int64) {
|
||||
if len(sections) == 0 {
|
||||
// НЕ «журнал такого не приносил»: перечень отвечает по колонкам
|
||||
// доживших учётных записей, а не по истории потока. Обещание, которое
|
||||
// носитель не даёт, закрыло бы владельцу вопрос ложным ответом.
|
||||
fmt.Fprintln(w, "В учётных записях журнала непокрытых секций сейчас нет.")
|
||||
writeUncoveredLimits(w)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Непокрытых секций: %d\n\n", total)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "СЕКЦИЯ\tДОСТАВОК\tПЕРВАЯ\tПОСЛЕДНЯЯ")
|
||||
for _, s := range sections {
|
||||
fmt.Fprintf(tw, "%q\t%d\t%s %s\t%s %s\n",
|
||||
s.Name, s.Deliveries,
|
||||
store.FormatTime(s.FirstSeen), s.FirstDeliveryID,
|
||||
store.FormatTime(s.LastSeen), s.LastDeliveryID)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
// Остаток называется числом, а не обрывается молча: перечень — инструмент
|
||||
// диагностики, и «здесь всё» против «здесь двести из тысячи» это разные
|
||||
// ответы.
|
||||
if rest := total - int64(len(sections)); rest > 0 {
|
||||
fmt.Fprintf(w, "\nЕщё %d имён не показано.\n", rest)
|
||||
}
|
||||
writeUncoveredLimits(w)
|
||||
}
|
||||
|
||||
// writeUncoveredLimits называет границы носителя — в любом исходе, включая
|
||||
// пустой.
|
||||
//
|
||||
// Перечень производен от колонки учёта, а не от истории потока, и умолчать об
|
||||
// этом значило бы отдать владельцу ответ, которого носитель не даёт: пустой
|
||||
// перечень он прочитал бы как «ничего не приезжало» и закрыл бы вопрос.
|
||||
func writeUncoveredLimits(w io.Writer) {
|
||||
fmt.Fprint(w, `
|
||||
Перечень собран по колонке учёта `+"`delivery.uncovered_sections`"+`, и границ у неё три:
|
||||
- имена сверх 32 на одну доставку разбор в неё не кладёт;
|
||||
- пересборка заполняет колонку заново и только по сохранившимся телам;
|
||||
- секция, которую разбор научился покрывать, уходит из перечня при пересвёртке.
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func at(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
v, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("метка %q: %v", s, err)
|
||||
}
|
||||
return v.UTC()
|
||||
}
|
||||
|
||||
// Перечень — инструмент диагностики, и границы встреч в нём нужны затем, чтобы
|
||||
// достать тело из архива по идентификатору доставки.
|
||||
func TestПереченьНазываетГраницыВстреч(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeUncovered(&buf, []store.UncoveredSection{{
|
||||
Name: "ecg",
|
||||
Deliveries: 3,
|
||||
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
|
||||
FirstDeliveryID: "01AAA",
|
||||
LastSeen: at(t, "2026-08-02T11:00:00Z"),
|
||||
LastDeliveryID: "01BBB",
|
||||
}}, 1)
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{"ecg", "3", "2026-08-01T10:00:00Z", "01AAA", "2026-08-02T11:00:00Z", "01BBB"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("в выводе нет %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой перечень говорит о себе словами: молчаливый пустой вывод неотличим от
|
||||
// «команда ничего не сделала».
|
||||
func TestПустойПереченьНазванСловами(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeUncovered(&buf, nil, 0)
|
||||
|
||||
if strings.TrimSpace(buf.String()) == "" {
|
||||
t.Error("пустой перечень напечатал пустоту")
|
||||
}
|
||||
// И не обещает того, чего носитель не даёт: колонка отвечает про дожившие
|
||||
// учётные записи, а не про историю потока.
|
||||
if strings.Contains(buf.String(), "не приносил") {
|
||||
t.Errorf("пустой перечень говорит за весь поток:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Границы носителя называются в любом исходе: пустой перечень без них владелец
|
||||
// прочитает как «ничего не приезжало» и закроет вопрос.
|
||||
func TestГраницыНосителяНазваныВОбоихИсходах(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rows := []store.UncoveredSection{{
|
||||
Name: "ecg", Deliveries: 1,
|
||||
FirstSeen: at(t, "2026-08-01T10:00:00Z"), FirstDeliveryID: "01AAA",
|
||||
LastSeen: at(t, "2026-08-01T10:00:00Z"), LastDeliveryID: "01AAA",
|
||||
}}
|
||||
for name, sections := range map[string][]store.UncoveredSection{
|
||||
"пустой": nil,
|
||||
"непустой": rows,
|
||||
} {
|
||||
var buf bytes.Buffer
|
||||
writeUncovered(&buf, sections, int64(len(sections)))
|
||||
if !strings.Contains(buf.String(), "uncovered_sections") {
|
||||
t.Errorf("%s перечень не назвал носителя:\n%s", name, buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "32") {
|
||||
t.Errorf("%s перечень не назвал границу списка:\n%s", name, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Имя приходит верхнеуровневым ключом чужого тела: длина ограничена разбором,
|
||||
// содержимое — ничем. Сырая печать впустила бы в терминал оператора управляющие
|
||||
// последовательности.
|
||||
func TestИмяСекцииЭкранируется(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeUncovered(&buf, []store.UncoveredSection{{
|
||||
Name: "ecg\x1b[31m\nfake",
|
||||
Deliveries: 1,
|
||||
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
|
||||
FirstDeliveryID: "01AAA",
|
||||
LastSeen: at(t, "2026-08-01T10:00:00Z"),
|
||||
LastDeliveryID: "01AAA",
|
||||
}}, 1)
|
||||
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "\x1b") {
|
||||
t.Errorf("управляющий байт доехал до терминала:\n%q", out)
|
||||
}
|
||||
// Строка перечня обязана остаться одной: перевод строки из имени разорвал
|
||||
// бы её надвое, и вторая половина читалась бы как отдельная секция.
|
||||
var rows int
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
|
||||
if strings.HasPrefix(line, `"`) {
|
||||
rows++
|
||||
}
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Errorf("строк перечня %d, ожидалась одна:\n%q", rows, out)
|
||||
}
|
||||
if !strings.Contains(out, `\n`) {
|
||||
t.Errorf("перевод строки в имени не экранирован:\n%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Остаток называется числом: «здесь всё» и «здесь двести из тысячи» — разные
|
||||
// ответы, и молчаливый обрыв делает их неотличимыми.
|
||||
func TestОстатокПеречняНазванЧислом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeUncovered(&buf, []store.UncoveredSection{{
|
||||
Name: "ecg",
|
||||
Deliveries: 1,
|
||||
FirstSeen: at(t, "2026-08-01T10:00:00Z"),
|
||||
FirstDeliveryID: "01AAA",
|
||||
LastSeen: at(t, "2026-08-01T10:00:00Z"),
|
||||
LastDeliveryID: "01AAA",
|
||||
}}, 5)
|
||||
|
||||
if !strings.Contains(buf.String(), "4") {
|
||||
t.Errorf("остаток не назван числом:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Базы по указанному пути нет — отказ с причиной и ненулевым кодом. Пустой
|
||||
// перечень здесь был бы ложью: «ничего не приезжало» и «смотреть не во что» —
|
||||
// разные ответы.
|
||||
func TestОтсутствиеБазыДаётОтказ(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.toml")
|
||||
cfg := "[server]\naddr = \":8080\"\ningest_token = \"t\"\nread_token = \"r\"\n" +
|
||||
"[storage]\ndb_path = \"" + filepath.Join(dir, "нет.db") + "\"\n" +
|
||||
"raw_dir = \"" + filepath.Join(dir, "raw") + "\"\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatalf("конфиг: %v", err)
|
||||
}
|
||||
|
||||
if err := runUncovered([]string{"--config", cfgPath}); err == nil {
|
||||
t.Error("команда на несуществующей базе завершилась успехом")
|
||||
}
|
||||
}
|
||||
|
||||
// Сквозной прогон: перечень, собранный командой, сходится с тем, что посчитано
|
||||
// по ТЕЛАМ архива независимо от её кода.
|
||||
//
|
||||
// Оракул строится от тел намеренно: сверка вывода с `SELECT DISTINCT` по той же
|
||||
// колонке тем же `json_each` доказывала бы только согласие кода с самим собой —
|
||||
// и молчала бы обо всём, что команда добавляет сверх множества имён.
|
||||
//
|
||||
// Не параллельный: подменяет `os.Stdout`.
|
||||
func TestПереченьСходитсяСТеламиАрхива(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "healthlog.db")
|
||||
rawDir := filepath.Join(dir, "raw")
|
||||
|
||||
arch, err := archive.New(rawDir)
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
svc := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("..", "..", "internal", "hae", "testdata", "uncovered_sections.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("тело: %v", err)
|
||||
}
|
||||
want := uncoveredInBody(t, body)
|
||||
if len(want) == 0 {
|
||||
t.Fatal("в теле нет непокрытых секций — проверять нечего")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, id := range []string{"d1", "d2"} {
|
||||
at := store.Now()
|
||||
rawPath, err := arch.Write(id, at, body)
|
||||
if err != nil {
|
||||
t.Fatalf("запись в архив: %v", err)
|
||||
}
|
||||
err = st.CreateDelivery(ctx, store.Delivery{
|
||||
ID: id, ReceivedAt: at, AutomationID: "a1", Aggregation: "Minutes",
|
||||
Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath,
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("учёт доставки: %v", err)
|
||||
}
|
||||
if _, err := svc.Fold(ctx, id); err != nil {
|
||||
t.Fatalf("свёртка %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// База закрывается до команды: та открывает её сама, только на чтение.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(dir, "config.toml")
|
||||
cfg := "[storage]\ndb_path = \"" + dbPath + "\"\narchive_dir = \"" + rawDir + "\"\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatalf("конфиг: %v", err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := runUncovered([]string{"--config", cfgPath}); err != nil {
|
||||
t.Fatalf("команда: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, name := range want {
|
||||
if !strings.Contains(out, name) {
|
||||
t.Errorf("в выводе нет секции %q, которая есть в теле:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
// Обе доставки принесли одно и то же тело, значит у каждой секции ровно две
|
||||
// доставки, а границы — первая и последняя.
|
||||
if !strings.Contains(out, " 2 ") && !strings.Contains(out, "\t2\t") {
|
||||
t.Errorf("число доставок в выводе не 2:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "d1") || !strings.Contains(out, "d2") {
|
||||
t.Errorf("границы встреч не названы обеими доставками:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// uncoveredInBody считает непокрытые секции ПО ТЕЛУ, не трогая разбор: ключи
|
||||
// `data` минус три покрытых имени.
|
||||
func uncoveredInBody(t *testing.T, body []byte) []string {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("тело не разбирается: %v", err)
|
||||
}
|
||||
covered := map[string]bool{"metrics": true, "workouts": true, "stateOfMind": true}
|
||||
var out []string
|
||||
for name := range envelope.Data {
|
||||
if !covered[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// captureStdout ловит пользовательский вывод команды.
|
||||
func captureStdout(t *testing.T, run func()) string {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("канал: %v", err)
|
||||
}
|
||||
saved := os.Stdout
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = saved }()
|
||||
|
||||
run()
|
||||
_ = w.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("чтение вывода: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user