хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user