mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Decode+encode is a few ms (the encoder downscales before its pixel loops), and the tee fires on Close after the response is fully written, so the hash can be computed in the serving goroutine: the queue, wake/stop lifecycle, lazy-start admin context, and the e2e worker-teardown ordering all become unnecessary and are removed. This also closes two correctness holes the queue had: a deletion signal could be dropped behind a pending serve job, and buffered image bytes had no global cap. The in-memory dedup now remembers a checksum of the served bytes plus the persisted version: identical serves skip the decode and the write, but the same bytes under a newer entity version re-persist, so blur_hash_updated_at keeps pace with row updates and the Jellyfin DTO's staleness gate keeps emitting the stored hash after scans. Placeholder-triggered clears check the seen map, then the stored row, so coverless entities cost one row read once per process instead of a probe and write per serve. UpdateBlurHash is a plain UPDATE with no user filtering, so the request context is used directly (a client abort is survived via context.WithoutCancel).
190 lines
4.8 KiB
Go
190 lines
4.8 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"image/jpeg"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
)
|
|
|
|
// setupE2EBenchmark creates an artwork instance with a real album cover image on disk,
|
|
// backed by either a real file cache or disabled cache depending on cacheSize.
|
|
// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers
|
|
// the critical path (source selection, decode, resize, encode, cache). This is a deliberate
|
|
// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure
|
|
// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant.
|
|
//
|
|
// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together).
|
|
func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) {
|
|
b.Helper()
|
|
cleanup := configtest.SetupConfig()
|
|
b.Cleanup(cleanup)
|
|
|
|
tmpDir, err := os.MkdirTemp("", "artwork-bench-*")
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
// Create a realistic cover image on disk
|
|
coverPath := filepath.Join(tmpDir, "cover.jpg")
|
|
coverImg := generateGradientImage(1000, 1000)
|
|
f, err := os.Create(coverPath)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil {
|
|
f.Close()
|
|
b.Fatal(err)
|
|
}
|
|
f.Close()
|
|
|
|
// Configure cache
|
|
conf.Server.ImageCacheSize = cacheSize
|
|
conf.Server.CacheFolder = conf.NewDir(tmpDir)
|
|
conf.Server.CoverArtQuality = 75
|
|
conf.Server.CoverArtPriority = "cover.*"
|
|
|
|
// Set up mock data store with album pointing to our cover.
|
|
// Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls.
|
|
album := model.Album{
|
|
ID: "bench-album-1",
|
|
Name: "Benchmark Album",
|
|
FolderIDs: []string{"f1"},
|
|
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
folderRepo := &fakeFolderRepo{
|
|
result: []model.Folder{{
|
|
Path: tmpDir,
|
|
ImageFiles: []string{"cover.jpg"},
|
|
}},
|
|
}
|
|
ds := &tests.MockDataStore{
|
|
MockedTranscoding: &tests.MockTranscodingRepo{},
|
|
MockedFolder: folderRepo,
|
|
}
|
|
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album})
|
|
|
|
artID := album.CoverArtID()
|
|
|
|
imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0,
|
|
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
|
r, _, err := arg.(artworkReader).Reader(ctx)
|
|
return r, err
|
|
})
|
|
|
|
// Wait for cache init if enabled
|
|
if cacheSize != "0" {
|
|
for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) {
|
|
runtime.Gosched() // Yield to allow background init goroutine to run
|
|
}
|
|
}
|
|
|
|
ffmpeg := tests.NewMockFFmpeg("fallback content")
|
|
aw := NewArtwork(ds, imgCache, ffmpeg, nil)
|
|
|
|
cleanupAll := func() {
|
|
os.RemoveAll(tmpDir)
|
|
}
|
|
return aw, artID, cleanupAll
|
|
}
|
|
|
|
func BenchmarkArtworkGetE2E(b *testing.B) {
|
|
cacheConfigs := []struct {
|
|
name string
|
|
cacheSize string
|
|
}{
|
|
{"no_cache", "0"},
|
|
{"with_cache", "100MB"},
|
|
}
|
|
sizes := []int{0, 300}
|
|
|
|
for _, cc := range cacheConfigs {
|
|
for _, size := range sizes {
|
|
b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) {
|
|
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
|
defer cleanup()
|
|
|
|
// Warm the cache on first call if cache is enabled
|
|
if cc.cacheSize != "0" {
|
|
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
_, _ = io.ReadAll(r)
|
|
r.Close()
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
r, _, err := aw.Get(context.Background(), artID, size, size > 0)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
_, _ = io.ReadAll(r)
|
|
r.Close()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkArtworkGetE2EConcurrent(b *testing.B) {
|
|
cacheConfigs := []struct {
|
|
name string
|
|
cacheSize string
|
|
}{
|
|
{"no_cache", "0"},
|
|
{"with_cache", "100MB"},
|
|
}
|
|
concurrencyLevels := []int{10, 50}
|
|
|
|
for _, cc := range cacheConfigs {
|
|
for _, n := range concurrencyLevels {
|
|
b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) {
|
|
aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize)
|
|
defer cleanup()
|
|
|
|
// Warm cache
|
|
if cc.cacheSize != "0" {
|
|
r, _, _ := aw.Get(context.Background(), artID, 300, true)
|
|
if r != nil {
|
|
_, _ = io.ReadAll(r)
|
|
r.Close()
|
|
}
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for range n {
|
|
go func() {
|
|
defer wg.Done()
|
|
r, _, err := aw.Get(context.Background(), artID, 300, true)
|
|
if err != nil {
|
|
b.Error(err)
|
|
return
|
|
}
|
|
_, _ = io.ReadAll(r)
|
|
r.Close()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|