diff --git a/lib/galaxy/tools/search/__init__.py b/lib/galaxy/tools/search/__init__.py index 29e82e2d4c5..ed6887ab4de 100644 --- a/lib/galaxy/tools/search/__init__.py +++ b/lib/galaxy/tools/search/__init__.py @@ -131,9 +131,9 @@ class LazyToolboxSearch(ToolBoxSearch): """Drop-in for :class:`ToolBoxSearch` in lazy-toolbox mode. The populator (``galaxy.tools.source_store.populator``) builds and owns - the whoosh index; this class is a thin reader that opens it on each - query. Per-panel-view fan-out is collapsed: ``search`` ignores - ``panel_view`` and reads the single populator-owned index. + one whoosh index per store; this class is a thin reader that opens them + on each query and merges hits by score. Per-panel-view fan-out is + collapsed: ``search`` ignores ``panel_view``. ``build_index`` is a no-op (the populator's job). ``index_count`` is still incremented so :func:`galaxy.queue_worker.rebuild_toolbox_search_index` @@ -144,8 +144,8 @@ class LazyToolboxSearch(ToolBoxSearch): def __init__(self, config: GalaxyAppConfiguration) -> None: # Skip ToolBoxSearch.__init__ — it walks ``toolbox.panel_views()`` and # builds a ToolPanelViewSearch per view. Under lazy mode the - # populator owns one whoosh index for the default store; per-view - # filtering is a follow-up if needed. + # populator owns the whoosh indexes; per-view filtering is a + # follow-up if needed. self.config = config self.panel_searches: dict[str, ToolPanelViewSearch] = {} self.index_count = -1 @@ -166,13 +166,30 @@ class LazyToolboxSearch(ToolBoxSearch): ToolWhooshIndex, ) - index_dir = whoosh_dir_for_store(config.tool_search_index_dir, DEFAULT_STORE_NAME) - if index_dir is None: + if not config.tool_search_index_dir: + # No index dir means whoosh search is off entirely. return [] - searcher = ToolWhooshIndex(index_dir=index_dir, tuning=ToolSearchTuning.from_config(config)) - # limit=None matches ToolPanelViewSearch below, which searches - # unlimited — capping here would truncate uniform-score matches. - return searcher.search(q, limit=None) + # The populator writes one whoosh index per store — searching only + # the default's would make every named-store tool invisible to + # ``/api/tools?q=``. Search each configured store's index and merge + # by score. Over-searching a catalog store no conf references is + # harmless: ``resolve_search_hit`` drops ids not in this toolbox. + store_names = [DEFAULT_STORE_NAME, *sorted(config.tool_source_stores or {})] + tuning = ToolSearchTuning.from_config(config) + scored: dict[str, float] = {} + for store_name in store_names: + index_dir = whoosh_dir_for_store(config.tool_search_index_dir, store_name) + assert index_dir # tool_search_index_dir checked above + searcher = ToolWhooshIndex(index_dir=index_dir, tuning=tuning) + # limit=None matches ToolPanelViewSearch below, which searches + # unlimited — capping here would truncate uniform-score matches. + for tool_id, score in searcher.search_scored(q, limit=None): + if tool_id not in scored or score > scored[tool_id]: + scored[tool_id] = score + # BM25 scores from different indexes aren't strictly comparable + # (per-corpus statistics), but interleaving by score beats + # concatenation; ties keep first-seen order (default store first). + return [tool_id for tool_id, _score in sorted(scored.items(), key=lambda kv: -kv[1])] class ToolPanelViewSearch: diff --git a/test/unit/app/tools/source_store/test_multi_store_search.py b/test/unit/app/tools/source_store/test_multi_store_search.py new file mode 100644 index 00000000000..3c625dc5a14 --- /dev/null +++ b/test/unit/app/tools/source_store/test_multi_store_search.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace +from typing import cast + +from galaxy.config import GalaxyAppConfiguration +from galaxy.tools.search import LazyToolboxSearch +from galaxy.tools.source_store.index import ( + ToolIndex, + ToolIndexEntry, +) +from galaxy.tools.source_store.populator import ( + DEFAULT_STORE_NAME, + whoosh_dir_for_store, +) +from galaxy.tools.source_store.search import ( + ToolSearchTuning, + ToolWhooshIndex, +) + +_TUNING = ToolSearchTuning( + id_boost=9.0, + name_boost=9.0, + name_exact_multiplier=10.0, + stub_boost=2.0, + section_boost=1.0, + description_boost=0.5, + label_boost=1.0, + ngram_minsize=3, + ngram_maxsize=4, + enable_ngram_search=False, + ngram_factor=0.2, +) + + +def _build_store_index(index_root, store_name, entries): + tool_index = ToolIndex() + for entry in entries: + tool_index.add_entry(entry) + index_dir = whoosh_dir_for_store(index_root, store_name) + assert index_dir + whoosh = ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING) + assert whoosh.build(tool_index) == len(entries) + + +def test_search_merges_hits_across_store_indexes(tmp_path, monkeypatch): + monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING)) + index_root = str(tmp_path) + _build_store_index( + index_root, + DEFAULT_STORE_NAME, + [ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0")], + ) + _build_store_index( + index_root, + "cvmfs_main", + [ToolIndexEntry(id="cvmfs_mapper", name="Sequence mapper deluxe", version="2.0")], + ) + config = cast( + GalaxyAppConfiguration, + SimpleNamespace( + tool_search_index_dir=index_root, + tool_source_stores={"cvmfs_main": {"type": "sqlalchemy"}}, + ), + ) + hits = LazyToolboxSearch(config).search("mapper", panel_view="default", config=config) + assert set(hits) == {"local_mapper", "cvmfs_mapper"} + + +def test_search_without_index_dir_returns_empty(): + config = cast(GalaxyAppConfiguration, SimpleNamespace(tool_search_index_dir=None, tool_source_stores={})) + assert LazyToolboxSearch(config).search("mapper", panel_view="default", config=config) == []