свёртка доставки в объекты и сшивка с приёмом
- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же код, каким пойдёт пересборка витрины - приём зовёт свёртку на context.WithoutCancel с собственным дедлайном; исход разбора на код ответа не влияет - слой доставки хранится в delivery.derived_layer и наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка переставала быть функцией от префикса журнала (1737 объектов против 1742) - task verify:archive — сходимость на живом архиве, 99 доставок из 99
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// Package fold — свёртка доставки в часовые объекты.
|
||||
//
|
||||
// Хранилище — свёртка по журналу: состояние пересобирается как
|
||||
// `import(экспорт) + replay(доставки по received_at)`. Поэтому свёртка
|
||||
// принимает идентификатор доставки, а тело читает из архива — тем же кодом,
|
||||
// каким его прочитает пересборка. Разбор «из памяти, раз тело всё равно в
|
||||
// руках» дал бы второй путь, который разошёлся бы с первым молча.
|
||||
package fold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// maxBodyBytes — граница размера тела при чтении из архива.
|
||||
//
|
||||
// Наблюдалось 42 МиБ; сотня даёт запас втрое и при этом не даёт битому или
|
||||
// враждебному архивному файлу выесть память процесса. Граница явная, потому
|
||||
// что молчаливое «сколько дадут» — это отказ, который проявится только на
|
||||
// пике потока.
|
||||
const maxBodyBytes = 100 << 20
|
||||
|
||||
// Service сворачивает доставки в часовые объекты.
|
||||
type Service struct {
|
||||
arch *archive.Archive
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает свёртку.
|
||||
func New(arch *archive.Archive, st *store.Store, log *slog.Logger) *Service {
|
||||
return &Service{arch: arch, store: st, log: log.With("capability", "fold")}
|
||||
}
|
||||
|
||||
// Stats — итог свёртки одной доставки.
|
||||
type Stats struct {
|
||||
Metrics int
|
||||
Points int
|
||||
Buckets int
|
||||
Unchanged int
|
||||
Overwrites int
|
||||
SealedHits int
|
||||
SkippedNoTime int
|
||||
SkippedMalformed int
|
||||
Layer string
|
||||
LayerMismatch bool
|
||||
}
|
||||
|
||||
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
|
||||
//
|
||||
// Это единственный логирующий чекпоинт свёртки: транспорт и приём исход
|
||||
// разбора не логируют. Значения точек и имена устройств в лог не попадают —
|
||||
// данные о здоровье чувствительнее токенов.
|
||||
func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
var stats Stats
|
||||
|
||||
d, err := s.store.DeliveryForParse(ctx, deliveryID)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
body, err := s.readBody(d.RawPath)
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err)
|
||||
return stats, err
|
||||
}
|
||||
|
||||
fallback, err := s.store.LastDerivedLayer(ctx, d.AutomationID, d.ReceivedAt, d.ID)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
parsed, err := hae.Parse(body, hae.Meta{
|
||||
Aggregation: d.Aggregation,
|
||||
FallbackLayer: hae.Layer(fallback),
|
||||
})
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err)
|
||||
return stats, err
|
||||
}
|
||||
|
||||
stats.Metrics = parsed.Metrics
|
||||
stats.SkippedNoTime = parsed.SkippedNoTime
|
||||
stats.SkippedMalformed = parsed.SkippedMalformed
|
||||
stats.Layer = string(parsed.Layer)
|
||||
stats.LayerMismatch = parsed.LayerMismatch
|
||||
|
||||
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err)
|
||||
return stats, err
|
||||
}
|
||||
|
||||
stats.Points = merge.Points
|
||||
stats.Buckets = merge.Buckets
|
||||
stats.Unchanged = merge.Unchanged
|
||||
stats.Overwrites = merge.Overwrites
|
||||
stats.SealedHits = merge.SealedHits
|
||||
|
||||
if err := s.store.FinishParse(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
s.logResult(ctx, deliveryID, stats)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
attrs := []any{
|
||||
"delivery_id", deliveryID,
|
||||
"metrics", st.Metrics,
|
||||
"points", st.Points,
|
||||
"buckets", st.Buckets,
|
||||
"unchanged", st.Unchanged,
|
||||
"overwrites", st.Overwrites,
|
||||
"skipped_no_time", st.SkippedNoTime,
|
||||
"skipped_malformed", st.SkippedMalformed,
|
||||
"layer", st.Layer,
|
||||
}
|
||||
|
||||
switch {
|
||||
case st.SealedHits > 0:
|
||||
// Досчёт часа, в который его уже не ждали: единственное наблюдение, по
|
||||
// которому вообще можно судить о глубине досчёта.
|
||||
s.log.WarnContext(ctx, "delivery folded, sealed hour changed",
|
||||
append(attrs, "sealed_hits", st.SealedHits)...)
|
||||
case st.LayerMismatch:
|
||||
// Расхождение сверяется только с надёжным заголовком: `Default` не
|
||||
// означает режима, и сравнение с ним давало бы WARN на каждой доставке.
|
||||
s.log.WarnContext(ctx, "delivery folded, layer differs from header", attrs...)
|
||||
default:
|
||||
s.log.InfoContext(ctx, "delivery folded", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт
|
||||
// пересборка — приём при этом не затрагивается: сохранили значит приняли.
|
||||
func (s *Service) fail(ctx context.Context, deliveryID string, cause error) {
|
||||
level := slog.LevelError
|
||||
if errors.Is(cause, hae.ErrLayerUnknown) {
|
||||
// Слой не определился — это не поломка, а ожидаемый исход для доставки
|
||||
// без плотных метрик. Тело ждёт пересборки.
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID)
|
||||
|
||||
if err := s.store.FinishParse(ctx, deliveryID, store.ParseFailed, 0, ""); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) readBody(rawPath string) ([]byte, error) {
|
||||
r, err := s.arch.Open(rawPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение тела из архива: %w", err)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("тело больше %d байт", maxBodyBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func toIncoming(points []hae.Point) []store.IncomingPoint {
|
||||
out := make([]store.IncomingPoint, 0, len(points))
|
||||
for _, p := range points {
|
||||
out = append(out, store.IncomingPoint{
|
||||
Metric: p.Metric,
|
||||
Layer: string(p.Layer),
|
||||
Units: p.Units,
|
||||
Point: store.Point{
|
||||
Start: p.Start,
|
||||
End: p.End,
|
||||
OffsetSeconds: p.OffsetSeconds,
|
||||
Raw: p.Raw,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func newFold(t *testing.T) (*fold.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
return fold.New(arch, st, log), arch, st
|
||||
}
|
||||
|
||||
// deliver кладёт тело в архив и заводит доставку — ровно то, что делает приём.
|
||||
func deliver(t *testing.T, arch *archive.Archive, st *store.Store, id, aggregation, automationID string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
at := store.Now()
|
||||
rawPath, err := arch.Write(id, at, body)
|
||||
if err != nil {
|
||||
t.Fatalf("запись в архив: %v", err)
|
||||
}
|
||||
err = st.CreateDelivery(context.Background(), store.Delivery{
|
||||
ID: id,
|
||||
ReceivedAt: at,
|
||||
AutomationID: automationID,
|
||||
Aggregation: aggregation,
|
||||
Bytes: int64(len(body)),
|
||||
SHA256: "-",
|
||||
RawPath: rawPath,
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("запись доставки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("фикстура %s: %v", name, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Свёртка читает тело из архива по идентификатору доставки — тем же кодом,
|
||||
// каким его прочитает пересборка витрины.
|
||||
func TestFoldРаскладываетТочкиПоОбъектам(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
||||
|
||||
stats, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
if stats.Points == 0 || stats.Buckets == 0 {
|
||||
t.Fatalf("свёртка ничего не разложила: %+v", stats)
|
||||
}
|
||||
if stats.Layer != "minute" {
|
||||
t.Errorf("слой доставки %q, ожидался minute", stats.Layer)
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
if n != int64(stats.Buckets) {
|
||||
t.Errorf("объектов в базе %d, свёртка насчитала %d", n, stats.Buckets)
|
||||
}
|
||||
}
|
||||
|
||||
// Слой сохраняется, чтобы его унаследовала следующая доставка той же
|
||||
// автоматизации: без плотных метрик выводить его не из чего, а после
|
||||
// перезапуска сервиса память об этом иначе теряется.
|
||||
func TestFoldСлойНаследуетсяВнутриАвтоматизации(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
|
||||
// Доставка без плотных метрик и с бесполезным заголовком: сама по себе
|
||||
// слой не выводится.
|
||||
deliver(t, arch, st, "d2", "Default", "auto-1", fixture(t, "sparse_sleep.json"))
|
||||
stats, err := f.Fold(ctx, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("вторая свёртка: %v", err)
|
||||
}
|
||||
if stats.Layer != "minute" {
|
||||
t.Errorf("слой %q, ожидался унаследованный minute", stats.Layer)
|
||||
}
|
||||
}
|
||||
|
||||
// Наследовать нечего, заголовок ненадёжен: точки не сохраняются, доставка
|
||||
// помечается неразобранной и ждёт пересборки. Молчаливый выбор raw создал бы
|
||||
// призрачный разрез.
|
||||
func TestFoldНеопределимыйСлойНеПишетТочек(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Default", "auto-1", fixture(t, "sparse_sleep.json"))
|
||||
|
||||
if _, err := f.Fold(ctx, "d1"); err == nil {
|
||||
t.Fatal("свёртка с неопределимым слоем прошла успешно")
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("записано %d объектов при неизвестном слое", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторная свёртка той же доставки не меняет состояния: журнал можно
|
||||
// проигрывать сколько угодно раз.
|
||||
func TestFoldИдемпотентен(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
||||
|
||||
first, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
second, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("повторная свёртка: %v", err)
|
||||
}
|
||||
|
||||
if second.Unchanged != second.Buckets {
|
||||
t.Errorf("изменилось %d объектов из %d при повторе того же тела",
|
||||
second.Buckets-second.Unchanged, second.Buckets)
|
||||
}
|
||||
if first.Buckets != second.Buckets {
|
||||
t.Errorf("объектов %d против %d", first.Buckets, second.Buckets)
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок воспроизведения доставок не влияет на итог: свёртка по журналу
|
||||
// обязана давать то же состояние, что приём в реальном времени.
|
||||
func TestFoldНеЗависитОтПорядкаДоставок(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := func(order []string) (int64, string) {
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
bodies := map[string]string{"a": "minute.json", "b": "hour.json", "c": "raw.json"}
|
||||
aggs := map[string]string{"a": "Minutes", "b": "Hours", "c": "Default"}
|
||||
for _, id := range order {
|
||||
deliver(t, arch, st, id, aggs[id], "auto-"+id, fixture(t, bodies[id]))
|
||||
if _, err := f.Fold(ctx, id); err != nil {
|
||||
t.Fatalf("свёртка %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
b, err := st.Bucket(ctx, "active_energy", "minute", mustHour(t, st, "active_energy", "minute"))
|
||||
if err != nil {
|
||||
return n, ""
|
||||
}
|
||||
return n, b.Hash
|
||||
}
|
||||
|
||||
nForward, hForward := state([]string{"a", "b", "c"})
|
||||
nBackward, hBackward := state([]string{"c", "b", "a"})
|
||||
|
||||
if nForward != nBackward {
|
||||
t.Errorf("объектов %d против %d при разном порядке доставок", nForward, nBackward)
|
||||
}
|
||||
if hForward != hBackward {
|
||||
t.Errorf("содержимое разошлось: %s против %s", hForward, hBackward)
|
||||
}
|
||||
}
|
||||
|
||||
// mustHour находит час, в котором лежит объект метрики: фикстуры сдвинуты по
|
||||
// времени, и зашивать конкретную дату в тест значило бы ломать его при каждой
|
||||
// пересборке набора.
|
||||
func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time {
|
||||
t.Helper()
|
||||
|
||||
hours, err := st.BucketHours(context.Background(), metric, layer)
|
||||
if err != nil {
|
||||
t.Fatalf("часы объекта: %v", err)
|
||||
}
|
||||
if len(hours) == 0 {
|
||||
t.Fatalf("объектов метрики %s слоя %s нет", metric, layer)
|
||||
}
|
||||
return hours[0]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// archiveDir включает прогон сходимости на живом архиве.
|
||||
//
|
||||
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
|
||||
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
|
||||
// должен висеть на каждом `task gate`. Запускается командой
|
||||
// `task verify:archive`.
|
||||
var archiveDir = flag.String("healthlog.archive", "",
|
||||
"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")
|
||||
|
||||
// Сходимость на живом архиве: тот же корпус, на котором выводились правила
|
||||
// разбора, обязан пройти через код без потерь и без расхождений — и повторный
|
||||
// прогон журнала обязан дать то же состояние.
|
||||
func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
if *archiveDir == "" {
|
||||
t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
|
||||
}
|
||||
root := *archiveDir
|
||||
bodies := collectBodies(t, root)
|
||||
if len(bodies) == 0 {
|
||||
t.Skipf("живого архива нет в %s — прогон пропущен", root)
|
||||
}
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var folded, failed int
|
||||
for _, path := range bodies {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение %s: %v", path, err)
|
||||
}
|
||||
// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
|
||||
// путь чтения был ровно тот, каким пойдёт пересборка.
|
||||
//
|
||||
// Заголовки доставки в архиве не лежат — они были заголовками запроса.
|
||||
// Поэтому автоматизация у всех одна: так проверяется в том числе
|
||||
// наследование слоя по цепочке доставок.
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
|
||||
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
|
||||
|
||||
if _, err := f.Fold(ctx, id); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
folded++
|
||||
}
|
||||
|
||||
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d", len(bodies), folded, failed)
|
||||
|
||||
if folded == 0 {
|
||||
t.Fatal("ни одна доставка не свернулась")
|
||||
}
|
||||
|
||||
// Повторный прогон того же журнала не меняет состояния: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
before, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
var refolded int
|
||||
for _, path := range bodies {
|
||||
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
|
||||
continue
|
||||
}
|
||||
refolded++
|
||||
}
|
||||
if refolded != folded {
|
||||
t.Fatalf("повторно свёрнуто %d доставок из %d — проверка идемпотентности вхолостую",
|
||||
refolded, folded)
|
||||
}
|
||||
after, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
if before != after {
|
||||
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
|
||||
}
|
||||
|
||||
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
|
||||
// интервалу — 174 (docs/local-research.md, находка 47). Если координата
|
||||
// когда-нибудь схлопнется обратно до метки, здесь станет 170.
|
||||
if got := countPoints(t, st, "sleep_analysis"); got != 174 {
|
||||
t.Errorf("координат sleep_analysis %d, измерено 174: ключ схлопнул записи", got)
|
||||
}
|
||||
}
|
||||
|
||||
// countPoints считает точки метрики во всех слоях. Каталог разрезов — отдельная
|
||||
// задача, поэтому здесь перебор по известным слоям, а не запрос к нему.
|
||||
func countPoints(t *testing.T, st *store.Store, metric string) int {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
total := 0
|
||||
for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
|
||||
hours, err := st.BucketHours(ctx, metric, layer)
|
||||
if err != nil {
|
||||
t.Fatalf("часы объектов: %v", err)
|
||||
}
|
||||
for _, h := range hours {
|
||||
b, err := st.Bucket(ctx, metric, layer, h)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение объекта: %v", err)
|
||||
}
|
||||
total += len(b.Points)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func collectBodies(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
|
||||
var out []string
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // архива может не быть — это не отказ теста
|
||||
}
|
||||
if !info.IsDir() && filepath.Ext(path) == ".gz" {
|
||||
out = append(out, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func gunzip(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("распаковка: %v", err)
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
|
||||
out, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user