Fix/explore art scanner requests #21
No files matched your search
+141
-15
@@ -289,8 +289,13 @@ func (l *Library) scanInternal(
|
|||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// The configured mode, not a hardcoded "auto". `ScanConcurrency`
|
||||||
|
// has been a validated config field with three values and one
|
||||||
|
// caller passing a constant, so choosing `ssd` or `hdd` by hand
|
||||||
|
// did nothing at all.
|
||||||
|
diskProfile := system.ProfileForPath(libraryPath)
|
||||||
workerCount := resolveScanWorkerCount(
|
workerCount := resolveScanWorkerCount(
|
||||||
ScanConcurrencyAuto,
|
l.conf.ScanConcurrency,
|
||||||
libraryPath,
|
libraryPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -300,6 +305,10 @@ func (l *Library) scanInternal(
|
|||||||
"libraryName", libraryName,
|
"libraryName", libraryName,
|
||||||
"libraryPath", libraryPath,
|
"libraryPath", libraryPath,
|
||||||
"workers", workerCount,
|
"workers", workerCount,
|
||||||
|
"mode", l.conf.ScanConcurrency,
|
||||||
|
"device", diskProfile.Device,
|
||||||
|
"rotational", diskProfile.Rotational,
|
||||||
|
"queueDepth", diskProfile.QueueDepth,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper to build a ScanProgress with library identification.
|
// Helper to build a ScanProgress with library identification.
|
||||||
@@ -818,7 +827,7 @@ func (l *Library) scanInternal(
|
|||||||
g := new(errgroup.Group)
|
g := new(errgroup.Group)
|
||||||
g.SetLimit(workerCount)
|
g.SetLimit(workerCount)
|
||||||
|
|
||||||
for work := range workChan {
|
for work := range readaheadWork(scanCtx, workChan, diskProfile) {
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
if err := l.waitIfPaused(scanCtx); err != nil {
|
if err := l.waitIfPaused(scanCtx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1285,9 +1294,101 @@ func surveyAudioFiles(
|
|||||||
return count, maxModTime
|
return count, maxModTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// hddWorkerCount is the maximum number of concurrent extraction
|
// How many extraction workers a spinning disk gets, and why it is two
|
||||||
// workers when the library resides on a spinning disk.
|
// numbers rather than one.
|
||||||
const hddWorkerCount = 2
|
//
|
||||||
|
// Extraction is not CPU work — every parser here reads headers and
|
||||||
|
// returns — so on a spinning disk the whole cost is seek latency, and
|
||||||
|
// the only question worth asking is how many reads should be in flight
|
||||||
|
// at once. That has two different right answers and the drive says
|
||||||
|
// which:
|
||||||
|
//
|
||||||
|
// - A drive with command queueing (NCQ: /sys/block/<dev>/device/
|
||||||
|
// queue_depth reports 31 or 32 on any SATA disk with it enabled)
|
||||||
|
// reorders outstanding reads into the order its head passes over
|
||||||
|
// them. Handing it several at once is most of why a parallel scan
|
||||||
|
// beats a serial one at all, and four is where the returns flatten:
|
||||||
|
// the drive needs a few requests to have anything to reorder, and
|
||||||
|
// past that it is queueing requests it was already going to
|
||||||
|
// service in that order.
|
||||||
|
// - A drive without it — queue_depth 1, which is what a USB bridge
|
||||||
|
// or a pre-2004 disk reports — services one command at a time in
|
||||||
|
// the order given. Every extra worker there is one more seek
|
||||||
|
// competing for one head, and the scan gets *slower* the harder it
|
||||||
|
// is pushed. Two is kept rather than one because the readahead
|
||||||
|
// hints (see readaheadWork) do the overlapping that concurrency
|
||||||
|
// was standing in for, and one worker cannot hide a stall.
|
||||||
|
//
|
||||||
|
// This used to be a flat 2 for anything rotational, which is a
|
||||||
|
// pre-NCQ assumption: it left a modern spinning disk with a quarter of
|
||||||
|
// the queue depth it can use.
|
||||||
|
const (
|
||||||
|
hddWorkerCountQueued = 4
|
||||||
|
hddWorkerCountSerial = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Readahead tuning.
|
||||||
|
const (
|
||||||
|
// readaheadDepth is how many files ahead of the workers the
|
||||||
|
// prefetcher runs. It is the channel's buffer, so it is also the
|
||||||
|
// number of `WILLNEED` hints outstanding at once — comfortably more
|
||||||
|
// than a queueing drive's 32-command window is worth filling with
|
||||||
|
// one library, and small enough that a cancelled scan is not
|
||||||
|
// holding a long tail of queued reads.
|
||||||
|
readaheadDepth = 16
|
||||||
|
|
||||||
|
// readaheadBytes is how much of each file to pull in. Everything
|
||||||
|
// the scanner reads lives at the head: ID3v2 and FLAC's
|
||||||
|
// STREAMINFO/VORBIS_COMMENT/PICTURE blocks, and the first MPEG
|
||||||
|
// frame with its Xing header. 512 KB covers a tag carrying
|
||||||
|
// embedded cover art, which is the large case — and reading a
|
||||||
|
// little too much sequentially costs a spinning disk almost
|
||||||
|
// nothing next to the seek that got there.
|
||||||
|
readaheadBytes = 512 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// readaheadWork forwards scan work while asking the kernel to fetch
|
||||||
|
// each file's header before a worker reaches it.
|
||||||
|
//
|
||||||
|
// The buffered channel *is* the lookahead: this goroutine runs ahead
|
||||||
|
// of the workers until the buffer fills, hinting every file as it goes,
|
||||||
|
// so by the time a worker takes an item the read it needs has been in
|
||||||
|
// flight for `readaheadDepth` files' worth of parsing. That is the
|
||||||
|
// only thing that helps a spinning disk here, because the per-file work
|
||||||
|
// is already header-only — every parser in `backend/metadata` reads a
|
||||||
|
// few hundred bytes and returns, so the scan is not waiting on CPU or
|
||||||
|
// on bytes, it is waiting on the head to arrive.
|
||||||
|
//
|
||||||
|
// It runs on rotational disks only. An SSD has no seek to hide and
|
||||||
|
// already has one worker per core; issuing hints there is pure syscall
|
||||||
|
// overhead against an OS readahead that is already ahead of us.
|
||||||
|
func readaheadWork(
|
||||||
|
ctx context.Context,
|
||||||
|
in <-chan scanWork,
|
||||||
|
profile system.DiskProfile,
|
||||||
|
) <-chan scanWork {
|
||||||
|
if !profile.Rotational {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(chan scanWork, readaheadDepth)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
|
||||||
|
for work := range in {
|
||||||
|
hintReadahead(work.absolutePath, readaheadBytes)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case out <- work:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// resolveScanWorkerCount returns the number of concurrent
|
// resolveScanWorkerCount returns the number of concurrent
|
||||||
// extraction workers based on the configured concurrency mode
|
// extraction workers based on the configured concurrency mode
|
||||||
@@ -1296,20 +1397,45 @@ func resolveScanWorkerCount(
|
|||||||
mode ScanConcurrency,
|
mode ScanConcurrency,
|
||||||
libraryPath string,
|
libraryPath string,
|
||||||
) int {
|
) int {
|
||||||
|
return workersForProfile(
|
||||||
|
mode,
|
||||||
|
system.ProfileForPath(libraryPath),
|
||||||
|
goruntime.NumCPU(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workersForProfile is the policy on its own, so it can be tested
|
||||||
|
// against drives this machine does not have.
|
||||||
|
//
|
||||||
|
// `hdd` and `ssd` override what the device says rather than being a
|
||||||
|
// separate branch: the mode is the user overruling detection, and
|
||||||
|
// detection is right about the queue depth either way — a user who
|
||||||
|
// picks `hdd` on a queueing drive still wants that drive's queue used.
|
||||||
|
func workersForProfile(
|
||||||
|
mode ScanConcurrency,
|
||||||
|
profile system.DiskProfile,
|
||||||
|
cpus int,
|
||||||
|
) int {
|
||||||
|
spinning := profile.Rotational
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case ScanConcurrencySSD:
|
case ScanConcurrencySSD:
|
||||||
return goruntime.NumCPU()
|
spinning = false
|
||||||
case ScanConcurrencyHDD:
|
case ScanConcurrencyHDD:
|
||||||
return min(hddWorkerCount, goruntime.NumCPU())
|
spinning = true
|
||||||
default: // auto
|
case ScanConcurrencyAuto:
|
||||||
if system.IsRotationalDisk(libraryPath) {
|
|
||||||
return min(
|
|
||||||
hddWorkerCount, goruntime.NumCPU(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return goruntime.NumCPU()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !spinning {
|
||||||
|
return cpus
|
||||||
|
}
|
||||||
|
|
||||||
|
workers := hddWorkerCountSerial
|
||||||
|
if profile.Queues() {
|
||||||
|
workers = hddWorkerCountQueued
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(workers, cpus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanWork represents a file to be processed by a worker.
|
// scanWork represents a file to be processed by a worker.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hintReadahead asks the kernel to start fetching the head of a file
|
||||||
|
// that is about to be read.
|
||||||
|
//
|
||||||
|
// `POSIX_FADV_WILLNEED` returns immediately and queues the read, which
|
||||||
|
// is the whole point: on a spinning disk the first access to a file
|
||||||
|
// costs a seek of several milliseconds, and that latency can only be
|
||||||
|
// hidden by having the next seek already in flight while the current
|
||||||
|
// file is being parsed. A drive with command queueing can then service
|
||||||
|
// the queued reads in head order rather than in the order they were
|
||||||
|
// asked for.
|
||||||
|
//
|
||||||
|
// Errors are dropped on purpose. This is a hint: a file that has since
|
||||||
|
// been deleted, a filesystem that does not implement fadvise, or a
|
||||||
|
// permission the walk saw and this open does not, all mean "no
|
||||||
|
// prefetch", never "fail the scan". The read that follows is what
|
||||||
|
// reports a genuine problem.
|
||||||
|
func hintReadahead(path string, bytes int64) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
_ = unix.Fadvise(
|
||||||
|
int(f.Fd()), 0, bytes, unix.FADV_WILLNEED,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package library
|
||||||
|
|
||||||
|
// hintReadahead is a no-op off Linux.
|
||||||
|
//
|
||||||
|
// macOS has `F_RDADVISE` and Windows has `FILE_FLAG_SEQUENTIAL_SCAN`,
|
||||||
|
// and neither is wired up here for the reason the scan concurrency
|
||||||
|
// heuristic is not either: this package cannot tell a spinning disk
|
||||||
|
// from an SSD on those platforms (see system.ProfileForPath), so it
|
||||||
|
// would be prefetching without knowing whether prefetching is what the
|
||||||
|
// device wants.
|
||||||
|
func hintReadahead(_ string, _ int64) {}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How many workers a scan gets is decided by two facts about the
|
||||||
|
// device, and the second one is new: a spinning disk that can queue
|
||||||
|
// commands wants several reads in flight, and one that cannot wants
|
||||||
|
// almost none. Before this it was a flat 2 for anything rotational,
|
||||||
|
// which is a pre-NCQ assumption — a modern SATA disk reports a queue
|
||||||
|
// depth of 32 and was being given a quarter of what it can use.
|
||||||
|
func TestWorkersForProfile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const cpus = 16
|
||||||
|
|
||||||
|
ssd := system.DiskProfile{Device: "sda", QueueDepth: 32}
|
||||||
|
hddQueued := system.DiskProfile{
|
||||||
|
Device: "sdb", Rotational: true, QueueDepth: 32,
|
||||||
|
}
|
||||||
|
hddSerial := system.DiskProfile{
|
||||||
|
Device: "sdc", Rotational: true, QueueDepth: 1,
|
||||||
|
}
|
||||||
|
// Neither NVMe nor a device-mapper volume publishes queue_depth.
|
||||||
|
// An unknown depth must not be read as "cannot queue", or every
|
||||||
|
// such device would be scanned as if it were a 2003 drive.
|
||||||
|
unknown := system.DiskProfile{Device: "dm-0", Rotational: true}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mode ScanConcurrency
|
||||||
|
profile system.DiskProfile
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"ssd auto", ScanConcurrencyAuto, ssd, cpus},
|
||||||
|
{"queueing hdd auto", ScanConcurrencyAuto, hddQueued, hddWorkerCountQueued},
|
||||||
|
{"serial hdd auto", ScanConcurrencyAuto, hddSerial, hddWorkerCountSerial},
|
||||||
|
{"unknown depth queues", ScanConcurrencyAuto, unknown, hddWorkerCountQueued},
|
||||||
|
|
||||||
|
// The mode overrules detection about the *disk*, never about
|
||||||
|
// its queue: forcing hdd on a queueing drive still uses it.
|
||||||
|
{"forced hdd on an ssd", ScanConcurrencyHDD, ssd, hddWorkerCountQueued},
|
||||||
|
{"forced ssd on an hdd", ScanConcurrencySSD, hddQueued, cpus},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if got := workersForProfile(tt.mode, tt.profile, cpus); got != tt.want {
|
||||||
|
t.Errorf(
|
||||||
|
"workersForProfile(%q, %+v) = %d, want %d",
|
||||||
|
tt.mode, tt.profile, got, tt.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine with fewer cores than the policy asks for gets its cores.
|
||||||
|
func TestWorkersNeverExceedTheCPUCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hdd := system.DiskProfile{Rotational: true, QueueDepth: 32}
|
||||||
|
|
||||||
|
if got := workersForProfile(ScanConcurrencyAuto, hdd, 1); got != 1 {
|
||||||
|
t.Errorf("single-core hdd = %d workers, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prefetch stage must forward every item and nothing else: it is a
|
||||||
|
// pass-through with a side effect, and a scan that drops a file because
|
||||||
|
// of a *hint* would be a spectacular way to lose part of a library.
|
||||||
|
func TestReadaheadForwardsEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := make(chan scanWork, 4)
|
||||||
|
for _, p := range []string{"/a", "/b", "/c", "/d"} {
|
||||||
|
in <- scanWork{absolutePath: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(in)
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for w := range readaheadWork(
|
||||||
|
context.Background(),
|
||||||
|
in,
|
||||||
|
system.DiskProfile{Rotational: true, QueueDepth: 32},
|
||||||
|
) {
|
||||||
|
got = append(got, w.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"/a", "/b", "/c", "/d"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("forwarded %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("item %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On an SSD the stage is not inserted at all — the channel comes back
|
||||||
|
// unchanged, so a scan there pays nothing for a feature it cannot use.
|
||||||
|
func TestReadaheadIsSkippedOnSolidState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := make(chan scanWork)
|
||||||
|
out := readaheadWork(
|
||||||
|
context.Background(), in, system.DiskProfile{QueueDepth: 32},
|
||||||
|
)
|
||||||
|
|
||||||
|
if out != (<-chan scanWork)(in) {
|
||||||
|
t.Error("an ssd must get the original channel, unwrapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user