fix(scanner): read file birth time via statx on Linux (#6046)

* fix(scanner): read file birth time via statx on Linux

On Linux the file birth time is only reachable through statx(2). We were
reading it with times.Get(), which looks only at the plain stat() result,
where the field does not exist: djherbis/times declares HasBirthTime=false
for Linux, so the check was always false and every file fell back to
time.Now(). This has been the case since #2553 introduced the feature, which
means that PR was a no-op on Linux from day one. macOS and Windows were
never affected, as there the birth time does come back from plain stat.

BirthTime() now tries times.Get() first, which costs no syscall and is
already correct on macOS, Windows and BSD, and only falls back to
times.Stat() on the path when that comes back empty. Ordering matters: on
Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which
fails on a read-only share before falling back.

Not every filesystem stores a birth time. Measured with a probe over real
mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE
never do. Asking those on every file is pure overhead, so a miss is
remembered per device on the localFS and skipped from then on. The memo is
keyed by device rather than by library, so a library spanning two mounts
does not lose birth times on the mount that does support them.

Cost of the extra call is ~2us per file against ~52us just to open a file
for tag reading, so 0.23s across a 97k-file library, and only for files
whose tags are actually read.

Existing rows keep their current birth_time: the repository drops that
column on update, so only newly added files get the real value.

* fix(scanner): return the device id opaquely to satisfy unconvert

st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is
redundant on one and required on the other. Returning it as an opaque value
drops the cast entirely, which also removes the gosec suppression that came
with it. The value is only ever used as a sync.Map key.
This commit is contained in:
Deluan Quintão 2026-08-29 16:36:08 -04:00 committed by GitHub
parent b5f530e90c
commit 4b60b21316
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 98 additions and 1 deletions

View File

@ -0,0 +1,18 @@
//go:build !windows
package local
import (
"io/fs"
"syscall"
)
// deviceID identifies the filesystem a file lives on, used to key birth time support per mount.
// It is returned opaquely because its width varies by platform, and it is only used as a map key.
func deviceID(fi fs.FileInfo) (any, bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return nil, false
}
return st.Dev, true
}

View File

@ -0,0 +1,8 @@
//go:build windows
package local
import "io/fs"
// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo.
func deviceID(fs.FileInfo) (any, bool) { return nil, false }

View File

@ -6,6 +6,7 @@ import (
"net/url"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
@ -61,6 +62,8 @@ type localFS struct {
fs.FS
extractor Extractor
root string
// devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once
noBirthTime sync.Map
}
// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the
@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
if err != nil {
return nil, err
}
v.FileInfo = localFileInfo{info}
v.FileInfo = localFileInfo{
FileInfo: info,
path: filepath.Join(lfs.root, filepath.FromSlash(path)),
noBirthTime: &lfs.noBirthTime,
}
res[path] = v
}
}
@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
// with metadata.FileInfo
type localFileInfo struct {
fs.FileInfo
path string
noBirthTime *sync.Map
}
func (lfi localFileInfo) BirthTime() time.Time {
if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
if bt, ok := lfi.statxBirthTime(); ok {
return bt
}
return time.Now()
}
// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it.
// Filesystems that never report one are remembered per device, so a scan asks each only once.
func (lfi localFileInfo) statxBirthTime() (time.Time, bool) {
if lfi.path == "" {
return time.Time{}, false
}
dev, hasDev := deviceID(lfi.FileInfo)
memo := lfi.noBirthTime
if hasDev && memo != nil {
if _, skip := memo.Load(dev); skip {
return time.Time{}, false
}
}
ts, err := times.Stat(lfi.path)
if err != nil {
return time.Time{}, false
}
if ts.HasBirthTime() {
return ts.BirthTime(), true
}
if hasDev && memo != nil {
memo.Store(dev, struct{}{})
}
return time.Time{}, false
}
func init() {
storage.Register(storage.LocalSchemaID, newLocalStorage)
}

View File

@ -6,8 +6,10 @@ import (
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() {
// Should be around the current time (within last few minutes)
Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute))
})
It("reads the birth time from the path, not the time of the call", func() {
// On Linux, birth time is only available via statx(2) on the path.
lfi := localFileInfo{FileInfo: fileInfo, path: testFile}
time.Sleep(300 * time.Millisecond)
Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond)))
})
It("does not remember filesystems that do report a birth time", func() {
memo := &sync.Map{}
lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo}
lfi.BirthTime()
count := 0
memo.Range(func(_, _ any) bool { count++; return true })
Expect(count).To(BeZero())
})
It("skips statx on filesystems already known to have none", func() {
if times.Get(fileInfo).HasBirthTime() {
Skip("this platform reports birth time from FileInfo, so statx is never called")
}
dev, ok := deviceID(fileInfo)
Expect(ok).To(BeTrue())
memo := &sync.Map{}
memo.Store(dev, struct{}{})
lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo}
time.Sleep(300 * time.Millisecond)
Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond))
})
})
It("should delegate all other FileInfo methods", func() {