- `healthlog reindex` собирает витрину из журнала (тела архива + учёт доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую базу читает без наката миграций и не трогает вовсе. Подмену делает человек при остановленном сервисе: переименование поверх открытого дескриптора портит базу молча. - Журналом считается архив, а не таблица доставок: тело без учётной записи заводится заново (метка из ULID, размер и хеш по распакованному телу), запись без тела переносится, но не сворачивается. Оракул сходимости встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не считается. - Прогон живого архива переехал на новый пакет: второго проигрывателя журнала в проекте не осталось, а его утверждение о ключе сна перестало быть константой, протухающей с каждой доставкой.
184 lines
7.4 KiB
Go
184 lines
7.4 KiB
Go
// Package store — SQLite-витрина healthlog: доставки и (позже) разобранные
|
|
// точки. Витрина производна от сырого архива и пересобирается из него.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/pressly/goose/v3"
|
|
|
|
_ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// Store — доступ к витрине.
|
|
type Store struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
// Open открывает БД по пути и накатывает миграции.
|
|
func Open(dbPath string) (*Store, error) {
|
|
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
|
}
|
|
|
|
if err := migrate(db); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// ErrSchemaMismatch — версия схемы базы не та, которую знает бинарь.
|
|
var ErrSchemaMismatch = errors.New("версия схемы базы не совпадает с версией бинаря")
|
|
|
|
// OpenForRead открывает базу только для чтения и **без наката миграций**.
|
|
//
|
|
// Обычный Open мигрирует безусловно, а миграции здесь меняют не только схему, но
|
|
// и данные: та, что ввела частичный разбор, переписала parse_status у всех строк.
|
|
// Значит утилита, которой достаточно прочитать учёт, обычным открытием нарушала
|
|
// бы обещание «рабочую базу не трогаем», — и хуже: свежий бинарь мигрировал бы
|
|
// схему под работающим старым сервисом, который держит запросы к прежней.
|
|
//
|
|
// Расхождение версий — отказ с указанием обеих, а не повод мигрировать.
|
|
func OpenForRead(dbPath string) (*Store, error) {
|
|
db, err := sqlx.Connect("sqlite", readOnlyDSN(dbPath))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite %q read-only: %w", dbPath, err)
|
|
}
|
|
|
|
want, err := latestMigration()
|
|
if err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
var got int64
|
|
if err := db.Get(&got, `SELECT max(version_id) FROM goose_db_version`); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("read schema version: %w", err)
|
|
}
|
|
if got != want {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, got, want)
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// latestMigration — номер последней миграции, вшитой в бинарь.
|
|
func latestMigration() (int64, error) {
|
|
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read migrations dir: %w", err)
|
|
}
|
|
var top int64
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
// Неразобранное имя — отказ, а не пропуск: страж «версия схемы не та»,
|
|
// молча не заметивший миграцию, перестаёт страховать, не сказав об этом.
|
|
idx := strings.IndexByte(name, '_')
|
|
if idx <= 0 {
|
|
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
|
|
}
|
|
v, err := strconv.ParseInt(name[:idx], 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
|
|
}
|
|
if v > top {
|
|
top = v
|
|
}
|
|
}
|
|
if top == 0 {
|
|
return 0, errors.New("миграций не найдено")
|
|
}
|
|
return top, nil
|
|
}
|
|
|
|
// Close закрывает соединение с БД.
|
|
func (s *Store) Close() error {
|
|
if err := s.db.Close(); err != nil {
|
|
return fmt.Errorf("close sqlite: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
|
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
|
//
|
|
// `_txlock=immediate` обязателен, а не «на всякий случай»: запись объекта —
|
|
// это read-modify-write, и при отложенной блокировке повышение с чтения на
|
|
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
|
|
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
|
|
// слияния из 800 против 800 из 800.
|
|
func dsn(path string) string {
|
|
q := url.Values{}
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "foreign_keys(on)")
|
|
q.Add("_txlock", "immediate")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
// readOnlyDSN — подключение только для чтения.
|
|
//
|
|
// `journal_mode` здесь не задаётся: сменить его на read-only соединении нельзя,
|
|
// а читать базу в режиме WAL это не мешает. `_txlock=immediate` тоже не нужен —
|
|
// он лечит повышение блокировки с чтения на запись, которого здесь не бывает.
|
|
func readOnlyDSN(path string) string {
|
|
q := url.Values{}
|
|
q.Add("mode", "ro")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
// migrate накатывает миграции.
|
|
//
|
|
// Через Provider, а не через пакетные функции: goose.SetBaseFS и
|
|
// goose.SetDialect пишут глобальное состояние пакета, и два одновременных
|
|
// Open дают гонку — её ловит детектор. Сегодня открытие одно, на старте, но
|
|
// пересборка витрины откроет второе, и хранилище не должно зависеть от того,
|
|
// что вызывающий этого не сделает.
|
|
func migrate(db *sqlx.DB) error {
|
|
sub, err := fs.Sub(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("goose migrations fs: %w", err)
|
|
}
|
|
|
|
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
|
if err != nil {
|
|
return fmt.Errorf("goose provider: %w", err)
|
|
}
|
|
if _, err := p.Up(context.Background()); err != nil {
|
|
return fmt.Errorf("goose up: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Now — единая точка генерации времени: UTC, секундная точность.
|
|
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
|
// сортировка TEXT совпадает с хронологией.
|
|
func Now() time.Time { return time.Now().UTC().Truncate(time.Second) }
|
|
|
|
// FormatTime приводит время к каноническому виду хранения: RFC 3339, UTC.
|
|
func FormatTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
|
|
|
// ParseTime разбирает метку времени из БД.
|
|
func ParseTime(s string) (time.Time, error) {
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("parse time %q: %w", s, err)
|
|
}
|
|
return t.UTC(), nil
|
|
}
|