- слияние read-modify-write в одной транзакции, ключ точки — интервал, победа более полной точки, тай-брейк по канонической форме - хеш объекта как детектор изменений: совпал — записи нет - _txlock=immediate и повтор всей тройки при SQLITE_BUSY/BUSY_SNAPSHOT - миграции через goose.Provider: пакетные SetBaseFS/SetDialect писали глобалы и давали гонку при двух Open
105 lines
3.9 KiB
Go
105 lines
3.9 KiB
Go
// Package store — SQLite-витрина healthlog: доставки и (позже) разобранные
|
|
// точки. Витрина производна от сырого архива и пересобирается из него.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
}
|