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