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