первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
@@ -60,7 +60,8 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
|||||||
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
|
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
|
||||||
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
|
полноте. Тренировки и записи со своим `id` (`workouts`, `stateOfMind`) тоже
|
||||||
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
|
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
|
||||||
честно помечаются как неразобранные.
|
честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
|
||||||
|
даёт `WARN` в логе свёртки один раз и попадает в перечень `healthlog uncovered`.
|
||||||
|
|
||||||
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
|
Есть и пересборка: `healthlog reindex` проигрывает журнал доставок в свежую
|
||||||
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
|
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
|
||||||
@@ -83,6 +84,7 @@ iPhone ──HTTPS POST──► healthlog ──► журнал доставо
|
|||||||
healthlog serve приём + read API + MCP
|
healthlog serve приём + read API + MCP
|
||||||
healthlog import родной экспорт Apple Health (в планах)
|
healthlog import родной экспорт Apple Health (в планах)
|
||||||
healthlog reindex пересборка витрины из журнала
|
healthlog reindex пересборка витрины из журнала
|
||||||
|
healthlog uncovered перечень секций, которых разбор не покрыл
|
||||||
healthlog healthcheck проверка живости для docker HEALTHCHECK
|
healthlog healthcheck проверка живости для docker HEALTHCHECK
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
|
// healthlog [serve] --config <path> принимать пакеты (по умолчанию)
|
||||||
// healthlog reindex --config <path> пересобрать витрину из журнала
|
// healthlog reindex --config <path> пересобрать витрину из журнала
|
||||||
|
// healthlog uncovered --config <path> перечень секций, которых разбор не покрыл
|
||||||
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
// healthlog healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ func main() {
|
|||||||
err = runServe(args)
|
err = runServe(args)
|
||||||
case "reindex":
|
case "reindex":
|
||||||
err = runReindex(args)
|
err = runReindex(args)
|
||||||
|
case "uncovered":
|
||||||
|
err = runUncovered(args)
|
||||||
case "healthcheck":
|
case "healthcheck":
|
||||||
err = runHealthcheck(args)
|
err = runHealthcheck(args)
|
||||||
default:
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Новизна имени секции выводится из журнала, а не хранится реестром
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-04
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-04-aktivnaya-proverka-novyh-sekcij/design.md
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Признак «имя непокрытой секции встречено впервые» **не хранится**: он считается
|
||||||
|
запросом к журналу — «встречалось ли имя в доставках, стоящих строго раньше этой
|
||||||
|
по паре `(received_at, id)`». Реестр-таблица по образцу `category_value` —
|
||||||
|
очевидный ответ на тот же вопрос, уже применённый в этом проекте, — отвергнут.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата из источника:
|
||||||
|
|
||||||
|
> Форма ответа взята у `category_value` — «когда имя встретилось впервые по
|
||||||
|
> журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
|
||||||
|
> Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
|
||||||
|
> считанные разы за жизнь имени.
|
||||||
|
|
||||||
|
И там же, о цене реестра:
|
||||||
|
|
||||||
|
> Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
|
||||||
|
> пересборке, плюс миграция и новая единица хранения витрины (а значит и
|
||||||
|
> отпечатка). Ноль новых сведений: имя выводимо из журнала.
|
||||||
|
|
||||||
|
Третья рассмотренная форма — множество виденных имён в памяти процесса —
|
||||||
|
отвергнута по инварианту «хранилище есть свёртка по журналу»: состояние стало бы
|
||||||
|
функцией жизни процесса, и живой приём разошёлся бы с пересборкой в том, что
|
||||||
|
считает первой встречей.
|
||||||
|
|
||||||
|
## Чем платим
|
||||||
|
|
||||||
|
Ценой названы три вещи, и все они следствия выбранного носителя:
|
||||||
|
|
||||||
|
- **проход по журналу** на каждой доставке с непокрытыми секциями. Измерено на
|
||||||
|
синтетическом журнале годового объёма: у секции, приезжающей давно, ранний
|
||||||
|
выход даёт десятки микросекунд, у появившейся только что — около 52 мс на
|
||||||
|
доставку, пока её не покроет отдельная задача;
|
||||||
|
- **границы носителя наследуются целиком**: имя, вытесненное границей списка в
|
||||||
|
32 имени, события не даёт вовсе; пересборка заполняет колонку заново и только
|
||||||
|
по сохранившимся телам; покрытая разбором секция уходит из перечня;
|
||||||
|
- **история не переживает удаления тел.** Ретеншен, срезающий архив, унесёт с
|
||||||
|
собой и записи о непокрытых секциях за те же периоды.
|
||||||
|
|
||||||
|
## Когда пересматривать
|
||||||
|
|
||||||
|
Последнее и есть условие пересмотра, названное заранее: **задаче ретеншена
|
||||||
|
архива реестр понадобится** — именно затем, чтобы история пережила удаление тел,
|
||||||
|
и тогда это уже другая цена, а не вторая копия факта. Запрет реестра в спеке
|
||||||
|
`uncovered-sections` — решение этого изменения, а не запрет навсегда.
|
||||||
@@ -33,6 +33,10 @@
|
|||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
||||||
|
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
|
||||||
|
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
|
||||||
|
образцу `category_value` отвергнут как вторая копия факта, с названным
|
||||||
|
условием пересмотра — ретеншен архива.
|
||||||
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
|
- [ADR-2026-08-04-tie-break-po-poryadku-zhurnala](ADR-2026-08-04-tie-break-po-poryadku-zhurnala.md)
|
||||||
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
|
— тай-брейк точек при равной полноте: побеждает пришедшая, то есть правило
|
||||||
становится явной функцией порядка журнала; хранимая метка провенанса
|
становится явной функцией порядка журнала; хранимая метка провенанса
|
||||||
|
|||||||
+35
-1
@@ -226,7 +226,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
|||||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
||||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
||||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||||||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md) |
|
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
|
||||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||||
@@ -355,6 +355,40 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
|||||||
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
|
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
|
||||||
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
|
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
|
||||||
|
|
||||||
|
**Первая встреча имени — другое дело**
|
||||||
|
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
|
||||||
|
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
|
||||||
|
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
|
||||||
|
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
|
||||||
|
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
|
||||||
|
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
|
||||||
|
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
|
||||||
|
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
|
||||||
|
полного журнала повторяет ровно те же события.
|
||||||
|
|
||||||
|
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
|
||||||
|
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
|
||||||
|
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
|
||||||
|
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
|
||||||
|
меняет, доставка вернётся следующим проходом.
|
||||||
|
|
||||||
|
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
|
||||||
|
первая и последняя встреча, чтением только на чтение и с экранированием имён
|
||||||
|
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
|
||||||
|
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
|
||||||
|
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
|
||||||
|
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||||||
|
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
|
||||||
|
истории.
|
||||||
|
|
||||||
|
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
|
||||||
|
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
|
||||||
|
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
|
||||||
|
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
|
||||||
|
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
|
||||||
|
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
|
||||||
|
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
|
||||||
|
|
||||||
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
|
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
|
||||||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||||||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||||||
|
|||||||
@@ -71,3 +71,12 @@
|
|||||||
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
||||||
структуры обновляем схему в [database.md](../database.md) тем же изменением —
|
структуры обновляем схему в [database.md](../database.md) тем же изменением —
|
||||||
это проверяет `task gate`.
|
это проверяет `task gate`.
|
||||||
|
|
||||||
|
- **Значение, читаемое табличной функцией SQLite (`json_each` и родня), проходит
|
||||||
|
проверку ВНУТРИ её аргумента, а не условием в `WHERE`.** Функция получает
|
||||||
|
значение строки раньше, чем применится фильтр, и порядок этот SQLite не
|
||||||
|
обещает: неразбираемое значение роняет **весь** запрос, а не пропускает
|
||||||
|
строку. Условие в `WHERE` работает, пока планировщик проталкивает его вниз, и
|
||||||
|
перестаёт молча. Проверено на закреплённом драйвере: одна испорченная строка
|
||||||
|
`delivery.uncovered_sections` обесценивала и сверку новизны (вечное «сверка не
|
||||||
|
состоялась» на каждой доставке), и перечень целиком.
|
||||||
|
|||||||
@@ -35,6 +35,17 @@
|
|||||||
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
|
посчитает другое и разойдётся молча (так и вышло: ключ без слоя дал 29-кратное
|
||||||
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
|
расхождение). Три случая одного класса за три дня: записи 2026-08-02,
|
||||||
2026-08-03 и 2026-08-04 в [review.md](../review.md).
|
2026-08-03 и 2026-08-04 в [review.md](../review.md).
|
||||||
|
|
||||||
|
Метода мало — **синтетический корпус обязан содержать измеряемый случай в той
|
||||||
|
форме, в какой он бывает в жизни**. Сверка новизны секции мерялась на журнале,
|
||||||
|
где новое имя стояло во всех доставках, то есть его первая встреча лежала в
|
||||||
|
начале — ранний выход давал 31 мкс. В жизни секцию включают сегодня, первая
|
||||||
|
встреча оказывается в хвосте, и та же операция стоит 52 мс: три порядка
|
||||||
|
разницы, а на числе стояло решение «индекс не нужен» (запись 2026-08-04).
|
||||||
|
|
||||||
|
И **число живёт в одном месте.** Один и тот же замер, записанный в
|
||||||
|
комментарий кода и в `architecture.md`, разошёлся внутри одного изменения.
|
||||||
|
Дом числа — `design.md` изменения; остальные формулируют правило и ссылаются.
|
||||||
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
|
- **Оракул сходимости называет свою посылку рядом с собой, и прогон её
|
||||||
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
|
печатает.** «Пересборка = приём» — не тождество, а утверждение с условиями:
|
||||||
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
|
живая свёртка шла в порядке журнала, в журнале нет доставок, чью свёртку живой
|
||||||
|
|||||||
@@ -1852,7 +1852,12 @@ instant heart_rate, respiratory_rate, blood_oxygen_saturation,
|
|||||||
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
|
Меняется ли что-то на глубине часов и суток — покажет более длинный ряд
|
||||||
доставок.
|
доставок.
|
||||||
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
||||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
`heartRateNotifications`, `cycleTracking`, `medications`. Разбор покрывает
|
||||||
|
ровно остальные три (`metrics`, `workouts`, `stateOfMind` — `decodeCovered` в
|
||||||
|
`internal/hae`), сверено поимённо 2026-08-04. Момент их появления больше не
|
||||||
|
требует догадки: первая встреча имени даёт `WARN` в логе свёртки, а перечень
|
||||||
|
накопленного отдаёт `healthlog uncovered`. Разбор самой секции пишется, когда
|
||||||
|
её будет на чём проверить, — вслепую он не пишется.
|
||||||
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
||||||
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
||||||
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
||||||
|
|||||||
@@ -499,3 +499,56 @@
|
|||||||
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
|
- **Что осталось незакрытым:** гейт после интеграции обязан звать `BASE`
|
||||||
вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
|
вершиной **до** слияния. Сейчас это знание живёт только в этой записи —
|
||||||
ни `Taskfile.yml`, ни скилл батча его не несут.
|
ни `Taskfile.yml`, ни скилл батча его не несут.
|
||||||
|
|
||||||
|
## 2026-08-04 — событие о новой секции терялось на отказе слияния [пойман]
|
||||||
|
|
||||||
|
- **Где:** `internal/fold/fold.go`, ветвь отказа `store.Merge` в change
|
||||||
|
`2026-08-04-aktivnaya-proverka-novyh-sekcij`
|
||||||
|
- **Симптом:** доставка, принёсшая имя секции впервые, при нетранзиентном отказе
|
||||||
|
слияния писала имя в `delivery.uncovered_sections`, но запись об отказе его не
|
||||||
|
называла. Следующая доставка считала имя виденным — событие, однократное за
|
||||||
|
всю жизнь имени, пропадало **навсегда**, то есть ровно то, ради чего задача и
|
||||||
|
делалась.
|
||||||
|
- **Причина:** ветвей записи исхода в свёртке четыре, а дизайн рассмотрел одну.
|
||||||
|
Признак новизны считался до ветвления и корректно доезжал до `residueOf`
|
||||||
|
(отказ разбора), но ветвь отказа слияния собирала остаток **вручную** и поле
|
||||||
|
новизны в него не клала. Дельта-спека говорила «до ветвления на успех и
|
||||||
|
отказ», подразумевая один отказ.
|
||||||
|
- **Чем воспроизведён:** свёртка доставки с новой секцией при снесённой таблице
|
||||||
|
`bucket` — запись `ERROR` без `uncovered_new`, а `SectionsSeenBefore` на
|
||||||
|
следующей доставке уже отвечает «виденное». Тест закреплён:
|
||||||
|
`TestFoldОтказСлиянияНазываетНовуюСекцию`.
|
||||||
|
- **Чем пойман:** тремя проходами независимо (`specs`, `code`, `adversary`),
|
||||||
|
причём двое написали падающий тест. Дешёвый `code`-проход нашёл его наравне с
|
||||||
|
дорогими — признак того, что дефект был в форме «ветвь собрана руками рядом с
|
||||||
|
ветвью, собранной функцией», а такое видно чтением.
|
||||||
|
- **Что изменено:** новизна передаётся и в эту ветвь; дельта-спека переписана в
|
||||||
|
терминах «каждый исход, который пишет список в учётную запись», и отдельно
|
||||||
|
названы исходы, которые список очищают (нечитаемое тело, паника) и потому
|
||||||
|
события не теряют.
|
||||||
|
|
||||||
|
## 2026-08-04 — замер стоимости снят на корпусе, где измеряемого случая не бывает [пойман]
|
||||||
|
|
||||||
|
- **Где:** `design.md` того же change, решение 3; утверждение «в режиме
|
||||||
|
постоянного приезда секции сверка стоит 18 мкс на доставку»
|
||||||
|
- **Симптом:** на числе стояло решение «частичный индекс не нужен». Число
|
||||||
|
описывало **не тот** режим.
|
||||||
|
- **Причина:** синтетический журнал наполнялся так, что новая секция была во
|
||||||
|
**всех** доставках, то есть её первая встреча лежала в самом начале журнала —
|
||||||
|
и `LIMIT 1` выходил рано. В жизни секцию включают на телефоне сегодня: первая
|
||||||
|
встреча оказывается в хвосте, и проход идёт почти по всему журналу на каждой
|
||||||
|
доставке. Разница — три порядка (31 мкс против 52 мс).
|
||||||
|
- **Чем воспроизведён:** `tmp/seenmeasure` с хвостовым именем: голова 31 мкс,
|
||||||
|
хвост 52 мс, отсутствующее имя 50 мс.
|
||||||
|
- **Чем пойман:** `adversary` — он не поверил числу и построил корпус, в котором
|
||||||
|
измеряемый случай выглядит как в жизни. Это третий случай за три дня, когда
|
||||||
|
оценка оказалась функцией того, **как устроен корпус**, а не того, что
|
||||||
|
измеряют (записи 2026-08-02, 2026-08-04 про `verify:archive`).
|
||||||
|
- **Что изменено:** замер перемерян тремя случаями (голова, хвост, отсутствие),
|
||||||
|
числа сведены в одно место (`design.md`), код и `architecture.md` формулируют
|
||||||
|
правило и ссылаются на источник. Развилка «принять цену или завести индекс»
|
||||||
|
вынесена владельцу.
|
||||||
|
- **Что осталось незакрытым:** правило «число замера обязано нести метод и
|
||||||
|
описывать тот случай, ради которого снято» действует только для тестов
|
||||||
|
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
|
||||||
|
ниже, но механизировать его нечем.
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ disabled`, `read auth disabled`), но стартовать не отказыв
|
|||||||
`export.xml`, который выбирает человек, но формируется он устройством и по
|
`export.xml`, который выбирает человек, но формируется он устройством и по
|
||||||
объёму (3,6 млн записей) глазами не проверяется.
|
объёму (3,6 млн записей) глазами не проверяется.
|
||||||
|
|
||||||
|
**Новый адресат недоверенного входа — терминал оператора.** Подкоманда
|
||||||
|
`healthlog uncovered` печатает имена секций, а имя это верхнеуровневый ключ
|
||||||
|
чужого тела: длина у него ограничена разбором (64 байта, не больше 32 имён),
|
||||||
|
содержимое — ничем. Печатается оно экранированным (`%q`), иначе управляющая
|
||||||
|
последовательность из тела подделала бы строки вывода. Тот же вход попадает
|
||||||
|
структурным атрибутом в лог свёртки, где его экранирует кодировщик `slog`.
|
||||||
|
|
||||||
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
|
Ответы внешних систем в недоверенный вход не входят: исходящих вызовов у
|
||||||
сервиса нет.
|
сервиса нет.
|
||||||
|
|
||||||
|
|||||||
+119
-4
@@ -92,6 +92,17 @@ type Stats struct {
|
|||||||
Uncovered []string
|
Uncovered []string
|
||||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||||
UncoveredDropped int
|
UncoveredDropped int
|
||||||
|
// UncoveredNew — имена непокрытых секций, которых не было ни в одной
|
||||||
|
// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
|
||||||
|
// имени: поток дописывает метрики на телефоне молча, и момент появления
|
||||||
|
// секции наблюдать больше нечем.
|
||||||
|
UncoveredNew []string
|
||||||
|
// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
|
||||||
|
// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
|
||||||
|
// один раз, промолчавшее событие не восстанавливается ничем.
|
||||||
|
UncoveredSeenUnknown bool
|
||||||
|
// UncoveredSeenError — почему не состоялась.
|
||||||
|
UncoveredSeenError error
|
||||||
|
|
||||||
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
|
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
|
||||||
// CategoricalUnknown — сколько из них словарь не знает;
|
// CategoricalUnknown — сколько из них словарь не знает;
|
||||||
@@ -158,6 +169,16 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
|||||||
FallbackLayer: hae.Layer(fallback),
|
FallbackLayer: hae.Layer(fallback),
|
||||||
Locale: localeOf(d.Headers),
|
Locale: localeOf(d.Headers),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых
|
||||||
|
// секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи
|
||||||
|
// здесь — и следующая доставка сочтёт его виденным, а событие не вернётся
|
||||||
|
// ничем, кроме ручного запроса в базу.
|
||||||
|
novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{
|
||||||
|
ID: d.ID,
|
||||||
|
ReceivedAt: d.ReceivedAt,
|
||||||
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||||
// определился слой, обязана остаться записью о том, что в теле есть
|
// определился слой, обязана остаться записью о том, что в теле есть
|
||||||
@@ -166,12 +187,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
|||||||
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
|
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
|
||||||
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
|
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
|
||||||
// записать этот ноль значило бы объявить доставку проверенной.
|
// записать этот ноль значило бы объявить доставку проверенной.
|
||||||
s.fail(ctx, deliveryID, err, residueOf(parsed))
|
s.fail(ctx, deliveryID, err, residueOf(parsed, novelty))
|
||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.Uncovered = parsed.Uncovered
|
stats.Uncovered = parsed.Uncovered
|
||||||
stats.UncoveredDropped = parsed.UncoveredDropped
|
stats.UncoveredDropped = parsed.UncoveredDropped
|
||||||
|
stats.UncoveredNew = novelty.fresh
|
||||||
|
stats.UncoveredSeenUnknown = novelty.unknown
|
||||||
|
stats.UncoveredSeenError = novelty.cause
|
||||||
stats.Metrics = parsed.Metrics
|
stats.Metrics = parsed.Metrics
|
||||||
stats.Points = len(parsed.Points)
|
stats.Points = len(parsed.Points)
|
||||||
stats.SkippedNoTime = parsed.SkippedNoTime
|
stats.SkippedNoTime = parsed.SkippedNoTime
|
||||||
@@ -196,6 +220,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
|||||||
s.fail(ctx, deliveryID, err, parseResidue{
|
s.fail(ctx, deliveryID, err, parseResidue{
|
||||||
uncovered: parsed.Uncovered,
|
uncovered: parsed.Uncovered,
|
||||||
skipped: skippedEntities(parsed),
|
skipped: skippedEntities(parsed),
|
||||||
|
// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
|
||||||
|
// как ветвь отказа разбора, — значит и терять событие ей нельзя:
|
||||||
|
// следующая доставка сочтёт имя виденным, а отказ слияния бывает
|
||||||
|
// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
|
||||||
|
novelty: novelty,
|
||||||
})
|
})
|
||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
@@ -283,6 +312,18 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
// построчный разбор логов. Содержимого секций здесь нет.
|
// построчный разбор логов. Содержимого секций здесь нет.
|
||||||
"uncovered", st.Uncovered,
|
"uncovered", st.Uncovered,
|
||||||
"uncovered_dropped", st.UncoveredDropped,
|
"uncovered_dropped", st.UncoveredDropped,
|
||||||
|
// Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень
|
||||||
|
// поднимается отдельной ветвью ниже. Наблюдаемый признак события это
|
||||||
|
// он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой
|
||||||
|
// доставки, которая её принесла, и по нему первую встречу не отличить.
|
||||||
|
"uncovered_new", st.UncoveredNew,
|
||||||
|
"uncovered_seen_unknown", st.UncoveredSeenUnknown,
|
||||||
|
// Причина несостоявшейся сверки — рядом с признаком. Занятость базы
|
||||||
|
// проходит сама, испорченная колонка не проходит никогда и поднимает
|
||||||
|
// признак на каждой доставке; по одному булеву это неразличимо.
|
||||||
|
// Значений точек в ошибке нет: до текста доезжает только имя секции, и
|
||||||
|
// оно обрезано.
|
||||||
|
"uncovered_seen_error", st.UncoveredSeenError,
|
||||||
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
|
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
|
||||||
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
|
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
|
||||||
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
|
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
|
||||||
@@ -318,6 +359,15 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
|
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case len(st.UncoveredNew) > 0:
|
||||||
|
// ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях,
|
||||||
|
// повторяющихся на живом потоке; это — однократное за всю жизнь имени, и
|
||||||
|
// замаскировать его перезаписью точек значило бы потерять ровно то, ради
|
||||||
|
// чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой
|
||||||
|
// секции — штатное событие внешнего мира, «посмотри», а не «разбери
|
||||||
|
// сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а
|
||||||
|
// не измерение.
|
||||||
|
s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...)
|
||||||
case st.EntitiesHeld > 0:
|
case st.EntitiesHeld > 0:
|
||||||
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
|
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
|
||||||
// за отказ объединять поля: событие обязано быть видно, потому что на
|
// за отказ объединять поля: событие обязано быть видно, потому что на
|
||||||
@@ -446,10 +496,61 @@ type parseResidue struct {
|
|||||||
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
|
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
|
||||||
// ретеншен принимает необратимое решение об удалении тела.
|
// ретеншен принимает необратимое решение об удалении тела.
|
||||||
skipped *int64
|
skipped *int64
|
||||||
|
// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
|
||||||
|
// не пятым параметром `fail`: параметры путают местами, а поле называет
|
||||||
|
// себя само.
|
||||||
|
novelty sectionNovelty
|
||||||
}
|
}
|
||||||
|
|
||||||
func residueOf(parsed hae.Result) parseResidue {
|
func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue {
|
||||||
return parseResidue{uncovered: parsed.Uncovered}
|
return parseResidue{uncovered: parsed.Uncovered, novelty: novelty}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionNovelty — исход сверки имён непокрытых секций с журналом.
|
||||||
|
type sectionNovelty struct {
|
||||||
|
// fresh — имена, которых не было ни в одной доставке раньше этой.
|
||||||
|
fresh []string
|
||||||
|
// unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена
|
||||||
|
// доставки.
|
||||||
|
unknown bool
|
||||||
|
// cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и
|
||||||
|
// испорченное содержимое колонки (не пройдёт никогда, и признак будет
|
||||||
|
// подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем
|
||||||
|
// самым ручным запросом в базу, от которого задача избавляет.
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// novelty спрашивает журнал, какие из непокрытых имён встречаются впервые.
|
||||||
|
//
|
||||||
|
// Отказ запроса свёртку не роняет и исходом доставки не становится: правила
|
||||||
|
// классификации исходов наблюдение не трогает, занятая база и отменённый
|
||||||
|
// контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя,
|
||||||
|
// о котором смолчали, уже записано в учёт, — поэтому при отказе новыми
|
||||||
|
// объявляются все имена, а признак несостоявшейся сверки идёт в запись.
|
||||||
|
//
|
||||||
|
// Запрос берётся только при непустом списке: на живом потоке все три
|
||||||
|
// приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего.
|
||||||
|
func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty {
|
||||||
|
if len(uncovered) == 0 {
|
||||||
|
return sectionNovelty{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at)
|
||||||
|
if err != nil {
|
||||||
|
return sectionNovelty{fresh: uncovered, unknown: true, cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh := make([]string, 0, len(uncovered))
|
||||||
|
for _, name := range uncovered {
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fresh = append(fresh, name)
|
||||||
|
}
|
||||||
|
if len(fresh) == 0 {
|
||||||
|
return sectionNovelty{}
|
||||||
|
}
|
||||||
|
return sectionNovelty{fresh: fresh}
|
||||||
}
|
}
|
||||||
|
|
||||||
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||||
@@ -469,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новые имена доезжают до записи об отказе: она уже выше рутинного уровня,
|
||||||
|
// а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно
|
||||||
|
// — там учётная запись не меняется, доставка вернётся следующим проходом, и
|
||||||
|
// повторение признака на каждом проходе занятой базы превратило бы
|
||||||
|
// однократное событие в дребезг.
|
||||||
|
attrs := []any{"error", cause, "delivery_id", deliveryID}
|
||||||
|
if len(residue.novelty.fresh) > 0 {
|
||||||
|
attrs = append(attrs, "uncovered_new", residue.novelty.fresh)
|
||||||
|
}
|
||||||
|
if residue.novelty.unknown {
|
||||||
|
attrs = append(attrs, "uncovered_seen_unknown", true,
|
||||||
|
"uncovered_seen_error", residue.novelty.cause)
|
||||||
|
}
|
||||||
|
|
||||||
level := slog.LevelError
|
level := slog.LevelError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(cause, hae.ErrLayerUnknown):
|
case errors.Is(cause, hae.ErrLayerUnknown):
|
||||||
@@ -481,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
|
|||||||
// одного класса ошибки давали бы постоянный ERROR-шум.
|
// одного класса ошибки давали бы постоянный ERROR-шум.
|
||||||
level = slog.LevelWarn
|
level = slog.LevelWarn
|
||||||
}
|
}
|
||||||
s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID)
|
s.log.Log(ctx, level, "delivery fold failed", attrs...)
|
||||||
|
|
||||||
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
|
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
|
||||||
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
|
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
|
||||||
|
|||||||
@@ -238,6 +238,15 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
|||||||
t.Fatalf("свёртка: %v", err)
|
t.Fatalf("свёртка: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Смотрим на ВТОРУЮ доставку с той же секцией. Первая встреча имени —
|
||||||
|
// событие само по себе и уровень поднимает законно; здесь же проверяется
|
||||||
|
// другое: что уровень не поднимает сама по себе частичность, установившееся
|
||||||
|
// состояние половины потока.
|
||||||
|
deliver(t, arch, st, "d2", "Minutes", "a1", []byte(body))
|
||||||
|
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||||
|
t.Fatalf("повторная свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, "ecg") {
|
if !strings.Contains(out, "ecg") {
|
||||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package fold
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Внутренний тест, а не через `Fold`, и это осознанно. Сломать сверку снаружи
|
||||||
|
// больше нечем: испорченную учётную запись запрос теперь пропускает, а
|
||||||
|
// единственная настоящая причина отказа — отмена контекста и занятость базы —
|
||||||
|
// в полном проходе свёртки утащила бы за собой и слияние, то есть проверялась
|
||||||
|
// бы уже другая ветвь. Заводить интерфейс хранилища ради мока дороже: он
|
||||||
|
// пережил бы тест и остался бы в коде навсегда.
|
||||||
|
func TestNoveltyОтменённыйКонтекстНеМолчит(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("база: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
s := &Service{store: st, log: slog.New(slog.DiscardHandler)}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
got := s.novelty(ctx, []string{"ecg", "symptoms"}, store.DeliveryRef{
|
||||||
|
ID: "d1",
|
||||||
|
ReceivedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !got.unknown {
|
||||||
|
t.Fatal("сверка на отменённом контексте объявила себя состоявшейся")
|
||||||
|
}
|
||||||
|
if len(got.fresh) != 2 {
|
||||||
|
t.Errorf("новыми объявлены %v, ожидались все имена доставки", got.fresh)
|
||||||
|
}
|
||||||
|
if got.cause == nil {
|
||||||
|
t.Error("причина отказа потеряна: занятость базы и вечную порчу колонки по признаку не различить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список сверку не берёт вовсе: на живом потоке все три приезжающие
|
||||||
|
// секции покрыты, и запрос по журналу в штатном режиме не стоит ничего.
|
||||||
|
func TestNoveltyПустойСписокЗапросаНеБерёт(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &Service{store: nil, log: slog.New(slog.DiscardHandler)}
|
||||||
|
|
||||||
|
// Хранилище nil: возьмись запрос — тест упал бы паникой.
|
||||||
|
got := s.novelty(context.Background(), nil, store.DeliveryRef{ID: "d1"})
|
||||||
|
if got.unknown || len(got.fresh) != 0 {
|
||||||
|
t.Errorf("на пустом списке сверка что-то решила: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Признак несостоявшейся сверки без причины неразбираем: занятость базы пройдёт
|
||||||
|
// сама, испорченная колонка не пройдёт никогда и будет поднимать признак на
|
||||||
|
// каждой доставке.
|
||||||
|
func TestLogResultПечатаетПричинуНесостоявшейсяСверки(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := &Service{log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))}
|
||||||
|
|
||||||
|
s.logResult(context.Background(), "d1", Stats{
|
||||||
|
Uncovered: []string{"ecg"},
|
||||||
|
UncoveredNew: []string{"ecg"},
|
||||||
|
UncoveredSeenUnknown: true,
|
||||||
|
UncoveredSeenError: errors.New("журнал недоступен"),
|
||||||
|
})
|
||||||
|
|
||||||
|
var rec map[string]any
|
||||||
|
line := strings.TrimSpace(buf.String())
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
t.Fatalf("запись не разбирается: %v", err)
|
||||||
|
}
|
||||||
|
if rec["uncovered_seen_unknown"] != true {
|
||||||
|
t.Error("признак несостоявшейся сверки не выставлен")
|
||||||
|
}
|
||||||
|
if rec["uncovered_seen_error"] != "журнал недоступен" {
|
||||||
|
t.Errorf("причина в записи: %v", rec["uncovered_seen_error"])
|
||||||
|
}
|
||||||
|
if rec["level"] != "WARN" {
|
||||||
|
t.Errorf("уровень %v, ожидался WARN: имена объявлены новыми", rec["level"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись об отказе несёт и признак несостоявшейся сверки, и причину: у отказа
|
||||||
|
// разбора та же цена молчания, что у успешного пути — имя уже в учёте.
|
||||||
|
func TestFailПечатаетНесостоявшуюсяСверку(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("база: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err = st.CreateDelivery(ctx, store.Delivery{
|
||||||
|
ID: "d1", ReceivedAt: time.Now().UTC(), RawPath: "d1.json.gz",
|
||||||
|
SHA256: "-", ParseStatus: store.ParsePending,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("учёт доставки: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := &Service{
|
||||||
|
store: st,
|
||||||
|
log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.fail(ctx, "d1", errors.New("непонятое содержимое"), parseResidue{
|
||||||
|
uncovered: []string{"ecg"},
|
||||||
|
novelty: sectionNovelty{fresh: []string{"ecg"}, unknown: true, cause: errors.New("журнал недоступен")},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{"uncovered_new", "ecg", "uncovered_seen_unknown", "журнал недоступен"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("в записи об отказе нет %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package fold_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // прямое подключение к файлу базы — ради теста, ломающего сверку
|
||||||
|
)
|
||||||
|
|
||||||
|
// loggedFold — свёртка с логгером, чьи записи можно прочитать.
|
||||||
|
//
|
||||||
|
// Отдельный конструктор, потому что событие о новой секции наблюдаемо ТОЛЬКО
|
||||||
|
// через лог: в учёте его нет и быть не может — колонка говорит, какие секции в
|
||||||
|
// теле есть, а не какая из них встретилась впервые. Путь к файлу базы нужен
|
||||||
|
// тесту, ломающему сверку: сломать её изнутри нечем — свёртка держит настоящее
|
||||||
|
// хранилище, а заводить интерфейс ради мока значит менять код под тест.
|
||||||
|
type loggedFold struct {
|
||||||
|
svc *fold.Service
|
||||||
|
arch *archive.Archive
|
||||||
|
st *store.Store
|
||||||
|
log *bytes.Buffer
|
||||||
|
dbPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoggedFold(t *testing.T) loggedFold {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("архив: %v", err)
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(dir, "healthlog.db")
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("база: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
return loggedFold{
|
||||||
|
svc: fold.New(arch, st, 0, log),
|
||||||
|
arch: arch,
|
||||||
|
st: st,
|
||||||
|
log: &buf,
|
||||||
|
dbPath: dbPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// breakMerge сносит таблицу объектов: слияние отказывает нетранзиентно, а разбор
|
||||||
|
// и запись исхода продолжают работать.
|
||||||
|
//
|
||||||
|
// В жизни в ту же ветвь ведёт исчерпанный дедлайн свёртки под большим телом
|
||||||
|
// (измерено: 63 МиБ держат блокировку 5.019 с) — `store.Transient` дедлайн
|
||||||
|
// намеренно не признаёт.
|
||||||
|
func breakMerge(t *testing.T, dbPath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы напрямую: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(context.Background(), `DROP TABLE bucket`); err != nil {
|
||||||
|
t.Fatalf("снос таблицы объектов: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRecords разбирает захваченные записи лога.
|
||||||
|
func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
t.Fatalf("строка лога не JSON: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSections достаёт имена новых секций из записи лога.
|
||||||
|
func newSections(t *testing.T, rec map[string]any) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, ok := rec["uncovered_new"]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
// Пустой список кодировщик пишет как `null` — это и есть «новых имён
|
||||||
|
// нет», а не сломанный атрибут.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("атрибут uncovered_new не список: %#v", raw)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(list))
|
||||||
|
for _, v := range list {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("имя секции не строка: %#v", v)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSection дописывает в тело непокрытую секцию с данным именем.
|
||||||
|
func withSection(t *testing.T, body []byte, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const anchor = `"data": {`
|
||||||
|
if !bytes.Contains(body, []byte(anchor)) {
|
||||||
|
t.Fatalf("в теле нет объекта data — дописать секцию некуда")
|
||||||
|
}
|
||||||
|
return bytes.Replace(body, []byte(anchor), []byte(anchor+"\n \""+name+"\": [],"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Момент появления секции наблюдать больше нечем: телефон дописывает метрики
|
||||||
|
// молча, а колонка учёта говорит, какие секции в теле есть, а не какая из них
|
||||||
|
// приехала впервые.
|
||||||
|
func TestFoldПерваяВстречаСекцииДаётСобытие(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||||
|
stats, err := lf.svc.Fold(ctx, "d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "symptoms" {
|
||||||
|
t.Fatalf("новых секций %v, ожидалась ровно `symptoms`", stats.UncoveredNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := logRecords(t, lf.log)
|
||||||
|
var announced int
|
||||||
|
for _, rec := range recs {
|
||||||
|
for _, name := range newSections(t, rec) {
|
||||||
|
if name != "symptoms" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced++
|
||||||
|
if rec["level"] != "WARN" {
|
||||||
|
t.Errorf("уровень записи %v, ожидался WARN: приезд новой секции — «посмотри», а не «разбери сбой»",
|
||||||
|
rec["level"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if announced != 1 {
|
||||||
|
t.Errorf("записей о новой секции %d, ожидалась ровно одна", announced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вторая доставка с тем же именем молчит: событие однократно за всю жизнь
|
||||||
|
// имени, иначе поток раз в пять минут обесценил бы уровень.
|
||||||
|
func TestFoldВтораяВстречаСобытияНеДаёт(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||||
|
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||||
|
t.Fatalf("первая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
lf.log.Reset()
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", body)
|
||||||
|
stats, err := lf.svc.Fold(ctx, "d2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("вторая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stats.UncoveredNew) != 0 {
|
||||||
|
t.Errorf("вторая доставка объявила новыми %v", stats.UncoveredNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := logRecords(t, lf.log)
|
||||||
|
if len(recs) == 0 {
|
||||||
|
t.Fatal("вторая свёртка не записала ничего — чекпоинт молчит вовсе")
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
if len(newSections(t, rec)) != 0 {
|
||||||
|
t.Errorf("вторая доставка назвала новые секции: %v", rec)
|
||||||
|
}
|
||||||
|
if rec["msg"] == "delivery folded" && rec["level"] != "INFO" {
|
||||||
|
t.Errorf("уровень повторной доставки %v, ожидался INFO", rec["level"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечисление непокрытых секций при этом не меняется: атрибут `uncovered`
|
||||||
|
// продолжает называть их все — по нему первую встречу и не отличить.
|
||||||
|
if !strings.Contains(lf.log.String(), "symptoms") {
|
||||||
|
t.Error("имя секции пропало из записи вовсе: атрибут непокрытых секций не трогается")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новое имя рядом с уже виденным: событие адресное, а не «в теле что-то новое».
|
||||||
|
func TestFoldНовоеИмяРядомСВиденным(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
first := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||||
|
second := withSection(t, first, "ecg")
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", first)
|
||||||
|
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||||
|
t.Fatalf("первая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", second)
|
||||||
|
stats, err := lf.svc.Fold(ctx, "d2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("вторая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "ecg" {
|
||||||
|
t.Errorf("новых секций %v, ожидалась ровно `ecg`", stats.UncoveredNew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Событие однократно за жизнь имени, значит замаскировать его рутинным
|
||||||
|
// событием нельзя: перезаписи точек случаются на живом потоке постоянно, а
|
||||||
|
// приезд новой секции — один раз.
|
||||||
|
func TestFoldНоваяСекцияНеМаскируетсяПерезаписью(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
base := fixture(t, "minute.json")
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", base)
|
||||||
|
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||||
|
t.Fatalf("первая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
lf.log.Reset()
|
||||||
|
|
||||||
|
// Те же координаты, другое значение: набор полей совпадает, полнота равна,
|
||||||
|
// поэтому побеждает пришедшая — это перезапись.
|
||||||
|
changed := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+`).ReplaceAll(base, []byte(`"qty": 777.5`))
|
||||||
|
if bytes.Equal(changed, base) {
|
||||||
|
t.Fatal("фикстура не содержит qty — перезапись не устроить")
|
||||||
|
}
|
||||||
|
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", withSection(t, changed, "ecg"))
|
||||||
|
stats, err := lf.svc.Fold(ctx, "d2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("вторая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
if stats.Overwrites == 0 {
|
||||||
|
t.Fatal("перезаписей нет — рутинного события, которое могло бы замаскировать новое, не случилось")
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, rec := range logRecords(t, lf.log) {
|
||||||
|
if rec["msg"] != "delivery folded, new uncovered section" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if rec["overwrites"] == nil {
|
||||||
|
t.Error("счётчик перезаписей исчез из записи: признаки идут атрибутами всегда")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("запись назвала перезапись, а не новую секцию:\n%s", lf.log.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список непокрытых секций переживает отказ разбора, то есть имя уже записано в
|
||||||
|
// учёт. Промолчи здесь — и следующая доставка сочтёт его виденным, а событие не
|
||||||
|
// вернётся ничем.
|
||||||
|
func TestFoldОтказРазбораНазываетНовуюСекцию(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Слой не выводится: плотных метрик в теле нет вовсе.
|
||||||
|
body := withSection(t, fixture(t, "sparse_sleep.json"), "cycleTracking")
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Default", "auto-1", body)
|
||||||
|
if _, err := lf.svc.Fold(ctx, "d1"); err == nil {
|
||||||
|
t.Fatal("свёртка с неопределимым слоем прошла успешно")
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, rec := range logRecords(t, lf.log) {
|
||||||
|
for _, name := range newSections(t, rec) {
|
||||||
|
if name == "cycleTracking" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("запись об отказе не назвала новую секцию:\n%s", lf.log.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// И имя доехало до учёта — иначе терять было бы нечего.
|
||||||
|
d, err := lf.st.LastDelivery(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение доставки: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d.UncoveredSections, "cycleTracking") {
|
||||||
|
t.Errorf("список в учёте %q не содержит секции", d.UncoveredSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересборка проигрывает журнал заново, и повторная свёртка обязана дать тот же
|
||||||
|
// состав событий: признак новизны — функция журнала, а не числа прогонов.
|
||||||
|
func TestFoldПовторнаяСвёрткаДаётТотЖеСоставСобытий(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||||
|
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||||
|
first, err := lf.svc.Fold(ctx, "d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("первая свёртка: %v", err)
|
||||||
|
}
|
||||||
|
second, err := lf.svc.Fold(ctx, "d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("повторная свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(first.UncoveredNew) != len(second.UncoveredNew) {
|
||||||
|
t.Fatalf("новых секций %v против %v", first.UncoveredNew, second.UncoveredNew)
|
||||||
|
}
|
||||||
|
for i := range first.UncoveredNew {
|
||||||
|
if first.UncoveredNew[i] != second.UncoveredNew[i] {
|
||||||
|
t.Errorf("на месте %d %q против %q", i, first.UncoveredNew[i], second.UncoveredNew[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отказ СЛИЯНИЯ пишет имя в учёт ровно так же, как отказ разбора, — значит и
|
||||||
|
// событие терять ему нельзя. Ветвь отдельная, и однажды она уже теряла его:
|
||||||
|
// residue собирался вручную и новизну не нёс.
|
||||||
|
func TestFoldОтказСлиянияНазываетНовуюСекцию(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lf := newLoggedFold(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
breakMerge(t, lf.dbPath)
|
||||||
|
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1",
|
||||||
|
withSection(t, fixture(t, "minute.json"), "ecg"))
|
||||||
|
if _, err := lf.svc.Fold(ctx, "d1"); err == nil {
|
||||||
|
t.Fatal("свёртка без таблицы объектов прошла успешно")
|
||||||
|
}
|
||||||
|
|
||||||
|
var told bool
|
||||||
|
for _, rec := range logRecords(t, lf.log) {
|
||||||
|
for _, name := range newSections(t, rec) {
|
||||||
|
if name == "ecg" {
|
||||||
|
told = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !told {
|
||||||
|
t.Errorf("запись об отказе слияния не назвала новую секцию:\n%s", lf.log.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// И событие действительно потеряно быть не могло: имя уже в учёте, то есть
|
||||||
|
// следующая доставка сочтёт его виденным.
|
||||||
|
d, err := lf.st.LastDelivery(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение доставки: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d.UncoveredSections, "ecg") {
|
||||||
|
t.Errorf("список в учёте %q не содержит секции — терять было бы нечего", d.UncoveredSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UncoveredSection — строка перечня непокрытых секций: имя, которое поток
|
||||||
|
// приносил, и границы его встреч по журналу.
|
||||||
|
//
|
||||||
|
// Число доставок и границы считаются по ВСЕМ статусам разбора: список непокрытых
|
||||||
|
// секций переживает отказ свёртки, и молчать о таком имени значило бы терять как
|
||||||
|
// раз подозрительное.
|
||||||
|
type UncoveredSection struct {
|
||||||
|
// Name — верхнеуровневый ключ `data`, дословно как прислал HAE. Приходит из
|
||||||
|
// чужого тела: длина ограничена разбором, содержимое — ничем.
|
||||||
|
Name string
|
||||||
|
// Deliveries — сколько доставок принесло это имя.
|
||||||
|
Deliveries int64
|
||||||
|
// FirstSeen и FirstDeliveryID — первая встреча ПО ЖУРНАЛУ; LastSeen и
|
||||||
|
// LastDeliveryID — последняя. Идентификатор нужен, чтобы достать тело из
|
||||||
|
// архива и посмотреть форму секции глазами.
|
||||||
|
FirstSeen time.Time
|
||||||
|
FirstDeliveryID string
|
||||||
|
LastSeen time.Time
|
||||||
|
LastDeliveryID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// usableList — условие «списку непокрытых секций есть что сказать».
|
||||||
|
//
|
||||||
|
// Отдельной константой, потому что стоит во всех трёх запросах и означает одно:
|
||||||
|
// по умолчанию колонка держит `[]`, а строки с ним до json_each доходить не
|
||||||
|
// должны.
|
||||||
|
//
|
||||||
|
// `json_valid` здесь не перестраховка. `json_each` над неразбираемым значением
|
||||||
|
// отвечает ошибкой и валит ВЕСЬ запрос, а не пропускает одну строку (проверено
|
||||||
|
// на закреплённом драйвере: `SQL logic error: malformed JSON`). Одна такая
|
||||||
|
// строка — из ручной правки, из будущей миграции данных мимо `json.Marshal`,
|
||||||
|
// из восстановления базы чужим инструментом — обесценила бы и сверку (вечное
|
||||||
|
// «сверка не состоялась» на каждой доставке), и перечень (полный отказ команды
|
||||||
|
// без указания виновника). Штатный путь записи такого значения не производит, и
|
||||||
|
// именно поэтому отказ был бы необъясним.
|
||||||
|
const usableList = `uncovered_sections <> '[]' AND uncovered_sections <> ''`
|
||||||
|
|
||||||
|
// listOf — содержимое колонки, приведённое к разбираемому виду.
|
||||||
|
//
|
||||||
|
// Проверка стоит ВНУТРИ аргумента `json_each`, а не условием в `WHERE`:
|
||||||
|
// табличная функция получает значение строки раньше, чем применится фильтр, и
|
||||||
|
// порядок этот SQLite не обещает. Условие в `WHERE` работало бы, пока
|
||||||
|
// планировщик проталкивает его вниз, и молча перестало бы — с ошибкой, роняющей
|
||||||
|
// весь запрос, а не строку.
|
||||||
|
const listOf = `json_each(CASE WHEN json_valid(d.uncovered_sections)
|
||||||
|
THEN d.uncovered_sections ELSE '[]' END)`
|
||||||
|
|
||||||
|
// SectionsSeenBefore отвечает, какие из имён уже встречались в доставках,
|
||||||
|
// стоящих в журнале СТРОГО РАНЬШЕ указанной.
|
||||||
|
//
|
||||||
|
// Строгое сравнение пары `(received_at, id)` решает три вещи разом: собственная
|
||||||
|
// строка доставки в счёт не идёт при любом порядке записи исхода; повторная
|
||||||
|
// свёртка той же доставки даёт тот же ответ, поэтому пересборка воспроизводит те
|
||||||
|
// же события; и судит имя порядок ЖУРНАЛА, а не порядок прогона — строка учёта
|
||||||
|
// становится видимой воркеру только после записи тела, так что при конкурентном
|
||||||
|
// приёме доставка может стать видимой после более новой.
|
||||||
|
//
|
||||||
|
// Индекса по `uncovered_sections` в схеме нет, поэтому проход по журналу
|
||||||
|
// полный, и форма запроса выбрана ЗАМЕРОМ (числа и метод — в `design.md`
|
||||||
|
// изменения `aktivnaya-proverka-novyh-sekcij`, решение 3; повторяется прогоном
|
||||||
|
// `tmp/seenmeasure`). Здесь только правило, которое из замера следует:
|
||||||
|
//
|
||||||
|
// - ранний выход `LIMIT 1` срабатывает, лишь когда первая встреча имени лежит
|
||||||
|
// БЛИЗКО К НАЧАЛУ журнала, — строки просматриваются от старых к новым.
|
||||||
|
// Секция, приезжающая давно, стоит десятки микросекунд; секция, появившаяся
|
||||||
|
// только что, — почти полный проход на каждой доставке, пока её не покроет
|
||||||
|
// отдельная задача;
|
||||||
|
// - имён больше одного спрашиваются ОДНИМ запросом: тридцать два запроса
|
||||||
|
// подряд стоили секунду с лишним на доставку, одна выборка — столько же,
|
||||||
|
// сколько один полный проход, независимо от числа имён.
|
||||||
|
//
|
||||||
|
// Вызывается ВНЕ транзакции записи: проход по растущему журналу внутри неё
|
||||||
|
// удерживал бы блокировку, а конкурирующий приём отвечает `500` по доставке, чьё
|
||||||
|
// тело уже на диске.
|
||||||
|
func (s *Store) SectionsSeenBefore(ctx context.Context, names []string, before DeliveryRef) (map[string]struct{}, error) {
|
||||||
|
unique := make([]string, 0, len(names))
|
||||||
|
dedup := make(map[string]struct{}, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
if _, ok := dedup[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dedup[name] = struct{}{}
|
||||||
|
unique = append(unique, name)
|
||||||
|
}
|
||||||
|
if len(unique) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
at := FormatTime(before.ReceivedAt)
|
||||||
|
if len(unique) == 1 {
|
||||||
|
return s.sectionSeenOnce(ctx, unique[0], at, before.ID)
|
||||||
|
}
|
||||||
|
return s.sectionsSeenBatch(ctx, unique, at, before.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionSeenOnce спрашивает про одно имя с ранним выходом.
|
||||||
|
func (s *Store) sectionSeenOnce(ctx context.Context, name, at, id string) (map[string]struct{}, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT 1
|
||||||
|
FROM delivery d, ` + listOf + ` j
|
||||||
|
WHERE ` + usableList + `
|
||||||
|
AND (d.received_at, d.id) < (?, ?)
|
||||||
|
AND j.value = ?
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
var one int
|
||||||
|
err := s.db.QueryRowxContext(ctx, q, at, id, name).Scan(&one)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return map[string]struct{}{name: {}}, nil
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
// Имя в текст ошибки не попадает целиком: оно приходит из чужого тела, а
|
||||||
|
// ошибка уходит в лог уровня выше DEBUG. Та же граница, что у координат
|
||||||
|
// наблюдения категориального значения.
|
||||||
|
return nil, fmt.Errorf("section seen before (%s): %w", clipCoord(name), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionsSeenBatch спрашивает про все имена одним проходом по журналу.
|
||||||
|
func (s *Store) sectionsSeenBatch(ctx context.Context, names []string, at, id string) (map[string]struct{}, error) {
|
||||||
|
q := `
|
||||||
|
SELECT DISTINCT j.value
|
||||||
|
FROM delivery d, ` + listOf + ` j
|
||||||
|
WHERE ` + usableList + `
|
||||||
|
AND (d.received_at, d.id) < (?, ?)
|
||||||
|
AND j.value IN (?` + strings.Repeat(", ?", len(names)-1) + `)`
|
||||||
|
|
||||||
|
args := make([]any, 0, len(names)+2)
|
||||||
|
args = append(args, at, id)
|
||||||
|
for _, name := range names {
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sections seen before: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(names))
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan section seen before: %w", err)
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("sections seen before: %w", err)
|
||||||
|
}
|
||||||
|
return seen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UncoveredSections возвращает перечень непокрытых секций — не больше limit
|
||||||
|
// строк — и общее число различных имён в журнале.
|
||||||
|
//
|
||||||
|
// Предел объявлен, а не подразумевается: граница разбора в 32 имени действует на
|
||||||
|
// ОДНУ доставку, а различных имён журнал накопит сколько угодно — достаточно
|
||||||
|
// версии HAE, кладущей в ключ переменную часть. Второе возвращаемое значение
|
||||||
|
// нужно, чтобы вывод мог назвать остаток числом, а не молча оборваться.
|
||||||
|
//
|
||||||
|
// Границы встреч берутся минимумом и максимумом склейки `received_at` и `id`:
|
||||||
|
// метка хранится в RFC3339 фиксированной ширины, поэтому склейка сравнивается
|
||||||
|
// лексикографически ровно как пара, а одна выборка с двумя агрегатами не
|
||||||
|
// опирается на то, из какой строки SQLite возьмёт голые колонки.
|
||||||
|
func (s *Store) UncoveredSections(ctx context.Context, limit int) ([]UncoveredSection, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, 0, fmt.Errorf("предел строк перечня должен быть положительным, дано %d", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const countQ = `
|
||||||
|
SELECT count(DISTINCT j.value)
|
||||||
|
FROM delivery d, ` + listOf + ` j
|
||||||
|
WHERE ` + usableList
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := s.db.GetContext(ctx, &total, countQ); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count uncovered sections: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT j.value,
|
||||||
|
count(DISTINCT d.id),
|
||||||
|
min(d.received_at || ' ' || d.id),
|
||||||
|
max(d.received_at || ' ' || d.id)
|
||||||
|
FROM delivery d, ` + listOf + ` j
|
||||||
|
WHERE ` + usableList + `
|
||||||
|
GROUP BY j.value
|
||||||
|
ORDER BY j.value
|
||||||
|
LIMIT ?`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("select uncovered sections: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var out []UncoveredSection
|
||||||
|
for rows.Next() {
|
||||||
|
var u UncoveredSection
|
||||||
|
var first, last string
|
||||||
|
if err := rows.Scan(&u.Name, &u.Deliveries, &first, &last); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan uncovered section: %w", err)
|
||||||
|
}
|
||||||
|
if u.FirstSeen, u.FirstDeliveryID, err = splitJournalKey(first); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if u.LastSeen, u.LastDeliveryID, err = splitJournalKey(last); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("select uncovered sections: %w", err)
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitJournalKey разбирает склейку метки журнала и идентификатора доставки.
|
||||||
|
func splitJournalKey(key string) (time.Time, string, error) {
|
||||||
|
at, id, ok := strings.Cut(key, " ")
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, "", fmt.Errorf("ключ журнала без разделителя: %q", key)
|
||||||
|
}
|
||||||
|
t, err := ParseTime(at)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, "", err
|
||||||
|
}
|
||||||
|
return t, id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // прямая запись в файл базы — ради теста на битую колонку
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedUncovered заводит доставку с уже записанным исходом разбора: список
|
||||||
|
// непокрытых секций живёт в учётной записи, и заполняет его свёртка.
|
||||||
|
func seedUncovered(t *testing.T, st *store.Store, id string, at time.Time, status string, sections ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
seedPending(t, st, id, at)
|
||||||
|
err := st.FinishParse(context.Background(), id, store.ParseOutcome{
|
||||||
|
Status: status,
|
||||||
|
Layer: "hour",
|
||||||
|
Uncovered: sections,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("исход разбора %q: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Признак новизны обязан быть функцией ЖУРНАЛА, а не порядка свёртки: имя
|
||||||
|
// считается виденным, только если встречалось строго раньше по паре
|
||||||
|
// `(received_at, id)`.
|
||||||
|
func TestSectionsSeenBeforeСудитПоЖурналу(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
early := at
|
||||||
|
late := at.Add(time.Second)
|
||||||
|
seedUncovered(t, st, "d-early", early, store.ParsePartial, "ecg")
|
||||||
|
seedUncovered(t, st, "d-late", late, store.ParsePartial, "ecg")
|
||||||
|
|
||||||
|
seen, err := st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-late", ReceivedAt: late})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := seen["ecg"]; !ok {
|
||||||
|
t.Error("для поздней доставки имя обязано быть виденным: раньше неё в журнале оно есть")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ранняя доставка судится тем же запросом и обязана признать имя новым —
|
||||||
|
// собственная строка в счёт не идёт, а более поздняя лежит после неё.
|
||||||
|
seen, err = st.SectionsSeenBefore(ctx, []string{"ecg"}, store.DeliveryRef{ID: "d-early", ReceivedAt: early})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := seen["ecg"]; ok {
|
||||||
|
t.Error("для ранней доставки имя обязано быть новым: раньше неё его в журнале нет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метка приёма хранится с секундной точностью, поэтому доставки одной секунды
|
||||||
|
// разводятся идентификатором — иначе исход зависел бы от того, какая из них
|
||||||
|
// свернулась первой.
|
||||||
|
func TestSectionsSeenBeforeРазводитОднуСекундуИдентификатором(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
seedUncovered(t, st, "d-a", at, store.ParsePartial, "symptoms")
|
||||||
|
seedUncovered(t, st, "d-b", at, store.ParsePartial, "symptoms")
|
||||||
|
|
||||||
|
seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-b", ReceivedAt: at})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := seen["symptoms"]; !ok {
|
||||||
|
t.Error("для второй доставки той же секунды имя обязано быть виденным")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen, err = st.SectionsSeenBefore(ctx, []string{"symptoms"}, store.DeliveryRef{ID: "d-a", ReceivedAt: at})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
if len(seen) != 0 {
|
||||||
|
t.Errorf("для первой доставки той же секунды виденных имён быть не может, получено %v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный вопрос обязан давать тот же ответ: на этом стоит воспроизводимость
|
||||||
|
// событий при пересборке журнала.
|
||||||
|
func TestSectionsSeenBeforeИдемпотентен(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg", "symptoms")
|
||||||
|
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "ecg")
|
||||||
|
|
||||||
|
ref := store.DeliveryRef{ID: "d2", ReceivedAt: at.Add(time.Minute)}
|
||||||
|
first, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
second, err := st.SectionsSeenBefore(ctx, []string{"ecg", "medications"}, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SectionsSeenBefore: %v", err)
|
||||||
|
}
|
||||||
|
if len(first) != 1 || len(second) != 1 {
|
||||||
|
t.Fatalf("виденных имён %d и %d, ожидалось по одному", len(first), len(second))
|
||||||
|
}
|
||||||
|
if _, ok := first["ecg"]; !ok {
|
||||||
|
t.Error("`ecg` обязан быть виденным")
|
||||||
|
}
|
||||||
|
if _, ok := second["medications"]; ok {
|
||||||
|
t.Error("`medications` журнал не приносил и виденным быть не может")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечень отвечает по всем статусам разбора: список непокрытых секций
|
||||||
|
// переживает отказ свёртки, и молчать о таком имени значило бы терять как раз
|
||||||
|
// подозрительное.
|
||||||
|
func TestUncoveredSectionsСчитаетВстречиИГраницы(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
seedUncovered(t, st, "d1", at, store.ParsePartial, "symptoms")
|
||||||
|
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms", "ecg")
|
||||||
|
// Отказавшая доставка: имя записано, статус — `failed`.
|
||||||
|
seedUncovered(t, st, "d3", at.Add(2*time.Minute), store.ParseFailed, "ecg")
|
||||||
|
|
||||||
|
got, total, err := st.UncoveredSections(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UncoveredSections: %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("различных имён %d, ожидалось 2", total)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("строк перечня %d, ожидалось 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок детерминирован — по имени.
|
||||||
|
if got[0].Name != "ecg" || got[1].Name != "symptoms" {
|
||||||
|
t.Fatalf("порядок строк %q, %q", got[0].Name, got[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ecg := got[0]
|
||||||
|
if ecg.Deliveries != 2 {
|
||||||
|
t.Errorf("`ecg` принесли %d доставок, ожидалось 2", ecg.Deliveries)
|
||||||
|
}
|
||||||
|
if ecg.FirstDeliveryID != "d2" || ecg.LastDeliveryID != "d3" {
|
||||||
|
t.Errorf("границы `ecg`: %q…%q, ожидалось d2…d3", ecg.FirstDeliveryID, ecg.LastDeliveryID)
|
||||||
|
}
|
||||||
|
if !ecg.FirstSeen.Equal(at.Add(time.Minute)) || !ecg.LastSeen.Equal(at.Add(2*time.Minute)) {
|
||||||
|
t.Errorf("метки `ecg`: %s…%s", ecg.FirstSeen, ecg.LastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
sym := got[1]
|
||||||
|
if sym.Deliveries != 2 || sym.FirstDeliveryID != "d1" || sym.LastDeliveryID != "d2" {
|
||||||
|
t.Errorf("`symptoms`: %d доставок, %q…%q", sym.Deliveries, sym.FirstDeliveryID, sym.LastDeliveryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предел объявлен, а остаток обязан быть назван числом: «здесь всё» и «здесь
|
||||||
|
// часть» — разные ответы, и молчаливый обрыв делает их неотличимыми.
|
||||||
|
func TestUncoveredSectionsОстатокНазванЧислом(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
seedUncovered(t, st, "d1", at, store.ParsePartial, "aaa", "bbb", "ccc")
|
||||||
|
|
||||||
|
got, total, err := st.UncoveredSections(ctx, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UncoveredSections: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("строк перечня %d, ожидалось 2", len(got))
|
||||||
|
}
|
||||||
|
if total != 3 {
|
||||||
|
t.Errorf("всего имён %d, ожидалось 3", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой журнал и журнал без непокрытых секций отвечают пустым перечнем, а не
|
||||||
|
// отказом: колонка по умолчанию держит `[]`, и до json_each такие строки не
|
||||||
|
// доходят.
|
||||||
|
func TestUncoveredSectionsНаЧистомЖурнале(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
|
||||||
|
seedUncovered(t, st, "d1", at, store.ParseDone)
|
||||||
|
|
||||||
|
got, total, err := st.UncoveredSections(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UncoveredSections: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 || total != 0 {
|
||||||
|
t.Errorf("перечень %v, всего %d — ожидалась пустота", got, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одна учётная запись с неразбираемым содержимым колонки не имеет права
|
||||||
|
// обесценить и сверку, и перечень: `json_each` над таким значением отвечает
|
||||||
|
// ошибкой и валит ВЕСЬ запрос, а не пропускает строку.
|
||||||
|
//
|
||||||
|
// Штатный путь записи такого значения не производит — тем хуже был бы отказ:
|
||||||
|
// он необъясним, а последствие вечно (сверка на каждой доставке говорила бы
|
||||||
|
// «не состоялась», команда отказывала бы целиком).
|
||||||
|
func TestUncoveredSectionsПереживаетБитуюСтроку(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "healthlog.db")
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
at := ts(t, "2026-08-01T12:00:00Z")
|
||||||
|
seedUncovered(t, st, "d1", at, store.ParsePartial, "ecg")
|
||||||
|
seedUncovered(t, st, "d2", at.Add(time.Minute), store.ParsePartial, "symptoms")
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("прямое подключение: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE delivery SET uncovered_sections = '{не JSON' WHERE id = ?`, "d1"); err != nil {
|
||||||
|
t.Fatalf("порча колонки: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, total, err := st.UncoveredSections(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("перечень отказал целиком из-за одной строки: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(got) != 1 || got[0].Name != "symptoms" {
|
||||||
|
t.Errorf("перечень %v (всего %d), ожидалась одна уцелевшая секция", got, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen, err := st.SectionsSeenBefore(ctx, []string{"symptoms"},
|
||||||
|
store.DeliveryRef{ID: "d3", ReceivedAt: at.Add(2 * time.Minute)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("сверка отказала из-за одной строки: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := seen["symptoms"]; !ok {
|
||||||
|
t.Error("уцелевшая строка перестала участвовать в сверке")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-04
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Разбор перечисляет верхнеуровневые ключи `data`, которых он не покрывает, и
|
||||||
|
свёртка пишет их в `delivery.uncovered_sections` JSON-массивом (change
|
||||||
|
`2026-08-01-nerazobrannye-sekcii-dostavki`). Покрыты три секции — `metrics`,
|
||||||
|
`workouts`, `stateOfMind`; не виденными живьём остаются `symptoms`, `ecg`,
|
||||||
|
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||||
|
|
||||||
|
Колонка отвечает на вопрос «что останется потерянным, если тело удалить». На
|
||||||
|
вопрос «а когда именно поток принёс что-то новое» она отвечает только тому, кто
|
||||||
|
догадается спросить: события нет, а лог свёртки печатает `uncovered` атрибутом
|
||||||
|
на уровне `INFO`, где оно неотличимо от рутины — поток идёт раз в пять минут.
|
||||||
|
|
||||||
|
Ограничения, в которых живёт решение:
|
||||||
|
|
||||||
|
- **Хранилище — свёртка по журналу** (`critical`). Пересборка обязана дать то же
|
||||||
|
состояние; всё, что наблюдаемо, обязано быть функцией журнала, а не порядка
|
||||||
|
прогонов.
|
||||||
|
- **Поток не останавливается** — наблюдательный механизм не имеет права ронять
|
||||||
|
свёртку и не имеет права держать блокировку базы: конкурирующий приём при
|
||||||
|
исчерпанном `busy_timeout` отвечает `500` по доставке, тело которой уже на
|
||||||
|
диске.
|
||||||
|
- Схема не трогается: колонка уже есть, задача её и использует.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Первая по журналу встреча имени непокрытой секции видна владельцу без запроса
|
||||||
|
в базу; повторные встречи молчат.
|
||||||
|
- Событие переживает отказ свёртки — иначе оно теряется навсегда.
|
||||||
|
- Перечень накопленного достаётся одной командой.
|
||||||
|
- Пересборка полного журнала воспроизводит ровно те же события.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Разбор новых секций. Формы никто не видел; вслепую разбор не пишется.
|
||||||
|
- Хранение реестра секций, HTTP-эндпоинт, уведомление наружу. Первое —
|
||||||
|
вторая копия факта, второе и третье — отдельные цели (`Read API`,
|
||||||
|
`Наблюдаемость`).
|
||||||
|
- История непокрытых секций, переживающая удаление тел архива. Это предмет
|
||||||
|
ретеншена, и цена решения названа ниже.
|
||||||
|
|
||||||
|
## Три формы решения и компромисс каждой
|
||||||
|
|
||||||
|
Рассматривались три носителя признака «имя встречено впервые»; выбрана первая.
|
||||||
|
|
||||||
|
1. **Вывод из журнала запросом (выбрано).** Носителя нет вовсе — признак
|
||||||
|
считается по существующей колонке. Компромисс: платим запросом по журналу на
|
||||||
|
каждую доставку с непокрытыми секциями (см. решение 3) и наследуем все
|
||||||
|
границы колонки — обрезку списка и обнуление пересборкой (решение 7).
|
||||||
|
2. **Реестр-таблица по образцу `category_value`.** Строка на имя с провенансом
|
||||||
|
первой встречи; запрос новизны становится точечным по первичному ключу.
|
||||||
|
Компромисс: вторая копия факта, обязанная сходиться с колонкой при каждой
|
||||||
|
пересборке, плюс миграция и новая единица хранения витрины (а значит и
|
||||||
|
отпечатка). Ноль новых сведений: имя выводимо из журнала. Отвергнуто —
|
||||||
|
но не навсегда: ретеншену, срезающему тела, реестр понадобится именно затем,
|
||||||
|
чтобы история пережила удаление, и тогда это уже другая цена.
|
||||||
|
3. **Множество виденных имён в памяти процесса**, наполняемое при старте.
|
||||||
|
Запрос один на запуск, дальше — проверка по map. Компромисс: состояние
|
||||||
|
становится функцией жизни процесса, а не журнала; наполнение при старте — тот
|
||||||
|
же полный проход, только раньше; пересборка и живой приём расходятся в том,
|
||||||
|
что считают первой встречей. Отвергнуто по инварианту «свёртка по журналу».
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Новизна выводится из журнала, а не хранится реестром
|
||||||
|
|
||||||
|
Форма ответа взята у `category_value` — «когда имя встретилось впервые по
|
||||||
|
журналу», — а носитель другой: факт уже лежит в `delivery.uncovered_sections`.
|
||||||
|
Реестр здесь не добавляет ни одного сведения, он кэш запроса, а запрос идёт
|
||||||
|
считанные разы за жизнь имени.
|
||||||
|
|
||||||
|
Новизна считается запросом: «какие из этих имён встречались в доставках, стоящих
|
||||||
|
в журнале **строго раньше** текущей». Пусто — имя новое.
|
||||||
|
|
||||||
|
Прочтение колонки — `json_each` по `delivery`; колонка заведена массивом ровно с
|
||||||
|
этим расчётом («читается из SQLite через `json_each`», миграция `00005`).
|
||||||
|
|
||||||
|
### 2. Сравнение парой `(received_at, id)`, а не по идентификатору
|
||||||
|
|
||||||
|
Строгое сравнение пары решает три вещи разом:
|
||||||
|
|
||||||
|
- **самоисключение** — своя же строка (её пишет `finish` после) в счёт не идёт
|
||||||
|
при любом порядке записи;
|
||||||
|
- **идемпотентность** — повторная свёртка той же доставки даёт тот же исход,
|
||||||
|
поэтому пересборка не выдумывает и не глотает события;
|
||||||
|
- **порядок журнала, а не порядок прогона.** `received_at` хранится с секундной
|
||||||
|
точностью, а строка учёта становится видимой воркеру только после записи тела:
|
||||||
|
при конкурентном приёме доставка может стать видимой после более новой. Пока
|
||||||
|
окно существует, сравнение по журналу делает исход от него независимым; когда
|
||||||
|
окно закроют на самом приёме, сравнение всё равно останется — на нём стоят
|
||||||
|
самоисключение и идемпотентность.
|
||||||
|
|
||||||
|
Та же лексикографическая пара уже стоит в `mergeCategories` и по той же причине;
|
||||||
|
здесь она в `WHERE`, а не в `ON CONFLICT`.
|
||||||
|
|
||||||
|
Следствие названо вслух: если две доставки стали видимы не в журнальном порядке,
|
||||||
|
имя может дать событие дважды — сначала на более новой, потом на более старой.
|
||||||
|
Это не дефект, а честное «первой в журнале была вот эта»; потери здесь нет, а
|
||||||
|
дублирование ограничено одной парой.
|
||||||
|
|
||||||
|
### 3. Цена запроса измерена, а форма выбрана по замеру
|
||||||
|
|
||||||
|
Индекса по `uncovered_sections` в схеме нет, и это изменение его не заводит.
|
||||||
|
Значит запрос — **полный проход по `delivery`** с фильтром по непустому списку,
|
||||||
|
а не «индексный».
|
||||||
|
|
||||||
|
**Это единственное место, где живут числа замера.** Комментарий кода и
|
||||||
|
`docs/architecture.md` формулируют правило и ссылаются сюда; дублировать цифры
|
||||||
|
запрещено — они уже разошлись однажды внутри одного изменения.
|
||||||
|
|
||||||
|
Метод: синтетический журнал в `./tmp` (`tmp/seenmeasure`, прогон повторяем),
|
||||||
|
105 тысяч доставок — годовой объём при 288 в сутки; 20 прогонов на случай;
|
||||||
|
машина ничем другим не занята.
|
||||||
|
|
||||||
|
| случай | по имени отдельно | одним запросом (в коде) |
|
||||||
|
|---|---|---|
|
||||||
|
| одно имя, первая встреча в начале журнала | 31 мкс | 31 мкс |
|
||||||
|
| одно имя, первая встреча в хвосте | — | 52 мс |
|
||||||
|
| одно имя, в журнале не встречалось | 50 мс | 50 мс |
|
||||||
|
| 32 имени | 1.36 с | 65 мс |
|
||||||
|
|
||||||
|
Читается это так, и первое было названо неверно в первой редакции дизайна:
|
||||||
|
|
||||||
|
- **ранний выход есть только у давно приезжающей секции.** Строки
|
||||||
|
просматриваются от старых к новым, поэтому `LIMIT 1` выходит рано, лишь когда
|
||||||
|
первая встреча имени лежит в начале журнала;
|
||||||
|
- **у секции, появившейся только что, раннему выходу не на чем сработать** — её
|
||||||
|
первая встреча в хвосте, и проход идёт почти по всему журналу. Это и есть
|
||||||
|
заявленный сценарий задачи: 52 мс на каждой доставке, 288 раз в сутки — около
|
||||||
|
15 секунд чтения в сутки, пока секцию не покроет отдельная задача. Позиция
|
||||||
|
первой встречи зафиксирована навсегда, поэтому цена сама не рассосётся;
|
||||||
|
- **32 имени по одному стоили секунду с лишним на доставку**, и тело, выбившее
|
||||||
|
границу списка, приезжает раз в пять минут — это режим, а не случай. Поэтому
|
||||||
|
имён больше одного спрашиваются одним запросом; частому случаю (одно имя
|
||||||
|
давней секции) это ничего не стоит.
|
||||||
|
|
||||||
|
Что это стоит в жизни: **пока непокрытых секций нет** (сегодняшний режим — все
|
||||||
|
три приезжающие секции покрыты) запрос не берётся вовсе, он берётся только при
|
||||||
|
непустом списке.
|
||||||
|
|
||||||
|
**Развилка, вынесенная владельцу** (записана в докладе задачи): принять ли эти
|
||||||
|
52 мс на доставку или завести частичный индекс по непустому списку. Индекс — это
|
||||||
|
миграция и правка `docs/database.md`, то есть выход за рамку «схема не
|
||||||
|
трогается», поэтому в этом изменении он не делается. Остаток доведён с принятой
|
||||||
|
ценой, названной здесь числом.
|
||||||
|
|
||||||
|
### 4. Запрос идёт вне транзакции записи
|
||||||
|
|
||||||
|
Прецедент проекта прямой: канонизация внутри транзакции держала блокировку
|
||||||
|
5.019 с и дала 768 МиБ пика. Проход по журналу внутри транзакции записи объектов
|
||||||
|
повторил бы ровно этот класс — с той разницей, что отказ конкурирующего приёма
|
||||||
|
необратим. Сверка выполняется отдельным чтением до записи.
|
||||||
|
|
||||||
|
### 5. Событие живёт в едином логирующем чекпоинте свёртки
|
||||||
|
|
||||||
|
Своей строки лога у события нет: конвенция проекта — один логирующий чекпоинт на
|
||||||
|
доменной границе. Имена новых секций идут **атрибутом всегда**, а уровень
|
||||||
|
поднимается веткой `switch`.
|
||||||
|
|
||||||
|
Нормируется **поведение**, а не позиция ветви: запись обязана называть новую
|
||||||
|
секцию, какие бы повторяющиеся события ни случились в той же доставке. В коде это
|
||||||
|
достигается тем, что ветвь стоит первой, и это остаётся решением кода, а не
|
||||||
|
требованием спеки — иначе следующая задача, добавляющая свою ветвь, получит
|
||||||
|
арбитра в чужой capability.
|
||||||
|
|
||||||
|
Уровень назван поимённо — `WARN`. «Выше `INFO`» зеленело бы и на `ERROR`, а
|
||||||
|
`ERROR` у владельца означает сбой, который надо разбирать.
|
||||||
|
|
||||||
|
### 6. Путь отказа проверяется, отложенный — нет
|
||||||
|
|
||||||
|
Список непокрытых секций переживает отказ разбора (`residueOf`): доставка, у
|
||||||
|
которой не вывелся слой, всё равно записывает имена. Значит проверка новизны идёт
|
||||||
|
**до** ветвления на успех и отказ, а новые имена доезжают до `fail` и печатаются
|
||||||
|
его строкой.
|
||||||
|
|
||||||
|
Отложенный исход (занятость базы, отмена) — отдельный случай: он не пишет учётной
|
||||||
|
записи вовсе, доставка возвращается следующим проходом. Событие там не
|
||||||
|
печатается: оно не потеряно, а повторение на каждом проходе занятой базы
|
||||||
|
превратило бы однократный признак в дребезг.
|
||||||
|
|
||||||
|
### 7. Границы носителя названы, а не замолчаны
|
||||||
|
|
||||||
|
Признак и перечень производны от колонки, у которой три слепые зоны, и все три
|
||||||
|
идут в спеку:
|
||||||
|
|
||||||
|
- **обрезка списка**: имя, стоящее в теле после 32 незнакомых ключей, в колонку
|
||||||
|
не попадает — события не будет. Наблюдаемым остаётся счётчик отброшенных имён,
|
||||||
|
который уже поднимает уровень записи («тело на HAE не похоже вовсе»).
|
||||||
|
Объявлять при обрезке новыми **видимые** имена рассматривалось и отвергнуто:
|
||||||
|
невидимого это не возвращает, а сигнал об обрезке уже есть и уже громкий;
|
||||||
|
- **пересборка** обнуляет производные от разбора поля и заполняет их заново
|
||||||
|
только по сохранившимся телам. Ретеншен, срезающий тела, стирает историю
|
||||||
|
непокрытых секций вместе с ними — поэтому «те же события» пересборка
|
||||||
|
воспроизводит при **полном** архиве. Это же и есть будущая цена решения 1;
|
||||||
|
- **покрытие секции**: пересвёртка убирает имя из колонки, и перечень отвечает о
|
||||||
|
текущем состоянии покрытия, а не об истории. Она же может дать событие
|
||||||
|
повторно — журнал тот же, а колонка заполняется заново.
|
||||||
|
|
||||||
|
### 8. Перечень отдаёт подкоманда `healthlog uncovered`
|
||||||
|
|
||||||
|
Той же формы, что `reindex` и `healthcheck`: конфиг флагом, человекочитаемый
|
||||||
|
вывод в stdout, ненулевой код на отказ. HTTP-маршрут отвергнут — Read API ещё
|
||||||
|
нет, а инструмент нужен на той же машине, где лежит база.
|
||||||
|
|
||||||
|
Имя `uncovered`, а не `sections`: команда перечисляет только непокрытое, а
|
||||||
|
широкое имя заняло бы место под будущий вопрос «какие секции покрыты» (его
|
||||||
|
сегодня закрывает сверка глазами, критерий К4). Словарь при этом закреплён:
|
||||||
|
`uncovered` — состояние покрытия (колонка, атрибут, capability
|
||||||
|
`uncovered-sections`), `new` — событие первой встречи (атрибут новых секций).
|
||||||
|
|
||||||
|
База открывается **только на чтение** (`store.OpenForRead`): команда
|
||||||
|
диагностическая, и запуск её при живом сервисе не должен ни мигрировать схему,
|
||||||
|
ни писать. Отказ открытия говорит причину и даёт ненулевой код — пустой перечень
|
||||||
|
от молчаливого отказа неотличим.
|
||||||
|
|
||||||
|
Строки — имя, число доставок, первая и последняя встреча (метка журнала и
|
||||||
|
идентификатор доставки; по идентификатору достают тело из архива). Вывод имеет
|
||||||
|
объявленный предел строк с остатком числом: предел разбора в 32 имени действует
|
||||||
|
на одну доставку, а различных имён журнал накопит сколько угодно — достаточно
|
||||||
|
версии HAE, кладущей в ключ переменную часть.
|
||||||
|
|
||||||
|
Имя печатается **экранированным** (`%q`): оно приходит верхнеуровневым ключом
|
||||||
|
чужого тела, обрезано по длине на разборе, но по содержимому не ограничено ничем
|
||||||
|
— сырая печать в терминал впустила бы туда управляющие последовательности.
|
||||||
|
|
||||||
|
Данных о здоровье в выводе нет: имя секции — структурный ключ, а не измерение.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Полный проход по журналу на каждой доставке с непокрытыми секциями**
|
||||||
|
(решение 3) → измерено на синтетическом годовом журнале; цена принята и
|
||||||
|
названа числом там же, развилка про индекс вынесена владельцу. Замер
|
||||||
|
повторяем (`tmp/seenmeasure`).
|
||||||
|
- **Долгий читатель против чекпоинта WAL**: команда перечня делает проход по
|
||||||
|
`delivery` вторым процессом, а удерживаемый читатель останавливает продвижение
|
||||||
|
чекпоинта (измерено раньше: журнал 51 МБ при лимите 8 МиБ) → запрос
|
||||||
|
одиночный и короткий, снимок не удерживается дольше вывода; команда
|
||||||
|
диагностическая и запускается руками.
|
||||||
|
- **Слепые зоны носителя** (решение 7) → названы в спеке требованием «перечень
|
||||||
|
честен относительно своего носителя»; молчание о них было бы хуже самих зон.
|
||||||
|
- **Дублирование события при несовпадении видимости с журналом** (решение 2) →
|
||||||
|
ограничено парой строк, потери нет; альтернатива сделала бы событие функцией
|
||||||
|
очереди и разошлась бы с пересборкой.
|
||||||
|
- **Лишние `WARN` при отказе сверки** (спека «Несостоявшаяся сверка не молчит»)
|
||||||
|
→ ограничены доставками с непокрытыми секциями и сопровождаются атрибутом о
|
||||||
|
причине.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Разбор уже перечисляет секции тела, которых он не покрывает, и пишет их в
|
||||||
|
`delivery.uncovered_sections`. Момент, ради которого колонка заводилась —
|
||||||
|
«поток принёс секцию, которой раньше не было», — **фиксируется, но ничем не
|
||||||
|
наблюдается**: узнать о нём можно только запросом в базу руками, а догадаться
|
||||||
|
заглянуть в колонку некому.
|
||||||
|
|
||||||
|
Пользователь дописывает на телефоне оставшиеся метрики (`symptoms`, `ecg`,
|
||||||
|
`heartRateNotifications`, `cycleTracking`, `medications`, вес), и данные
|
||||||
|
появятся сами. Пропущенный момент стоит дорого не сразу: тело живёт в архиве до
|
||||||
|
следующего проверенного экспорта, и разбор новой секции, написанный через
|
||||||
|
квартал, писать будет уже не по чему.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Первая по журналу встреча имени непокрытой секции даёт запись уровня `WARN` в
|
||||||
|
логирующем чекпоинте свёртки, с именами отдельным атрибутом; повторные встречи
|
||||||
|
того же имени события не дают.
|
||||||
|
- Тот же признак переживает **отказ** свёртки: доставка, у которой не вывелся
|
||||||
|
слой, всё равно записывает список непокрытых секций, поэтому промолчать о
|
||||||
|
новом имени здесь значило бы потерять событие навсегда — следующая доставка
|
||||||
|
сочтёт имя уже виденным. Отложенный по обстоятельствам исход события не даёт:
|
||||||
|
учётной записи он не меняет, доставка вернётся следующим проходом.
|
||||||
|
- Новизна **выводится из журнала**, а не хранится: строгое сравнение пары
|
||||||
|
`(received_at, id)` с прежними доставками, запросом вне транзакции записи.
|
||||||
|
Схема не трогается, отдельного реестра не заводится, состояние остаётся
|
||||||
|
свёрткой по журналу — пересборка полного архива повторяет те же события.
|
||||||
|
- Появляется подкоманда `healthlog uncovered`: перечень непокрытых секций,
|
||||||
|
накопленных журналом, — имя, число доставок, первая и последняя встреча.
|
||||||
|
Без ручного SQL по рабочей базе, чтением только на чтение, с объявленным
|
||||||
|
пределом вывода и экранированием имён.
|
||||||
|
- Границы носителя названы спекой, а не замолчаны: обрезка списка на 32 имени,
|
||||||
|
обнуление производных полей пересборкой и уход имени из колонки после того,
|
||||||
|
как секцию покрыли.
|
||||||
|
|
||||||
|
Разбор самих новых секций сюда **не входит**: их формы никто не видел, и вслепую
|
||||||
|
разбор не пишется. Каждая приехавшая секция станет отдельной задачей — тогда,
|
||||||
|
когда её будет на чём проверить.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `uncovered-sections`: наблюдение за секциями, которых разбор не покрывает —
|
||||||
|
событие первой по журналу встречи имени и перечень накопленного.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `storage`: перечень оснований, повышающих уровень записи чекпоинта свёртки,
|
||||||
|
дополнен первой встречей имени непокрытой секции. Перечень нормирован там и
|
||||||
|
читается как исчерпывающий — без правки следующий автор снял бы новую ветвь как
|
||||||
|
незаказанную.
|
||||||
|
|
||||||
|
Capability `parsing` не меняется: разбор непокрытых секций остаётся как есть,
|
||||||
|
новое поведение читает его результат.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/store` — запрос «какие из этих имён встречались строго раньше в
|
||||||
|
журнале» и запрос перечня для команды; обе — чтение по существующей колонке
|
||||||
|
через `json_each`, вне транзакции записи.
|
||||||
|
- `internal/fold` — признак новизны в `Stats`, ветвь эскалации в едином
|
||||||
|
логирующем чекпоинте и в пути отказа.
|
||||||
|
- `cmd/healthlog` — подкоманда `uncovered`.
|
||||||
|
- `docs/architecture.md`, `docs/research/apple-health.md` — раздел про
|
||||||
|
неразобранные секции и сверка перечня не виденных живьём секций с множеством
|
||||||
|
покрытых имён разбора.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Триаж ревью: aktivnaya-proverka-novyh-sekcij
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Профиль:** deep, режим по графу. **Гейт:** зелёный (exit 0 дважды,
|
||||||
|
покрытие диффа 96%, 2 непокрытые строки — унаследованный пробел `main.go`).
|
||||||
|
- **Сверка состава с профилем:** расхождений нет. Deep — 7–8 проходов
|
||||||
|
(`docs/review.md`, итог по конвейеру); запущено 7, восьмой (`reimpl`) —
|
||||||
|
по триггеру, и триггер не сработал (см. границы покрытия).
|
||||||
|
- **Проходы поимённо:**
|
||||||
|
- `gate` — отработал, 2 находки, зелёный;
|
||||||
|
- `specs` — отработал, 5 находок + наблюдения вне спеки;
|
||||||
|
- `code` — отработал, 3 находки;
|
||||||
|
- `adversary` — отработал, 4 находки с прогнанными оракулами + 3 свойства
|
||||||
|
без построенного пути;
|
||||||
|
- `ops` — отработал, 2 находки, обязательные вопросы отвечены;
|
||||||
|
- `architecture` — отработал, 2 находки + 1 правка «дешевле до мерджа»;
|
||||||
|
- `reimpl` — **не запускался**: триггер «новое правило слияния, идентичности
|
||||||
|
или разбора» не сработал — изменение вводит наблюдение поверх существующей
|
||||||
|
логики;
|
||||||
|
- `triage` — этот отчёт.
|
||||||
|
- **Счёт:** на входе 19 именованных находок, после дедупликации по причине —
|
||||||
|
13. В отчёте: 2 блокируют, 4 сейчас, 6 в гипотезах, 2 promote, 1 подтверждённая
|
||||||
|
не влезла в потолок (названа в границах покрытия).
|
||||||
|
- Находка №1 найдена тремя проходами независимо — это подняло её приоритет;
|
||||||
|
подтверждением служит прогнанный оракул, а не согласие проходов.
|
||||||
|
- Совпадений с «Типовыми ложноположительными» из `docs/review.md` нет; отсев
|
||||||
|
шёл по проектному списку.
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### Отказ слияния навсегда съедает событие о новой секции — владелец о ней не узнает никогда
|
||||||
|
|
||||||
|
- Файл: `internal/fold/fold.go:214-222`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: `tmp/adv/lost_event_test.go`, перепрогнан триажем — FAIL
|
||||||
|
воспроизведён: запись об отказе (`delivery fold failed`) не несёт
|
||||||
|
`uncovered_new`, при этом `fail` пишет `residue.uncovered` в колонку
|
||||||
|
(`fold.go:587`), и следующая доставка считает `ecg` виденным. Путь достижим:
|
||||||
|
`context.DeadlineExceeded` не транзиентен, тело ~63 МиБ держит блокировку
|
||||||
|
5.019 с (замер adversary).
|
||||||
|
- Последствие: единственная возможность события «поток начал приносить новую
|
||||||
|
секцию» — первая доставка с этим именем. Если её свёртка упала на слиянии,
|
||||||
|
событие теряется молча и не повторяется: имя уже записано как виденное.
|
||||||
|
Прямое нарушение требования дельта-спеки «Отказ свёртки не глотает событие»
|
||||||
|
и класс «молчание» — самый дорогой после порчи данных.
|
||||||
|
- Предложение: в ветви отказа слияния передавать `novelty` в остаток:
|
||||||
|
`s.fail(ctx, deliveryID, err, parseResidue{uncovered: parsed.Uncovered,
|
||||||
|
skipped: skippedEntities(parsed), novelty: novelty})`. Тест из
|
||||||
|
`tmp/adv/lost_event_test.go` перенести в постоянные.
|
||||||
|
- Найдено проходом: specs, code, adversary (дедуплицировано; оракул один)
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
### Вывод команды обещает «журнал не приносил», хотя носитель перечня обрезается и обнуляется — оператор поверит пустоте, которой нельзя верить
|
||||||
|
|
||||||
|
- Файл: `cmd/healthlog/uncovered.go:71-95`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: дословный пункт дельта-спеки —
|
||||||
|
`openspec/changes/aktivnaya-proverka-novyh-sekcij/specs/uncovered-sections/spec.md:198-203`:
|
||||||
|
«Система SHALL называть границы перечня в спеке **и в выводе команды**, а не
|
||||||
|
обещать „всё, что поток когда-либо приносил"». Вывод границ не называет, а
|
||||||
|
пустая ветвь печатает ровно запрещённое обещание: «Непокрытых секций журнал
|
||||||
|
не приносил.»
|
||||||
|
- Последствие: перечень производен от колонки, которую обрезает разбор
|
||||||
|
(32 имени на доставку) и обнуляет пересборка (тела, удалённые ретеншеном,
|
||||||
|
список теряют). Пустой вывод после ретеншена или вытеснения читается как
|
||||||
|
«поток ничего не приносил» — оператор примет решение по неполному ответу,
|
||||||
|
считая его полным.
|
||||||
|
- Предложение: переписать пустую ветвь («в учётных записях журнала непокрытых
|
||||||
|
имён нет») и добавить в вывод строку границ носителя (обрезка разбором,
|
||||||
|
обнуление пересборкой). Закрепить сквозным тестом `runUncovered` на непустой
|
||||||
|
базе — он же закрывает критерий приёмки К3 и находку specs о его отсутствии.
|
||||||
|
- Найдено проходом: specs
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### Одна битая строка `uncovered_sections` роняет и сверку (вечный WARN-дребезг), и команду перечня целиком, не называя виновника
|
||||||
|
|
||||||
|
- Файл: `internal/store/uncovered.go:37,92-98,167-186`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: тест триажа `tmp/triage/jsoneach_test.go` — воспроизведено:
|
||||||
|
`SQL logic error: malformed JSON (1)` на весь запрос; с
|
||||||
|
`AND json_valid(uncovered_sections)` битая строка пропускается, живая
|
||||||
|
считается. Достижимо не штатным путём (`json.Marshal` битого не пишет):
|
||||||
|
ручная правка, будущая миграция мимо кода, восстановление БД. Схемной защиты
|
||||||
|
`CHECK(json_valid(...))` в миграции 00005 нет.
|
||||||
|
- Последствие: `json_each` над невалидным JSON — ошибка всего запроса, а guard
|
||||||
|
`notEmptyList` проверяет только `'[]'` и `''`. Итог: сверка новизны вечно
|
||||||
|
`unknown=true` (постоянный WARN, неотличимый от занятости базы), а
|
||||||
|
`healthlog uncovered` отказывает целиком и не говорит, какая строка битая.
|
||||||
|
- Предложение: добавить `AND json_valid(uncovered_sections)` в `notEmptyList`
|
||||||
|
(действует на оба запроса) — проверено, чинит.
|
||||||
|
- Найдено проходом: ops
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
### Обоснование «индекс не нужен» стоит на замере не того случая: в заявленном сценарии скан почти полный на каждой доставке, а `LIMIT` не ограничивает работу
|
||||||
|
|
||||||
|
- Файл: `internal/store/uncovered.go:49-64,177-186`; `docs/architecture.md:383-387`
|
||||||
|
- Severity: minor (факт high, операционная угроза low)
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: `tmp/adv/seen_cost_test.go` — журнал 105 000: имя с первой встречей в
|
||||||
|
начале — 31 мкс, в хвосте — 13.9 мс, новое — 14.1 мс. Ранний выход `LIMIT 1`
|
||||||
|
окупается только для имени из **начала** журнала; в заявленном сценарии
|
||||||
|
(секция начала приезжать недавно) первая встреча — в хвосте, и цена платится
|
||||||
|
на каждой доставке, а не «один раз за жизнь имени». Плюс `EXPLAIN QUERY PLAN`
|
||||||
|
для `UncoveredSections` (ops): `SCAN d`, `USE TEMP B-TREE FOR GROUP BY` —
|
||||||
|
`--limit 5` стоит столько же, сколько `--limit 200`.
|
||||||
|
- Последствие: сегодня терпимо (~14 мс × 288 доставок ≈ 4 с чтения в сутки),
|
||||||
|
но записанное рассуждение, на котором держится решение «индекса нет»,
|
||||||
|
неверно, и следующая задача обопрётся на него как на факт. Вдобавок числа
|
||||||
|
замера разошлись между двумя местами: `uncovered.go:53-58` — 28 мкс / 45 мс;
|
||||||
|
`architecture.md:383-386` — 18 мкс / 46 мс / 57 мс (находка gate и code).
|
||||||
|
- Предложение (развилка, варианты):
|
||||||
|
- (а) принять цену и переписать обоснование в `uncovered.go` и
|
||||||
|
`architecture.md` честно (худший случай — почти полный скан на доставку),
|
||||||
|
заодно свести разошедшиеся числа и оставить их в одном месте со ссылкой из
|
||||||
|
другого — ноль кода, минуты работы;
|
||||||
|
- (б) завести индекс или таблицу первых встреч имён — миграция плюс правка
|
||||||
|
`docs/database.md`; окупится только если различных имён станет много;
|
||||||
|
- (в) ничего не менять — отвергается: обоснование записано неверно.
|
||||||
|
- Найдено проходом: adversary, ops (дедуплицировано: одна причина — оценка
|
||||||
|
стоимости снята на нерепрезентативном случае); расхождение чисел — gate, code
|
||||||
|
- Действие: развилка
|
||||||
|
|
||||||
|
### Несостоявшаяся сверка не называет причину: `uncovered_seen_unknown=true` без ошибки, разбираться не по чему
|
||||||
|
|
||||||
|
- Файл: `internal/fold/fold.go:514-522`; `internal/store/uncovered.go:111`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: положение конвенции `docs/conventions/logging.md` (проход code);
|
||||||
|
чтение кода — `novelty()` отбрасывает `err` из `SectionsSeenBefore` молча,
|
||||||
|
из-за чего `clipCoord` в тексте ошибки `sectionSeenOnce` — мёртвый
|
||||||
|
предохранитель: ошибка никуда не доезжает.
|
||||||
|
- Последствие: оператор видит «сверка не состоялась, новыми объявлены все
|
||||||
|
имена», но не видит, почему — занятость, отмена, битая строка (см. находку
|
||||||
|
про `json_valid`) неразличимы. Молчание причины при говорящем признаке.
|
||||||
|
- Предложение: при `unknown` доводить причину до записи — либо логировать в
|
||||||
|
`novelty()`, либо нести ошибку в `sectionNovelty` до атрибутов чекпоинта /
|
||||||
|
записи об отказе.
|
||||||
|
- Найдено проходом: specs, code, adversary (дедуплицировано)
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
### Сообщение чекпоинта вводит третий термин мимо словаря — операторский контракт (grep, алерты) затвердеет с неверным именем
|
||||||
|
|
||||||
|
- Файл: `internal/fold/fold.go` (msg `"delivery folded, unseen section name"`)
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Оракул: словарь изменения — `uncovered` (состояние) и `new` (событие);
|
||||||
|
«unseen» не существует ни в спеке, ни в атрибутах (атрибут называется
|
||||||
|
`uncovered_new` — виден в прогоне `tmp/adv/dupclip_test.go`).
|
||||||
|
- Последствие: после мерджа сообщение — операторский контракт; grep и алерты
|
||||||
|
завяжутся на «unseen», и переименование станет дороже с каждой неделей.
|
||||||
|
Сейчас — одна строка и один тест.
|
||||||
|
- Предложение: переименовать msg в термины словаря, например
|
||||||
|
`"delivery folded, uncovered section new"`.
|
||||||
|
- Найдено проходом: architecture
|
||||||
|
- Действие: инлайн
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Вторая сериализация журнального ключа** (`received_at || ' ' || id` +
|
||||||
|
`splitJournalKey`, `internal/store/uncovered.go:180-181,216-226`) —
|
||||||
|
корректность лексикографического сравнения держится на незакреплённой
|
||||||
|
фиксированной ширине `FormatTime`. Оракула (теста, ломающего ширину) не
|
||||||
|
построено; понижено до гипотезы. Дешёвая страховка — тест-шпилька на ширину
|
||||||
|
формата. (architecture, minor/medium)
|
||||||
|
- **Два SQL-пути в `SectionsSeenBefore`** (одно имя / батч,
|
||||||
|
`internal/store/uncovered.go:84-87`) — экономия быстрого пути ≈ 13 с чтения
|
||||||
|
в сутки; вопрос «что опытный человек удалил бы». Последствие — только цена
|
||||||
|
сопровождения двух запросов; ущерб не построен. Если владелец возьмёт
|
||||||
|
вариант (б) развилки про индекс — вопрос снимется сам. (architecture,
|
||||||
|
minor/low)
|
||||||
|
- **Сценарий «Отложенная доставка события не порождает» не закреплён тестом** —
|
||||||
|
держится на порядке двух блоков в `fail` (`fold.go:546-565`); перестановка
|
||||||
|
при рефакторинге даст дребезг события на каждом проходе занятой базы.
|
||||||
|
(specs, minor)
|
||||||
|
- **Ветвь новизны при переменной части в ключе навсегда занимает `msg`** — если
|
||||||
|
HAE начнёт класть в ключ переменную часть, каждая доставка будет «с новой
|
||||||
|
секцией» и чекпоинт станет постоянным WARN. Путь не построен (наблюдённые
|
||||||
|
ключи стабильны). (adversary, свойство без пути)
|
||||||
|
- **Два снимка в `UncoveredSections` (count и перечень) могут разойтись на
|
||||||
|
единицу** — два запроса без общей транзакции; окно — конкурентная запись
|
||||||
|
между ними. Команда диагностическая, ущерб — косметика остатка. (adversary,
|
||||||
|
свойство без пути)
|
||||||
|
- **Поведение вне спеки, исход не меняющее:** флаг `-limit` не заказан спекой;
|
||||||
|
`context.Background()` вместо `signal.NotifyContext` (у соседнего `reindex` —
|
||||||
|
второе). Оставлено на решение оркестратора без severity. (specs)
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **`CHECK (json_valid(...))` для JSON-колонок в будущих миграциях** — схемная
|
||||||
|
защита, которой не хватило в 00005; правило для `docs/conventions/` или
|
||||||
|
чек миграций в гейте. (из находки ops про битую строку)
|
||||||
|
- **«Число замера живёт в одном месте, остальные ссылаются»** — числа одного
|
||||||
|
замера разошлись между `uncovered.go` и `architecture.md` уже до мерджа;
|
||||||
|
класс тот же, что у записи 2026-08-04 в `docs/review.md` (числа, на которых
|
||||||
|
стоит нормативный текст, читаются как факт и не перепроверяются). (gate, code)
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
**Прогон:** профиль deep, режим по графу. База диффа — рабочее дерево
|
||||||
|
(HEAD == master). Гейт зелёный, exit 0 дважды; diff-coverage 96%.
|
||||||
|
|
||||||
|
**Запускалось:** gate, specs, code, adversary, ops, architecture, triage.
|
||||||
|
Оракулы adversary прогнаны в `./tmp/adv`, оракул триажа — `./tmp/triage`.
|
||||||
|
`task verify:archive` прогнан оркестратором — зелёный, 90 с.
|
||||||
|
|
||||||
|
**Не запускалось:**
|
||||||
|
|
||||||
|
- `reimpl` — триггер `docs/review.md` («новое правило слияния, идентичности или
|
||||||
|
разбора») не сработал: изменение наблюдает поверх существующей логики, правил
|
||||||
|
не двигает. Если считать сверку новизны «правилом наблюдения» — это
|
||||||
|
расширительное чтение триггера; решение не запускать принял оркестратор, и
|
||||||
|
класс «независимая реализация нашла бы другую форму» не покрыт.
|
||||||
|
- `task verify:busy` — не гонялся. Именно он проверил бы поведение новой сверки
|
||||||
|
при удерживаемой блокировке (ветвь `uncovered_seen_unknown` — ровно про
|
||||||
|
занятую базу). Изменение правил разбора/слияния не двигает, поэтому
|
||||||
|
обязательный порог CLAUDE.md формально не задет, но ветвь unknown проверена
|
||||||
|
только юнит-тестами, не прогоном.
|
||||||
|
|
||||||
|
**Не влезло в потолок (подтверждено, но ниже линии):**
|
||||||
|
|
||||||
|
- Два разных ключа, совпадающих в первых 64 байтах, дают дубль после
|
||||||
|
`clipSection`: одна доставка показана в перечне как две
|
||||||
|
(`internal/store/uncovered.go:177-186`, `count(*)` считает строки
|
||||||
|
`json_each`, а не доставки). Оракул: `tmp/adv/dupclip_test.go`, перепрогнан —
|
||||||
|
FAIL воспроизведён. Вероятность мизерная (нужны имена длиннее 64 байт с общим
|
||||||
|
префиксом), последствие — неверное число в диагностической команде. Чинится
|
||||||
|
`count(DISTINCT d.received_at || ' ' || d.id)` либо дедупликацией имён после
|
||||||
|
обрезки на разборе.
|
||||||
|
- Непокрытые строки диспетчера подкоманд `cmd/healthlog/main.go:34-35` —
|
||||||
|
унаследованный пробел (весь `main()` 0% и до изменения). (gate)
|
||||||
|
|
||||||
|
**Что запущенные проходы не могли проверить по построению:**
|
||||||
|
|
||||||
|
- `gate` — только механизируемое; «в Go так не пишут» не проверяет никто (см.
|
||||||
|
ниже).
|
||||||
|
- `specs` — судит против записанной спеки; сами границы спеки: отказ слияния
|
||||||
|
как исход в дельте не описан (находка №1 стоит на общем требовании «не
|
||||||
|
глотает событие»); приоритет ветви новизны над событиями потери содержания
|
||||||
|
(`EntitiesHeld`, `PointsErased`) — выбор кода, не спеки; `Deliveries` считает
|
||||||
|
пары (доставка, элемент массива) — семантика зафиксирована кодом.
|
||||||
|
- `adversary` — три свойства названы без построенного пути (перечислены в
|
||||||
|
гипотезах); не построенный путь не означает недостижимый.
|
||||||
|
- `ops` — истории инцидентов у нового кода нет; поведение под реальным потоком
|
||||||
|
не наблюдалось. Сигналов о молчании потока нет — существующий пробел, не
|
||||||
|
этого диффа.
|
||||||
|
- `architecture` — судит форму, не поведение; шов `loggedFold`/`breakSeenQuery`
|
||||||
|
рассмотрен и находкой не признан.
|
||||||
|
- `triage` — ничего нового не находит по построению; пропуск любого прохода —
|
||||||
|
пропуск триажа тоже.
|
||||||
|
|
||||||
|
**Целиком на человеке** (`docs/review.md`, «Недоступно проверке» — два списка,
|
||||||
|
намеренно раздельных):
|
||||||
|
|
||||||
|
*Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча и
|
||||||
|
непрерывно); поведение HAE за пределами наблюдённого; полнота словаря переводов
|
||||||
|
после обновления iOS; секции, которых поток ещё не приносил (`symptoms`, `ecg`,
|
||||||
|
`heartRateNotifications`, `cycleTracking`, `medications`) — для этого изменения
|
||||||
|
это особенно прямо: команда `uncovered` написана ровно про них, и судить можно
|
||||||
|
только форму кода, не встречу с реальностью. Плюс общее: история инцидентов,
|
||||||
|
поведение внешних систем в их версиях, завязка потребителей на текущее
|
||||||
|
поведение, вопрос «а нужна ли эта функциональность вообще».
|
||||||
|
|
||||||
|
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта
|
||||||
|
(в этом прогоне archive прогнан, busy — нет, см. выше); класс «в Go так не
|
||||||
|
пишут» — не покрыт вовсе после упразднения `idiom` (запись 2026-08-02); класс
|
||||||
|
«чего нет в зрелой реализации такого узла» — вне профиля `design`.
|
||||||
|
|
||||||
|
**Документы проекта:** всё нужное было на месте — инварианты `CLAUDE.md`
|
||||||
|
(severity брались из них, не выводились), `docs/review.md` с журналом, типовыми
|
||||||
|
ложноположительными и обоими списками «Недоступно проверке». Ни один проход не
|
||||||
|
заявил недостающего документа; отдельных строк деградации нет.
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Значения точек не попадают в логи
|
||||||
|
|
||||||
|
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||||
|
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||||||
|
|
||||||
|
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||||||
|
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||||||
|
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||||||
|
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||||||
|
описывают, что случилось, а не что измерено.
|
||||||
|
|
||||||
|
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||||||
|
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||||||
|
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||||||
|
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||||||
|
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||||||
|
из чужого тела и ограничен по длине при разборе.
|
||||||
|
|
||||||
|
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||||
|
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||||
|
Повышает уровень другое, и оснований два:
|
||||||
|
|
||||||
|
- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
|
||||||
|
на HAE не похоже вовсе;
|
||||||
|
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
|
||||||
|
всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
|
||||||
|
секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
|
||||||
|
следующий читатель снимет ветвь как незаказанную.
|
||||||
|
|
||||||
|
#### Scenario: Разбор доставки логируется без значений
|
||||||
|
|
||||||
|
- **WHEN** доставка разобрана
|
||||||
|
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||||||
|
идентификатор доставки
|
||||||
|
- **AND** не содержит ни значений точек, ни имён устройств
|
||||||
|
|
||||||
|
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||||||
|
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||||||
|
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||||||
|
|
||||||
|
#### Scenario: Непокрытые секции названы именами ключей
|
||||||
|
|
||||||
|
- **WHEN** доставка содержит непокрытую секцию
|
||||||
|
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
|
||||||
|
- **AND** не содержит ничего из содержимого этих секций
|
||||||
|
- **AND** уровень записи из-за одной лишь частичности не повышается
|
||||||
|
|
||||||
|
#### Scenario: Границы списка сработали
|
||||||
|
|
||||||
|
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
|
||||||
|
- **THEN** запись лога имеет уровень `WARN`
|
||||||
|
- **AND** содержит число отброшенных имён
|
||||||
|
|
||||||
|
#### Scenario: Имя непокрытой секции встречено впервые
|
||||||
|
|
||||||
|
- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
|
||||||
|
доставке раньше неё в журнале
|
||||||
|
- **THEN** запись лога имеет уровень `WARN`
|
||||||
|
- **AND** содержит имя отдельным атрибутом новых секций
|
||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Первая встреча непокрытой секции порождает событие
|
||||||
|
|
||||||
|
Свёртка SHALL писать запись уровня `WARN`, когда доставка принесла имя
|
||||||
|
непокрытой секции, которого **не было ни в одной доставке, стоящей в журнале
|
||||||
|
раньше**. Имена, признанные новыми, SHALL идти отдельным атрибутом записи —
|
||||||
|
всегда, независимо от выбранного уровня.
|
||||||
|
|
||||||
|
Уровень назван поимённо: `WARN` — «может стать проблемой, посмотри». `ERROR`
|
||||||
|
означал бы сбой, который надо разбирать, а приезд новой секции — штатное событие
|
||||||
|
внешнего мира.
|
||||||
|
|
||||||
|
Событие однократно за всю жизнь имени. Поэтому запись SHALL называть его
|
||||||
|
независимо от того, какие повторяющиеся события (перезаписи точек, запечатанный
|
||||||
|
час, расхождение слоя) случились в той же доставке: замаскировать однократное
|
||||||
|
рутинным значило бы потерять ровно то, ради чего наблюдение заведено.
|
||||||
|
|
||||||
|
Отдельной строки лога у события нет: чекпоинт свёртки остаётся единственным.
|
||||||
|
|
||||||
|
#### Scenario: Имя, которого журнал раньше не видел
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `symptoms`
|
||||||
|
- **WHEN** доставка с секцией `symptoms` свёрнута
|
||||||
|
- **THEN** запись чекпоинта свёртки имеет уровень `WARN`
|
||||||
|
- **AND** атрибут новых секций содержит ровно `symptoms`
|
||||||
|
|
||||||
|
#### Scenario: Новая секция не маскируется рутинным событием
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
|
||||||
|
- **WHEN** доставка с секцией `ecg` одновременно перезаписывает точки уже
|
||||||
|
сохранённого часа
|
||||||
|
- **THEN** запись чекпоинта называет новую секцию, а не перезапись точек
|
||||||
|
- **AND** счётчик перезаписей остаётся в атрибутах записи
|
||||||
|
|
||||||
|
### Requirement: Повторная встреча имени события не порождает
|
||||||
|
|
||||||
|
Система SHALL считать виденным всякое имя непокрытой секции, уже встречавшееся в
|
||||||
|
доставке, стоящей в журнале раньше текущей: в атрибут новых секций оно не
|
||||||
|
попадает и уровень записи не поднимает. Перечисление непокрытых секций при этом
|
||||||
|
не меняется: атрибут `uncovered` SHALL продолжать называть их все.
|
||||||
|
|
||||||
|
Отсюда следствие для приёмки: наблюдаемый признак события — **атрибут новых
|
||||||
|
секций**, а не присутствие имени в записи вообще. Имя непокрытой секции стоит в
|
||||||
|
записи каждой доставки, которая её принесла, и так было до этого изменения.
|
||||||
|
|
||||||
|
#### Scenario: Вторая доставка с тем же именем молчит
|
||||||
|
|
||||||
|
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
|
||||||
|
- **WHEN** следующая доставка приносит ту же секцию `symptoms`
|
||||||
|
- **THEN** атрибут новых секций у второй записи пуст
|
||||||
|
- **AND** уровень второй записи рутинный
|
||||||
|
- **AND** секция `symptoms` остаётся в атрибуте непокрытых секций
|
||||||
|
|
||||||
|
#### Scenario: Новое имя рядом с уже виденным
|
||||||
|
|
||||||
|
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
|
||||||
|
- **WHEN** следующая доставка приносит `symptoms` и `ecg`
|
||||||
|
- **THEN** атрибут новых секций содержит ровно `ecg`
|
||||||
|
|
||||||
|
### Requirement: Новизна выводится из журнала, а не из порядка свёртки
|
||||||
|
|
||||||
|
Признак новизны SHALL определяться сравнением с доставками, стоящими в журнале
|
||||||
|
**строго раньше** текущей — по паре `(received_at, id)`, как порядок журнала
|
||||||
|
определён capability пересборки. Собственная учётная запись доставки в сравнение
|
||||||
|
не входит при любом порядке записи исхода.
|
||||||
|
|
||||||
|
Отсюда следует, что признак идемпотентен: повторная свёртка той же доставки даёт
|
||||||
|
тот же исход, а проигрывание полного журнала пересборкой воспроизводит ровно те
|
||||||
|
же события — свёртка по журналу остаётся функцией журнала, а не числа прогонов.
|
||||||
|
|
||||||
|
Хранимого реестра встреченных имён это изменение заводить MUST NOT: факт уже
|
||||||
|
лежит в учётной записи доставки, и вторая его копия расходилась бы с первой при
|
||||||
|
пересборке молча. Это решение **этого** изменения, а не запрет навсегда:
|
||||||
|
потребителю, которому понадобится история непокрытых секций, переживающая
|
||||||
|
удаление тел (ретеншен архива), придётся его пересмотреть — цена названа в
|
||||||
|
требовании о честности перечня.
|
||||||
|
|
||||||
|
Запрос новизны SHALL выполняться **вне транзакции записи** свёртки: он идёт по
|
||||||
|
растущему журналу, а транзакция записи объектов не имеет права держать блокировку
|
||||||
|
дольше, чем позволено конкурирующему приёму.
|
||||||
|
|
||||||
|
#### Scenario: Повторная свёртка той же доставки не меняет исход
|
||||||
|
|
||||||
|
- **GIVEN** доставка с новой секцией свёрнута и событие записано
|
||||||
|
- **WHEN** та же доставка свёрнута повторно
|
||||||
|
- **THEN** событие повторяется тем же составом имён
|
||||||
|
|
||||||
|
#### Scenario: Доставка, свёрнутая позже более новой, судится по журналу
|
||||||
|
|
||||||
|
- **GIVEN** доставка A стоит в журнале раньше доставки B, и обе содержат
|
||||||
|
секцию `ecg`
|
||||||
|
- **WHEN** B свёрнута первой, а A — после неё
|
||||||
|
- **THEN** A признаёт `ecg` новым, потому что раньше неё в журнале этого имени
|
||||||
|
не было
|
||||||
|
|
||||||
|
#### Scenario: Запрос новизны не удерживает блокировку записи
|
||||||
|
|
||||||
|
- **WHEN** доставка с непокрытыми секциями сворачивается
|
||||||
|
- **THEN** обращение к журналу за признаком новизны идёт вне транзакции записи
|
||||||
|
объектов
|
||||||
|
|
||||||
|
### Requirement: Отказ свёртки не глотает событие
|
||||||
|
|
||||||
|
Список непокрытых секций переживает отказ разбора, поэтому проверка новизны SHALL
|
||||||
|
идти до ветвления на успех и отказ, а новые имена SHALL попадать в запись об
|
||||||
|
отказе — она уже выше рутинного уровня. Уровень такой записи определяет отказ;
|
||||||
|
событие опознаётся атрибутом новых секций.
|
||||||
|
|
||||||
|
Это относится к **каждому** исходу, который пишет список в учётную запись, а не
|
||||||
|
к одному только отказу разбора: отказ слияния тоже записывает имена, и терять
|
||||||
|
событие ему нельзя ровно по той же причине. Отказы, случившиеся до разбора
|
||||||
|
(нечитаемое тело, паника), список очищают и потому события не теряют.
|
||||||
|
|
||||||
|
Иначе событие теряется необратимо: имя записано в учётную запись отказавшей
|
||||||
|
доставки, и следующая доставка сочтёт его виденным.
|
||||||
|
|
||||||
|
Исход, отложенный по обстоятельствам (занятость базы, отмена снаружи), — другое
|
||||||
|
дело: он учётной записи не меняет вовсе, список непокрытых секций в базу не
|
||||||
|
попадает, и доставка вернётся следующим проходом. Новые имена в такой записи
|
||||||
|
система называть MUST NOT: событие не потеряно, а повторение его на каждом
|
||||||
|
проходе занятой базы превратило бы однократный признак в дребезг.
|
||||||
|
|
||||||
|
#### Scenario: Отказ слияния при новой секции
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
|
||||||
|
- **WHEN** разбор доставки прошёл, а слияние отказало нетранзиентно
|
||||||
|
- **THEN** запись об отказе называет новую секцию `ecg`
|
||||||
|
- **AND** учётная запись доставки сохраняет её в списке непокрытых
|
||||||
|
|
||||||
|
#### Scenario: Доставка с новой секцией и невыводимым слоем
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `cycleTracking`
|
||||||
|
- **WHEN** доставка с этой секцией не свернулась, потому что слой не вывелся
|
||||||
|
- **THEN** запись об отказе называет новую секцию `cycleTracking`
|
||||||
|
- **AND** учётная запись доставки сохраняет её в списке непокрытых
|
||||||
|
|
||||||
|
#### Scenario: Отложенная доставка события не порождает
|
||||||
|
|
||||||
|
- **WHEN** свёртка доставки отложена по занятости базы
|
||||||
|
- **THEN** запись об отложенном исходе новых секций не называет
|
||||||
|
- **AND** доставка остаётся в очереди
|
||||||
|
|
||||||
|
### Requirement: Несостоявшаяся сверка не молчит
|
||||||
|
|
||||||
|
Отказ самого запроса новизны исходом доставки система объявлять MUST NOT: правила
|
||||||
|
классификации исходов свёртки это изменение не трогает, отменённый контекст и
|
||||||
|
занятая база остаются обстоятельствами, а не свойствами доставки.
|
||||||
|
|
||||||
|
Когда доставка дошла до записи исхода по прочим правилам, а сверка не удалась,
|
||||||
|
все её непокрытые имена SHALL считаться новыми, и запись SHALL нести отдельный
|
||||||
|
признак того, что сверка не состоялась, **вместе с причиной**: занятость базы
|
||||||
|
проходит сама, а испорченное содержимое колонки не пройдёт никогда и будет
|
||||||
|
поднимать признак на каждой доставке — по одному булеву эти случаи неразличимы.
|
||||||
|
|
||||||
|
Асимметрия названа: лишняя запись стоит внимания владельца один раз, а
|
||||||
|
промолчавшее событие не восстанавливается ничем, кроме ручного запроса в базу.
|
||||||
|
|
||||||
|
#### Scenario: Сверка не удалась, разбор прошёл
|
||||||
|
|
||||||
|
- **WHEN** запрос новизны отказал, а разбор доставки прошёл
|
||||||
|
- **THEN** свёртка доходит до конца и записывает исход
|
||||||
|
- **AND** все непокрытые имена доставки объявлены новыми
|
||||||
|
- **AND** запись несёт признак несостоявшейся сверки и причину
|
||||||
|
|
||||||
|
### Requirement: Перечень непокрытых секций отдаётся одной командой
|
||||||
|
|
||||||
|
Система SHALL отдавать перечень непокрытых секций, накопленных журналом,
|
||||||
|
отдельной подкомандой — без ручного SQL по рабочей базе. Строка перечня SHALL
|
||||||
|
называть имя секции, число доставок с ним, первую и последнюю встречу (метку
|
||||||
|
журнала и идентификатор доставки); идентификатор нужен, чтобы достать тело из
|
||||||
|
архива.
|
||||||
|
|
||||||
|
В перечень SHALL входить доставки **всех** статусов разбора: список непокрытых
|
||||||
|
секций сохраняется и при отказе, и молчать о таком имени значило бы терять как
|
||||||
|
раз подозрительное.
|
||||||
|
|
||||||
|
Порядок строк SHALL быть детерминированным — по имени секции.
|
||||||
|
|
||||||
|
Вывод SHALL иметь объявленный предел числа строк, а остаток называться числом:
|
||||||
|
предел разбора в 32 имени действует на **одну доставку**, а различных имён
|
||||||
|
журнал способен накопить сколько угодно.
|
||||||
|
|
||||||
|
База SHALL открываться только на чтение: команда диагностическая, и запуск её при
|
||||||
|
живом сервисе не должен ни мигрировать схему, ни писать. Отказ открытия (базы
|
||||||
|
нет, версия схемы не та) SHALL давать ненулевой код возврата и внятное
|
||||||
|
сообщение — молчаливый пустой перечень неотличим от «ничего не приезжало».
|
||||||
|
|
||||||
|
#### Scenario: Перечень на базе с непокрытыми секциями
|
||||||
|
|
||||||
|
- **GIVEN** журнал содержит доставки с секциями `symptoms` и `ecg`
|
||||||
|
- **WHEN** оператор запускает подкоманду перечня
|
||||||
|
- **THEN** вывод содержит обе секции с числом доставок и границами встреч
|
||||||
|
- **AND** порядок строк детерминирован
|
||||||
|
|
||||||
|
#### Scenario: Перечень на базе без непокрытых секций
|
||||||
|
|
||||||
|
- **WHEN** ни одна доставка непокрытых секций не приносила
|
||||||
|
- **THEN** команда завершается нулевым кодом и говорит, что перечень пуст
|
||||||
|
|
||||||
|
#### Scenario: Базы по указанному пути нет
|
||||||
|
|
||||||
|
- **WHEN** оператор запускает подкоманду с конфигом, указывающим на
|
||||||
|
несуществующую базу
|
||||||
|
- **THEN** команда завершается ненулевым кодом и называет причину
|
||||||
|
|
||||||
|
#### Scenario: Имён больше предела вывода
|
||||||
|
|
||||||
|
- **WHEN** различных имён в журнале больше объявленного предела
|
||||||
|
- **THEN** вывод содержит предел строк и называет число оставшихся имён
|
||||||
|
|
||||||
|
### Requirement: Перечень честен относительно своего носителя
|
||||||
|
|
||||||
|
Система SHALL называть границы перечня в спеке и в выводе команды, а не обещать
|
||||||
|
«всё, что поток когда-либо приносил»: перечень и признак новизны производны от
|
||||||
|
`delivery.uncovered_sections` — колонки, которая обрезается разбором и
|
||||||
|
обнуляется пересборкой.
|
||||||
|
|
||||||
|
Границы SHALL называться и в **выводе команды**, а не только в спеке: пустой
|
||||||
|
перечень без них читается как «поток ничего не приносил» — обещание, которого
|
||||||
|
носитель не даёт.
|
||||||
|
|
||||||
|
- Имя, вытесненное границей списка (не больше 32 имён на доставку), в колонку не
|
||||||
|
попадает вовсе — ни события, ни строки перечня оно не даст; наблюдаемым
|
||||||
|
остаётся счётчик отброшенных имён, который уже поднимает уровень записи.
|
||||||
|
- Пересборка обнуляет производные от разбора поля и заполняет их заново только по
|
||||||
|
сохранившимся телам: доставка, тело которой удалено ретеншеном, свой список
|
||||||
|
теряет, поэтому «те же события» пересборка воспроизводит **при полном архиве**.
|
||||||
|
- Имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||||||
|
пересвёртке — перечень отвечает о текущем состоянии покрытия, а не об истории.
|
||||||
|
- Поэтому пересвёртка ранее частично разобранных доставок (правило «покрыли
|
||||||
|
секцию — пересверните») может дать событие о новизне повторно: журнал тот же, а
|
||||||
|
колонка заполняется заново.
|
||||||
|
|
||||||
|
#### Scenario: Пустой перечень не говорит за весь поток
|
||||||
|
|
||||||
|
- **WHEN** в учётных записях журнала непокрытых секций нет
|
||||||
|
- **THEN** вывод команды говорит именно это, а не «журнал такого не приносил»
|
||||||
|
- **AND** называет границы носителя
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись с неразбираемым списком не роняет ответ
|
||||||
|
|
||||||
|
- **GIVEN** в колонке одной доставки лежит значение, не разбираемое как JSON
|
||||||
|
- **WHEN** выполняется сверка новизны или собирается перечень
|
||||||
|
- **THEN** ответ строится по остальным записям, а не отказывает целиком
|
||||||
|
|
||||||
|
#### Scenario: Имя вытеснено границей списка
|
||||||
|
|
||||||
|
- **GIVEN** тело доставки содержит 32 незнакомых ключа перед секцией `ecg`
|
||||||
|
- **WHEN** доставка свёрнута
|
||||||
|
- **THEN** события о новизне `ecg` нет, потому что имя в учётную запись не попало
|
||||||
|
- **AND** запись несёт счётчик отброшенных имён и уровень `WARN`
|
||||||
|
|
||||||
|
### Requirement: Вывод перечня не доверяет содержимому тела
|
||||||
|
|
||||||
|
Печатаемое имя секции SHALL быть экранировано, чтобы управляющие
|
||||||
|
последовательности из тела не влияли на терминал оператора: имя приходит
|
||||||
|
верхнеуровневым ключом чужого тела, длина его ограничена разбором, содержимое —
|
||||||
|
ничем.
|
||||||
|
|
||||||
|
Вывод SHALL оставаться свободным от значений точек, имён устройств и любых
|
||||||
|
других данных о здоровье: имя секции — структурный ключ, а не измерение.
|
||||||
|
|
||||||
|
#### Scenario: Имя секции с управляющими символами
|
||||||
|
|
||||||
|
- **GIVEN** учётная запись доставки содержит имя секции с управляющим символом
|
||||||
|
- **WHEN** оператор запускает подкоманду перечня
|
||||||
|
- **THEN** символ выводится экранированной последовательностью, а не сырым
|
||||||
|
байтом
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
## 1. Хранилище: два запроса по существующей колонке
|
||||||
|
|
||||||
|
- [x] 1.1 `store.SectionsSeenBefore(ctx, names, before DeliveryRef) (map[string]struct{}, error)` —
|
||||||
|
какие из имён встречались в доставках, стоящих в журнале строго раньше
|
||||||
|
`before`; сравнение парой `(received_at, id)`, чтение через `json_each`,
|
||||||
|
`EXISTS` на имя (ранний выход для виденного), вне транзакции записи
|
||||||
|
- [x] 1.2 `store.UncoveredSections(ctx, limit)` — перечень: имя, число доставок,
|
||||||
|
первая и последняя встреча (метка и идентификатор доставки), порядок по
|
||||||
|
имени, предел строк с числом остатка; доставки всех статусов разбора
|
||||||
|
- [x] 1.3 Тесты хранилища: имя из более ранней доставки виденное, из более
|
||||||
|
поздней — нет, своя же строка в счёт не идёт, доставки одной секунды
|
||||||
|
разводятся идентификатором; перечень сходится с независимо посчитанным
|
||||||
|
ожиданием; отказавшая доставка в перечень входит; предел вывода срабатывает
|
||||||
|
- [x] 1.4 Замер стоимости на **синтетическом** журнале в `./tmp` (~105 тысяч
|
||||||
|
доставок, годовой объём): время запроса для виденного и для нового имени;
|
||||||
|
число печатается, метод замера записывается рядом с результатом
|
||||||
|
|
||||||
|
## 2. Свёртка: признак новизны и эскалация
|
||||||
|
|
||||||
|
- [x] 2.1 Проверка новизны сразу после разбора — до ветвления на успех и отказ,
|
||||||
|
**вне транзакции записи**; берётся только при непустом списке непокрытых
|
||||||
|
- [x] 2.2 Отказ запроса не роняет свёртку и исходом доставки не становится: все
|
||||||
|
имена считаются новыми, признак несостоявшейся сверки идёт в запись
|
||||||
|
- [x] 2.3 Новые имена в `Stats` и в атрибутах чекпоинта; ветвь `WARN` ставится
|
||||||
|
первой в `switch` (решение кода — спека нормирует поведение, а не позицию)
|
||||||
|
- [x] 2.4 Новые имена доезжают до пути отказа (`parseResidue` → `fail`) и
|
||||||
|
печатаются его записью; отложенный исход их не называет
|
||||||
|
- [x] 2.5 Тесты свёртки: первая встреча даёт `WARN` с атрибутом новых секций,
|
||||||
|
вторая доставка молчит, новое имя рядом с виденным, новая секция не
|
||||||
|
маскируется перезаписью точек, отказ слоя называет новое имя, отложенный
|
||||||
|
исход не называет, отказ сверки объявляет всё новым и не роняет свёртку
|
||||||
|
|
||||||
|
## 3. Команда перечня
|
||||||
|
|
||||||
|
- [x] 3.1 Подкоманда `healthlog uncovered --config <path>`: `store.OpenForRead`,
|
||||||
|
человекочитаемый вывод в stdout, ненулевой код и причина на отказ открытия
|
||||||
|
- [x] 3.2 Имя печатается экранированным (`%q`); пустой перечень — отдельная
|
||||||
|
строка и нулевой код; предел строк с числом остатка
|
||||||
|
- [x] 3.3 Регистрация подкоманды в `main.go` и в шапке пакета
|
||||||
|
- [x] 3.4 Тесты команды: перечень на базе с секциями, пустой перечень, имя с
|
||||||
|
управляющим символом, базы по пути нет, имён больше предела
|
||||||
|
|
||||||
|
## 4. Документация
|
||||||
|
|
||||||
|
- [x] 4.1 `docs/architecture.md` — раздел «Неразобранные секции доставки»
|
||||||
|
дополнен событием, командой и границами носителя
|
||||||
|
- [x] 4.2 `docs/research/apple-health.md` — перечень не виденных живьём секций
|
||||||
|
сверен с множеством покрытых имён в `internal/hae` поимённо
|
||||||
|
- [x] 4.3 `README.md` / `CLAUDE.md` — команда названа там, где перечислены
|
||||||
|
прочие подкоманды (если перечислены)
|
||||||
|
|
||||||
|
## 5. Приёмка
|
||||||
|
|
||||||
|
- [x] 5.1 `task gate` зелёный
|
||||||
|
- [x] 5.2 `task verify:archive` — повторный прогон живого архива даёт то же
|
||||||
|
состояние (правило наблюдения затрагивает путь свёртки)
|
||||||
|
- [x] 5.3 Поведенческая верификация: сервис поднят, подсунута доставка с
|
||||||
|
выдуманной секцией — ровно одна запись с атрибутом новых секций; вторая
|
||||||
|
доставка молчит; `healthlog uncovered` на живой базе
|
||||||
|
|
||||||
|
## 6. Отработка ревью кода (профиль `deep`)
|
||||||
|
|
||||||
|
- [x] 6.1 Отказ **слияния** доносит новизну до записи об отказе: он пишет имя в
|
||||||
|
учёт так же, как отказ разбора, и терял событие навсегда (найдено тремя
|
||||||
|
проходами с оракулом). Тест `TestFoldОтказСлиянияНазываетНовуюСекцию`
|
||||||
|
- [x] 6.2 Вывод команды называет границы носителя в **обоих** исходах; пустая
|
||||||
|
ветвь больше не говорит за весь поток
|
||||||
|
- [x] 6.3 Учётная запись с неразбираемым списком не роняет ни сверку, ни
|
||||||
|
перечень: проверка стоит внутри аргумента `json_each`, а не условием в
|
||||||
|
`WHERE` (порядок вычисления SQLite не обещает)
|
||||||
|
- [x] 6.4 Причина несостоявшейся сверки идёт в запись рядом с признаком
|
||||||
|
- [x] 6.5 Сообщение чекпоинта приведено к словарю: `delivery folded, new
|
||||||
|
uncovered section`
|
||||||
|
- [x] 6.6 Число доставок считается `count(DISTINCT d.id)`: обрезка имён по 64
|
||||||
|
байтам давала дубли внутри одной доставки
|
||||||
|
- [x] 6.7 Замер перемерян и исправлен: ранний выход есть только у давно
|
||||||
|
приезжающей секции; у только что появившейся — почти полный проход.
|
||||||
|
Числа живут в одном месте (`design.md`), код и `architecture.md` ссылаются
|
||||||
|
- [x] 6.8 Сквозной тест команды: перечень сходится с посчитанным по **телам**
|
||||||
|
архива независимо от кода команды (оракул К3)
|
||||||
|
|
||||||
|
## Критерии приёмки задачи
|
||||||
|
|
||||||
|
Из `docs/tasks/items/unseen-sections-check.md`; оракулы К1–К3 уточнены после
|
||||||
|
ревью предложения — наблюдаемый признак события есть **атрибут новых секций**, а
|
||||||
|
не присутствие имени в записи (имя непокрытой секции стоит в записи каждой
|
||||||
|
доставки, которая её принесла, и так было до этого изменения).
|
||||||
|
|
||||||
|
- [x] К1 имя секции, которого разбор раньше не встречал, порождает событие
|
||||||
|
уровня выше рутины — оракул: тест на доставке с выдуманной секцией, ровно
|
||||||
|
одна запись уровня `WARN` с этим именем **в атрибуте новых секций**
|
||||||
|
- [x] К2 то же имя во второй доставке события больше не порождает — оракул: тот
|
||||||
|
же тест на двух доставках подряд, у второй атрибут новых секций пуст
|
||||||
|
- [x] К3 список всего, что поток когда-либо приносил и разбор не покрыл,
|
||||||
|
достаётся одной командой, без ручного SQL — оракул: перечень собран из
|
||||||
|
**тел** `internal/hae/testdata` независимо от кода команды и сходится со
|
||||||
|
строками её вывода (имя, число доставок, границы); дешёвой добавкой —
|
||||||
|
сверка с `SELECT DISTINCT` по `delivery.uncovered_sections` на живой базе.
|
||||||
|
**Живая база проверку не пропустила по своей причине:** её схема (версия 8)
|
||||||
|
отстала от бинаря (10) — контейнер не пересобирался, — и `OpenForRead`
|
||||||
|
строго отказал, как и заказано. Сверка сделана на поднятом сервисе с
|
||||||
|
отдельной базой; на живой базе непокрытых секций сегодня нет вовсе
|
||||||
|
(`SELECT DISTINCT` даёт пустое множество), так что сверять было бы нечего
|
||||||
|
- [x] К4 перечень не виденных живьём секций в `docs/research/apple-health.md`
|
||||||
|
сходится с множеством покрытых имён в `internal/hae` — оракул: глазами,
|
||||||
|
сверка двух списков поимённо
|
||||||
|
- [x] К5 повторный прогон живого архива даёт то же состояние — оракул:
|
||||||
|
`task verify:archive`
|
||||||
|
|
||||||
|
## Приёмочные критерии из рубрики ревью (профиль `design`)
|
||||||
|
|
||||||
|
Рубрика прохода `review-rubric`, порождённая до чтения предложения. Пункты,
|
||||||
|
которые предложение не закрывало, отработаны правкой спеки и дизайна; здесь они
|
||||||
|
остаются проверяемыми свойствами.
|
||||||
|
|
||||||
|
- [x] Р1 признак «впервые» есть функция префикса журнала: перестановка порядка
|
||||||
|
свёртки не переносит событие с доставки на доставку
|
||||||
|
- [x] Р2 узел не читает состояние, которое сам же пишет в этом шаге:
|
||||||
|
собственная строка исключена сравнением, а не порядком записи
|
||||||
|
- [x] Р3 идемпотентность и отсутствие второй копии факта: повторная свёртка даёт
|
||||||
|
тот же состав имён, хранимого реестра нет
|
||||||
|
- [x] Р4 наблюдательный механизм не роняет поток: отказ сверки не меняет
|
||||||
|
`parse_status` и не превращает `pending` в `failed`
|
||||||
|
- [x] Р5 запрос новизны выполняется вне транзакции записи; в транзакции записи
|
||||||
|
обращений к `delivery` с `json_each` нет
|
||||||
|
- [x] Р6 стоимость названа числом с методом замера (задача 1.4), а не посылкой
|
||||||
|
«список почти всегда пуст»
|
||||||
|
- [x] Р7 событие однократно на имя: сто доставок подряд с одним новым именем
|
||||||
|
дают ровно одну запись повышенного уровня; уровень назван поимённо (`WARN`)
|
||||||
|
- [x] Р8 признак и перечень честны относительно носителя: обрезка списка,
|
||||||
|
обнуление пересборкой и уход имени после покрытия названы в спеке
|
||||||
|
- [x] Р9 имя секции — недоверенный вход: экранировано на выводе, ограничено по
|
||||||
|
длине разбором, данных о здоровье в выводе нет
|
||||||
|
- [x] Р10 CLI читает и только читает: без миграций и записи, расхождение версии
|
||||||
|
схемы — явный отказ
|
||||||
|
- [x] Р11 вывод детерминирован, ограничен пределом и отличает пустоту от отказа
|
||||||
|
- [x] Р12 оракулы не пришпилены к числу, производному от размера корпуса, и не
|
||||||
|
тавтологичны (К3 перестроен от тел архива)
|
||||||
@@ -412,8 +412,14 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
|
|||||||
|
|
||||||
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||||
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||||
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
|
Повышает уровень другое, и оснований два:
|
||||||
с именем длиннее предела на HAE не похоже вовсе.
|
|
||||||
|
- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
|
||||||
|
на HAE не похоже вовсе;
|
||||||
|
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
|
||||||
|
всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
|
||||||
|
секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
|
||||||
|
следующий читатель снимет ветвь как незаказанную.
|
||||||
|
|
||||||
#### Scenario: Разбор доставки логируется без значений
|
#### Scenario: Разбор доставки логируется без значений
|
||||||
|
|
||||||
@@ -441,6 +447,13 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
|
|||||||
- **THEN** запись лога имеет уровень `WARN`
|
- **THEN** запись лога имеет уровень `WARN`
|
||||||
- **AND** содержит число отброшенных имён
|
- **AND** содержит число отброшенных имён
|
||||||
|
|
||||||
|
#### Scenario: Имя непокрытой секции встречено впервые
|
||||||
|
|
||||||
|
- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
|
||||||
|
доставке раньше неё в журнале
|
||||||
|
- **THEN** запись лога имеет уровень `WARN`
|
||||||
|
- **AND** содержит имя отдельным атрибутом новых секций
|
||||||
|
|
||||||
### Requirement: Учёт частично разобранной доставки
|
### Requirement: Учёт частично разобранной доставки
|
||||||
|
|
||||||
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
|
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# uncovered-sections Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Наблюдение за секциями тела Health Auto Export, которых разбор не покрывает:
|
||||||
|
первая по журналу встреча имени видна владельцу событием в логе, а перечень
|
||||||
|
накопленного отдаётся отдельной подкомандой. Разбор самих секций сюда не входит
|
||||||
|
— их формы никто не видел, и вслепую он не пишется.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Первая встреча непокрытой секции порождает событие
|
||||||
|
|
||||||
|
Свёртка SHALL писать запись уровня `WARN`, когда доставка принесла имя
|
||||||
|
непокрытой секции, которого **не было ни в одной доставке, стоящей в журнале
|
||||||
|
раньше**. Имена, признанные новыми, SHALL идти отдельным атрибутом записи —
|
||||||
|
всегда, независимо от выбранного уровня.
|
||||||
|
|
||||||
|
Уровень назван поимённо: `WARN` — «может стать проблемой, посмотри». `ERROR`
|
||||||
|
означал бы сбой, который надо разбирать, а приезд новой секции — штатное событие
|
||||||
|
внешнего мира.
|
||||||
|
|
||||||
|
Событие однократно за всю жизнь имени. Поэтому запись SHALL называть его
|
||||||
|
независимо от того, какие повторяющиеся события (перезаписи точек, запечатанный
|
||||||
|
час, расхождение слоя) случились в той же доставке: замаскировать однократное
|
||||||
|
рутинным значило бы потерять ровно то, ради чего наблюдение заведено.
|
||||||
|
|
||||||
|
Отдельной строки лога у события нет: чекпоинт свёртки остаётся единственным.
|
||||||
|
|
||||||
|
#### Scenario: Имя, которого журнал раньше не видел
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `symptoms`
|
||||||
|
- **WHEN** доставка с секцией `symptoms` свёрнута
|
||||||
|
- **THEN** запись чекпоинта свёртки имеет уровень `WARN`
|
||||||
|
- **AND** атрибут новых секций содержит ровно `symptoms`
|
||||||
|
|
||||||
|
#### Scenario: Новая секция не маскируется рутинным событием
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
|
||||||
|
- **WHEN** доставка с секцией `ecg` одновременно перезаписывает точки уже
|
||||||
|
сохранённого часа
|
||||||
|
- **THEN** запись чекпоинта называет новую секцию, а не перезапись точек
|
||||||
|
- **AND** счётчик перезаписей остаётся в атрибутах записи
|
||||||
|
|
||||||
|
### Requirement: Повторная встреча имени события не порождает
|
||||||
|
|
||||||
|
Система SHALL считать виденным всякое имя непокрытой секции, уже встречавшееся в
|
||||||
|
доставке, стоящей в журнале раньше текущей: в атрибут новых секций оно не
|
||||||
|
попадает и уровень записи не поднимает. Перечисление непокрытых секций при этом
|
||||||
|
не меняется: атрибут `uncovered` SHALL продолжать называть их все.
|
||||||
|
|
||||||
|
Отсюда следствие для приёмки: наблюдаемый признак события — **атрибут новых
|
||||||
|
секций**, а не присутствие имени в записи вообще. Имя непокрытой секции стоит в
|
||||||
|
записи каждой доставки, которая её принесла, и так было до этого изменения.
|
||||||
|
|
||||||
|
#### Scenario: Вторая доставка с тем же именем молчит
|
||||||
|
|
||||||
|
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
|
||||||
|
- **WHEN** следующая доставка приносит ту же секцию `symptoms`
|
||||||
|
- **THEN** атрибут новых секций у второй записи пуст
|
||||||
|
- **AND** уровень второй записи рутинный
|
||||||
|
- **AND** секция `symptoms` остаётся в атрибуте непокрытых секций
|
||||||
|
|
||||||
|
#### Scenario: Новое имя рядом с уже виденным
|
||||||
|
|
||||||
|
- **GIVEN** доставка с секцией `symptoms` уже свёрнута
|
||||||
|
- **WHEN** следующая доставка приносит `symptoms` и `ecg`
|
||||||
|
- **THEN** атрибут новых секций содержит ровно `ecg`
|
||||||
|
|
||||||
|
### Requirement: Новизна выводится из журнала, а не из порядка свёртки
|
||||||
|
|
||||||
|
Признак новизны SHALL определяться сравнением с доставками, стоящими в журнале
|
||||||
|
**строго раньше** текущей — по паре `(received_at, id)`, как порядок журнала
|
||||||
|
определён capability пересборки. Собственная учётная запись доставки в сравнение
|
||||||
|
не входит при любом порядке записи исхода.
|
||||||
|
|
||||||
|
Отсюда следует, что признак идемпотентен: повторная свёртка той же доставки даёт
|
||||||
|
тот же исход, а проигрывание полного журнала пересборкой воспроизводит ровно те
|
||||||
|
же события — свёртка по журналу остаётся функцией журнала, а не числа прогонов.
|
||||||
|
|
||||||
|
Хранимого реестра встреченных имён это изменение заводить MUST NOT: факт уже
|
||||||
|
лежит в учётной записи доставки, и вторая его копия расходилась бы с первой при
|
||||||
|
пересборке молча. Это решение **этого** изменения, а не запрет навсегда:
|
||||||
|
потребителю, которому понадобится история непокрытых секций, переживающая
|
||||||
|
удаление тел (ретеншен архива), придётся его пересмотреть — цена названа в
|
||||||
|
требовании о честности перечня.
|
||||||
|
|
||||||
|
Запрос новизны SHALL выполняться **вне транзакции записи** свёртки: он идёт по
|
||||||
|
растущему журналу, а транзакция записи объектов не имеет права держать блокировку
|
||||||
|
дольше, чем позволено конкурирующему приёму.
|
||||||
|
|
||||||
|
#### Scenario: Повторная свёртка той же доставки не меняет исход
|
||||||
|
|
||||||
|
- **GIVEN** доставка с новой секцией свёрнута и событие записано
|
||||||
|
- **WHEN** та же доставка свёрнута повторно
|
||||||
|
- **THEN** событие повторяется тем же составом имён
|
||||||
|
|
||||||
|
#### Scenario: Доставка, свёрнутая позже более новой, судится по журналу
|
||||||
|
|
||||||
|
- **GIVEN** доставка A стоит в журнале раньше доставки B, и обе содержат
|
||||||
|
секцию `ecg`
|
||||||
|
- **WHEN** B свёрнута первой, а A — после неё
|
||||||
|
- **THEN** A признаёт `ecg` новым, потому что раньше неё в журнале этого имени
|
||||||
|
не было
|
||||||
|
|
||||||
|
#### Scenario: Запрос новизны не удерживает блокировку записи
|
||||||
|
|
||||||
|
- **WHEN** доставка с непокрытыми секциями сворачивается
|
||||||
|
- **THEN** обращение к журналу за признаком новизны идёт вне транзакции записи
|
||||||
|
объектов
|
||||||
|
|
||||||
|
### Requirement: Отказ свёртки не глотает событие
|
||||||
|
|
||||||
|
Список непокрытых секций переживает отказ разбора, поэтому проверка новизны SHALL
|
||||||
|
идти до ветвления на успех и отказ, а новые имена SHALL попадать в запись об
|
||||||
|
отказе — она уже выше рутинного уровня. Уровень такой записи определяет отказ;
|
||||||
|
событие опознаётся атрибутом новых секций.
|
||||||
|
|
||||||
|
Это относится к **каждому** исходу, который пишет список в учётную запись, а не
|
||||||
|
к одному только отказу разбора: отказ слияния тоже записывает имена, и терять
|
||||||
|
событие ему нельзя ровно по той же причине. Отказы, случившиеся до разбора
|
||||||
|
(нечитаемое тело, паника), список очищают и потому события не теряют.
|
||||||
|
|
||||||
|
Иначе событие теряется необратимо: имя записано в учётную запись отказавшей
|
||||||
|
доставки, и следующая доставка сочтёт его виденным.
|
||||||
|
|
||||||
|
Исход, отложенный по обстоятельствам (занятость базы, отмена снаружи), — другое
|
||||||
|
дело: он учётной записи не меняет вовсе, список непокрытых секций в базу не
|
||||||
|
попадает, и доставка вернётся следующим проходом. Новые имена в такой записи
|
||||||
|
система называть MUST NOT: событие не потеряно, а повторение его на каждом
|
||||||
|
проходе занятой базы превратило бы однократный признак в дребезг.
|
||||||
|
|
||||||
|
#### Scenario: Отказ слияния при новой секции
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `ecg`
|
||||||
|
- **WHEN** разбор доставки прошёл, а слияние отказало нетранзиентно
|
||||||
|
- **THEN** запись об отказе называет новую секцию `ecg`
|
||||||
|
- **AND** учётная запись доставки сохраняет её в списке непокрытых
|
||||||
|
|
||||||
|
#### Scenario: Доставка с новой секцией и невыводимым слоем
|
||||||
|
|
||||||
|
- **GIVEN** ни одна прежняя доставка не содержала секции `cycleTracking`
|
||||||
|
- **WHEN** доставка с этой секцией не свернулась, потому что слой не вывелся
|
||||||
|
- **THEN** запись об отказе называет новую секцию `cycleTracking`
|
||||||
|
- **AND** учётная запись доставки сохраняет её в списке непокрытых
|
||||||
|
|
||||||
|
#### Scenario: Отложенная доставка события не порождает
|
||||||
|
|
||||||
|
- **WHEN** свёртка доставки отложена по занятости базы
|
||||||
|
- **THEN** запись об отложенном исходе новых секций не называет
|
||||||
|
- **AND** доставка остаётся в очереди
|
||||||
|
|
||||||
|
### Requirement: Несостоявшаяся сверка не молчит
|
||||||
|
|
||||||
|
Отказ самого запроса новизны исходом доставки система объявлять MUST NOT: правила
|
||||||
|
классификации исходов свёртки это изменение не трогает, отменённый контекст и
|
||||||
|
занятая база остаются обстоятельствами, а не свойствами доставки.
|
||||||
|
|
||||||
|
Когда доставка дошла до записи исхода по прочим правилам, а сверка не удалась,
|
||||||
|
все её непокрытые имена SHALL считаться новыми, и запись SHALL нести отдельный
|
||||||
|
признак того, что сверка не состоялась, **вместе с причиной**: занятость базы
|
||||||
|
проходит сама, а испорченное содержимое колонки не пройдёт никогда и будет
|
||||||
|
поднимать признак на каждой доставке — по одному булеву эти случаи неразличимы.
|
||||||
|
|
||||||
|
Асимметрия названа: лишняя запись стоит внимания владельца один раз, а
|
||||||
|
промолчавшее событие не восстанавливается ничем, кроме ручного запроса в базу.
|
||||||
|
|
||||||
|
#### Scenario: Сверка не удалась, разбор прошёл
|
||||||
|
|
||||||
|
- **WHEN** запрос новизны отказал, а разбор доставки прошёл
|
||||||
|
- **THEN** свёртка доходит до конца и записывает исход
|
||||||
|
- **AND** все непокрытые имена доставки объявлены новыми
|
||||||
|
- **AND** запись несёт признак несостоявшейся сверки и причину
|
||||||
|
|
||||||
|
### Requirement: Перечень непокрытых секций отдаётся одной командой
|
||||||
|
|
||||||
|
Система SHALL отдавать перечень непокрытых секций, накопленных журналом,
|
||||||
|
отдельной подкомандой — без ручного SQL по рабочей базе. Строка перечня SHALL
|
||||||
|
называть имя секции, число доставок с ним, первую и последнюю встречу (метку
|
||||||
|
журнала и идентификатор доставки); идентификатор нужен, чтобы достать тело из
|
||||||
|
архива.
|
||||||
|
|
||||||
|
В перечень SHALL входить доставки **всех** статусов разбора: список непокрытых
|
||||||
|
секций сохраняется и при отказе, и молчать о таком имени значило бы терять как
|
||||||
|
раз подозрительное.
|
||||||
|
|
||||||
|
Порядок строк SHALL быть детерминированным — по имени секции.
|
||||||
|
|
||||||
|
Вывод SHALL иметь объявленный предел числа строк, а остаток называться числом:
|
||||||
|
предел разбора в 32 имени действует на **одну доставку**, а различных имён
|
||||||
|
журнал способен накопить сколько угодно.
|
||||||
|
|
||||||
|
База SHALL открываться только на чтение: команда диагностическая, и запуск её при
|
||||||
|
живом сервисе не должен ни мигрировать схему, ни писать. Отказ открытия (базы
|
||||||
|
нет, версия схемы не та) SHALL давать ненулевой код возврата и внятное
|
||||||
|
сообщение — молчаливый пустой перечень неотличим от «ничего не приезжало».
|
||||||
|
|
||||||
|
#### Scenario: Перечень на базе с непокрытыми секциями
|
||||||
|
|
||||||
|
- **GIVEN** журнал содержит доставки с секциями `symptoms` и `ecg`
|
||||||
|
- **WHEN** оператор запускает подкоманду перечня
|
||||||
|
- **THEN** вывод содержит обе секции с числом доставок и границами встреч
|
||||||
|
- **AND** порядок строк детерминирован
|
||||||
|
|
||||||
|
#### Scenario: Перечень на базе без непокрытых секций
|
||||||
|
|
||||||
|
- **WHEN** ни одна доставка непокрытых секций не приносила
|
||||||
|
- **THEN** команда завершается нулевым кодом и говорит, что перечень пуст
|
||||||
|
|
||||||
|
#### Scenario: Базы по указанному пути нет
|
||||||
|
|
||||||
|
- **WHEN** оператор запускает подкоманду с конфигом, указывающим на
|
||||||
|
несуществующую базу
|
||||||
|
- **THEN** команда завершается ненулевым кодом и называет причину
|
||||||
|
|
||||||
|
#### Scenario: Имён больше предела вывода
|
||||||
|
|
||||||
|
- **WHEN** различных имён в журнале больше объявленного предела
|
||||||
|
- **THEN** вывод содержит предел строк и называет число оставшихся имён
|
||||||
|
|
||||||
|
### Requirement: Перечень честен относительно своего носителя
|
||||||
|
|
||||||
|
Система SHALL называть границы перечня в спеке и в выводе команды, а не обещать
|
||||||
|
«всё, что поток когда-либо приносил»: перечень и признак новизны производны от
|
||||||
|
`delivery.uncovered_sections` — колонки, которая обрезается разбором и
|
||||||
|
обнуляется пересборкой.
|
||||||
|
|
||||||
|
Границы SHALL называться и в **выводе команды**, а не только в спеке: пустой
|
||||||
|
перечень без них читается как «поток ничего не приносил» — обещание, которого
|
||||||
|
носитель не даёт.
|
||||||
|
|
||||||
|
- Имя, вытесненное границей списка (не больше 32 имён на доставку), в колонку не
|
||||||
|
попадает вовсе — ни события, ни строки перечня оно не даст; наблюдаемым
|
||||||
|
остаётся счётчик отброшенных имён, который уже поднимает уровень записи.
|
||||||
|
- Пересборка обнуляет производные от разбора поля и заполняет их заново только по
|
||||||
|
сохранившимся телам: доставка, тело которой удалено ретеншеном, свой список
|
||||||
|
теряет, поэтому «те же события» пересборка воспроизводит **при полном архиве**.
|
||||||
|
- Имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||||||
|
пересвёртке — перечень отвечает о текущем состоянии покрытия, а не об истории.
|
||||||
|
- Поэтому пересвёртка ранее частично разобранных доставок (правило «покрыли
|
||||||
|
секцию — пересверните») может дать событие о новизне повторно: журнал тот же, а
|
||||||
|
колонка заполняется заново.
|
||||||
|
|
||||||
|
#### Scenario: Пустой перечень не говорит за весь поток
|
||||||
|
|
||||||
|
- **WHEN** в учётных записях журнала непокрытых секций нет
|
||||||
|
- **THEN** вывод команды говорит именно это, а не «журнал такого не приносил»
|
||||||
|
- **AND** называет границы носителя
|
||||||
|
|
||||||
|
#### Scenario: Учётная запись с неразбираемым списком не роняет ответ
|
||||||
|
|
||||||
|
- **GIVEN** в колонке одной доставки лежит значение, не разбираемое как JSON
|
||||||
|
- **WHEN** выполняется сверка новизны или собирается перечень
|
||||||
|
- **THEN** ответ строится по остальным записям, а не отказывает целиком
|
||||||
|
|
||||||
|
#### Scenario: Имя вытеснено границей списка
|
||||||
|
|
||||||
|
- **GIVEN** тело доставки содержит 32 незнакомых ключа перед секцией `ecg`
|
||||||
|
- **WHEN** доставка свёрнута
|
||||||
|
- **THEN** события о новизне `ecg` нет, потому что имя в учётную запись не попало
|
||||||
|
- **AND** запись несёт счётчик отброшенных имён и уровень `WARN`
|
||||||
|
|
||||||
|
### Requirement: Вывод перечня не доверяет содержимому тела
|
||||||
|
|
||||||
|
Печатаемое имя секции SHALL быть экранировано, чтобы управляющие
|
||||||
|
последовательности из тела не влияли на терминал оператора: имя приходит
|
||||||
|
верхнеуровневым ключом чужого тела, длина его ограничена разбором, содержимое —
|
||||||
|
ничем.
|
||||||
|
|
||||||
|
Вывод SHALL оставаться свободным от значений точек, имён устройств и любых
|
||||||
|
других данных о здоровье: имя секции — структурный ключ, а не измерение.
|
||||||
|
|
||||||
|
#### Scenario: Имя секции с управляющими символами
|
||||||
|
|
||||||
|
- **GIVEN** учётная запись доставки содержит имя секции с управляющим символом
|
||||||
|
- **WHEN** оператор запускает подкоманду перечня
|
||||||
|
- **THEN** символ выводится экранированной последовательностью, а не сырым
|
||||||
|
байтом
|
||||||
Reference in New Issue
Block a user