mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Create re-opened the path with O_TRUNC, which shrinks the file out from under an older stream that may still be serving readers. stream.Reader then hits EOF from the OS at the new, shorter length while the broadcaster still reports the original size, so Wait() reports 'more data exists' and the reader retries forever - a tight pread loop that burns a core and never releases its handle, which in turn blocks Stream.Remove() indefinitely. Unlink first and create with O_EXCL so the new entry gets a fresh inode. Existing readers keep their descriptor on the old inode, see its full contents, and reach a clean EOF. Note this does not address the deferred unlink deleting the re-created file, which is handled separately by the re-fetch in fileCache.Get.
192 lines
6.4 KiB
Go
192 lines
6.4 KiB
Go
package cache
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/djherbis/atime"
|
|
"github.com/djherbis/fscache"
|
|
"github.com/djherbis/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
const completeMarkerSuffix = ".complete"
|
|
const sentinelName = ".nd-migrated"
|
|
|
|
type spreadFS struct {
|
|
root string
|
|
mode os.FileMode
|
|
init func() error
|
|
}
|
|
|
|
// NewSpreadFS returns a FileSystem rooted at directory dir. This FS hashes the key and
|
|
// distributes all files in a layout like XX/XX/XXXXXXXXXX. Ex:
|
|
//
|
|
// Key is abc123.300x300.jpg
|
|
// Hash would be: c574aeb3caafcf93ee337f0cf34e31a428ba3f13
|
|
// File in cache would be: c5 / 74 / c574aeb3caafcf93ee337f0cf34e31a428ba3f13
|
|
//
|
|
// The idea is to avoid having too many files in one dir, which could potentially cause performance issues
|
|
// and may hit limitations depending on the OS.
|
|
// See discussion here: https://github.com/djherbis/fscache/issues/8#issuecomment-614319323
|
|
//
|
|
// dir is created with specified mode if it doesn't exist.
|
|
func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) {
|
|
f := &spreadFS{root: dir, mode: mode, init: func() error {
|
|
return os.MkdirAll(dir, mode)
|
|
}}
|
|
return f, f.init()
|
|
}
|
|
|
|
func (sfs *spreadFS) Reload(f func(key string, name string)) error {
|
|
// On the first run after upgrade (no sentinel yet), pre-existing files have
|
|
// no completion marker. Migrate them instead of discarding them as partials,
|
|
// so a user's whole cache isn't wiped. After the sentinel exists, an unmarked
|
|
// file is a crash partial and is discarded.
|
|
sentinel := filepath.Join(sfs.root, sentinelName)
|
|
_, sErr := os.Stat(sentinel)
|
|
migrating := os.IsNotExist(sErr)
|
|
|
|
count := 0
|
|
err := sfs.walkDataFiles(func(absoluteFilePath string) {
|
|
if _, statErr := os.Stat(sfs.markerPath(absoluteFilePath)); statErr != nil {
|
|
switch {
|
|
case migrating:
|
|
if mErr := sfs.MarkComplete(absoluteFilePath); mErr != nil {
|
|
log.Warn("Error migrating cache file", "file", absoluteFilePath, mErr)
|
|
}
|
|
case os.IsNotExist(statErr):
|
|
// No completion marker: this is a partial left by a crash. Discard it.
|
|
log.Debug("Removing incomplete cache file", "file", absoluteFilePath)
|
|
_ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload
|
|
return
|
|
default:
|
|
// Marker may exist but is unreadable (transient I/O, permissions):
|
|
// skip adoption without destroying a possibly-valid entry.
|
|
log.Warn("Error reading cache completion marker", "file", absoluteFilePath, statErr)
|
|
return
|
|
}
|
|
}
|
|
f(absoluteFilePath, absoluteFilePath)
|
|
count++
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Debug("Loaded cache", "dir", sfs.root, "numItems", count)
|
|
// Only record the migration as done after a clean walk, so a partial walk
|
|
// doesn't leave valid-but-unmarked files to be discarded on the next run.
|
|
if migrating {
|
|
if wErr := os.WriteFile(sentinel, nil, 0600); wErr != nil {
|
|
log.Warn("Error writing cache migration sentinel", "file", sentinel, wErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// walkDataFiles visits every cache data file (named XX/XX/<40-hex>), skipping
|
|
// completion markers and opportunistically cleaning up orphaned ones.
|
|
func (sfs *spreadFS) walkDataFiles(visit func(absoluteFilePath string)) error {
|
|
return filepath.WalkDir(sfs.root, func(absoluteFilePath string, _ fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
log.Error("Error loading cache", "dir", sfs.root, err)
|
|
return nil
|
|
}
|
|
path, err := filepath.Rel(sfs.root, absoluteFilePath)
|
|
if err != nil {
|
|
return nil //nolint:nilerr
|
|
}
|
|
|
|
// Skip marker files; also clean orphan markers (data file gone).
|
|
if strings.HasSuffix(path, completeMarkerSuffix) {
|
|
dataPath := strings.TrimSuffix(absoluteFilePath, completeMarkerSuffix)
|
|
if _, statErr := os.Stat(dataPath); os.IsNotExist(statErr) {
|
|
_ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Skip if name is not in the format XX/XX/XXXXXXXXXXXX
|
|
parts := strings.Split(path, string(os.PathSeparator))
|
|
if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) != 40 {
|
|
return nil
|
|
}
|
|
|
|
visit(absoluteFilePath)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (sfs *spreadFS) Create(name string) (stream.File, error) {
|
|
path := filepath.Dir(name)
|
|
err := os.MkdirAll(path, sfs.mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Unlink instead of truncating: an older stream may still be serving readers from
|
|
// this path, and shrinking the file under them spins them at a premature EOF.
|
|
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
|
}
|
|
|
|
func (sfs *spreadFS) Open(name string) (stream.File, error) {
|
|
return os.Open(name)
|
|
}
|
|
|
|
func (sfs *spreadFS) markerPath(dataPath string) string {
|
|
return dataPath + completeMarkerSuffix
|
|
}
|
|
|
|
// MarkComplete records that the cache entry for key was written in full.
|
|
// Only files with a marker are adopted on the next Reload; this is what
|
|
// distinguishes a complete cache entry from a partial one left by a crash.
|
|
// key may be an original cache key or an already-mapped data path; KeyMapper
|
|
// is idempotent for the latter (see KeyMapper).
|
|
func (sfs *spreadFS) MarkComplete(key string) error {
|
|
f, err := os.OpenFile(sfs.markerPath(sfs.KeyMapper(key)), os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
func (sfs *spreadFS) Remove(name string) error {
|
|
if err := os.Remove(sfs.markerPath(name)); err != nil && !os.IsNotExist(err) {
|
|
log.Warn("Error removing cache completion marker", "file", name, err)
|
|
}
|
|
return os.Remove(name)
|
|
}
|
|
|
|
func (sfs *spreadFS) Stat(name string) (fscache.FileInfo, error) {
|
|
stat, err := os.Stat(name)
|
|
if err != nil {
|
|
return fscache.FileInfo{}, err
|
|
}
|
|
return fscache.FileInfo{FileInfo: stat, Atime: atime.Get(stat)}, nil
|
|
}
|
|
|
|
func (sfs *spreadFS) RemoveAll() error {
|
|
if err := os.RemoveAll(sfs.root); err != nil {
|
|
return err
|
|
}
|
|
return sfs.init()
|
|
}
|
|
|
|
func (sfs *spreadFS) KeyMapper(key string) string {
|
|
// When running the Haunter, fscache can call this KeyMapper with the cached filepath instead of the key.
|
|
// That's because we don't inform the original cache keys when reloading in the Reload function above.
|
|
// If that's the case, just return the file path, as it is the actual mapped key.
|
|
if strings.HasPrefix(key, sfs.root) {
|
|
return key
|
|
}
|
|
hash := fmt.Sprintf("%x", sha1.Sum([]byte(key)))
|
|
return filepath.Join(sfs.root, hash[0:2], hash[2:4], hash)
|
|
}
|