mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(smartplaylists): merge negated artist/tag rules in AND groups Smart playlists with many negated role/tag conditions ANDed together (e.g. 100+ "isNot artist" rules, issue #5511) generated one correlated NOT EXISTS subquery per rule, scanning media_file_artists for every candidate row. On large libraries this took minutes and triggered API timeouts and SQLite lock contention. By De Morgan, "NOT EXISTS(role=X) AND NOT EXISTS(role=Y)" is equivalent to "NOT EXISTS(role=X OR role=Y)", so multiple negated conditions for the same field can be collapsed into a single batched NOT EXISTS. This mirrors the existing OR-group merge that #5515 added for positive conditions. The shared grouping/batching logic is extracted into mergeSameFieldConds, parameterized by polarity, so the OR/positive and AND/negated paths reuse one algorithm instead of duplicating it. roleCondGroup/tagCondGroup gain a 'not' flag to emit the negated subquery. Benchmark (323k tracks, 120 isNot artist rules, reporter's exact shape): merged ~54ms vs unmerged ~8.7s steady-state (~160x faster). * docs: trim redundant comments on merge helpers The De Morgan explanation was repeated across three doc comments. Keep it in one place (mergeNegatedJsonConds, where negation is introduced) and reduce the shared core and group-type comments to concise one-liners.
292 lines
8.8 KiB
Go
292 lines
8.8 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
const (
|
|
benchNumArtists = 1_000
|
|
benchNumTracks = 40_000
|
|
benchNumPatterns = 500
|
|
benchArtistsPerTrack = 3
|
|
)
|
|
|
|
// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance
|
|
// between the current implementation (merged join-table via criteria pipeline) and
|
|
// the old baseline (unmerged json_tree subqueries).
|
|
func BenchmarkSmartPlaylistRole(b *testing.B) {
|
|
configtest.SetupConfig()
|
|
tmpDir := b.TempDir()
|
|
conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db")
|
|
cleanup := db.Init(context.Background())
|
|
defer cleanup()
|
|
log.SetLevel(log.LevelFatal)
|
|
|
|
conn := dbx.NewFromDB(db.Db(), db.Dialect)
|
|
ctx := log.NewContext(context.Background())
|
|
user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
|
|
ctx = request.WithUser(ctx, user)
|
|
|
|
setupBenchData(b, ctx, conn, user)
|
|
criteria.AddRoles([]string{"artist"})
|
|
|
|
// Build the criteria expression: 500 "contains artist" patterns in an OR group
|
|
anyExprs := make(criteria.Any, benchNumPatterns)
|
|
for i := range benchNumPatterns {
|
|
anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)}
|
|
}
|
|
expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500}
|
|
|
|
b.Run("Current", func(b *testing.B) {
|
|
benchmarkCriteriaPipeline(b, ctx, expr)
|
|
})
|
|
b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) {
|
|
benchmarkUnmergedJSONTree(b, ctx)
|
|
})
|
|
}
|
|
|
|
// BenchmarkSmartPlaylistNegatedRole compares performance for smart playlists with many
|
|
// negated role conditions ANDed together (e.g. 500 "isNot artist" rules, issue #5511)
|
|
// between the current implementation (merged NOT EXISTS via criteria pipeline) and the
|
|
// old baseline (one separate NOT EXISTS subquery per pattern).
|
|
func BenchmarkSmartPlaylistNegatedRole(b *testing.B) {
|
|
configtest.SetupConfig()
|
|
tmpDir := b.TempDir()
|
|
conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl-neg.db")
|
|
cleanup := db.Init(context.Background())
|
|
defer cleanup()
|
|
log.SetLevel(log.LevelFatal)
|
|
|
|
conn := dbx.NewFromDB(db.Db(), db.Dialect)
|
|
ctx := log.NewContext(context.Background())
|
|
user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true}
|
|
ctx = request.WithUser(ctx, user)
|
|
|
|
setupBenchData(b, ctx, conn, user)
|
|
criteria.AddRoles([]string{"artist"})
|
|
|
|
// Build the criteria expression: 500 "isNot artist" patterns in an AND group
|
|
allExprs := make(criteria.All, benchNumPatterns)
|
|
for i := range benchNumPatterns {
|
|
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist %04d", i)}
|
|
}
|
|
expr := criteria.Criteria{Expression: allExprs, Sort: "title", Limit: 500}
|
|
|
|
b.Run("Current", func(b *testing.B) {
|
|
benchmarkCriteriaPipeline(b, ctx, expr)
|
|
})
|
|
b.Run("Baseline_UnmergedNotExists", func(b *testing.B) {
|
|
benchmarkUnmergedNegatedJSONTree(b, ctx)
|
|
})
|
|
}
|
|
|
|
// benchmarkUnmergedNegatedJSONTree builds the old-style query with N separate negated
|
|
// json_tree EXISTS subqueries ANDed together (the pre-optimization baseline).
|
|
func benchmarkUnmergedNegatedJSONTree(b *testing.B, ctx context.Context) {
|
|
b.Helper()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
|
|
args := make([]any, 0, benchNumPatterns)
|
|
for i := range benchNumPatterns {
|
|
if i > 0 {
|
|
sb.WriteString(" AND ")
|
|
}
|
|
sb.WriteString("not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)")
|
|
args = append(args, fmt.Sprintf("Artist %04d", i))
|
|
}
|
|
sb.WriteString(") ORDER BY media_file.title LIMIT 500")
|
|
|
|
runBenchQuery(b, ctx, sb.String(), args)
|
|
}
|
|
|
|
// benchmarkCriteriaPipeline runs the criteria through the actual production code path:
|
|
// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query.
|
|
func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) {
|
|
b.Helper()
|
|
|
|
cSQL := newSmartPlaylistCriteria(expr)
|
|
|
|
// Build the full query matching buildSmartPlaylistQuery + addCriteria
|
|
sq := squirrel.Select("media_file.id").From("media_file")
|
|
cond, err := cSQL.Where()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
sq = sq.Where(cond)
|
|
if expr.Limit > 0 {
|
|
sq = sq.Limit(uint64(expr.Limit))
|
|
}
|
|
if order := cSQL.OrderBy(); order != "" {
|
|
sq = sq.OrderBy(order)
|
|
}
|
|
|
|
query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
runBenchQuery(b, ctx, query, args)
|
|
}
|
|
|
|
// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS
|
|
// subqueries (the pre-optimization baseline).
|
|
func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) {
|
|
b.Helper()
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("SELECT media_file.id FROM media_file WHERE (")
|
|
args := make([]any, 0, benchNumPatterns)
|
|
for i := range benchNumPatterns {
|
|
if i > 0 {
|
|
sb.WriteString(" OR ")
|
|
}
|
|
sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)")
|
|
args = append(args, fmt.Sprintf("%%Artist %04d%%", i))
|
|
}
|
|
sb.WriteString(") ORDER BY media_file.title LIMIT 500")
|
|
|
|
runBenchQuery(b, ctx, sb.String(), args)
|
|
}
|
|
|
|
func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) {
|
|
b.Helper()
|
|
sqlDB := db.Db()
|
|
b.ResetTimer()
|
|
for range b.N {
|
|
rows, err := sqlDB.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
_ = rows.Scan(&id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) {
|
|
b.Helper()
|
|
|
|
sqlDB := db.Db()
|
|
|
|
ur := NewUserRepository(ctx, conn)
|
|
if err := ur.Put(&user); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
tx, err := sqlDB.Begin()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
// Create artists
|
|
artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)")
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
for i := range benchNumArtists {
|
|
if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
artistStmt.Close()
|
|
|
|
// Ensure folder exists
|
|
folderID := "bench-folder"
|
|
if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
// Create media files with participants JSON, cycling through artists
|
|
mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id,
|
|
duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
// Populate media_file_artists join table
|
|
mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)")
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
for i := range benchNumTracks {
|
|
trackID := fmt.Sprintf("track-%05d", i)
|
|
|
|
// Assign benchArtistsPerTrack artists to each track, cycling through the pool
|
|
artistEntries := make([]map[string]string, benchArtistsPerTrack)
|
|
for a := range benchArtistsPerTrack {
|
|
artistIdx := (i + a) % benchNumArtists
|
|
artistEntries[a] = map[string]string{
|
|
"id": fmt.Sprintf("artist-%04d", artistIdx),
|
|
"name": fmt.Sprintf("Artist %04d", artistIdx),
|
|
}
|
|
}
|
|
primaryArtistIdx := i % benchNumArtists
|
|
primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx)
|
|
primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx)
|
|
|
|
participants := map[string][]map[string]string{"artist": artistEntries}
|
|
participantsJSON, _ := json.Marshal(participants)
|
|
|
|
if _, err := mfStmt.Exec(
|
|
trackID,
|
|
fmt.Sprintf("music/%s.mp3", trackID),
|
|
fmt.Sprintf("Track %05d", i),
|
|
"Bench Album",
|
|
primaryArtistName,
|
|
primaryArtistID,
|
|
"bench-album",
|
|
180, 2024, 5000000, "mp3",
|
|
"{}",
|
|
string(participantsJSON),
|
|
"[]",
|
|
1, folderID, trackID, "mp3",
|
|
); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
// Insert all artist associations into the join table
|
|
for a := range benchArtistsPerTrack {
|
|
artistIdx := (i + a) % benchNumArtists
|
|
artistID := fmt.Sprintf("artist-%04d", artistIdx)
|
|
if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
mfStmt.Close()
|
|
mfaStmt.Close()
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
|
|
b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns",
|
|
benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns)
|
|
}
|