- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
111 lines
5.5 KiB
Go
111 lines
5.5 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
|
|
"github.com/pressly/goose/v3"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
|
|
)
|
|
|
|
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
|
|
// данных рядом с базой: замок принадлежит каталогу, а не машине.
|
|
const migrationLockFile = "migrate.lock"
|
|
|
|
// Migrate приводит схему к последнему шагу.
|
|
//
|
|
// # Порядок
|
|
//
|
|
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
|
|
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
|
|
// запрос и на каждый прогон воркера — вместо одной строки о причине их
|
|
// становятся сотни, и первопричина в них теряется.
|
|
//
|
|
// # Чем держится неделимость
|
|
//
|
|
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
|
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
|
|
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
|
|
//
|
|
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
|
|
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
|
|
// дают отказ сбора, а не молчаливый выбор одного.
|
|
//
|
|
// # Почему замок наш
|
|
//
|
|
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
|
|
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
|
|
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
|
|
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
|
|
// замка, который надо чистить руками, не остаётся.
|
|
//
|
|
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
|
|
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
|
|
// повышает.
|
|
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
|
|
provider, err := goose.NewProvider(
|
|
goose.DialectSQLite3,
|
|
db.Writer(),
|
|
nil,
|
|
goose.WithGoMigrations(migrations.All()...),
|
|
// Глобальный список библиотеки не читается: перечень шагов приходит
|
|
// доводом, и два провайдера в одном процессе за общее состояние не
|
|
// спорят.
|
|
goose.WithDisableGlobalRegistry(true),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prepare schema migrations: %w", err)
|
|
}
|
|
|
|
return withMigrationLock(dataDir, func() error {
|
|
results, err := provider.Up(ctx)
|
|
if err != nil {
|
|
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
|
|
// владелец сервиса по ней находит файл шага.
|
|
return fmt.Errorf("failed to apply schema migration: %w", err)
|
|
}
|
|
|
|
for _, result := range results {
|
|
logger.Info("Schema migration applied",
|
|
"migration_version", result.Source.Version,
|
|
"duration_ms", result.Duration.Milliseconds())
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
|
|
//
|
|
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
|
|
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
|
|
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
|
|
func withMigrationLock(dataDir string, run func() error) error {
|
|
path := filepath.Join(dataDir, migrationLockFile)
|
|
|
|
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open migration lock: %w", err)
|
|
}
|
|
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
|
|
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
|
|
// снимает ядро тем же движением — просроченного замка, который надо чистить
|
|
// руками, не остаётся.
|
|
defer func() { _ = file.Close() }()
|
|
|
|
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
|
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
|
|
}
|
|
|
|
return run()
|
|
}
|