хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
|
||||
// каталогом.
|
||||
//
|
||||
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
|
||||
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
|
||||
// читается как «репозитории поверх SQLite» без знания кода.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
|
||||
// выбран: сборка бинарника остаётся без компилятора C.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
|
||||
const driverName = "sqlite"
|
||||
|
||||
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
|
||||
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
|
||||
// целиком, а не одним файлом.
|
||||
const DatabaseFile = "transcriber.db"
|
||||
|
||||
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
|
||||
// константой кода: крутят их при одном и том же отказе — «база занята» под
|
||||
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
|
||||
// пересборки образа.
|
||||
type Settings struct {
|
||||
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
|
||||
BusyTimeoutMs int
|
||||
// ReadConnections — сколько соединений держит читающий пул.
|
||||
ReadConnections int
|
||||
}
|
||||
|
||||
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
|
||||
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
|
||||
// чтения означает пул без предела, то есть настройку, которой не управляют.
|
||||
func (s Settings) Validate() error {
|
||||
if s.BusyTimeoutMs <= 0 {
|
||||
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
|
||||
}
|
||||
if s.ReadConnections <= 0 {
|
||||
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
|
||||
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
|
||||
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
|
||||
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
|
||||
// перестал бы освобождать захват и писать причину остановки — то есть отмена
|
||||
// ломала бы ровно ту уборку, ради которой она и делается.
|
||||
//
|
||||
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
|
||||
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
|
||||
|
||||
// DB — база сервиса двумя пулами.
|
||||
//
|
||||
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
|
||||
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
|
||||
// получают отказ по занятости — на записи результата шага, то есть после
|
||||
// оплаченной работы. Пул с одним соединением обращает их в очередь.
|
||||
//
|
||||
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
|
||||
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||
type DB struct {
|
||||
// writer — единственное пишущее соединение. Через него идёт всякая
|
||||
// операция, которая читает состояние и следом его пишет: транзакцию,
|
||||
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
|
||||
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
|
||||
// лечит, ждать там нечего.
|
||||
writer *sql.DB
|
||||
// reader — пул чтения.
|
||||
reader *sql.DB
|
||||
}
|
||||
|
||||
// Writer отдаёт пишущее соединение.
|
||||
func (db *DB) Writer() *sql.DB { return db.writer }
|
||||
|
||||
// Reader отдаёт читающий пул.
|
||||
func (db *DB) Reader() *sql.DB { return db.reader }
|
||||
|
||||
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
|
||||
//
|
||||
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
|
||||
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
|
||||
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
|
||||
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
|
||||
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
|
||||
// молча.
|
||||
func Open(dataDir string, settings Settings) (*DB, error) {
|
||||
if err := settings.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, DatabaseFile)
|
||||
|
||||
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
|
||||
// операция, которая читает и следом пишет, иначе взяла бы читающую
|
||||
// транзакцию и упёрлась бы в отказ при первой же записи.
|
||||
writer, err := open(path, settings, "immediate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.SetMaxOpenConns(1)
|
||||
writer.SetMaxIdleConns(1)
|
||||
|
||||
reader, err := open(path, settings, "deferred")
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
|
||||
}
|
||||
reader.SetMaxOpenConns(settings.ReadConnections)
|
||||
reader.SetMaxIdleConns(settings.ReadConnections)
|
||||
|
||||
db := &DB{writer: writer, reader: reader}
|
||||
|
||||
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
|
||||
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
|
||||
// то есть отказом каждого запроса вместо одной строки о причине.
|
||||
if err := writer.PingContext(context.Background()); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// open заводит один пул с общими настройками соединения.
|
||||
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
|
||||
query := url.Values{}
|
||||
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
|
||||
query.Add("_pragma", "journal_mode(WAL)")
|
||||
query.Add("_pragma", "foreign_keys(1)")
|
||||
query.Set("_txlock", txlock)
|
||||
|
||||
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
|
||||
// закрытого пула отказом не считается.
|
||||
func (db *DB) Close() error {
|
||||
var errs []error
|
||||
if db.reader != nil {
|
||||
if err := db.reader.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
|
||||
}
|
||||
db.reader = nil
|
||||
}
|
||||
if db.writer != nil {
|
||||
if err := db.writer.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
|
||||
}
|
||||
db.writer = nil
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
Reference in New Issue
Block a user