mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(subsonic): make search3 empty-query pagination fast at large offsets Empty-query search3 (used by clients like Symfonium to sync the whole library) degraded linearly with songOffset: the offset optimization in optimizePagination keeps the original query's LEFT JOINs (annotation, bookmark, library) inside its rowid NOT IN subquery, making it as slow as plain OFFSET (~5s per page at offset 900K on a 920K-track library). Rewrite the empty-query branch of doSearch to use the same two-phase approach as the FTS search: Phase 1 paginates rowids on the bare main table, which SQLite satisfies with a covering index at any offset; Phase 2 hydrates only the page's rows with all JOINs. The Phase 2 hydration logic is extracted into hydrateRowidPage, now shared with ftsSearch.execute. Also replace the media_file_missing index with a composite covering index on (missing, library_id), so Phase 1 stays covering for non-admin users, whose queries include a library_id filter. The composite serves all missing-only lookups via its prefix. With a 920K-track / 85K-album test library, search3 empty-query responses are now flat (~0.1s) at every offset, for both admin and non-admin users (previously 3-5s at offsets above 600K). * refactor(persistence): share search Phase 1 contract and dedup junction fan-out Extract the Phase 1 query assembly that was duplicated between the FTS search and the empty-query search into executeTwoPhase: both paths now supply only their strategy-specific FROM/JOINs and ORDER BY, while the shared contract (missing filter, library access, options.Filters, and Max/Offset semantics) lives in one place. Also fix a pagination integrity bug: the artist library filter joins the library_artist junction table, so an artist present in multiple libraries produced duplicate rowids in Phase 1, corrupting offset-based pagination (short pages and repeated artists during full-library syncs). Phase 1 now applies DISTINCT whenever a junction-based LibraryFilter is set. DISTINCT is used instead of GROUP BY because bm25() cannot be evaluated in a grouped query; plain-filter tables (media_file, album) skip the dedup so their Phase 1 keeps the streaming covering-index plan. This also fixes the same duplication in the pre-existing FTS search path. * fix(persistence): pin artist search Phase 1 join order with CROSS JOIN search3 always filters artists by library (library_artist.library_id IN ...), and with the junction JOIN in the search Phase 1 rowid query SQLite chose to drive from library_artist, sorting every junction row with a temp b-tree on each page — a flat ~200ms penalty per request at 405K artists, even at offset 0 (the previous code avoided this by accident: its GROUP BY artist.id pinned an artist-driven plan). Use CROSS JOIN (SQLite's explicit join-order override) in a search-only variant of the artist library filter, keeping artist as the outer table so Phase 1 streams rowids in artist.id order from the primary key index and LIMIT/OFFSET short-circuits. The DISTINCT dedup stays and costs nothing under the streaming plan. Other artist queries keep the planner's freedom. With 405K artists, empty-query artist search is now 0.07s at offset 0 and 0.25s at offset 399K end-to-end (was 0.31s/0.34s before this fix, and up to 1.2s on master at deep offsets). Artist FTS text search is unaffected.
146 lines
5.7 KiB
Go
146 lines
5.7 KiB
Go
package persistence
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/google/uuid"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
)
|
|
|
|
func formatFullText(text ...string) string {
|
|
fullText := str.SanitizeStrings(text...)
|
|
return " " + fullText
|
|
}
|
|
|
|
// searchConfig holds per-repository constants for doSearch.
|
|
type searchConfig struct {
|
|
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
|
|
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
|
|
MBIDFields []string // columns to match when query is a UUID
|
|
// LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of
|
|
// two-phase searches (FTS and empty-query). Needed when library access goes through a
|
|
// junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for
|
|
// entities in multiple libraries — Phase 1 dedups whenever this is set.
|
|
LibraryFilter func(sq SelectBuilder) SelectBuilder
|
|
}
|
|
|
|
// searchStrategy defines how to execute a text search against a repository table.
|
|
// options carries filters and pagination that must reach all query phases,
|
|
// including FTS Phase 1 which builds its own query outside sq.
|
|
type searchStrategy interface {
|
|
Sqlizer
|
|
execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error
|
|
}
|
|
|
|
// getSearchStrategy returns the appropriate search strategy based on config and query content.
|
|
// Returns nil when the query produces no searchable tokens.
|
|
func getSearchStrategy(tableName, query string) searchStrategy {
|
|
if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString {
|
|
return newLegacySearch(tableName, query)
|
|
}
|
|
if containsCJK(query) {
|
|
return newLikeSearch(tableName, query)
|
|
}
|
|
return newFTSSearch(tableName, query)
|
|
}
|
|
|
|
// doSearch dispatches a search query: empty → natural order, UUID → MBID match,
|
|
// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set
|
|
// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same
|
|
// filters and pagination independently.
|
|
func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error {
|
|
q = strings.TrimSpace(q)
|
|
q = strings.TrimSuffix(q, "*")
|
|
|
|
sq = sq.Where(Eq{r.tableName + ".missing": false})
|
|
|
|
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
|
|
if q == "" || q == `""` {
|
|
rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder)
|
|
return r.executeTwoPhase(sq, results, rowidCore, cfg, options)
|
|
}
|
|
|
|
// MBID search: if query is a valid UUID, search by MBID fields instead
|
|
if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 {
|
|
sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...))
|
|
return r.queryAll(sq, results)
|
|
}
|
|
|
|
// Min-length guard: single-character queries are too broad for search3.
|
|
// This check lives here (not in the strategies) so that fullTextFilter
|
|
// (REST filter path) can still use single-character queries.
|
|
if len(q) < 2 {
|
|
return nil
|
|
}
|
|
|
|
strategy := getSearchStrategy(r.tableName, q)
|
|
if strategy == nil {
|
|
return nil
|
|
}
|
|
|
|
return strategy.execute(r, sq, results, cfg, options)
|
|
}
|
|
|
|
// executeTwoPhase runs a search in two phases:
|
|
// - Phase 1: rowidCore (strategy-specific FROM/JOINs and ORDER BY) plus the shared search
|
|
// contract applied here — non-missing rows only, library access, options.Filters, and
|
|
// pagination. Keeping Phase 1 free of the full SELECT's JOINs lets SQLite paginate via a
|
|
// covering index; with those JOINs, large offsets degrade to O(offset) join probes —
|
|
// multi-second responses on 100k+ libraries.
|
|
// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page.
|
|
func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error {
|
|
rowidQuery := rowidCore.
|
|
Where(Eq{r.tableName + ".missing": false})
|
|
if options.Max > 0 {
|
|
rowidQuery = rowidQuery.Limit(uint64(options.Max))
|
|
}
|
|
if options.Offset > 0 {
|
|
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
|
|
}
|
|
if cfg.LibraryFilter != nil {
|
|
// Junction-table library filters can repeat rowids for entities in multiple
|
|
// libraries, which would corrupt offset-based pagination — dedup before paginating.
|
|
// (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.)
|
|
rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct()
|
|
} else {
|
|
rowidQuery = r.applyLibraryFilter(rowidQuery)
|
|
}
|
|
if options.Filters != nil {
|
|
rowidQuery = rowidQuery.Where(options.Filters)
|
|
}
|
|
return r.hydrateRowidPage(sq, rowidQuery, results)
|
|
}
|
|
|
|
// hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its
|
|
// ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped.
|
|
func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuilder, results any) error {
|
|
rowidSQL, rowidArgs, err := rowidQuery.ToSql()
|
|
if err != nil {
|
|
return fmt.Errorf("building rowid query: %w", err)
|
|
}
|
|
sq = sq.RemoveLimit().RemoveOffset()
|
|
rankedSubquery := fmt.Sprintf(
|
|
"(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
|
|
rowidSQL,
|
|
)
|
|
sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...)
|
|
sq = sq.OrderBy("_ranked._rn")
|
|
return r.queryAll(sq, results)
|
|
}
|
|
|
|
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
|
if uuid.Validate(mbid) != nil || len(mbidFields) == 0 {
|
|
return nil
|
|
}
|
|
mbid = strings.ToLower(mbid)
|
|
var cond []Sqlizer
|
|
for _, mbidField := range mbidFields {
|
|
cond = append(cond, Eq{tableName + "." + mbidField: mbid})
|
|
}
|
|
return Or(cond)
|
|
}
|