хранилище переехало с 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
+172
View File
@@ -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...)
}
+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))
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"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"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
// место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
type FileRepository struct {
db *DB
store *Store
}
func NewFileRepository(db *DB, store *Store) *FileRepository {
return &FileRepository{db: db, store: store}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
work, err := newWorkFile(filepath.Ext(file.FileName))
if err != nil {
return nil, err
}
src, err := repo.store.Open(file.RecordID, file.FileName)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer func() { _ = src.Close() }()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
//
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
// файл, которого ещё нет или который короче принятого.
//
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
//
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
func (repo *FileRepository) Create(
recordID, name string,
work contract.WorkFile,
meta contract.FileMeta,
ownerID string,
) (*entity.File, error) {
source, err := os.Open(work.Path())
if err != nil {
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
// аудио, а отказ кончается в журнале.
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
}
size, putErr := repo.store.Put(recordID, name, source)
closeErr := source.Close()
if err := errors.Join(putErr, closeErr); err != nil {
return nil, err
}
file := &entity.File{
Id: ident.New(),
RecordID: recordID,
FileName: name,
Size: size,
Format: meta.Format,
DurationMs: meta.DurationMs,
CreatedAt: clock.Now(),
}
query, args := insertSQL("files", map[string]any{
"id": file.Id,
"owner_id": ownerID,
"record_id": file.RecordID,
"file_name": file.FileName,
"size_bytes": file.Size,
"format": file.Format,
"duration_ms": file.DurationMs,
"created_at": formatTime(file.CreatedAt),
})
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
repo.store.Remove(recordID, name),
)
}
return file, nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
file := &entity.File{}
var (
createdAt string
recordID string
fileName string
size int64
format string
durationMs int64
)
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
FROM files WHERE id = ?`, id,
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("file %s is not found", id)
}
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
}
file.Id = id
file.RecordID = recordID
file.FileName = fileName
file.Size = size
file.Format = format
file.DurationMs = durationMs
file.CreatedAt = requiredTimeOf(createdAt)
return file, nil
}
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
// диапазону читает кусок, а не файл целиком.
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
return repo.store.Open(file.RecordID, file.FileName)
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"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"
)
// UserRepository — учётные записи сервиса.
type UserRepository struct {
db *DB
}
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
login, ok := entity.AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, contract.ErrLoginNotAcceptable
}
account, err := findUserByLogin(repo.db.Reader(), login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, nil
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Повторный поиск закрывает окно между читающим пулом и пишущей
// транзакцией: пока её ждали, запись мог завести сосед.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
name := entity.AcceptDisplayName(identity.Name)
email, _ := entity.AcceptEmail(identity.Email)
account, err = insertUser(tx, login, name, email)
switch {
case err == nil:
return account, true, commitAccount(tx, account)
case !isUniqueViolation(err):
return nil, false, fmt.Errorf("failed to create user account: %w", err)
}
// **Два отказа уникальности различаются, и исход у них разный**, а какая
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
// просто взять заведённую соседом запись.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
// этого разреза второй человек с общим адресом не завёлся бы никогда —
// повторный поиск по логину снова ничего не находит.
account, err = insertUser(tx, login, name, "")
if err != nil {
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
}
return account, true, commitAccount(tx, account)
}
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
}
return nil
}
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
id := ident.New()
now := formatTime(clock.Now())
_, err := tx.ExecContext(context.Background(),
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, login, name, email, now, now,
)
if err != nil {
return nil, err
}
return &contract.UserAccount{ID: id, Name: name}, nil
}
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
account := &contract.UserAccount{}
err := q.QueryRowContext(context.Background(),
"SELECT id, name FROM users WHERE provider_login = ?", login,
).Scan(&account.ID, &account.Name)
switch {
case err == nil:
return account, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
}
+110
View File
@@ -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()
}
@@ -0,0 +1,254 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"strconv"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608220002 заводит схему сервиса целиком.
//
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
// ним — разовое снятие инварианта «применённая миграция не переписывается»
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
// применённого своей таблицей, которой отметки прежнего каталога не годятся
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
// инвариант как всякий прежний.
//
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
// ссылается, и только потом обратные ссылки записи на её приложения.
//
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
// вставка, забывшая проставить время, при умолчании прошла бы молча.
//
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
// и вид текста были закрыты схемой, потому что панель владельца правила запись
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
// стоило бы нового шага схемы — без покупателя.
func up202608220002(ctx context.Context, tx *sql.Tx) error {
for _, statement := range initStatements() {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("failed to apply initial schema: %w", err)
}
}
return nil
}
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
// приложения ссылаются на запись, запись — на учётную запись.
func down202608220002(ctx context.Context, tx *sql.Tx) error {
tables := []string{
"record_events",
"recognitions",
"structures",
"texts",
"record_topics",
"audio_records",
"topics",
"files",
"users",
}
for _, table := range tables {
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
return fmt.Errorf("failed to drop %s: %w", table, err)
}
}
return nil
}
// initStatements — шаг по одному оператору на элемент.
//
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
// запятой, и разбиение общей строки резало бы его пополам.
func initStatements() []string {
return []string{
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
// доверенного источника, и по нему запись находится при каждом
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
// и первое обращение с чужим адресом досталось бы чужой записи.
`CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
provider_login TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
// Уникальность почты частичная: пустая почта законна и не спорит с
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
// схемой — по этому отказу заведение переходит на ветвь «запись без
// почты», а не отдаёт чужую учётную запись.
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
// через запись: файл переживает свою запись — шаг заводит его до
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
//
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
// самой записи, и обязательная связь отвергала бы первую же принятую
// запись.
`CREATE TABLE files (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
record_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
format TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_files_owner ON files (owner_id)`,
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
// человека нужен целиком перед каждым обращением к модели, а собрать его
// из наборов строк можно только перебором всех его записей.
`CREATE TABLE topics (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
// чтение очереди её не тянет.
//
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
// записи не бывает, и держит это схема, а не проверка вызывающего.
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
// записей непустые, — поэтому ничью запись отвергает та же связь.
//
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
// метаданные которой прочитать не удалось, отвергается отказом и не
// заводится вовсе. Решение владельца 2026-08-15.
`CREATE TABLE audio_records (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
title TEXT,
brief TEXT,
original_filename TEXT,
duration_ms INTEGER NOT NULL,
size_bytes INTEGER NOT NULL,
state TEXT NOT NULL,
state_entered_at TEXT NOT NULL,
halted_at TEXT,
halt_reason TEXT,
error_text TEXT,
acquisition_id TEXT,
acquire_expires_at TEXT,
delay_time TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
original_file_id TEXT REFERENCES files (id),
normalized_file_id TEXT REFERENCES files (id),
transcript_text_id TEXT,
literary_text_id TEXT,
structure_id TEXT,
recognition_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
// Индекс заводится здесь, а не потом: применённый шаг схемы не
// переписывается, и добавление индекса стоило бы отдельного шага.
`CREATE INDEX idx_audio_records_acquire
ON audio_records (state, halted_at, created_at, id)`,
// Страница списка сужается владельцем и режется полным ключом
// сортировки — парой «время заведения и ключ записи».
`CREATE INDEX idx_audio_records_owner_page
ON audio_records (owner_id, created_at, id)`,
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
// набора строк в колонке нет ни связи, ни потолка.
`CREATE TABLE record_topics (
record_id TEXT NOT NULL REFERENCES audio_records (id),
topic_id TEXT NOT NULL REFERENCES topics (id),
PRIMARY KEY (record_id, topic_id)
)`,
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
// Потолок числа тем держит схема: без него часовой разговор даёт два
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
// то же самое, которое сервис объявляет приложению.
`CREATE TRIGGER trg_record_topics_limit
BEFORE INSERT ON record_topics
BEGIN
SELECT RAISE(ABORT, 'record has too many topics')
WHERE (
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
END`,
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
`CREATE TABLE texts (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
kind TEXT NOT NULL,
contents TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
// ответа изменится раньше, чем архив пересчитают.
`CREATE TABLE structures (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
version INTEGER NOT NULL,
contents TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
// Попытка распознавания у внешнего провайдера.
//
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
// здесь стоит только его имя: шаг опроса читает эту строку раз в
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
// ехал бы в память при каждом опросе.
`CREATE TABLE recognitions (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
provider TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
source_uri TEXT NOT NULL DEFAULT '',
payload_file TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
// секрете, и две колонки с этим именем сделали бы инвариант
// двусмысленным.
`CREATE TABLE record_events (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
origin TEXT NOT NULL,
step TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
outcome_text TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
}
}
@@ -0,0 +1,36 @@
// Package migrations — шаги схемы базы.
//
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
//
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог.
//
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
// заводится в точке входа и получает этот перечень доводом, поэтому два
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
// состояние.
package migrations
import (
"github.com/pressly/goose/v3"
)
// All — шаги схемы в порядке версий.
//
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
// накатывается вовсе.
func All() []*goose.Migration {
return []*goose.Migration{
goose.NewGoMigration(
202608220002,
&goose.GoFunc{RunTx: up202608220002},
&goose.GoFunc{RunTx: down202608220002},
),
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"errors"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
// провайдера. Имя задаёт сервис, как и у копий аудио.
const payloadSuffix = ".payload"
type RecognitionRepository struct {
db *DB
store *Store
}
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
return &RecognitionRepository{db: db, store: store}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
started := clock.Now()
if r.Id == "" {
r.Id = ident.New()
}
now := formatTime(started)
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO recognitions
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
)
if err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
_, err := repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
sourceURI, externalID, formatTime(clock.Now()), id,
)
if err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
// записи** и отмечает завершение попытки.
//
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
// опросе. Хранится он потому, что результат операции у провайдера не
// переспрашивается.
//
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
// сохранённое без возврата.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
attempt, err := repo.GetByID(id)
if err != nil {
return err
}
name := id + payloadSuffix
if len(raw) > 0 {
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
// её идентификатором.
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
}
}
finished := formatTime(clock.Now())
if len(raw) > 0 {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
name, finished, finished, id,
)
} else {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
finished, finished, id,
)
}
if err != nil {
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
attempt := &entity.Recognition{Id: id}
var startedAt, finishedAt, payloadFile nullString
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
FROM recognitions WHERE id = ?`, id,
).Scan(
&attempt.RecordID, &attempt.Provider, &attempt.Model,
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
&startedAt, &finishedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
attempt.StartedAt = timeOf(startedAt.NullString)
attempt.FinishedAt = timeOf(finishedAt.NullString)
return attempt, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
attempt, err := repo.GetByID(id)
if err != nil {
return nil, err
}
var payloadFile string
if err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT payload_file FROM recognitions WHERE id = ?", id,
).Scan(&payloadFile); err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
if payloadFile == "" {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
file, err := repo.store.Open(attempt.RecordID, payloadFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
raw, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
}
return raw, nil
}
@@ -0,0 +1,62 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
// значение, читается в неё, а домену отдаётся указателем.
type nullString struct {
sql.NullString
}
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
// приходит целиком байтами, а укладка принимает поток.
func bytesReader(raw []byte) io.Reader {
return bytes.NewReader(raw)
}
type RecordEventRepository struct {
db *DB
}
func NewRecordEventRepository(db *DB) *RecordEventRepository {
return &RecordEventRepository{db: db}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
if event.Id == "" {
event.Id = ident.New()
}
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO record_events
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.Id, event.RecordID, event.Origin, event.Step,
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
)
if err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
return nil
}
+260
View File
@@ -0,0 +1,260 @@
package sqlite
import (
"context"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
const defaultListLimit = 30
// List отдаёт страницу записей владельца, новыми сверху.
//
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
// элемент между ними не пришёл бы никогда — и оба раза молча.
//
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
// определён вовсе.
//
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
// колонками самой записи.
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
// Пустой владелец не совпадает ни с одной записью. Правило записано со
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
// пустую строку пропустила бы.
if q.OwnerID == "" {
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
}
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
// воркеров нет вовсе.
if q.Limit <= 0 {
q.Limit = defaultListLimit
}
conditions := []string{"owner_id = ?"}
args := []any{q.OwnerID}
state, stateArgs, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != "" {
conditions = append(conditions, state)
args = append(args, stateArgs...)
}
total, err := repo.countRecords(conditions, args)
if err != nil {
return nil, err
}
pageConditions := conditions
pageArgs := args
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору.
if q.Cursor != nil {
pageConditions = append(append([]string{}, conditions...),
"(created_at < ? OR (created_at = ? AND id < ?))")
cursorTime := formatTime(q.Cursor.CreatedAt)
pageArgs = append(append([]any{}, args...),
cursorTime, cursorTime, q.Cursor.ID)
}
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться.
query := "SELECT " + columns + " FROM " + recordsTable +
" WHERE " + strings.Join(pageConditions, " AND ") +
" ORDER BY created_at DESC, id DESC LIMIT ?"
pageArgs = append(pageArgs, q.Limit+1)
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err)
}
defer func() { _ = rows.Close() }()
items := []*entity.AudioRecord{}
for rows.Next() {
if err := rows.Scan(targets...); err != nil {
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
}
items = append(items, rowToAudioRecord(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
}
page := &contract.RecordPage{TotalItems: total}
if len(items) > q.Limit {
last := items[q.Limit-1]
page.NextCursor = &contract.RecordCursor{
CreatedAt: last.CreatedAt,
ID: last.Id,
}
items = items[:q.Limit]
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.Id)
}
topics, err := repo.topicsOf(ids)
if err != nil {
return nil, err
}
for _, item := range items {
item.TopicIDs = topics[item.Id]
}
page.Items = items
return page, nil
}
// stateCondition переводит состояние отбора в условие запроса.
//
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
if filter == nil {
return "", nil, nil
}
switch *filter {
case entity.ListFilterHalted:
return "halted_at IS NOT NULL", nil, nil
case entity.ListFilterWorking:
condition, args := stateIn(entity.WorkingStages())
return "halted_at IS NULL AND " + condition, args, nil
case entity.ListFilterDone:
condition, args := stateIn(entity.TerminalStages())
return "halted_at IS NULL AND " + condition, args, nil
}
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем.
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}
func stateIn(stages []entity.Stage) (string, []any) {
names := entity.StageNames(stages)
args := make([]any, 0, len(names))
placeholders := make([]string, 0, len(names))
for _, name := range names {
args = append(args, name)
placeholders = append(placeholders, "?")
}
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
}
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
var total int
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err)
}
return total, nil
}
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
// иначе стоила бы сотни обращений к базе.
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
out := map[string][]string{}
if len(recordIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(recordIDs))
args := make([]any, 0, len(recordIDs))
for _, id := range recordIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var recordID, topicID string
if err := rows.Scan(&recordID, &topicID); err != nil {
return nil, fmt.Errorf("failed to read a record topic: %w", err)
}
out[recordID] = append(out[recordID], topicID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
return out, nil
}
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
//
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
// чужой темы, как только темы начнёт писать языковая модель.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{}
if len(ids) == 0 || ownerID == "" {
return out, nil
}
placeholders := make([]string, 0, len(ids))
args := []any{ownerID}
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
strings.Join(placeholders, ", ") + ")"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, fmt.Errorf("failed to read a topic: %w", err)
}
out[id] = name
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
return out, nil
}
@@ -0,0 +1,173 @@
package sqlite
import (
"database/sql"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
//
// Работает оно **по имени колонки**: именованные параметры запроса и место
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
// подмена значения.
//
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
// можно забыть в отображении или в шаге схемы, и сверку держат правила
// `internal/archrules`.
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
// отправителя, длительность, размер и темы не трогаются вовсе.
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
return map[string]any{
"state": r.State,
"state_entered_at": formatTime(r.StateEnteredAt),
"halted_at": timeValue(r.HaltedAt),
"halt_reason": stringValue(r.HaltReason),
"error_text": stringValue(r.ErrorText),
"acquisition_id": stringValue(r.AcquisitionID),
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
"delay_time": timeValue(r.DelayTime),
"attempts": r.Attempts,
"original_file_id": stringValue(r.OriginalFileID),
"normalized_file_id": stringValue(r.NormalizedFileID),
"transcript_text_id": stringValue(r.TranscriptTextID),
"literary_text_id": stringValue(r.LiteraryTextID),
"structure_id": stringValue(r.StructureID),
"recognition_id": stringValue(r.RecognitionID),
"updated_at": formatTime(r.UpdatedAt),
}
}
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
//
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
// молча.
func writeRecord(r *entity.AudioRecord) map[string]any {
values := writeOwnedByPipeline(r)
own := map[string]any{
"id": r.Id,
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
// шага, записанный поверх, стёр бы его молча.
"owner_id": r.OwnerID,
"title": stringValue(r.Title),
"brief": stringValue(r.Brief),
// Имя файла отправителя, длительность и размер кладёт приём и только он:
// это снимок принятого, и конвейер его не пересчитывает.
"original_filename": stringValue(r.OriginalFilename),
"duration_ms": numberValue(r.DurationMs),
"size_bytes": numberValue(r.SizeBytes),
"created_at": formatTime(r.CreatedAt),
}
for name, value := range own {
values[name] = value
}
return values
}
// recordRow — сырые значения одной строки аудиозаписи.
type recordRow struct {
id string
ownerID string
title sql.NullString
brief sql.NullString
originalFilename sql.NullString
durationMs int64
sizeBytes int64
state string
stateEnteredAt string
haltedAt sql.NullString
haltReason sql.NullString
errorText sql.NullString
acquisitionID sql.NullString
acquireExpiresAt sql.NullString
delayTime sql.NullString
attempts int
originalFileID sql.NullString
normalizedFileID sql.NullString
transcriptTextID sql.NullString
literaryTextID sql.NullString
structureID sql.NullString
recognitionID sql.NullString
createdAt string
updatedAt string
}
// readRecordColumns — куда кладётся каждая колонка при чтении.
//
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
func readRecordColumns(row *recordRow) map[string]any {
return map[string]any{
"id": &row.id,
"owner_id": &row.ownerID,
"title": &row.title,
"brief": &row.brief,
"original_filename": &row.originalFilename,
"duration_ms": &row.durationMs,
"size_bytes": &row.sizeBytes,
"state": &row.state,
"state_entered_at": &row.stateEnteredAt,
"halted_at": &row.haltedAt,
"halt_reason": &row.haltReason,
"error_text": &row.errorText,
"acquisition_id": &row.acquisitionID,
"acquire_expires_at": &row.acquireExpiresAt,
"delay_time": &row.delayTime,
"attempts": &row.attempts,
"original_file_id": &row.originalFileID,
"normalized_file_id": &row.normalizedFileID,
"transcript_text_id": &row.transcriptTextID,
"literary_text_id": &row.literaryTextID,
"structure_id": &row.structureID,
"recognition_id": &row.recognitionID,
"created_at": &row.createdAt,
"updated_at": &row.updatedAt,
}
}
// rowToAudioRecord собирает доменную запись из прочитанной строки.
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
return &entity.AudioRecord{
Id: row.id,
OwnerID: row.ownerID,
Title: stringOf(row.title),
Brief: stringOf(row.brief),
OriginalFilename: stringOf(row.originalFilename),
DurationMs: numberOf(row.durationMs),
SizeBytes: numberOf(row.sizeBytes),
State: row.state,
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
HaltedAt: timeOf(row.haltedAt),
HaltReason: stringOf(row.haltReason),
ErrorText: stringOf(row.errorText),
AcquisitionID: stringOf(row.acquisitionID),
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
DelayTime: timeOf(row.delayTime),
Attempts: row.attempts,
OriginalFileID: stringOf(row.originalFileID),
NormalizedFileID: stringOf(row.normalizedFileID),
TranscriptTextID: stringOf(row.transcriptTextID),
LiteraryTextID: stringOf(row.literaryTextID),
StructureID: stringOf(row.structureID),
RecognitionID: stringOf(row.recognitionID),
TopicIDs: []string{},
CreatedAt: requiredTimeOf(row.createdAt),
UpdatedAt: requiredTimeOf(row.updatedAt),
}
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"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"
)
// recordsTable — таблица аудиозаписей.
const recordsTable = "audio_records"
type AudioRecordRepository struct {
db *DB
}
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
return &AudioRecordRepository{db: db}
}
// Create заводит аудиозапись.
//
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
// идентификатором. Пустой заполняется единой точкой выдачи.
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
if r.Id == "" {
r.Id = ident.New()
}
now := clock.Now()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
if r.StateEnteredAt.IsZero() {
r.StateEnteredAt = now
}
query, args := insertSQL(recordsTable, writeRecord(r))
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
return nil
}
// Save сохраняет запись, захват которой держит holder.
//
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
// именно значение, а не занятость записи — захват, перевыданный другому по
// протуханию срока или после того, как человек вернул запись в работу, обязан
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
//
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
// получают признак захвата от FindAndAcquire.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
r.UpdatedAt = clock.Now()
where := "id = :id"
whereArgs := []any{sql.Named("id", r.Id)}
if holder != "" {
where += " AND acquisition_id = :holder"
whereArgs = append(whereArgs, sql.Named("holder", holder))
}
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
if err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
}
if affected > 0 {
return nil
}
// Строк не тронуто по одной из двух причин, и различить их можно только
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
if _, err := repo.Get(r.Id); err != nil {
return err
}
return &contract.LostAcquisitionError{JobID: r.Id}
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
// иначе перебирается список заведённых записей, а идентификатор записи и есть
// то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
// учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
if err != nil {
return nil, err
}
return record, nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
return repo.read("id = ?", id)
}
// read читает одну запись по условию.
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
// настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
record := rowToAudioRecord(row)
topics, err := repo.topicsOf([]string{record.Id})
if err != nil {
return nil, err
}
record.TopicIDs = topics[record.Id]
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
// возвратом.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
now := clock.Now()
holder := ident.New()
args := []any{
sql.Named("holder", holder),
sql.Named("now", formatTime(now)),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
states := make([]string, 0, len(stages))
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
args = append(args,
sql.Named(stateKey, stage.Name),
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
)
states = append(states, ":"+stateKey)
}
expiry.WriteString(" END")
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
// Сравнения по неуникальному значению для этого мало — порядок обработки
// стал бы невоспроизводимым.
query := `
UPDATE ` + recordsTable + `
SET acquisition_id = :holder,
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated_at = :now
WHERE id = (
SELECT id FROM ` + recordsTable + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < :now)
AND (acquisition_id IS NULL
OR acquire_expires_at IS NULL
OR acquire_expires_at < :now)
ORDER BY created_at, id
LIMIT 1
)
RETURNING id`
var id string
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
}
+626
View File
@@ -0,0 +1,626 @@
package sqlite
import (
"context"
"log/slog"
"strings"
"sync"
"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"
)
// newWorkingRecord заводит запись, пригодную к захвату.
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, NewAudioRecordRepository(db).Create(record))
return record
}
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
// связью с учётной записью и пустого значения не принимает.
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
db, _, _ := newTestDB(t)
records := NewAudioRecordRepository(db)
t.Run("пустой владелец", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с пустым владельцем сохранилась")
})
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), OwnerID: ident.New(),
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
})
t.Run("запросом к базе тоже", func(t *testing.T) {
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, ?, ?, ?, ?)`,
ident.New(), entity.StateUploaded, now, now, now,
)
assert.Error(t, err, "ничья запись завелась запросом к базе")
})
}
// Файл без владельца не сохраняется — тем же правилом схемы.
func TestFileWithoutOwnerIsRejected(t *testing.T) {
db, store, _ := newTestDB(t)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
assert.Error(t, err, "файл с пустым владельцем сохранился")
}
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
// запрет держит схема обязательной связью, а не проверка вызывающего.
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
db, store, _ := newTestDB(t)
t.Run("с аудиозаписями", func(t *testing.T) {
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
})
t.Run("с одними файлами", func(t *testing.T) {
owner := newOwner(t, db)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с файлами удалилась")
})
t.Run("с одними темами", func(t *testing.T) {
owner := newOwner(t, db)
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
ident.New(), owner, "тема", now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с темами удалилась")
})
t.Run("пустая удаляется", func(t *testing.T) {
owner := newOwner(t, db)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
})
}
// У записи не больше пяти тем, и держит это схема.
func TestRecordTopicsAreCapped(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
now := clock.Now().Format(timeLayout)
for i := range entity.MaxTopicsPerRecord + 1 {
topicID := ident.New()
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
topicID, owner, "тема-"+topicID, now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
)
if i < entity.MaxTopicsPerRecord {
require.NoError(t, err, "тема %d не назначилась", i)
continue
}
assert.Error(t, err, "шестая тема назначилась")
}
}
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
//
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
// одному, остальные получают признак «работы сейчас нет».
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
const racers = 8
var (
mu sync.Mutex
acquired []*contract.AcquiredRecord
empty int
)
start := make(chan struct{})
var wg sync.WaitGroup
for range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := records.FindAndAcquire(entity.WorkingStages())
mu.Lock()
defer mu.Unlock()
var missing *contract.JobNotFoundError
switch {
case err == nil:
acquired = append(acquired, got)
case assert.ErrorAs(t, err, &missing):
empty++
}
}()
}
close(start)
wg.Wait()
require.Len(t, acquired, 1, "запись досталась не одному захвату")
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
assert.Equal(t, record.Id, acquired[0].ID)
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
}
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
// признака, а не занятостью записи.
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
first, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
// Захват уходит другому: срок протухает, и запись достаётся следующему.
_, err = db.Writer().ExecContext(context.Background(),
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
)
require.NoError(t, err)
second, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
// Прежний держатель пишет свой результат — и не пишет.
stale, err := records.Get(record.Id)
require.NoError(t, err)
stale.MoveToState(entity.StateNormalized)
err = records.Save(stale, first.Holder)
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
}
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
// которыми распоряжается сам.
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
held, err := records.Get(record.Id)
require.NoError(t, err)
// Владелец за это время правит поле, которого шаг не касается.
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
require.NoError(t, err)
held.MoveToState(entity.StateNormalized)
require.NoError(t, records.Save(held, acquired.Holder))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
require.NotNil(t, after.Title)
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
}
// Составная операция, которая читает и следом пишет, идёт по пишущему
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
}()
}
close(start)
wg.Wait()
var count int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
}
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
// стёрла бы расшифровку живого человека без следа.
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
require.NoError(t, err)
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
assert.Equal(t, stored.Id, again.Id, "строка та же")
read, err := texts.GetByID(stored.Id)
require.NoError(t, err)
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
structures := NewStructureRepository(db)
first, err := structures.Put(record.Id, entity.StructureVersion,
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
require.NoError(t, err)
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
require.NoError(t, err)
structure, err := structures.GetByID(first.Id)
require.NoError(t, err)
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
require.NoError(t, recognitions.Finish(attempt.Id, nil))
raw, err := recognitions.ReadRaw(attempt.Id)
require.NoError(t, err)
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
}
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
// опроса читает строку попытки без него.
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
// Строка попытки читается без ответа: он не колонка.
read, err := recognitions.GetByID(attempt.Id)
require.NoError(t, err)
require.NotNil(t, read.FinishedAt)
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
require.NoError(t, err)
require.NoError(t, file.Close())
}
// Два одновременных первых обращения одним значением дают ровно одну учётную
// запись: уникальность держит схема, а не порядок обращений.
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
var (
mu sync.Mutex
ids = map[string]bool{}
)
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
account, _, err := users.EnsureUser(contract.Identity{Login: login})
if assert.NoError(t, err) {
mu.Lock()
ids[account.ID] = true
mu.Unlock()
}
}()
}
close(start)
wg.Wait()
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
var rows int
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
}
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
require.NoError(t, err)
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
require.NoError(t, err)
require.True(t, created)
assert.NotEqual(t, first.ID, second.ID)
var email string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
}
// Найденную запись повторное обращение не переписывает: иначе правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
require.NoError(t, err)
require.True(t, created)
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
require.NoError(t, err)
assert.False(t, created)
assert.Equal(t, first.ID, again.ID)
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
}
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
// и занятый писатель его не держит.
//
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
// очередь к соединению ожиданием занятой базы не ограничена.
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
first, created, err := users.EnsureUser(contract.Identity{Login: login})
require.NoError(t, err)
require.True(t, created)
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
// поэтому пока она держится, второй транзакции не начаться.
tx, err := db.Writer().BeginTx(context.Background(), nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
type answer struct {
account *contract.UserAccount
created bool
err error
took time.Duration
}
done := make(chan answer, 1)
go func() {
started := time.Now()
account, created, err := users.EnsureUser(contract.Identity{Login: login})
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
}()
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
const limit = time.Second
select {
case got := <-done:
require.NoError(t, got.err)
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
assert.Equal(t, first.ID, got.account.ID)
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
case <-time.After(limit):
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
}
}
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
// там, где никого не назвал.
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
for name, login := range map[string]string{
"пустой": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
} {
t.Run(name, func(t *testing.T) {
_, _, err := users.EnsureUser(contract.Identity{Login: login})
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
})
}
var rows int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
}
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
//
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
// держит запись занятой до протухания срока, оставленное время входа в рубеж
// останавливает её снова первым же захватом, а оставленные отказы и пауза
// откладывают первый прогон на накопленный срок.
func TestResumeReleasesEveryGuard(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
events := NewRecordEventRepository(db)
// Так выглядит запись, остановленная после долгих отказов: захват на ней
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
// рубеже она простояла дольше предела.
_, err := db.Writer().ExecContext(context.Background(), `
UPDATE audio_records
SET halted_at = ?, halt_reason = ?, error_text = ?,
acquisition_id = ?, acquire_expires_at = ?,
delay_time = ?, attempts = 7, state_entered_at = ?
WHERE id = ?`,
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
clock.Now().Add(time.Hour).Format(timeLayout),
clock.Now().Add(-48*time.Hour).Format(timeLayout),
record.Id,
)
require.NoError(t, err)
// Остановленная запись захвату не выдаётся.
_, err = records.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
halted, err := records.Get(record.Id)
require.NoError(t, err)
halted.Resume()
require.NoError(t, records.Save(halted, ""))
require.NoError(t, events.Append(&entity.RecordEvent{
RecordID: record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.False(t, after.IsHalted(), "признак остановки остался")
assert.Nil(t, after.HaltReason, "причина остановки осталась")
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
assert.Equal(t, record.Id, acquired.ID)
// И возврат виден в журнале событий записи — происхождением «человек».
var origin, outcome string
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
).Scan(&origin, &outcome))
assert.Equal(t, entity.EventOriginHuman, origin)
assert.Equal(t, entity.EventOutcomeResumed, outcome)
}
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
// строки о готовности схема приведена целиком.
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
dir := t.TempDir()
journal := &strings.Builder{}
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, logger))
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
// первым приёмом нет.
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
}
+170
View File
@@ -0,0 +1,170 @@
package sqlite
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
const recordsDir = "records"
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
// имена из обычного перечисления каталога, а сама приставка отличает
// незавершённую укладку от рабочего имени.
const tempPrefix = ".partial-"
// Store — файлы записей в каталоге данных.
//
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
// запись убирается целиком одним движением — плоский каталог, где копии
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
//
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
// файла, ни именем каталога.
type Store struct {
root string
}
// NewStore заводит раздел записей в каталоге данных.
func NewStore(dataDir string) *Store {
return &Store{root: filepath.Join(dataDir, recordsDir)}
}
// dir — подкаталог одной записи.
func (s *Store) dir(recordID string) string {
return filepath.Join(s.root, recordID)
}
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
// в ответ, ни в метку метрики.
func (s *Store) path(recordID, name string) string {
return filepath.Join(s.dir(recordID), name)
}
// Put кладёт содержимое под рабочим именем **атомарно**.
//
// Содержимое пишется во временное имя в том же подкаталоге записи и
// переименовывается в рабочее только после того, как поток дочитан до конца без
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
// его пределах не копирует содержимое и не может оборваться на середине.
//
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
// результат — атомарная укладка единственное, что этого не допускает.
//
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
// имени не остаётся, рабочего имени не появляется.
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
}
temp := s.path(recordID, tempPrefix+ident.New())
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
}
size, copyErr := io.Copy(file, src)
syncErr := file.Sync()
closeErr := file.Close()
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
_ = os.Remove(temp)
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
// но по причине владелец различает исчерпание места, отсутствие прав и
// файловую систему только для чтения.
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
}
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
_ = os.Remove(temp)
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
}
return size, nil
}
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
// по диапазону читает кусок, а не файл целиком.
func (s *Store) Open(recordID, name string) (*os.File, error) {
file, err := os.Open(s.path(recordID, name))
if err != nil {
// Отказ называет запись её идентификатором и не несёт имени файла:
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
// нет» и «прав нет» ведут владельца к разным действиям.
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
}
return file, nil
}
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
// там, где укладка до него не дошла.
func (s *Store) Remove(recordID, name string) error {
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
}
return nil
}
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
// отсутствию рабочего имени.
func (s *Store) HasTemporary(recordID string) (bool, error) {
entries, err := os.ReadDir(s.dir(recordID))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
}
for _, entry := range entries {
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
return true, nil
}
}
return false, nil
}
// causeOf снимает с отказа файловой операции путь, оставляя причину.
//
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
// только для чтения — три поломки, требующие трёх разных действий, а отказ
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
// строку потом не убрать.
//
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
// обёрткой и от её снятия не страдает.
//
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
// сброса и закрытия, и путь лежит в каждой из них.
func causeOf(err error) error {
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
case *os.PathError:
return typed.Err
case *os.LinkError:
return typed.Err
case interface{ Unwrap() []error }:
parts := typed.Unwrap()
causes := make([]error, 0, len(parts))
for _, part := range parts {
causes = append(causes, causeOf(part))
}
return errors.Join(causes...)
}
return err
}
+205
View File
@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
type TextRepository struct {
db *DB
}
func NewTextRepository(db *DB) *TextRepository {
return &TextRepository{db: db}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
//
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
// вторую строку на гонке двух шагов.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && existing != "" {
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// драйвера несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, kind, contents, now, now,
); err != nil {
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
default:
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
// вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
}
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
text := &entity.Text{Id: id}
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
).Scan(&text.RecordID, &text.Kind, &text.Contents)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return text, nil
}
type StructureRepository struct {
db *DB
}
func NewStructureRepository(db *DB) *StructureRepository {
return &StructureRepository{db: db}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
// перечень реплик поверх непустого не кладётся.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
if replicas == nil {
replicas = []entity.Replica{}
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
if len(replicas) == 0 && len(existing) > len("[]") {
stored, decodeErr := decodeReplicas(id, existing)
if decodeErr != nil {
return nil, decodeErr
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, version, string(contents), now, now,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
structure := &entity.Structure{Id: id}
var contents string
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
).Scan(&structure.RecordID, &structure.Version, &contents)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
replicas, err := decodeReplicas(id, contents)
if err != nil {
return nil, err
}
structure.Replicas = replicas
return structure, nil
}
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
if contents == "" {
return nil, nil
}
var replicas []entity.Replica
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
return replicas, nil
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
sqlitedriver "modernc.org/sqlite"
sqlitelib "modernc.org/sqlite/lib"
)
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
// секундная точность.
//
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
// совпадает с хронологией, и отбор по колонке времени работает без разбора
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
// не выдавалась бы ни одному воркеру никогда.
const timeLayout = "2006-01-02T15:04:05Z"
// formatTime приводит метку времени к виду колонки.
func formatTime(v time.Time) string {
return v.UTC().Format(timeLayout)
}
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
// и отсутствующее — одно и то же: «времени нет».
func timeValue(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return formatTime(*v)
}
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
func timeOf(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
parsed, err := time.Parse(timeLayout, v.String)
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
// requiredTimeOf читает обязательную колонку времени.
func requiredTimeOf(v string) time.Time {
parsed, err := time.Parse(timeLayout, v)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
// же.
func stringValue(v *string) any {
if v == nil || *v == "" {
return nil
}
return *v
}
// stringOf читает необязательную строку.
func stringOf(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
out := v.String
return &out
}
// numberOf читает необязательное число.
//
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
// отсутствие.
func numberOf(v int64) *int64 {
out := v
return &out
}
// numberValue кладёт необязательное число нулём: колонка обязательна.
func numberValue(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// insertSQL собирает вставку из карты «колонка → значение».
//
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
// имени такого сдвига не существует вовсе.
//
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
// журналу читала бы каждый раз новый запрос.
func insertSQL(table string, values map[string]any) (string, []any) {
names := sortedNames(values)
placeholders := make([]string, 0, len(names))
args := make([]any, 0, len(names))
for _, name := range names {
placeholders = append(placeholders, ":"+name)
args = append(args, sql.Named(name, values[name]))
}
query := fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(names, ", "),
strings.Join(placeholders, ", "),
)
return query, args
}
// updateSQL собирает правку из карты «колонка → значение» и условия.
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
names := sortedNames(values)
assignments := make([]string, 0, len(names))
args := make([]any, 0, len(names)+len(whereArgs))
for _, name := range names {
assignments = append(assignments, name+" = :"+name)
args = append(args, sql.Named(name, values[name]))
}
args = append(args, whereArgs...)
query := fmt.Sprintf(
"UPDATE %s SET %s WHERE %s",
table,
strings.Join(assignments, ", "),
where,
)
return query, args
}
func sortedNames(values map[string]any) []string {
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
return names
}
// selectList собирает перечень колонок для выборки из той же карты, по которой
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
func selectList(targets map[string]any, prefix string) (string, []any) {
names := sortedNames(targets)
columns := make([]string, 0, len(names))
scan := make([]any, 0, len(names))
for _, name := range names {
columns = append(columns, prefix+name)
scan = append(scan, targets[name])
}
return strings.Join(columns, ", "), scan
}
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
//
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
func isUniqueViolation(err error) bool {
var sqliteErr *sqlitedriver.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
}