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() }