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 }