Address cached toolbox review feedback

This commit is contained in:
mvdbeek
2026-07-23 12:53:07 +02:00
parent 9f301863d1
commit fc7e9ebaf9
30 changed files with 579 additions and 514 deletions
-8
View File
@@ -16,11 +16,6 @@ on:
# Run at midnight UTC every Tuesday
- cron: '0 0 * * 2'
workflow_dispatch:
inputs:
use_cached_toolbox:
description: "Run with use_cached_toolbox=true (CachedToolBox)"
type: boolean
default: true
env:
GALAXY_TEST_RAISE_EXCEPTION_ON_HISTORYLESS_HDA: '1'
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
@@ -94,9 +89,6 @@ jobs:
echo "GALAXY_CONFIG_OVERRIDE_METADATA_STRATEGY=extended" >> $GITHUB_ENV
# Skip outputs_to_working_directory: true in integration tests, doesn't work with pulsar
# echo "GALAXY_CONFIG_OVERRIDE_OUTPUTS_TO_WORKING_DIRECTORY=true" >> $GITHUB_ENV
- if: github.event_name == 'workflow_dispatch' && inputs.use_cached_toolbox
run: |
echo "GALAXY_CONFIG_OVERRIDE_USE_CACHED_TOOLBOX=true" >> $GITHUB_ENV
- name: Prune unused docker image, volumes and containers
run: docker system prune -a -f
- name: Clean dotnet folder for space
+11 -24
View File
@@ -224,8 +224,8 @@ model. Tools are parsed in a
``ThreadPoolExecutor`` (``--parallel``, default 4 workers); each tool is
matched to its source path and carried forward when its raw file hash is unchanged
(``--incremental``, the default). Once the JSON index is committed the
populator rebuilds the Whoosh search index (``search.py``) so ranked tool
search stays in sync with the stored sources.
rendered cached toolbox builds one Whoosh corpus per panel view from the
merged ``ToolIndex`` and that view's in-memory membership.
Watch mode (``--watch``) uses ``watchdog`` to monitor every directory yielded
by ``discover_tools``. File events are debounced (default 2 s), the changed
@@ -255,12 +255,12 @@ materialising tools:
``ToolIndex`` entries when the cached toolbox is active, and iterate the toolbox
otherwise.
``CachedToolboxSearch`` (``tools/search/__init__.py``) queries the whoosh index
of *every* configured store — the default plus each named per-conf store —
via ``ToolWhooshIndex.search_scored``, then merges the per-store hit lists by
BM25 score and post-filters them to the requested panel view. A tool served
from a named store is therefore reachable through ``/api/tools?q=`` even
though its source lives outside the default store.
``CachedToolboxSearch`` (``tools/search/__init__.py``) builds and queries one
Whoosh index for each rendered panel view. Membership is read directly from
the toolbox and is not persisted in ``ToolIndex``. Eager and cached search use
the same document normalization, fields, boosts, scoring, and 20,000-character
help-text bound, so the same panel corpus produces the same ordered results.
Named-store tools participate through the merged runtime index.
App Wiring
----------
@@ -285,11 +285,6 @@ expansions is expensive and shouldn't block worker startup. Keeping the
populator separate also lets it run on a single host while many web workers
share the resulting store.
**Why subclass ToolBox instead of building a parallel hierarchy?**
``trans.app.toolbox`` is referenced from hundreds of call sites that expect
the full ToolBox interface. Subclassing keeps the Liskov-substitution
property and lets unmodified callers benefit from lazy loading transparently.
**Why hash-keyed storage?** Content-addressed storage gives us cheap
deduplication across versions and shed installations, and idempotent
incremental updates: re-running the populator over an unchanged tree is
@@ -298,14 +293,6 @@ effectively a no-op.
Testing
-------
- Store unit tests: ``test/unit/app/tools/source_store/`` exercises each backend
through the ``ToolSourceStore`` interface (``test_stores.py``,
``test_sqlite_store.py``, ``test_composite_store.py``,
``test_index_versions.py``, ``test_multi_store_search.py``).
- Populator/discovery unit tests: ``test/unit/scripts/tool_source/``
(``test_populate_store.py``, ``test_discover.py``,
``test_build_index_entry.py``, ``test_whoosh_dir.py``). These use fakes
(not mocks) of ``ToolSourceStore`` so behavior is verified against the real
interface.
- Integration tests: ``test/integration/test_tool_source_storage.py`` spins
up Galaxy against the store and verifies end-to-end behavior.
Tests cover backend contracts, discovery and population, per-view search
parity, lazy API behavior, store recovery, and selected integration workflows
under both eager and cached toolboxes.
+9 -10
View File
@@ -412,10 +412,8 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
self._set_enabled_container_types()
index_help = getattr(self.config, "index_tool_help", True)
if self._use_cached_toolbox():
# Populator owns the whoosh index in cached-toolbox mode; the toolbox search
# singleton is a thin reader. The toolbox is threaded in so
# ``search`` can scope hits to the requested panel view. See
# ``CachedToolboxSearch``.
# Build search corpora from rendered cached panel views without
# materializing their tool stubs.
search_singleton: ToolBoxSearch = CachedToolboxSearch(self.config, self.toolbox)
else:
search_singleton = ToolBoxSearch(
@@ -442,11 +440,8 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
stats = self.tool_source_store.get_stats()
tool_count = stats.get("count", 0)
log.info(f"Initialized tool source store (backend: {stats.get('backend', 'unknown')}, tools: {tool_count})")
# Hand the store + (eventual) cached toolbox to ``Galaxy.shutdown()``
# so embedded restarts (IntegrationTestCase.restart) drop their
# in-memory caches before the next boot wires its own. Without this,
# the prior boot's cached ToolIndex sticks around long enough to
# race with the new boot's _load_index_from_store.
# Close cached state before a replacement application boot wires its
# own store and toolbox, so no prior ToolIndex survives the handoff.
self.haltables.insert(1, ("tool source store", self._shutdown_tool_source_store))
self.haltables.insert(2, ("cached toolbox", self._shutdown_cached_toolbox))
@@ -532,7 +527,11 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
def reindex_tool_search(self) -> None:
# Call this when tools are added or removed.
self.toolbox_search.build_index(tool_cache=self.tool_cache, toolbox=self.toolbox)
self.toolbox_search.build_index(
tool_cache=self.tool_cache,
toolbox=self.toolbox,
index_help=self.config.index_tool_help,
)
self.tool_cache.reset_status()
def _set_enabled_container_types(self):
+1
View File
@@ -410,6 +410,7 @@ def reload_tool_source_cache(app, **kwargs):
toolbox = app.toolbox
if isinstance(toolbox, CachedToolBox):
toolbox.invalidate_index_cache()
app.reindex_tool_search()
log.info("Tool source index cache invalidated")
# Invalidate the tool source store cache if it exists
@@ -25,6 +25,19 @@ from galaxy.util.tool_shed.xml_util import parse_xml
log = logging.getLogger(__name__)
def toolbox_with_new_tool_path(config_filename: str, tool_path: str) -> Element:
"""Return a toolbox root that preserves attributes and overrides its path."""
root_attributes: dict[str, str] = {}
if os.path.exists(config_filename):
existing_tree, _error_message = parse_xml(config_filename)
if existing_tree is not None:
for key, value in existing_tree.getroot().attrib.items():
if isinstance(key, str) and isinstance(value, str):
root_attributes[key] = value
root_attributes["tool_path"] = tool_path
return Element("toolbox", root_attributes)
def _collect_new_tool_paths(elem_list, tool_path: str, shed_tool_conf: str) -> dict[str, str | None]:
"""Walk ``elem_list`` and map each new ``<tool>``'s absolute path to its guid.
@@ -150,7 +163,7 @@ class ToolPanelManager:
use_cached_toolbox = self.app.config.use_cached_toolbox
if use_cached_toolbox:
# The populator writes ``StoredToolSource`` + ``ToolIndexEntry``
# + whoosh for every new tool file, then broadcasts
# for every new tool file, then broadcasts
# ``reload_tool_source_cache`` so peer Galaxy processes
# refresh. ``create_tool`` raises on index miss, so this MUST
# run before ``load_item`` reaches the seam — and the
@@ -177,7 +190,6 @@ class ToolPanelManager:
populate_for_paths(
self.app.config,
paths=list(new_path_guids),
rebuild_whoosh=True,
path_guids=new_path_guids,
app=self.app,
)
@@ -195,6 +207,7 @@ class ToolPanelManager:
load_panel_dict=True,
guid=config_elem.get("guid"),
)
self.app.reindex_tool_search()
else:
# Eager path: append + load each elem, then persist the
# updated shed_tool_conf.
@@ -220,21 +233,7 @@ class ToolPanelManager:
value of config_filename.
"""
try:
# Managed shed confs may carry publisher/operator attributes such
# as ``store`` or ``monitor``. Rebuilding the root from only
# ``tool_path`` used to silently discard them on every install,
# update, metadata refresh, or uninstall.
root_attributes: dict[str, str] = {}
if os.path.exists(config_filename):
existing_tree, _error_message = parse_xml(config_filename)
if existing_tree is not None:
for key, value in existing_tree.getroot().attrib.items():
if isinstance(key, str) and isinstance(value, str):
root_attributes[key] = value
# The resolved tool path passed by the manager remains
# authoritative even if the old root contained another value.
root_attributes["tool_path"] = tool_path
root = Element("toolbox", root_attributes)
root = toolbox_with_new_tool_path(config_filename, tool_path)
for elem in config_elems:
root.append(elem)
with RenamedTemporaryFile(config_filename, mode="w") as fh:
@@ -75,10 +75,8 @@ class ToolLineage:
def reset(cls) -> None:
"""Clear the global ``lineages_by_id`` cache.
``lineages_by_id`` is a class attribute, so a ``ToolLineage`` built
in a prior process / embedded ``IntegrationTestCase.restart()`` boot
carries its ``tool_versions`` SortedSet into the next boot. Wired
into ``CachedToolBox.close()`` so a restart sees a clean cache.
``lineages_by_id`` is a class attribute, so toolbox shutdown clears
it before another boot reconstructs lineages from current metadata.
"""
with cls.lock:
cls.lineages_by_id.clear()
+8 -16
View File
@@ -746,7 +746,7 @@ class CachedToolBox(ToolBox):
return False
def _run_inline_populator(self) -> None:
"""Cold-start hook: write the index + whoosh in this process.
"""Cold-start hook: write source rows and metadata in this process.
Wraps ``populator.populate_store_inline`` so boot can use the same
single-writer machinery as the CLI script and the shed-install
@@ -756,10 +756,7 @@ class CachedToolBox(ToolBox):
load instead of degrading silently.
"""
log.info("CachedToolBox: running populator inline to backfill the index")
populate_store_inline(
self.app.config,
rebuild_whoosh=True,
)
populate_store_inline(self.app.config)
def _index_versions_for(self, tool_id: str) -> list[str]:
"""Return every version present in the index for ``tool_id``.
@@ -1545,23 +1542,17 @@ class CachedToolBox(ToolBox):
def close(self) -> None:
"""Drop in-memory state at app shutdown.
Wired into ``GalaxyUniverseApplication.haltables`` so an embedded
restart (``IntegrationTestCase.restart``) releases the LRU cache,
the ``ToolIndex`` reference, and the link back to the
``tool_source_store`` before the next boot wires up a fresh
toolbox. Idempotent; safe to call more than once.
Release the LRU cache, ``ToolIndex`` reference, and store link before
a replacement boot wires a fresh toolbox. Idempotent; safe to call
more than once.
"""
with self._cache_lock:
self._tool_object_cache.clear()
self._cached_tools.clear()
self._tool_index = None
self._store = None
# ``ToolLineage.lineages_by_id`` is a *class*-level dict, so a
# ``ToolLineage`` from a prior process / embedded restart would
# otherwise carry its ``tool_versions`` SortedSet across boots and
# shadow the new boot's index versions. Reset on shutdown so the
# next ``CachedLineageMap.get`` rebuilds from the freshly-loaded
# index.
# ``ToolLineage.lineages_by_id`` is class-level, so reset it on
# shutdown before the next toolbox rebuilds from its index.
ToolLineage.reset()
# ``_tools_by_id`` and friends still get GC'd when the surrounding
# app object drops. We don't clear them here because the eager
@@ -1615,6 +1606,7 @@ class CachedToolBox(ToolBox):
current = getattr(self.app, "toolbox", None)
if current is not self and isinstance(current, CachedToolBox):
current._remove_tool_in_memory(tool_id, remove_from_panel=remove_from_panel)
self.app.reindex_tool_search()
return result
def _remove_tool_in_memory(self, tool_id: str, remove_from_panel: bool = True):
+55 -167
View File
@@ -27,7 +27,6 @@ Filters - various filters are available for processing content as the index is
import logging
import os
import re
import shutil
from typing import (
Any,
@@ -35,36 +34,19 @@ from typing import (
TYPE_CHECKING,
)
from whoosh import (
analysis,
index,
)
from whoosh import index
from whoosh.fields import Schema
from whoosh.qparser import (
MultifieldParser,
OrGroup,
)
from whoosh.scoring import (
BM25F,
Frequency,
MultiWeighting,
)
from whoosh.writing import AsyncWriter
from galaxy.config import GalaxyAppConfiguration
from galaxy.tools.source_store.populator import (
DEFAULT_STORE_NAME,
whoosh_dir_for_store,
)
from galaxy.tools.source_store.search import (
build_search_document,
build_search_schema,
search_whoosh_index,
ToolSearchTuning,
ToolWhooshIndex,
)
from galaxy.util import (
ExecutionTimer,
unicodify,
)
from galaxy.util import ExecutionTimer
if TYPE_CHECKING:
from galaxy.tools import (
@@ -132,75 +114,47 @@ class ToolBoxSearch:
class CachedToolboxSearch(ToolBoxSearch):
"""Drop-in for :class:`ToolBoxSearch` in cached-toolbox mode.
The populator (``galaxy.tools.source_store.populator``) builds and owns
one whoosh index per store; this class is a thin reader that opens them
on each query and merges hits by score.
Per-view scoping mirrors the eager :class:`ToolBoxSearch`: the merged hits
are filtered down to the tools the requested panel view holds, and an
unknown ``panel_view`` raises ``KeyError``. Membership is read off the
toolbox's rendered panel by id (:meth:`AbstractToolBox.panel_view_tool_ids`),
so no tool is materialised just to filter.
``build_index`` is a no-op (the populator's job). ``index_count`` is
still incremented so :func:`galaxy.queue_worker.rebuild_toolbox_search_index`
keeps its watermark check happy — it stops at "in sync with the
toolbox reload count" without re-building anything.
"""
"""Build one metadata-only Whoosh corpus per rendered panel view."""
def __init__(self, config: GalaxyAppConfiguration, toolbox: Optional["ToolBox"] = None) -> None:
# Skip ToolBoxSearch.__init__ — it walks ``toolbox.panel_views()`` and
# builds a ToolPanelViewSearch per view. Under the cached toolbox the
# populator owns the whoosh indexes; view scoping is a post-filter on
# the merged hits instead of a per-view index.
self.config = config
self._toolbox = toolbox
self.panel_searches: dict[str, ToolPanelViewSearch] = {}
self.panel_searches: dict[str, ToolWhooshIndex] = {}
self._panel_view_ids: set[str] = set()
self.index_count = -1
if toolbox is not None:
self._sync_panel_searches(toolbox)
def build_index(self, tool_cache: Any, toolbox: Any, index_help: bool = True) -> None:
# Populator side owns whoosh writes; bump the watermark so the
# rebuild_toolbox_search_index control task observes "in sync".
self._toolbox = toolbox
self._sync_panel_searches(toolbox)
tool_index = toolbox.tool_index
if tool_index is not None:
for panel_view_id, searcher in self.panel_searches.items():
searcher.build(tool_index, toolbox.panel_view_tool_ids(panel_view_id))
self.index_count += 1
def search(self, q: str, panel_view: str, config: GalaxyAppConfiguration) -> list[str]:
# Resolve view membership first so an unknown view raises ``KeyError``
# even when whoosh search is disabled — parity with eager
# ``ToolBoxSearch.search``. ``None`` (no toolbox wired, e.g. in unit
# tests) skips scoping and returns the raw merged hits.
member_ids: set[str] | None = None
if self._toolbox is not None:
member_ids = self._toolbox.panel_view_tool_ids(panel_view)
if panel_view not in self._panel_view_ids:
raise KeyError(f"Unknown panel_view specified {panel_view}")
if not config.tool_search_index_dir:
# No index dir means whoosh search is off entirely.
return []
# 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: the panel-view filter (and ``resolve_search_hit``
# downstream) drops ids not placed 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).
ordered = [tool_id for tool_id, _score in sorted(scored.items(), key=lambda kv: -kv[1])]
if member_ids is None:
return ordered
return [tool_id for tool_id in ordered if tool_id in member_ids]
return self.panel_searches[panel_view].search(q, limit=None)
def _sync_panel_searches(self, toolbox: "ToolBox") -> None:
panel_view_ids = {panel_view.id for panel_view in toolbox.panel_views()}
self._panel_view_ids = panel_view_ids
if not self.config.tool_search_index_dir:
self.panel_searches = {}
return
tuning = ToolSearchTuning.from_config(self.config)
self.panel_searches = {
panel_view_id: ToolWhooshIndex(
index_dir=os.path.join(self.config.tool_search_index_dir, panel_view_id),
tuning=tuning,
)
for panel_view_id in panel_view_ids
}
class ToolPanelViewSearch:
@@ -217,14 +171,12 @@ class ToolPanelViewSearch:
index_help: bool = True,
) -> None:
"""Build the schema and validate against the index."""
# Shared with the store's ``ToolWhooshIndex`` so eager and cached
# search rank identically; ``help_boost`` adds the eager-only help
# field the populator can't index.
tuning = ToolSearchTuning.from_config(config)
self.schema = build_search_schema(
ToolSearchTuning.from_config(config),
help_boost=config.tool_help_boost,
tuning,
help_boost=tuning.help_boost if index_help else None,
)
self.rex = analysis.RegexTokenizer()
self.tuning = tuning
self.index_dir = index_dir
self.panel_view_id = panel_view_id
self.index = self._index_setup()
@@ -312,51 +264,22 @@ class ToolPanelViewSearch:
tool: "Tool",
index_help: bool = True,
) -> dict[str, str | list[str]]:
def clean(s: str) -> str:
"""Remove hyphens as they are Whoosh wildcards."""
if "-" in s:
return " ".join(token.text for token in self.rex(s))
else:
return s
if tool.tool_type == "manage_data":
# Do not add data managers to the public index
return {}
add_doc_kwds: dict[str, str | list[str]] = {
"id": unicodify(tool.id),
"id_exact": unicodify(tool.id),
"name": clean(tool.name),
"description": unicodify(tool.description),
"section": tool.get_panel_section()[1] or "",
"edam_operations": [clean(_) for _ in tool.edam_operations or []],
"edam_topics": [clean(_) for _ in tool.edam_topics or []],
"repository": unicodify(tool.repository_name),
"owner": unicodify(tool.repository_owner),
"help": unicodify(""),
}
if tool.guid:
# Create a stub consisting of owner, repo, and tool from guid
slash_indexes = [m.start() for m in re.finditer("/", tool.guid)]
id_stub = tool.guid[(slash_indexes[1] + 1) : slash_indexes[4]]
add_doc_kwds["stub"] = clean(id_stub)
else:
add_doc_kwds["stub"] = unicodify(tool.id)
if tool.labels:
add_doc_kwds["labels"] = unicodify(" ".join(tool.labels))
if tool.tool_tags:
add_doc_kwds["tool_tags"] = unicodify(",".join(tool.tool_tags))
if index_help:
raw_help = tool.raw_help
if raw_help:
try:
add_doc_kwds["help"] = unicodify(raw_help)
except Exception:
# Don't fail to build index when help fails to parse
pass
add_doc_kwds["name_exact"] = add_doc_kwds["name"]
return add_doc_kwds
document = build_search_document(
tool_id=tool.id,
guid=tool.guid,
name=tool.name,
description=tool.description,
section=tool.get_panel_section()[1],
edam_operations=tool.edam_operations,
edam_topics=tool.edam_topics,
repository=tool.repository_name,
owner=tool.repository_owner,
labels=tool.labels,
tool_tags=tool.tool_tags,
help_text=tool.raw_help if index_help else None,
tool_type=tool.tool_type,
)
return document or {}
def search(
self,
@@ -364,40 +287,5 @@ class ToolPanelViewSearch:
config: GalaxyAppConfiguration,
) -> list[str]:
"""Perform search on the in-memory index."""
# Change field boosts for searcher
self.searcher = self.index.searcher(
weighting=MultiWeighting(
Frequency(),
help=BM25F(K1=config.tool_help_bm25f_k1),
)
)
fields = [
"id",
"id_exact",
"name",
"name_exact",
"description",
"section",
"edam_operations",
"edam_topics",
"repository",
"owner",
"help",
"labels",
"tool_tags",
"stub",
]
self.parser = MultifieldParser(
fields,
schema=self.schema,
group=OrGroup,
)
parsed_query = self.parser.parse(q)
hits = self.searcher.search(
parsed_query,
limit=None,
sortedby="",
terms=True,
)
return [hit["id"] for hit in hits]
tuning = ToolSearchTuning.from_config(config)
return [tool_id for tool_id, _score in search_whoosh_index(self.index, q, tuning, limit=None)]
+3 -5
View File
@@ -237,11 +237,9 @@ class ToolSourceStore(ABC):
def close(self) -> None: # noqa: B027 — intentional empty default
"""Release any state the store is holding.
Wired into ``GalaxyUniverseApplication.haltables`` so a Python-side
``app.shutdown()`` (e.g. the embedded ``IntegrationTestCase.restart()``
path) clears references that would otherwise survive into the next
boot. Default is a no-op; backends holding an engine or cache
override, and the composite store propagates.
Application shutdown clears references before a replacement boot.
Default is a no-op; backends holding an engine or cache override, and
the composite store propagates.
"""
+8 -68
View File
@@ -3,8 +3,8 @@ Tool source store populator.
Walks Galaxy's tool configuration files, parses each tool source, and writes
the canonical ``StoredToolSource`` + ``ToolIndex`` rows into every writable
tool source store. The populator is the single writer of the index and the
whoosh search index; store consumers are read-only.
tool source store. The rendered toolbox owns its per-view search indexes;
the populator only persists source and metadata rows.
Two execution modes:
@@ -21,7 +21,6 @@ loop that updates the store as tool files change on disk and broadcasts a
import argparse
import hashlib
import logging
import os
import signal
import sys
import threading
@@ -96,10 +95,7 @@ from galaxy.tools.source_store.manifest import (
build_manifest,
write_manifest,
)
from galaxy.tools.source_store.search import (
ToolSearchTuning,
ToolWhooshIndex,
)
from galaxy.tools.source_store.search import MAX_TOOL_SEARCH_HELP_CHARS
from galaxy.util import listify
from galaxy.util.hash_util import md5_hash_file
from galaxy.util.properties import load_app_properties
@@ -116,12 +112,6 @@ from galaxy.util.watcher import (
log = logging.getLogger(__name__)
# Upper bound on the help text carried onto each ``ToolIndexEntry``. Tool help
# is occasionally enormous (embedded tables, long tutorials); cap it so the
# persisted index and whoosh corpus stay bounded while still covering the help
# body for search.
MAX_HELP_TEXT_CHARS = 20000
class _ReloadNotificationConfig(Protocol):
amqp_internal_connection: str | None
@@ -308,7 +298,7 @@ class ToolFileWatcher:
If the content is new, delegates to :func:`populate_for_paths`,
which runs the full parse-and-persist path: ``StoredToolSource``
write, ``ToolIndexEntry`` build (with section + labels from the
conf walk), whoosh rebuild, and the ``reload_tool_source_cache``
conf walk), metadata rebuild, and the ``reload_tool_source_cache``
broadcast — same machinery a shed install hits.
"""
try:
@@ -341,7 +331,6 @@ class ToolFileWatcher:
self._populate(
self.config,
paths=[path],
rebuild_whoosh=True,
write_manifests=self._write_manifests,
)
log.info("Updated tool: %s", path)
@@ -364,7 +353,6 @@ class ToolFileWatcher:
self._populate(
self.config,
paths=siblings,
rebuild_whoosh=True,
write_manifests=self._write_manifests,
)
log.info("Macro change in %s — re-expanded %d sibling file(s)", macro_path, len(siblings))
@@ -386,43 +374,6 @@ class ToolFileWatcher:
DEFAULT_STORE_NAME = "__default__"
# Sub-directory under ``config.tool_search_index_dir`` where the default
# store's whoosh index lives.
_WHOOSH_DEFAULT_SUBDIR = "_store_default"
def whoosh_dir_for_store(tool_search_index_dir: str | None, store_name: str) -> str | None:
"""Resolve the on-disk whoosh dir for ``store_name``.
Returns ``None`` if the config doesn't define ``tool_search_index_dir``
(whoosh search is then disabled). The default store maps to a fixed
sub-dir; named stores get their own sub-dir.
"""
if not tool_search_index_dir:
return None
sub = _WHOOSH_DEFAULT_SUBDIR if store_name == DEFAULT_STORE_NAME else store_name
return os.path.join(tool_search_index_dir, sub)
def build_whoosh_for_store(config: GalaxyAppConfiguration, store_name: str, tool_index: ToolIndex) -> None:
"""Rebuild the whoosh index for ``store_name`` from ``tool_index``.
No-ops if ``tool_search_index_dir`` is unset. Logs and swallows whoosh
failures: the toolbox surfaces them to users at query time (the
populator's job is to write what it can; an unbuildable index is a
deploy issue, not a populator-CLI fatal).
"""
index_dir = whoosh_dir_for_store(config.tool_search_index_dir, store_name)
if index_dir is None:
return
try:
tuning = ToolSearchTuning.from_config(config)
searcher = ToolWhooshIndex(index_dir=index_dir, tuning=tuning)
count = searcher.build(tool_index)
log.info("Built whoosh index for store %s at %s (%d docs)", store_name, index_dir, count)
except Exception as e:
log.error("Whoosh build for store %s failed: %s", store_name, e)
def _merge_panel_order(previous: list[ToolPanelItem], rebuilt: list[ToolPanelItem]) -> list[ToolPanelItem]:
"""Reorder a full rebuild's placements to the previous index's order.
@@ -526,14 +477,14 @@ def build_index_entry_from_source(
requirements, containers, _, _, _ = tool_source.parse_requirements()
tests = tool_source.parse_tests_to_dict().get("tests", [])
# Capture help text for the whoosh corpus. Parse failures drop help
# Capture bounded help text for the toolbox-owned search corpus. Parse failures drop help
# for this entry rather than failing the populate — a malformed help
# block must not lose the whole tool.
help_text = ""
try:
parsed_help = tool_source.parse_help()
if parsed_help and parsed_help.content:
help_text = parsed_help.content[:MAX_HELP_TEXT_CHARS]
help_text = parsed_help.content[:MAX_TOOL_SEARCH_HELP_CHARS]
except Exception:
help_text = ""
@@ -665,7 +616,6 @@ def populate_store_inline(
dry_run: bool = False,
incremental: bool = True,
verbose: bool = False,
rebuild_whoosh: bool = True,
broadcast: bool = False,
target: str | None = None,
prune: bool = False,
@@ -896,8 +846,8 @@ def populate_store_inline(
# One ordered stream matters: same-id twins are resolved by index
# add-order, so consuming as_completed (thread timing) or adding carried
# entries before parsed ones flips which twin wins ``entries[id]`` run to
# run — changing its panel section in the whoosh corpus and defeating the
# corpus-signature rebuild skip.
# run — changing its panel section in the search corpus even though no
# input changed.
index_inputs: dict[str, list[ToolIndexEntry | tuple[DiscoveredTool, StoredToolSource, Any]]] = {
name: [] for name in writable_names
}
@@ -1026,12 +976,6 @@ def populate_store_inline(
log.info("Skipping manifest for non-file SQLite store %s", store_name)
else:
log.info("Wrote tool source store manifest for %s to %s", store_name, manifest_path)
# Rebuild the whoosh search index from the persisted ToolIndex.
# Single-writer principle: the toolbox stops re-building this in
# the search hot path.
if rebuild_whoosh:
build_whoosh_for_store(config, store_name, index)
if broadcast:
# Tell peer Galaxy processes to drop their cached index so the
# next request reloads what we just wrote.
@@ -1044,7 +988,6 @@ def populate_for_paths(
config: GalaxyAppConfiguration,
paths: list[str],
*,
rebuild_whoosh: bool = True,
path_guids: dict[str, str | None] | None = None,
app=None,
) -> dict[str, int]:
@@ -1062,7 +1005,6 @@ def populate_for_paths(
return populate_store_inline(
config,
paths=paths,
rebuild_whoosh=rebuild_whoosh,
broadcast=True,
path_guids=path_guids,
app=app,
@@ -1072,7 +1014,6 @@ def populate_for_paths(
def reconcile_index(
config: GalaxyAppConfiguration,
*,
rebuild_whoosh: bool = True,
app=None,
) -> dict[str, int]:
"""Full prune-enabled scan; used by ``reset_shed_tools``.
@@ -1087,7 +1028,6 @@ def reconcile_index(
config,
paths=None,
prune=True,
rebuild_whoosh=rebuild_whoosh,
broadcast=True,
app=app,
)
+136 -69
View File
@@ -5,6 +5,7 @@ import logging
import os
import re
import shutil
from collections.abc import Collection
from dataclasses import dataclass
from whoosh import (
@@ -23,7 +24,11 @@ from whoosh.qparser import (
MultifieldParser,
OrGroup,
)
from whoosh.scoring import BM25F
from whoosh.scoring import (
BM25F,
Frequency,
MultiWeighting,
)
from galaxy.config import GalaxyAppConfiguration
from galaxy.tool_util.ontologies.ontology_data import curated_tool_tags
@@ -45,6 +50,11 @@ log = logging.getLogger(__name__)
# Matching sidecar signatures let ``build`` skip an unchanged corpus.
_CORPUS_SIGNATURE_FILE = "corpus.md5"
# Tool help can contain embedded tables and tutorials large enough to dominate
# both the persisted metadata index and Whoosh. Eager and cached search must
# apply the same bound so their corpora and ranking remain equivalent.
MAX_TOOL_SEARCH_HELP_CHARS = 20_000
@dataclass(frozen=True)
class ToolSearchTuning:
@@ -64,6 +74,7 @@ class ToolSearchTuning:
# Defaults preserve existing explicit tuning literals.
help_boost: float = 1.0
index_tool_help: bool = True
help_bm25f_k1: float = 1.2
@classmethod
def from_config(cls, config: GalaxyAppConfiguration) -> "ToolSearchTuning":
@@ -81,6 +92,7 @@ class ToolSearchTuning:
ngram_factor=float(config.tool_ngram_factor),
help_boost=float(config.tool_help_boost),
index_tool_help=bool(config.index_tool_help),
help_bm25f_k1=float(config.tool_help_bm25f_k1),
)
@@ -139,53 +151,129 @@ def _clean(s: str | None) -> str:
return text
def _entry_to_doc(entry: ToolIndexEntry, *, include_help: bool = False) -> dict | None:
"""Turn a ``ToolIndexEntry`` into the document shape ``Schema`` expects.
``include_help`` projects the entry's captured help text into a ``help``
field, mirroring the eager toolbox's ``index_tool_help`` behaviour. It's
off by default so callers that build a help-less schema never emit a field
the schema lacks.
"""
if entry.tool_type == DataManagerTool.tool_type:
def build_search_document(
*,
tool_id: str,
name: str,
description: str | None = None,
section: str | None = None,
edam_operations: Collection[str] | None = None,
edam_topics: Collection[str] | None = None,
repository: str | None = None,
owner: str | None = None,
labels: Collection[str] | None = None,
tool_tags: Collection[str] | None = None,
guid: str | None = None,
help_text: object | None = None,
tool_type: str = "default",
) -> dict | None:
"""Build the common eager/cached Whoosh document for one tool."""
if tool_type == DataManagerTool.tool_type:
return None
name_clean = _clean(entry.name)
name_clean = _clean(name)
doc: dict = {
"id": unicodify(entry.id),
"id_exact": unicodify(entry.id),
"id": unicodify(tool_id),
"id_exact": unicodify(tool_id),
"name": name_clean,
"name_exact": name_clean,
"description": unicodify(entry.description or ""),
"section": unicodify(entry.panel_section_name or ""),
"edam_operations": [_clean(op) for op in entry.edam_operations or []],
"edam_topics": [_clean(topic) for topic in entry.edam_topics or []],
"repository": unicodify(entry.repository_name or ""),
"owner": unicodify(entry.repository_owner or ""),
"description": unicodify(description or ""),
"section": unicodify(section or ""),
"edam_operations": [_clean(op) for op in edam_operations or []],
"edam_topics": [_clean(topic) for topic in edam_topics or []],
"repository": unicodify(repository or ""),
"owner": unicodify(owner or ""),
}
# GUID has shape ``shed/repos/owner/repo/tool/version``. The eager path
# carves out ``owner/repo/tool`` as the search stub; fall back to the
# plain id for local tools.
if "/" in entry.id:
slash_indexes = [m.start() for m in re.finditer("/", entry.id)]
# A Tool Shed GUID has shape ``shed/repos/owner/repo/tool/version``.
# Carve out ``owner/repo/tool`` as the search stub.
stub_source = guid or tool_id
if guid:
slash_indexes = [m.start() for m in re.finditer("/", stub_source)]
if len(slash_indexes) >= 5:
doc["stub"] = _clean(entry.id[slash_indexes[1] + 1 : slash_indexes[4]])
doc["stub"] = _clean(stub_source[slash_indexes[1] + 1 : slash_indexes[4]])
else:
doc["stub"] = unicodify(entry.id)
doc["stub"] = unicodify(tool_id)
else:
doc["stub"] = unicodify(entry.id)
if entry.labels:
doc["labels"] = unicodify(" ".join(entry.labels))
tool_id = (entry.id or "").lower()
all_ids = [tool_id]
if is_shed_guid(tool_id):
all_ids = [tool_id, remove_version_from_guid(tool_id) or tool_id, short_tool_id(tool_id)]
if tags := curated_tool_tags(all_ids):
doc["tool_tags"] = unicodify(",".join(tags))
if include_help and entry.help_text:
doc["help"] = unicodify(entry.help_text)
doc["stub"] = unicodify(tool_id)
if labels:
doc["labels"] = unicodify(" ".join(labels))
if tool_tags is None:
normalized_id = tool_id.lower()
all_ids = [normalized_id]
if is_shed_guid(normalized_id):
all_ids = [
normalized_id,
remove_version_from_guid(normalized_id) or normalized_id,
short_tool_id(normalized_id),
]
tool_tags = curated_tool_tags(all_ids)
if tool_tags:
doc["tool_tags"] = unicodify(",".join(tool_tags))
if help_text is not None:
help_content = getattr(help_text, "content", help_text)
doc["help"] = unicodify(help_content or "")[:MAX_TOOL_SEARCH_HELP_CHARS]
return doc
def entry_to_search_document(entry: ToolIndexEntry, *, include_help: bool = False) -> dict | None:
"""Project one cached metadata entry into the common document shape."""
entry_id = entry.id
return build_search_document(
tool_id=entry_id,
guid=entry_id if is_shed_guid(entry_id) else None,
name=entry.name,
description=entry.description,
section=entry.panel_section_name,
edam_operations=entry.edam_operations,
edam_topics=entry.edam_topics,
repository=entry.repository_name,
owner=entry.repository_owner,
labels=entry.labels,
help_text=entry.help_text if include_help else None,
tool_type=entry.tool_type,
)
def search_whoosh_index(
ix: index.FileIndex,
query: str,
tuning: ToolSearchTuning,
limit: int | None = None,
) -> list[tuple[str, float]]:
"""Search an eager or cached index with one parser and scoring policy."""
if not query or not query.strip():
return []
search_fields = [
"id",
"id_exact",
"name",
"name_exact",
"stub",
"description",
"section",
"edam_operations",
"edam_topics",
"repository",
"owner",
"labels",
"tool_tags",
]
if "help" in ix.schema.names():
search_fields.append("help")
parser = MultifieldParser(search_fields, schema=ix.schema, group=OrGroup)
parsed = parser.parse(query)
weighting = MultiWeighting(
Frequency(),
help=BM25F(K1=tuning.help_bm25f_k1),
)
with ix.searcher(weighting=weighting) as searcher:
hits = searcher.search(parsed, limit=None, sortedby="", terms=True)
scored = [(hit["id"], hit.score) for hit in hits]
# Whoosh does not define a stable tie order. Sharing an explicit secondary
# key makes eager and cached result ordering deterministic.
scored.sort(key=lambda hit: (-hit[1], hit[0]))
return scored if limit is None else scored[:limit]
class ToolWhooshIndex:
"""Build and query a Whoosh index over ``ToolIndexEntry`` rows."""
@@ -221,13 +309,18 @@ class ToolWhooshIndex:
+ json.dumps(sorted(docs, key=lambda d: d["id"]), sort_keys=True)
)
def build(self, tool_index: ToolIndex) -> int:
"""Rebuild from entries, returning zero when the corpus is unchanged."""
def build(self, tool_index: ToolIndex, tool_ids: Collection[str] | None = None) -> int:
"""Rebuild one panel-view corpus, or all entries when unscoped."""
docs = []
for entry in tool_index.entries.values():
entries = (
tool_index.entries.values()
if tool_ids is None
else (tool_index.entries[tool_id] for tool_id in sorted(tool_ids) if tool_id in tool_index.entries)
)
for entry in entries:
if entry.hidden:
continue
doc = _entry_to_doc(entry, include_help=self.index_help)
doc = entry_to_search_document(entry, include_help=self.index_help)
if doc is None:
continue
docs.append(doc)
@@ -265,36 +358,10 @@ class ToolWhooshIndex:
return [tool_id for tool_id, _score in self.search_scored(query, limit=limit)]
def search_scored(self, query: str, limit: int | None = None) -> list[tuple[str, float]]:
"""Like :meth:`search`, but pair each tool id with its BM25 score.
Callers merging hits across several store indexes need the scores
to interleave results instead of concatenating whole result lists.
"""
"""Like :meth:`search`, but pair each tool id with its score."""
if not query or not query.strip():
return []
if not (os.path.isdir(self.index_dir) and index.exists_in(self.index_dir)):
return []
ix = index.open_dir(self.index_dir)
search_fields = [
"id_exact",
"name",
"name_exact",
"stub",
"description",
"section",
"edam_operations",
"edam_topics",
"repository",
"owner",
"labels",
"tool_tags",
]
# ``help`` is only present when the corpus was built with help indexing
# on; querying a field the on-disk schema lacks raises in the parser.
if "help" in ix.schema.names():
search_fields.append("help")
parser = MultifieldParser(search_fields, schema=ix.schema, group=OrGroup)
parsed = parser.parse(query)
with ix.searcher(weighting=BM25F()) as searcher:
hits = searcher.search(parsed, limit=limit)
return [(hit["id"], hit.score) for hit in hits]
return search_whoosh_index(ix, query, self.tuning, limit=limit)
+2 -2
View File
@@ -9,8 +9,8 @@ signal, and it is cheap: one probe per store per tick.
The watcher itself is deliberately dumb: it detects token transitions and
hands the changed store names to ``on_change``. Reload mechanics —
disposing engines so new connections see the new CVMFS snapshot, index
reload, panel refresh, whoosh rebuild — belong to the callback owner
(``CachedToolBox``).
reload, panel refresh, and search rebuild — belong to the toolbox callback
owner.
"""
import logging
+6 -11
View File
@@ -691,22 +691,17 @@ class ToolsService(ServiceBase):
) -> list[str]:
"""Search tools via the ``app.toolbox_search`` singleton.
In cached-toolbox mode that singleton is :class:`CachedToolboxSearch`, which reads
the populator-owned whoosh index; in eager mode it's
:class:`ToolBoxSearch` walking ``tool_cache``. Both expose the same
``search(q, panel_view, config)`` interface, so this method doesn't
branch on which toolbox flavour is active.
Cached and eager toolbox search both own a Whoosh corpus for each
rendered panel view and expose the same
``search(q, panel_view, config)`` interface.
Every hit is resolved with a per-tool access check
(``allow_user_access``, e.g. ``require_login`` tools for anonymous
users) so denied tools are filtered out.
In cached-toolbox mode ``get_tool`` would *materialise* every hit (it loads the
tool from the store on demand) and the populator-owned whoosh index
can carry ids that aren't loaded in this toolbox, so hits are instead
resolved against the registered stubs via
:meth:`CachedToolBox.resolve_search_hit` — no parse, and hits foreign to
this toolbox are skipped just as eager search skips them.
In cached-toolbox mode ``get_tool`` would materialise every hit, so
hits are instead resolved against registered stubs via
:meth:`CachedToolBox.resolve_search_hit`.
"""
cached_toolbox = self._get_cached_toolbox(trans)
results: list[str] = []
@@ -53,6 +53,15 @@ SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__))
VAULT_CONF = os.path.join(SCRIPT_DIRECTORY, "vault_conf.yml")
class CachedToolBoxIntegrationMixin:
"""Run an existing integration-test configuration with lazy tools."""
@classmethod
def handle_galaxy_config_kwds(cls, config):
super().handle_galaxy_config_kwds(config) # type: ignore[misc]
config["use_cached_toolbox"] = True
def docker_run(image, name, *args, detach=True, remove=True, ports=None, env_vars: dict[str, str] | None = None):
cmd = ["docker", "run"]
+1 -1
View File
@@ -91,7 +91,7 @@ class UsesShed(UsesShedApi):
# would otherwise survive the ``reload_toolbox`` below: ``CachedToolBox``
# only re-runs the populator when discovery turns up a *new* path.
try:
reconcile_index(self._app.config, rebuild_whoosh=True, app=self._app)
reconcile_index(self._app.config, app=self._app)
except Exception as e:
log.warning("reset_shed_tools: reconcile_index raised (continuing): %s", e)
# deleting the containing folder doesn't trigger a toolbox reload, so signal it now and wait until it's done
+7
View File
@@ -201,3 +201,10 @@ class TestDataManagerIntegration(integration_util.IntegrationTestCase, UsesShed)
@classmethod
def get_secure_ascii_digits(cls, n=12):
return "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(12))
class TestCachedDataManagerIntegration(
integration_util.CachedToolBoxIntegrationMixin,
TestDataManagerIntegration,
):
pass
@@ -5,6 +5,7 @@ from galaxy_test.base.populators import (
WorkflowPopulator,
)
from galaxy_test.base.uses_shed_api import UsesShedApi
from galaxy_test.driver import integration_util
from .test_containerized_jobs import ContainerizedIntegrationTestCase
@@ -107,3 +108,10 @@ test_data:
# the 60s default invocation timeout is sized for lightweight tools.
timeout=180,
)
class TestCachedDataManagerWorkflowInvocation(
integration_util.CachedToolBoxIntegrationMixin,
TestDataManagerWorkflowInvocation,
):
pass
+14
View File
@@ -61,3 +61,17 @@ class TestEdamToolboxDefaultIntegration(integration_util.IntegrationTestCase):
assert isinstance(views, dict)
edam_panel_view = views["ontology:edam_topics"]
assert edam_panel_view["view_type"] == "ontology"
class TestCachedEdamToolboxIntegration(
integration_util.CachedToolBoxIntegrationMixin,
TestEdamToolboxIntegration,
):
pass
class TestCachedEdamToolboxDefaultIntegration(
integration_util.CachedToolBoxIntegrationMixin,
TestEdamToolboxDefaultIntegration,
):
pass
+14
View File
@@ -251,3 +251,17 @@ def get_sections(index_panel):
def model_classes(elements):
return [x["model_class"] for _, x in elements.items()]
class TestCachedPanelViewsFromDirectoryIntegration(
integration_util.CachedToolBoxIntegrationMixin,
TestPanelViewsFromDirectoryIntegration,
):
pass
class TestCachedPanelViewsWithShedTools(
integration_util.CachedToolBoxIntegrationMixin,
TestPanelViewsWithShedTools,
):
pass
@@ -176,3 +176,10 @@ class TestRepositoryInstallIntegrationTestCase(integration_util.IntegrationTestC
if assert_ok:
self._assert_status_code_is_ok(response)
return response.json()
class TestCachedRepositoryInstallIntegrationTestCase(
integration_util.CachedToolBoxIntegrationMixin,
TestRepositoryInstallIntegrationTestCase,
):
pass
+8 -10
View File
@@ -10,8 +10,6 @@ plumbing.
import os
import tempfile
import pytest
from galaxy.queue_worker import reload_toolbox
from galaxy.tools.cached_toolbox import CachedToolBox
from galaxy.tools.source_store import ToolIndex
@@ -50,11 +48,12 @@ class BaseToolSourceStorageIntegrationTestCase(integration_util.IntegrationTestC
class TestEagerBootSkipsStore(BaseToolSourceStorageIntegrationTestCase):
"""Default deployments never initialize a tool source store."""
@classmethod
def handle_galaxy_config_kwds(cls, config):
super().handle_galaxy_config_kwds(config)
config["use_cached_toolbox"] = False
def test_no_store_initialized(self):
if self._app.config.use_cached_toolbox:
# GALAXY_CONFIG_OVERRIDE_USE_CACHED_TOOLBOX (the cached-toolbox CI dispatch)
# trumps per-class config kwds, so an eager boot is impossible here.
pytest.skip("use_cached_toolbox forced on by environment override")
assert self._app.tool_source_store is None
self._test_api_tools_list()
self._test_api_tools_show()
@@ -397,10 +396,8 @@ class TestCachedToolBoxApi(BaseToolSourceStorageIntegrationTestCase):
_probe("tests_summary", self._get("tools/tests_summary"))
# all_requirements is admin-only; it aggregates from the index in cached-toolbox mode.
_probe("all_requirements", self._get("tools/all_requirements", admin=True))
# Search resolves whoosh hits against the registered stubs, not through
# the materialising get_tool — the populator-owned index carries ~150
# tool_conf.xml.sample ids that aren't loaded here, and resolving them
# via get_tool would parse every one.
# Search resolves Whoosh hits against registered stubs, not through the
# materialising get_tool.
_probe("search", self._get("tools", data={"q": "Concatenate multiple datasets"}))
assert all(v == 0 for v in deltas.values()), f"batch endpoints materialised tools (expected 0 each): {deltas}"
@@ -427,6 +424,7 @@ class TestCachedToolBoxApi(BaseToolSourceStorageIntegrationTestCase):
tool = tools_by_id["cat1"]
assert tool is not None
assert getattr(tool, "tool_requirements", None) is not None
assert tool.dependencies == []
# ``.copy()`` is what ``ContainerFinder.find_best_container_description``
# invokes via ``copy.copy`` on the registry; placeholder ``None``
# values would crash callers iterating the copy.
@@ -104,3 +104,10 @@ class TestUserDefinedToolRecommendedJobSetupTPV(TestUserDefinedToolRecommendedJo
cores = response["outputs"][0]
cores_content = self.dataset_populator.get_history_dataset_content(history_id, content_id=cores["id"])
assert cores_content == "2.0\n"
class TestCachedUserDefinedToolRecommendedJobSetupTPV(
integration_util.CachedToolBoxIntegrationMixin,
TestUserDefinedToolRecommendedJobSetupTPV,
):
pass
@@ -294,3 +294,10 @@ steps:
# Wait for workflow to complete — should delay then succeed
invocation = self.workflow_populator.wait_for_invocation_and_completion(invocation_id)
assert invocation["state"] == "completed", invocation
class TestCachedWorkflowInvocation(
integration_util.CachedToolBoxIntegrationMixin,
TestWorkflowInvocation,
):
pass
@@ -1046,6 +1046,31 @@ def _step_with_label(native_dict, label):
raise AssertionError(f"Failed to find step with label {label}")
class TestCachedWorkflowUpgradeAllSteps(
integration_util.CachedToolBoxIntegrationMixin,
integration_util.IntegrationTestCase,
UsesShedApi,
):
"""Cached regression for the workflow-upgrade failure seen in dispatch runs."""
framework_tool_and_types = True
def setUp(self):
super().setUp()
self.workflow_populator = WorkflowPopulator(self.galaxy_interactor)
_export_for_update = TestWorkflowRefactoringIntegration._export_for_update
_refactor = TestWorkflowRefactoringIntegration._refactor
_manager = TestWorkflowRefactoringIntegration._manager
_most_recent_stored_workflow = TestWorkflowRefactoringIntegration._most_recent_stored_workflow
_recent_stored_workflow = TestWorkflowRefactoringIntegration._recent_stored_workflow
_latest_workflow = TestWorkflowRefactoringIntegration._latest_workflow
_increment_nested_workflow_version = TestWorkflowRefactoringIntegration._increment_nested_workflow_version
def test_upgrade_all_steps(self):
TestWorkflowRefactoringIntegration.test_upgrade_all_steps(self)
class MockTrans(ProvidesAppContext):
def __init__(self, app, user):
self._app = app
@@ -9,14 +9,7 @@ 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,
)
from galaxy.tools.source_store.search import ToolSearchTuning
_TUNING = ToolSearchTuning(
id_boost=9.0,
@@ -33,89 +26,89 @@ _TUNING = ToolSearchTuning(
)
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 = CachedToolboxSearch(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 CachedToolboxSearch(config).search("mapper", panel_view="default", config=config) == []
class _FakeToolbox:
def __init__(self, views):
def __init__(self, entries, views):
self.tool_index = ToolIndex()
for entry in entries:
self.tool_index.add_entry(entry)
self._views = views
def panel_views(self):
return [SimpleNamespace(id=view_id) for view_id in self._views]
def panel_view_tool_ids(self, panel_view_id):
return self._views[panel_view_id]
def _single_store_config(index_root):
def _config(index_root):
return cast(
GalaxyAppConfiguration,
SimpleNamespace(tool_search_index_dir=index_root, tool_source_stores={}),
SimpleNamespace(tool_search_index_dir=index_root),
)
def _search(index_root, entries, views, monkeypatch):
monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING))
config = _config(index_root)
toolbox = _FakeToolbox(entries, views)
search = CachedToolboxSearch(config, toolbox) # type: ignore[arg-type]
search.build_index(tool_cache=None, toolbox=toolbox)
return search, config
def test_search_indexes_merged_runtime_tool_index(tmp_path, monkeypatch):
search, config = _search(
str(tmp_path),
[
ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0"),
ToolIndexEntry(id="cvmfs_mapper", name="Sequence mapper deluxe", version="2.0"),
],
{"default": {"local_mapper", "cvmfs_mapper"}},
monkeypatch,
)
assert set(search.search("mapper", panel_view="default", config=config)) == {
"local_mapper",
"cvmfs_mapper",
}
def test_search_without_index_dir_returns_empty(monkeypatch):
search, config = _search(
None,
[ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0")],
{"default": {"local_mapper"}},
monkeypatch,
)
assert search.search("mapper", panel_view="default", config=config) == []
def test_search_unknown_panel_view_raises_key_error(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")]
search, config = _search(
str(tmp_path),
[ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0")],
{"default": {"local_mapper"}},
monkeypatch,
)
config = _single_store_config(index_root)
search = CachedToolboxSearch(config, _FakeToolbox({"default": {"local_mapper"}})) # type: ignore[arg-type]
with pytest.raises(KeyError):
search.search("mapper", panel_view="does_not_exist", config=config)
def test_search_scopes_hits_to_requested_panel_view(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,
def test_search_builds_distinct_panel_view_corpora(tmp_path, monkeypatch):
search, config = _search(
str(tmp_path),
[
ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0"),
ToolIndexEntry(id="other_mapper", name="Sequence mapper deluxe", version="1.0"),
],
{"default": {"local_mapper", "other_mapper"}, "restricted": {"local_mapper"}},
monkeypatch,
)
config = _single_store_config(index_root)
search = CachedToolboxSearch(
config,
_FakeToolbox({"default": {"local_mapper", "other_mapper"}, "restricted": {"local_mapper"}}), # type: ignore[arg-type]
)
assert set(search.search("mapper", panel_view="default", config=config)) == {"local_mapper", "other_mapper"}
# The restricted view holds only one of the two matching tools; the
# out-of-view hit must be dropped.
assert set(search.search("mapper", panel_view="default", config=config)) == {
"local_mapper",
"other_mapper",
}
assert search.search("mapper", panel_view="restricted", config=config) == ["local_mapper"]
@@ -13,8 +13,8 @@ from galaxy.tools.source_store.discover import (
from galaxy.tools.source_store.interface import StoredToolSource
from galaxy.tools.source_store.populator import (
build_index_entry_from_source,
MAX_HELP_TEXT_CHARS,
)
from galaxy.tools.source_store.search import MAX_TOOL_SEARCH_HELP_CHARS
_TOOL_XML = """<tool id="help_tool" name="Help Tool" version="1.0">
<command>echo</command>
@@ -76,8 +76,8 @@ def test_entry_help_text_empty_without_help_block(tmp_path):
def test_entry_help_text_capped(tmp_path):
huge = "quaxifier " * (MAX_HELP_TEXT_CHARS // 2)
huge = "quaxifier " * (MAX_TOOL_SEARCH_HELP_CHARS // 2)
xml = _TOOL_XML.replace("This wraps the quaxifier subroutine.", huge)
entry = _build(tmp_path, xml)
assert entry is not None
assert len(entry.help_text) <= MAX_HELP_TEXT_CHARS
assert len(entry.help_text) <= MAX_TOOL_SEARCH_HELP_CHARS
@@ -0,0 +1,123 @@
from types import SimpleNamespace
from typing import cast
from galaxy.config import GalaxyAppConfiguration
from galaxy.tools.search import ToolPanelViewSearch
from galaxy.tools.source_store.index import (
ToolIndex,
ToolIndexEntry,
)
from galaxy.tools.source_store.search import (
entry_to_search_document,
MAX_TOOL_SEARCH_HELP_CHARS,
ToolSearchTuning,
ToolWhooshIndex,
)
_TUNING = ToolSearchTuning(
id_boost=20.0,
name_boost=10.0,
name_exact_multiplier=2.0,
stub_boost=5.0,
section_boost=4.0,
description_boost=3.0,
label_boost=3.0,
ngram_minsize=3,
ngram_maxsize=4,
enable_ngram_search=True,
ngram_factor=0.5,
help_boost=1.0,
help_bm25f_k1=1.2,
)
def _tool(entry: ToolIndexEntry):
return SimpleNamespace(
id=entry.id,
guid=None,
name=entry.name,
description=entry.description,
get_panel_section=lambda: (entry.panel_section_id, entry.panel_section_name),
edam_operations=entry.edam_operations,
edam_topics=entry.edam_topics,
repository_name=entry.repository_name,
repository_owner=entry.repository_owner,
labels=entry.labels,
tool_tags=[],
raw_help=entry.help_text,
tool_type=entry.tool_type,
)
def test_eager_and_cached_documents_share_help_bound(tmp_path, monkeypatch):
monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING))
config = cast(GalaxyAppConfiguration, SimpleNamespace())
eager = ToolPanelViewSearch("default", str(tmp_path / "eager"), config)
entry = ToolIndexEntry(
id="mapper",
version="1.0",
name="Sequence mapper",
description="Maps sequences",
panel_section_id="mapping",
panel_section_name="Mapping",
labels=["featured"],
edam_operations=["Mapping"],
edam_topics=["Genomics"],
help_text=("h" * MAX_TOOL_SEARCH_HELP_CHARS) + "not-indexed",
)
eager_document = eager._create_doc(_tool(entry))
cached_document = entry_to_search_document(entry, include_help=True)
assert eager_document == cached_document
assert len(eager_document["help"]) == MAX_TOOL_SEARCH_HELP_CHARS
def test_eager_help_content_object_is_normalized(tmp_path, monkeypatch):
monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING))
config = cast(GalaxyAppConfiguration, SimpleNamespace())
eager = ToolPanelViewSearch("default", str(tmp_path / "eager"), config)
entry = ToolIndexEntry(id="mapper", version="1.0", name="Sequence mapper")
tool = _tool(entry)
tool.raw_help = SimpleNamespace(content="quaxifier help")
assert eager._create_doc(tool)["help"] == "quaxifier help"
def test_eager_and_cached_indexes_return_same_ordered_hits(tmp_path, monkeypatch):
monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING))
config = cast(GalaxyAppConfiguration, SimpleNamespace())
entries = [
ToolIndexEntry(
id="mapper_a",
version="1.0",
name="Sequence mapper",
description="Maps genomic reads",
panel_section_name="Mapping",
labels=["featured"],
help_text="aligns reads with quaxifier",
),
ToolIndexEntry(
id="mapper_b",
version="1.0",
name="Mapper deluxe",
description="Maps sequences",
panel_section_name="Mapping",
labels=["standard"],
help_text="alignment helper",
),
]
tool_index = ToolIndex()
for entry in entries:
tool_index.add_entry(entry)
eager = ToolPanelViewSearch("default", str(tmp_path / "eager"), config)
with eager.index.writer() as writer:
for entry in entries:
writer.add_document(**eager._create_doc(_tool(entry)))
cached = ToolWhooshIndex(str(tmp_path / "cached"), _TUNING)
cached.build(tool_index, set(tool_index.entries))
for query in ("mapper", "mapper_a", "genomic", "Mapping", "featured", "quaxifier"):
assert eager.search(query, config) == cached.search(query)
@@ -6,6 +6,7 @@ from galaxy.tools.source_store.index import (
ToolIndexEntry,
)
from galaxy.tools.source_store.search import (
MAX_TOOL_SEARCH_HELP_CHARS,
ToolSearchTuning,
ToolWhooshIndex,
)
@@ -54,6 +55,14 @@ def test_changed_corpus_rebuilds_and_drops_stale_docs(tmp_path):
assert searcher.search("caller") == []
def test_build_scopes_corpus_to_panel_membership(tmp_path):
searcher = ToolWhooshIndex(index_dir=str(tmp_path / "ix"), tuning=_TUNING)
assert searcher.build(_index("mapper", "caller"), {"mapper"}) == 1
assert searcher.search("mapper") == ["mapper"]
assert searcher.search("caller") == []
def test_data_managers_are_excluded_from_search(tmp_path):
tool_index = ToolIndex()
tool_index.add_entry(
@@ -101,6 +110,21 @@ def test_help_only_phrase_matches_when_help_indexed(tmp_path):
assert ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).search("quaxifier") == ["mytool"]
def test_help_after_shared_limit_is_not_indexed(tmp_path):
index_dir = str(tmp_path / "ix")
bounded_prefix = "a" * MAX_TOOL_SEARCH_HELP_CHARS
ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_help_index("mytool", f"{bounded_prefix} quaxifier"))
assert ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).search("quaxifier") == []
def test_plain_id_field_is_searchable(tmp_path):
index_dir = str(tmp_path / "ix")
ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).build(_index("exact_tool_id"))
assert ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING).search("exact_tool_id") == ["exact_tool_id"]
def test_help_omitted_when_index_tool_help_disabled(tmp_path):
index_dir = str(tmp_path / "ix")
help_off = ToolSearchTuning(
@@ -380,7 +380,6 @@ class TestIncrementalFastPath:
paths=[str(tool_path)],
path_guids={str(tool_path): guid},
incremental=True,
rebuild_whoosh=False,
)
store, index = _load_default_index(cfg)
entry = index.entries[guid]
@@ -402,7 +401,6 @@ class TestIncrementalFastPath:
cfg,
pattern="fastp.xml",
incremental=True,
rebuild_whoosh=False,
)
assert result["unchanged"] == 1
store.invalidate_index_cache()
@@ -443,8 +441,8 @@ class TestTwinDeterminism:
Regression: the winner among same-id/same-version twins is decided by
index add-order. Consuming pool results as_completed or adding
carried-forward entries before re-parsed ones flipped the winner (and
its panel section in the whoosh corpus) between runs, defeating the
whoosh corpus-signature rebuild skip.
its panel section in the search corpus) between runs even though no input
changed.
"""
def test_twin_winner_stable_across_runs(self, tmp_path):
@@ -1,25 +0,0 @@
import sys
from pathlib import Path
galaxy_root = Path(__file__).parent.parent.parent.parent.parent
sys.path.insert(0, str(galaxy_root / "lib"))
from galaxy.tools.source_store.populator import (
DEFAULT_STORE_NAME,
whoosh_dir_for_store,
)
def test_default_store_maps_to_store_default_subdir():
assert (
whoosh_dir_for_store("/var/galaxy/tool_search", DEFAULT_STORE_NAME) == "/var/galaxy/tool_search/_store_default"
)
def test_named_store_gets_named_subdir():
assert whoosh_dir_for_store("/var/galaxy/tool_search", "cvmfs_mirror") == "/var/galaxy/tool_search/cvmfs_mirror"
def test_none_or_empty_base_yields_none():
assert whoosh_dir_for_store(None, DEFAULT_STORE_NAME) is None
assert whoosh_dir_for_store("", DEFAULT_STORE_NAME) is None