хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+554
View File
@@ -0,0 +1,554 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
return db, NewStore(dir), dir
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
t.Helper()
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err)
return account.ID
}
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
db, _, _ := newTestDB(t)
var mode string
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
var busy int
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
// поломка, ради которой настройка уехала в строку подключения.
var wg sync.WaitGroup
answers := make([]int, testSettings().ReadConnections)
start := make(chan struct{})
for i := range answers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conn, err := db.Reader().Conn(context.Background())
if !assert.NoError(t, err) {
return
}
defer func() { assert.NoError(t, conn.Close()) }()
assert.NoError(t,
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
// Соединение придерживается, пока спрашивают остальные: иначе пул
// раздал бы всем одно и то же и правило проверило бы одну настройку
// вместо четырёх.
time.Sleep(10 * time.Millisecond)
}()
}
close(start)
wg.Wait()
for i, answer := range answers {
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
}
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
// несуществующим владельцем отвергается обоими пулами.
now := clock.Now().Format(timeLayout)
insert := `INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
_, err := db.Writer().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
_, err = db.Reader().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
for name, settings := range map[string]Settings{
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
} {
t.Run(name, func(t *testing.T) {
_, err := Open(t.TempDir(), settings)
assert.Error(t, err, "старт на негодном числе прошёл молча")
})
}
}
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
owner := newOwner(t, db)
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
var login string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
var applied int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
dir := t.TempDir()
var (
mu sync.Mutex
inside int
overlap bool
)
hold := func() error {
mu.Lock()
inside++
if inside > 1 {
overlap = true
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
return nil
}
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, withMigrationLock(dir, hold))
}()
}
wg.Wait()
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
// применяется.
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
require.NoError(t, err)
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
require.Error(t, err, "отказ шага прошёл молча")
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
// состояние — то самое, из-за которого следующий запуск применил бы шаг
// второй раз и упал бы на заведённой таблице.
var version int
err = db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
if err == nil {
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
}
var tables int
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
db, _, _ := newTestDB(t)
tables := []string{
"users", "files", "topics", "audio_records",
"texts", "structures", "recognitions", "record_events",
}
seen := 0
for _, table := range tables {
rows, err := db.Reader().QueryContext(context.Background(),
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
require.NoError(t, err)
for rows.Next() {
var (
name string
columnType string
dflt any
)
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
if !isTimeColumn(name) {
continue
}
seen++
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
}
require.NoError(t, rows.Err())
closeRows(t, rows)
}
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
t.Helper()
require.NoError(t, rows.Close())
}
func isTimeColumn(name string) bool {
return strings.HasSuffix(name, "_at") || name == "delay_time"
}
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
records := NewAudioRecordRepository(db)
byService := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, records.Create(byService))
byHand := ident.New()
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
byHand, owner, entity.StateUploaded, now, now, now,
)
require.NoError(t, err)
acquired := map[string]bool{}
for range 2 {
got, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
acquired[got.ID] = true
}
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
db, _, _ := newTestDB(t)
acquire := explain(t, db, `
SELECT id FROM audio_records
WHERE state IN (?, ?)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
ORDER BY created_at, id
LIMIT 1`,
entity.StateUploaded, entity.StateNormalized, "now", "now")
list := explain(t, db, `
SELECT id FROM audio_records
WHERE owner_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 31`,
"owner", "now", "now", "id")
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
assert.NotContains(t, plan, "SCAN audio_records",
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
}
}
func explain(t *testing.T, db *DB, query string, args ...any) string {
t.Helper()
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var plan strings.Builder
for rows.Next() {
var id, parent, notUsed int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
plan.WriteString(detail)
plan.WriteString("; ")
}
require.NoError(t, rows.Err())
return plan.String()
}
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
db, err := Open(t.TempDir(), testSettings())
require.NoError(t, err)
require.NoError(t, db.Close())
assert.NoError(t, db.Close(), "повторное закрытие отказало")
}
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
_, store, dir := newTestDB(t)
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
require.Error(t, err, "отказ источника прошёл молча")
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
temporary, err := store.HasTemporary(recordID)
require.NoError(t, err)
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
sent bool
}
func (r *brokenReader) Read(p []byte) (int, error) {
if !r.sent {
r.sent = true
copy(p, strings.Repeat("a", min(len(p), 64)))
return min(len(p), 64), nil
}
return 0, errors.New("источник оборвался")
}
// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
_, store, dir := newTestDB(t)
// noPath судит вторую половину: ни каталога данных, ни временной приставки
// в цепочке отказа быть не должно.
noPath := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
}
t.Run("места на диске нет", func(t *testing.T) {
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
})
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
})
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
recordID := ident.New()
recordDir := filepath.Join(dir, recordsDir, recordID)
require.NoError(t, os.MkdirAll(recordDir, 0o750))
require.NoError(t, os.Chmod(recordDir, 0o500))
t.Cleanup(func() {
if err := os.Chmod(recordDir, 0o750); err != nil {
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
}
})
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
})
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
recordID := ident.New()
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
})
t.Run("копии нет", func(t *testing.T) {
_, err := store.Open(ident.New(), "voice.mp3")
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
})
}
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
path string
}
func (r *diskFullReader) Read([]byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
db, store, dir := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
for _, name := range []string{"original.mp3", "normalized.ogg"} {
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
require.NoError(t, err)
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
}
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
require.NoError(t, err)
assert.Len(t, records, 1, "второго места для копий записи не появляется")
}
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
require.NoError(t, err)
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
reader, err := files.Open(file.Id)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
_, err = reader.Seek(4, io.SeekStart)
require.NoError(t, err)
slice := make([]byte, 3)
_, err = io.ReadFull(reader, slice)
require.NoError(t, err)
assert.Equal(t, "456", string(slice))
}