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