добавлен приём пакетов Health Auto Export
- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой архив, учёт доставки в SQLite - код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200, данные уже сохранены и доразберутся позже - сохраняется полный набор заголовков запроса с вычисткой секретов по имени и по совпадению значения с токеном
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// Package archive — сырой архив принятых тел запросов.
|
||||
//
|
||||
// Архив, а не витрина, является источником истины: тело каждого запроса
|
||||
// ложится на диск как есть до любого разбора, и витрина в любой момент
|
||||
// пересобирается из него. Отсюда требование к записи — durability: пока файл
|
||||
// не лёг на диск, доставка не считается принятой.
|
||||
package archive
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Archive — каталог сырых тел, разложенных по дате приёма.
|
||||
type Archive struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// New создаёт архив в указанном каталоге (каталог создаётся при отсутствии).
|
||||
func New(root string) (*Archive, error) {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create archive dir %q: %w", root, err)
|
||||
}
|
||||
return &Archive{root: root}, nil
|
||||
}
|
||||
|
||||
// Root возвращает корневой каталог архива.
|
||||
func (a *Archive) Root() string { return a.root }
|
||||
|
||||
// Write сохраняет тело запроса и возвращает путь относительно корня архива.
|
||||
//
|
||||
// Запись durable: временный файл → fsync → атомарный rename → fsync каталога.
|
||||
// Без этого «сохранили — значит приняли» было бы обещанием, которое не
|
||||
// переживает потерю питания.
|
||||
func (a *Archive) Write(id string, at time.Time, body []byte) (string, error) {
|
||||
rel := filepath.Join(at.UTC().Format("2006/01/02"), id+".json.gz")
|
||||
full := filepath.Join(a.root, rel)
|
||||
|
||||
dir := filepath.Dir(full)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create archive dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
tmp := full + ".tmp"
|
||||
if err := writeGzip(tmp, body); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("rename archive file: %w", err)
|
||||
}
|
||||
if err := syncDir(dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// Open открывает сохранённое тело для чтения (распакованным). Нужен для
|
||||
// пересборки витрины из архива.
|
||||
func (a *Archive) Open(rel string) (io.ReadCloser, error) {
|
||||
f, err := os.Open(filepath.Join(a.root, rel))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open archive file %q: %w", rel, err)
|
||||
}
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("gunzip archive file %q: %w", rel, err)
|
||||
}
|
||||
return readCloser{gz: gz, f: f}, nil
|
||||
}
|
||||
|
||||
type readCloser struct {
|
||||
gz *gzip.Reader
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func (r readCloser) Read(p []byte) (int, error) { return r.gz.Read(p) } //nolint:wrapcheck // io.Reader: ошибку отдаём как есть
|
||||
|
||||
func (r readCloser) Close() error {
|
||||
err := r.gz.Close()
|
||||
if cerr := r.f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("close archive file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeGzip(path string, body []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create archive file: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
if _, err := gz.Write(body); err != nil {
|
||||
return fmt.Errorf("gzip archive body: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return fmt.Errorf("flush gzip: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("fsync archive file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDir фиксирует появление нового имени в каталоге: без этого rename может
|
||||
// не пережить внезапную перезагрузку.
|
||||
func syncDir(dir string) error {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open archive dir: %w", err)
|
||||
}
|
||||
defer func() { _ = d.Close() }()
|
||||
|
||||
if err := d.Sync(); err != nil {
|
||||
return fmt.Errorf("fsync archive dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
)
|
||||
|
||||
func TestWriteAndOpenRoundTrip(t *testing.T) {
|
||||
a := newArchive(t)
|
||||
body := []byte(`{"data":{"metrics":[]}}`)
|
||||
at := time.Date(2026, 7, 31, 14, 5, 0, 0, time.UTC)
|
||||
|
||||
rel, err := a.Write("01k1abc", at, body)
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
if want := filepath.Join("2026", "07", "31", "01k1abc.json.gz"); rel != want {
|
||||
t.Errorf("путь = %q, ожидался %q", rel, want)
|
||||
}
|
||||
|
||||
r, err := a.Open(rel)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Errorf("прочитано %q, записано %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладка по дате приёма — в UTC: иначе один и тот же момент попадал бы в
|
||||
// разные каталоги в зависимости от зоны машины.
|
||||
func TestWriteUsesUTCDate(t *testing.T) {
|
||||
a := newArchive(t)
|
||||
// 01:30 по московскому времени — это ещё предыдущие сутки по UTC.
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
at := time.Date(2026, 8, 1, 1, 30, 0, 0, msk)
|
||||
|
||||
rel, err := a.Write("01k1def", at, []byte(`{"data":{}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if want := filepath.Join("2026", "07", "31", "01k1def.json.gz"); rel != want {
|
||||
t.Errorf("путь = %q, ожидался %q", rel, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Временных файлов после успешной записи остаться не должно.
|
||||
func TestWriteLeavesNoTempFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
a, err := archive.New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := a.Write("01k1ghi", time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC), []byte(`{"data":{}}`)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(path, ".tmp") {
|
||||
t.Errorf("остался временный файл: %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("обход архива: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newArchive(t *testing.T) *archive.Archive {
|
||||
t.Helper()
|
||||
a, err := archive.New(filepath.Join(t.TempDir(), "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
Reference in New Issue
Block a user