- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
171 lines
8.8 KiB
Go
171 lines
8.8 KiB
Go
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
|
|
}
|