navidrome/persistence/criteria_sql_benchmark_test.go
Deluan Quintão f3887df334
perf(smartplaylists): merge negated artist/tag rules into one NOT EXISTS
* 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.
2026-06-14 10:47:11 -04:00

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)
}