Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+100
-38
@@ -9,8 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -27,7 +25,27 @@ type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// Open открывает БД по пути и накатывает миграции.
|
||||
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
||||
//
|
||||
// Версия базы ВЫШЕ версии бинаря — отказ, а не повод мигрировать. Иначе откат
|
||||
// бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно,
|
||||
// незнакомые секции игнорирует и доставки за окно отката помечает
|
||||
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
|
||||
// «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно
|
||||
// становится невосстановимым.
|
||||
//
|
||||
// Цена самого отказа названа вслух, потому что она реальна: сервис не
|
||||
// поднимется, а телефон шлёт непрерывно и молча — доставка, не попавшая в
|
||||
// архив, в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря
|
||||
// это действие оператора, который в этот момент рядом и видит отказ сразу
|
||||
// (контейнер уходит в цикл перезапуска), а дыры плотных метрик за время простоя
|
||||
// закроют широкий и глубокий проходы синхронизации. Не закроют `stateOfMind` —
|
||||
// у него доставки HAE единственный источник; это и есть цена. Она меньше цены
|
||||
// молчания, которое портит витрину за всё окно отката незаметно.
|
||||
//
|
||||
// Версия базы НИЖЕ версии бинаря отказом не является: ради этого случая
|
||||
// миграции и существуют. Асимметрия только здесь — OpenForRead остаётся
|
||||
// строгим.
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
||||
if err != nil {
|
||||
@@ -59,50 +77,77 @@ func OpenForRead(dbPath string) (*Store, error) {
|
||||
return nil, fmt.Errorf("open sqlite %q read-only: %w", dbPath, err)
|
||||
}
|
||||
|
||||
want, err := latestMigration()
|
||||
ctx := context.Background()
|
||||
// Журнал миграций спрашивается ДО goose и структурно, а не по тексту ошибки
|
||||
// драйвера. Причина не в стиле: `GetVersions` при отсутствии таблицы идёт
|
||||
// её СОЗДАВАТЬ, на соединении `mode=ro` это три секунды повторов и отказ
|
||||
// «attempt to write a readonly database» — оператор, спросивший про версию
|
||||
// схемы, получал бы ответ про права на файл. База без журнала миграций
|
||||
// нашей не является, и сказать это надо прямо.
|
||||
ok, err := hasMigrationLog(ctx, db)
|
||||
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 {
|
||||
if !ok {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("read schema version: %w", err)
|
||||
return nil, fmt.Errorf("%w: журнала миграций в базе нет", ErrSchemaMismatch)
|
||||
}
|
||||
if got != want {
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, got, want)
|
||||
return nil, err
|
||||
}
|
||||
// Строгое равенство, в отличие от Open: у чтения нет способа догнать схему,
|
||||
// а база старее бинаря отдала бы колонки, которых в ней ещё нет. Так уже
|
||||
// нормировано пересборкой, и настоящее правило её не ослабляет.
|
||||
if inDB != inBinary {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// latestMigration — номер последней миграции, вшитой в бинарь.
|
||||
func latestMigration() (int64, error) {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
// hasMigrationLog говорит, есть ли в базе журнал миграций goose. Структурный
|
||||
// вопрос к самой базе, а не разбор текста ошибки драйвера: сообщения драйвера
|
||||
// контрактом не являются — правило записано в isBusy и действует здесь.
|
||||
func hasMigrationLog(ctx context.Context, db *sqlx.DB) (bool, error) {
|
||||
const q = `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'goose_db_version'`
|
||||
|
||||
var n int
|
||||
if err := db.GetContext(ctx, &n, q); err != nil {
|
||||
return false, fmt.Errorf("read migration log presence: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// readSchemaVersion отвечает, какая версия схемы лежит в базе и какую знает
|
||||
// бинарь. Единственное место, где версия ЧИТАЕТСЯ, — сравнивают её два способа
|
||||
// открытия по-разному, а читают одинаково.
|
||||
//
|
||||
// Спрашиваем сам goose, а не собственный `SELECT max(version_id)`: имя таблицы
|
||||
// учёта, имя колонки и правило «максимум = текущая версия» принадлежат ему.
|
||||
// Рукописная копия его приватной схемы разошлась бы при обновлении зависимости,
|
||||
// причём не отказом, а тем, что страж перестал бы ловить, — то есть ровно тем,
|
||||
// что страж и обязан не допускать. Заодно исчезает собственный разбор имён
|
||||
// `NNNNN_*.sql` и вопрос «как отличить пустую таблицу от отсутствующей, не
|
||||
// читая текст ошибки драйвера»: на новой базе goose отдаёт 0 сам.
|
||||
//
|
||||
// Оговорка, без которой обещание непроверяемо: `GetVersions` при ОТСУТСТВИИ
|
||||
// таблицы учёта идёт её создавать. На соединении только для чтения это отказ, и
|
||||
// вызывающий обязан отсеять такую базу раньше (см. hasMigrationLog); на
|
||||
// соединении с записью создание законно — им и начинается новая база.
|
||||
func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64, err error) {
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read migrations dir: %w", err)
|
||||
return 0, 0, 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
|
||||
}
|
||||
inDB, inBinary, err = p.GetVersions(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
if top == 0 {
|
||||
return 0, errors.New("миграций не найдено")
|
||||
}
|
||||
return top, nil
|
||||
return inDB, inBinary, nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
@@ -150,21 +195,38 @@ func readOnlyDSN(path string) string {
|
||||
// пересборка витрины откроет второе, и хранилище не должно зависеть от того,
|
||||
// что вызывающий этого не сделает.
|
||||
func migrate(db *sqlx.DB) error {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
ctx := context.Background()
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose migrations fs: %w", err)
|
||||
return err
|
||||
}
|
||||
if inDB > inBinary {
|
||||
return fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose provider: %w", err)
|
||||
return err
|
||||
}
|
||||
if _, err := p.Up(context.Background()); err != nil {
|
||||
if _, err := p.Up(ctx); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProvider(db *sqlx.DB) (*goose.Provider, error) {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose migrations fs: %w", err)
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose provider: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Now — единая точка генерации времени: UTC, секундная точность.
|
||||
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
||||
// сортировка TEXT совпадает с хронологией.
|
||||
|
||||
Reference in New Issue
Block a user