mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-21 05:45:37 +08:00
CI (integration shard 1) hit the uninstall-resurrect again: after
uninstall_repository, GET /api/tools/{guid} still returned the tool. The
log shows the interleaving - the reload_toolbox queued by the preceding
install's conf write executed while the uninstall was mid-flight, and its
rebuild (including a potentially long inline index populate) ran with no
synchronization against remove_tool_by_id.
Two halves:
1. _get_new_toolbox now builds and swaps the new toolbox under
app._toolbox_lock, the lock remove_tool_by_id and
invalidate_index_cache already hold. Whichever side wins, the other
sees a consistent world: removal-first means the rebuild reads the
already-cleaned index and conf; rebuild-first means the removal runs
against the swapped-in box. The lock is an RLock, so the populate
broadcast delivered inline on the same thread re-enters safely.
2. remove_tool_by_id also cleans the CURRENT app.toolbox when it is a
different object than self - a rebuild that won the race swapped in a
box built from the pre-removal index, and cleaning the superseded
instance alone would leave the live box serving the uninstalled tool.
Verified: new unit test (swapped-in box cleaned), 45 lazy unit tests,
fastp shed-install + repository-uninstall integration tests in lazy mode
(the lock's deadlock canary - install triggers watcher reload + inline
populate under the lock), tox lint/format/mypy.
2020 lines
96 KiB
Python
2020 lines
96 KiB
Python
"""
|
|
Lazy ToolBox - On-demand tool loading with LRU caching.
|
|
|
|
This module provides a LazyToolBox that extends ToolBox but keeps only a
|
|
lightweight index in memory and loads full Tool objects on-demand with
|
|
LRU eviction.
|
|
"""
|
|
|
|
import errno
|
|
import logging
|
|
import os
|
|
import threading
|
|
from typing import (
|
|
Any,
|
|
Literal,
|
|
Optional,
|
|
overload,
|
|
TYPE_CHECKING,
|
|
)
|
|
from uuid import UUID
|
|
|
|
from cachetools import LRUCache
|
|
|
|
from galaxy.exceptions import (
|
|
ObjectNotFound,
|
|
RequestParameterInvalidException,
|
|
)
|
|
from galaxy.tool_util.id_util import extract_short_id_from_guid
|
|
from galaxy.tool_util.ontologies.ontology_data import curated_tool_tags
|
|
from galaxy.tool_util.parser import get_tool_source
|
|
from galaxy.tool_util.toolbox.base import (
|
|
resolve_tool_path,
|
|
SHED_TOOL_CONF_XML,
|
|
ToolConfRepository,
|
|
)
|
|
from galaxy.tool_util.toolbox.lineages.factory import LazyLineageMap
|
|
from galaxy.tool_util.toolbox.lineages.interface import ToolLineage
|
|
from galaxy.tool_util.toolbox.panel import ToolSection
|
|
from galaxy.tool_util.toolbox.parser import get_toolbox_parser
|
|
from galaxy.tool_util.version import parse_version
|
|
from galaxy.tools.source_store import (
|
|
StoredToolSource,
|
|
ToolSourceStore,
|
|
)
|
|
from galaxy.tools.source_store.composite import CompositeToolSourceStore
|
|
from galaxy.tools.source_store.discover import discover_tools
|
|
from galaxy.tools.source_store.index import (
|
|
ToolIndex,
|
|
ToolIndexEntry,
|
|
ToolPanelItem,
|
|
)
|
|
from galaxy.tools.source_store.populator import (
|
|
build_whoosh_for_store,
|
|
conf_to_store_map,
|
|
populate_for_paths,
|
|
populate_store_inline,
|
|
)
|
|
from galaxy.tools.source_store.watcher import ToolSourceStoreWatcher
|
|
from galaxy.util import listify
|
|
from galaxy.util.tool_version import remove_version_from_guid
|
|
from . import (
|
|
create_tool_from_source,
|
|
ToolBox,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from galaxy.app import UniverseApplication
|
|
from galaxy.model import User
|
|
from galaxy.tools import Tool
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# Whether to be strict about unknown attribute reads on a ``LazyTool``. Default
|
|
# is permissive: anything not on the stub surface and not in ``_MATERIALIZE_OK``
|
|
# materialises with a WARN log, instead of raising. Set ``LAZY_TOOL_STRICT=1``
|
|
# to flip to raise — useful when adding to the stub surface, since unaccounted
|
|
# reads then show up loudly. The eager pipeline + the integration suite hit a
|
|
# very wide tool surface; permissive is the pragmatic default once the
|
|
# explicit ``_MATERIALIZE_OK`` set has stabilised.
|
|
_LAZY_TOOL_PERMISSIVE = os.environ.get("LAZY_TOOL_STRICT") != "1"
|
|
|
|
|
|
def _entry_attr(name: str, entry_attr: str | None = None, mutable: bool = False):
|
|
"""Build a ``LazyTool`` property forwarding ``name`` to ``ToolIndexEntry``.
|
|
|
|
``_overrides`` shadows the entry so writes from the eager pipeline
|
|
(``tool.hidden = True``, ``tool.tool_shed = ...``) round-trip through
|
|
materialisation.
|
|
"""
|
|
src = entry_attr or name
|
|
|
|
def getter(self):
|
|
overrides = self._overrides
|
|
if name in overrides:
|
|
return overrides[name]
|
|
return getattr(self._entry, src)
|
|
|
|
if mutable:
|
|
|
|
def setter(self, value):
|
|
self._overrides[name] = value
|
|
|
|
return property(getter, setter)
|
|
return property(getter)
|
|
|
|
|
|
class LazyTool:
|
|
"""Lightweight stand-in for ``galaxy.tools.Tool`` backed by a ``ToolIndexEntry``.
|
|
|
|
Returned by :meth:`LazyToolBox.create_tool` whenever the tool's source is
|
|
already persisted in the tool source store. The eager
|
|
``AbstractToolBox._init_tools_from_configs`` pipeline reads and mutates
|
|
only a narrow attribute surface; ``LazyTool`` forwards every read off
|
|
``ToolIndexEntry`` and stores writes in ``_overrides`` so they are
|
|
re-applied if the stub is later materialised.
|
|
|
|
**Permissive by default:** an attribute outside the stub surface and not
|
|
in ``_MATERIALIZE_OK`` materialises a real Tool with a WARN log. Set
|
|
``LAZY_TOOL_STRICT=1`` to raise :class:`NotImplementedError` instead, so
|
|
unaccounted reads show up as clear failures rather than silent
|
|
multi-second parse stalls.
|
|
"""
|
|
|
|
__slots__ = ("_entry", "_materialize_cb", "_is_admin_user", "_overrides", "_real", "_lineage")
|
|
|
|
# ``watch_tool`` iterates ``tool._macro_paths`` for file-watching; lazy
|
|
# entries are content-addressed in the store, file-watching is a no-op.
|
|
# Class-level so it isn't writable on the instance.
|
|
_macro_paths: tuple = ()
|
|
|
|
# Methods / properties we accept will materialise a real Tool. Add to
|
|
# this set only when the parse cost is genuinely warranted at the call
|
|
# site — generally the tool-execution path (``handle_input``, the
|
|
# parameter machinery) and admin/container endpoints that need the
|
|
# fully-parsed Tool.
|
|
_MATERIALIZE_OK = frozenset(
|
|
{
|
|
"to_archive", # tool packaging endpoint
|
|
"build_dependency_cache", # explicit cache-warming
|
|
"tool_requirements", # container_resolvers/toolbox admin endpoint
|
|
"containers", # container resolution
|
|
"requirements", # alias used by some callers
|
|
# Tool-execution path. ``/api/tools/{id}`` POST → ``handle_input``
|
|
# → ``inputs`` / ``parameters`` / ``new_state`` / ``input_translator``.
|
|
# Materialising here is right: the caller is about to execute the
|
|
# tool, which fundamentally needs the parsed parameter tree.
|
|
"handle_input",
|
|
"inputs",
|
|
"parameters",
|
|
"new_state",
|
|
"input_translator",
|
|
"check_and_update_param_values", # validation called from handle_input
|
|
"wants_params_cleaned", # parameter scrub before execution
|
|
"tool_source", # raw ToolSource; some callers walk it directly
|
|
"dynamic_tool", # dynamic-tool linkage on execution
|
|
"produces_entry_points", # interactive tool entry points
|
|
"execute", # actual tool execute method
|
|
"expand_incoming", # parameter expansion for multi-run/map
|
|
"params_to_strings", # parameter serialisation
|
|
"tool_action", # execution action dispatch
|
|
"tool_dir", # on-disk tool directory accessor
|
|
"completed_jobs", # job-search via tool
|
|
"get_default_history_by_trans", # default-history lookup on execute
|
|
"regenerate_imported_metadata_if_needed", # import/export path
|
|
"outputs", # output spec read by execute / display
|
|
"tests", # tool test definitions read by /api/tools tests endpoints
|
|
"to_json", # tool JSON serialisation
|
|
"tool_requirements_status", # /api/tools requirements_status
|
|
"provided_metadata_file", # job-runner metadata path
|
|
"test_data_path", # tool test data lookup
|
|
"get_configured_job_handler", # job-runner handler routing
|
|
# Job runner reads this on the tool to decide environment setup.
|
|
"requires_galaxy_python_environment",
|
|
# Job-setup dependency/env path: the runner builds the dependency
|
|
# shell commands for the tool it's about to run (including the
|
|
# internal ``__SET_METADATA__`` tool that follows most jobs), which
|
|
# needs the parsed requirements. Materialising here is correct — the
|
|
# tool is executing.
|
|
"build_dependency_shell_commands",
|
|
# Parameter-validation path (data-manager / index-file tools): both
|
|
# walk the parsed parameter tree to check tool params against loaded
|
|
# data tables / index files at job time.
|
|
"params_with_missing_data_table_entry",
|
|
"params_with_missing_index_file",
|
|
# Admin dependency-management endpoints (install_dependencies /
|
|
# uninstall_dependencies) drive the resolver view on the tool.
|
|
"_view",
|
|
}
|
|
)
|
|
|
|
# --- forwarded read-only entry surface ---
|
|
id = _entry_attr("id")
|
|
uuid = _entry_attr("uuid")
|
|
name = _entry_attr("name", mutable=True)
|
|
description = _entry_attr("description")
|
|
tool_type = _entry_attr("tool_type")
|
|
tags = _entry_attr("tags")
|
|
require_login = _entry_attr("require_login")
|
|
edam_operations = _entry_attr("edam_operations")
|
|
edam_topics = _entry_attr("edam_topics")
|
|
icon = _entry_attr("icon")
|
|
xrefs = _entry_attr("xrefs")
|
|
is_workflow_compatible = _entry_attr("is_workflow_compatible")
|
|
|
|
# --- forwarded mutable entry surface ---
|
|
# Eager ``_load_tool_tag_set`` (base.py:964-987) mutates these post-create.
|
|
version = _entry_attr("version", mutable=True)
|
|
hidden = _entry_attr("hidden", mutable=True)
|
|
labels = _entry_attr("labels", mutable=True)
|
|
tool_shed = _entry_attr("tool_shed", mutable=True)
|
|
repository_name = _entry_attr("repository_name", mutable=True)
|
|
repository_owner = _entry_attr("repository_owner", mutable=True)
|
|
# Eager naming: ``installed_changeset_revision`` (vs entry's ``changeset_revision``).
|
|
installed_changeset_revision = _entry_attr(
|
|
"installed_changeset_revision",
|
|
entry_attr="changeset_revision",
|
|
mutable=True,
|
|
)
|
|
|
|
def __init__(self, entry: "ToolIndexEntry", materialize_callback, is_admin_user) -> None:
|
|
self._entry = entry
|
|
self._materialize_cb = materialize_callback
|
|
self._is_admin_user = is_admin_user
|
|
self._overrides: dict[str, Any] = {}
|
|
self._real: Tool | None = None
|
|
# Assigned by AbstractToolBox.__add_tool via _lineage_map.register(tool).
|
|
self._lineage: Any | None = None
|
|
|
|
# --- derived properties ---
|
|
@property
|
|
def guid(self):
|
|
return self._overrides.get("guid") or (self._entry.id if "/repos/" in self._entry.id else None)
|
|
|
|
@guid.setter
|
|
def guid(self, value):
|
|
self._overrides["guid"] = value
|
|
|
|
@property
|
|
def old_id(self) -> str:
|
|
if "old_id" in self._overrides:
|
|
return self._overrides["old_id"]
|
|
short = extract_short_id_from_guid(self._entry.id)
|
|
return short or self._entry.id
|
|
|
|
@property
|
|
def tool_tags(self) -> list[str]:
|
|
# Mirror ``Tool.__init__``'s ``all_ids`` construction: the curated
|
|
# tag mapping is keyed purely by tool id, so the stub can answer
|
|
# ``AbstractToolBox.curated_tool_tags``'s full-toolbox sweep without
|
|
# materialising anything.
|
|
if "tool_tags" in self._overrides:
|
|
return self._overrides["tool_tags"]
|
|
tool_id = (self._entry.id or "").lower()
|
|
if not tool_id:
|
|
return []
|
|
all_ids = [tool_id]
|
|
old_id = self.old_id.lower()
|
|
if old_id and old_id != tool_id:
|
|
all_ids = [tool_id, tool_id.rsplit("/", 1)[0], old_id]
|
|
return curated_tool_tags(all_ids)
|
|
|
|
@tool_tags.setter
|
|
def tool_tags(self, value):
|
|
self._overrides["tool_tags"] = value
|
|
|
|
@property
|
|
def config_file(self) -> str | None:
|
|
# ``source_path`` is stamped by the populator; entries serialized
|
|
# before the field existed deserialize as ``None`` and callers that
|
|
# need a real path fall through to ``__getattr__`` and materialise.
|
|
return self._entry.source_path
|
|
|
|
@property
|
|
def lineage(self):
|
|
return self._lineage
|
|
|
|
def get_panel_section(self) -> tuple[str, str] | tuple[None, None]:
|
|
"""Answer the tool's ``(section_id, section_name)`` off the entry.
|
|
|
|
``Tool.get_panel_section`` resolves this through
|
|
``toolbox.get_section_for_tool`` (a panel lookup), but the populator
|
|
already stamps the placement onto ``ToolIndexEntry``. Full-toolbox
|
|
sweeps read this — e.g. ``AgentTools.get_tool_categories`` iterates
|
|
``toolbox.tools()`` and reads ``get_panel_section()[1]`` per tool —
|
|
so forwarding off the entry keeps that O(N) walk from materialising
|
|
every tool.
|
|
"""
|
|
entry = self._entry
|
|
if entry.panel_section_id:
|
|
return (entry.panel_section_id, entry.panel_section_name or "")
|
|
return (None, None)
|
|
|
|
@property
|
|
def version_object(self):
|
|
# Mirror ``Tool.version_object`` (lib/galaxy/tools/__init__.py:1213).
|
|
# ``to_panel_view`` walks ``_lineage_in_panel`` → ``_newer_tool`` which
|
|
# compares ``tool.version_object``; computing it off the entry's
|
|
# ``version`` string keeps the stub self-sufficient.
|
|
GALAXY_VERSION_SUFFIX = "+galaxy"
|
|
version = self._entry.version or ""
|
|
if GALAXY_VERSION_SUFFIX not in version:
|
|
return parse_version(version)
|
|
base_version, suffix = version.split(GALAXY_VERSION_SUFFIX, 1)
|
|
if suffix:
|
|
# PEP-440 numeric sort hint — keep the eager Tool's behaviour.
|
|
version = f"{base_version}{GALAXY_VERSION_SUFFIX}.{suffix.lstrip('.')}"
|
|
return parse_version(version)
|
|
|
|
@property
|
|
def tool_shed_repository(self):
|
|
# The eager pipeline sets this on the real ``Tool`` for shed-installed
|
|
# tools (passing ``tool_shed_repository=<repo>`` to ``create_tool``);
|
|
# the LazyTool stub doesn't carry the repo object. Default to None —
|
|
# callers that need a real ``ToolShedRepository`` go through
|
|
# materialisation via ``_materialize_for_lazy_tool``, which routes the
|
|
# repo lookup via ``_lookup_tool_shed_repository``.
|
|
return self._overrides.get("tool_shed_repository")
|
|
|
|
@tool_shed_repository.setter
|
|
def tool_shed_repository(self, value):
|
|
self._overrides["tool_shed_repository"] = value
|
|
|
|
@property
|
|
def tool_errors(self):
|
|
return self._overrides.get("tool_errors")
|
|
|
|
@tool_errors.setter
|
|
def tool_errors(self, value):
|
|
self._overrides["tool_errors"] = value
|
|
|
|
# --- entry-only methods (no materialise) ---
|
|
def allow_user_access(self, user, attempting_access: bool = True) -> bool:
|
|
"""Mirror of :meth:`Tool.allow_user_access` derived from index metadata.
|
|
|
|
``DataManagerTool`` overrides ``allow_user_access`` to require admin
|
|
(lib/galaxy/tools/__init__.py:3893). On the stub we can't dispatch
|
|
polymorphically on subclass, so branch on ``tool_type`` instead.
|
|
``dynamic_tool`` (unprivileged-tool gating) is not in the index — fall
|
|
through to materialise via ``__getattr__`` for that one case if a
|
|
caller passes a dynamic tool. Stock filters never do.
|
|
"""
|
|
if self.require_login and user is None:
|
|
return False
|
|
if self.tool_type == "manage_data":
|
|
if user is None or not bool(self._is_admin_user(user)):
|
|
if attempting_access:
|
|
log.debug(
|
|
"User (%s) attempted to access a data manager tool (%s), but is not an admin.",
|
|
getattr(user, "id", None),
|
|
self.id,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def to_panel_entry(self, trans=None) -> dict[str, Any]:
|
|
"""Cheap entry-shape dict for the panel-view walk.
|
|
|
|
Returned by :meth:`AbstractToolBox.get_tool_to_dict` when no help
|
|
payload was requested. Stays off the materialise path entirely so
|
|
``/api/tool_panels/{view}`` and ``/api/tools?in_panel=true`` don't
|
|
parse every tool on the first request.
|
|
"""
|
|
entry = self._entry
|
|
if self._lineage is not None:
|
|
versions = list(self._lineage.tool_versions)
|
|
else:
|
|
versions = [entry.version] if entry.version else []
|
|
payload = {
|
|
"model_class": entry.model_class,
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"version": self.version,
|
|
"description": self.description,
|
|
"labels": self.labels if self.labels else [],
|
|
"icon": entry.icon,
|
|
"edam_operations": entry.edam_operations or [],
|
|
"edam_topics": entry.edam_topics or [],
|
|
"hidden": self.hidden,
|
|
"is_workflow_compatible": entry.is_workflow_compatible,
|
|
"xrefs": entry.xrefs or [],
|
|
"versions": versions,
|
|
"hidden_versions": [],
|
|
"link": f"/tool_runner?tool_id={self.id}",
|
|
"panel_section_id": entry.panel_section_id,
|
|
"panel_section_name": entry.panel_section_name,
|
|
"form_style": entry.form_style,
|
|
}
|
|
if entry.uuid:
|
|
payload["uuid"] = entry.uuid
|
|
if entry.tool_shed:
|
|
payload["tool_shed_repository"] = {
|
|
"name": entry.repository_name,
|
|
"owner": entry.repository_owner,
|
|
"changeset_revision": entry.changeset_revision,
|
|
"tool_shed": entry.tool_shed,
|
|
}
|
|
if trans is not None and getattr(trans, "user_is_admin", False):
|
|
payload["config_file"] = entry.source_path
|
|
return payload
|
|
|
|
def to_dict(self, trans=None, link_details: bool = False, tool_help: bool = False, **kw) -> dict[str, Any]:
|
|
"""Materialise and delegate — ``/api/tools/{id}`` contract.
|
|
|
|
The show endpoint ships ``xrefs`` / ``versions`` /
|
|
``is_workflow_compatible`` / ``tool_shed_repository`` / ``inputs``
|
|
/ ``outputs`` even with ``io_details=False``, so the parsed Tool
|
|
has to do the serialisation. The cheap panel-view path lives in
|
|
:meth:`to_panel_entry`; only ``to_dict`` materialises.
|
|
|
|
On materialise failure (tool XML the parameter factory can't
|
|
handle — ``upload_dataset``, ``column="value"`` against an
|
|
unresolvable column-name spec, …) fall back to the entry shape
|
|
so the caller still gets a renderable response; eager mode
|
|
catches the same in ``_load_tool_tag_set`` and drops the tool
|
|
from ``_tools_by_id``.
|
|
"""
|
|
try:
|
|
return self._materialize().to_dict(trans, link_details=link_details, tool_help=tool_help, **kw)
|
|
except Exception as e:
|
|
log.warning("LazyTool.to_dict: materialise failed for %s, falling back to entry: %s", self.id, e)
|
|
return self.to_panel_entry(trans)
|
|
|
|
# --- materialisation ---
|
|
def _materialize(self) -> "Tool":
|
|
if self._real is None:
|
|
real = self._materialize_cb(self._entry)
|
|
# Re-apply mutations the eager pipeline recorded against the stub
|
|
# before materialise (``hidden``, ``labels``, shed metadata, etc.).
|
|
for name, value in self._overrides.items():
|
|
try:
|
|
setattr(real, name, value)
|
|
except Exception:
|
|
log.debug("LazyTool._materialize could not re-apply override %r on %s", name, self.id)
|
|
if self._lineage is not None:
|
|
real._lineage = self._lineage
|
|
self._real = real
|
|
return self._real
|
|
|
|
# --- strict fallthrough ---
|
|
def __getattr__(self, name: str):
|
|
if name in self._MATERIALIZE_OK:
|
|
return getattr(self._materialize(), name)
|
|
# Other private/dunder attrs are never materialise triggers — surface
|
|
# as ``AttributeError`` so e.g. ``hasattr(tool, "__something__")``
|
|
# stays cheap.
|
|
if name.startswith("_"):
|
|
raise AttributeError(name)
|
|
if _LAZY_TOOL_PERMISSIVE:
|
|
log.warning(
|
|
"LazyTool.%r forced materialise of tool %r (LAZY_TOOL_PERMISSIVE=1); "
|
|
"add to the stub surface or _MATERIALIZE_OK to make this explicit.",
|
|
name,
|
|
self.id,
|
|
)
|
|
return getattr(self._materialize(), name)
|
|
raise NotImplementedError(
|
|
f"LazyTool.{name!r} is not on the stub surface for tool {self.id!r}. "
|
|
f"If this attribute can be read off ToolIndexEntry, add a forwarded "
|
|
f"property; if it genuinely needs a parsed Tool, add it to "
|
|
f"LazyTool._MATERIALIZE_OK. Set LAZY_TOOL_PERMISSIVE=1 to bypass "
|
|
f"this check while debugging."
|
|
)
|
|
|
|
|
|
class LazyToolBox(ToolBox):
|
|
"""
|
|
ToolBox that loads tools on-demand from the tool source store.
|
|
|
|
Extends ToolBox but overrides initialization to avoid loading all tools
|
|
at startup. Keeps a lightweight index in memory for API responses,
|
|
but only loads full Tool objects when needed for execution or form building.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config_filenames: list[str],
|
|
tool_root_dir: str,
|
|
app: "UniverseApplication",
|
|
tool_source_store: ToolSourceStore | None,
|
|
cache_size: int = 500,
|
|
save_integrated_tool_panel: bool = True,
|
|
) -> None:
|
|
# Lazy-only state set BEFORE ``super().__init__`` because the
|
|
# eager ``_init_tools_from_configs`` (which super invokes mid-init)
|
|
# is overridden below to consult ``self._store`` and populate
|
|
# ``self._tool_index``.
|
|
self._store = tool_source_store
|
|
self._tool_object_cache: LRUCache = LRUCache(maxsize=cache_size)
|
|
self._cache_lock = threading.RLock()
|
|
# Count of stubs promoted to real ``Tool`` objects since boot — every
|
|
# runtime materialise funnels through ``_create_tool_from_stored_source``.
|
|
# Batch/read endpoints must never move this counter; the integration
|
|
# suite asserts a zero delta across them to catch an accidental
|
|
# whole-toolbox sweep that ``LAZY_TOOL_STRICT`` alone can't (a legit
|
|
# ``_MATERIALIZE_OK`` attr read in a loop, or a tool-filter that parses).
|
|
self._lazy_materialize_count = 0
|
|
# ``_tool_index`` is filled by our ``_init_tools_from_configs`` override
|
|
# before the eager walk runs.
|
|
self._tool_index: ToolIndex | None = None
|
|
# Mirror the eager toolbox's short-id → Tool mapping so
|
|
# ``GET /api/tools/<short_id>`` resolves shed installs even when
|
|
# only the guid landed in the panel. Filled after the eager walk
|
|
# via ``_rebuild_shed_short_id_map``.
|
|
self._shed_short_id_to_guids: dict[str, set[str]] = {}
|
|
self._store_watcher: ToolSourceStoreWatcher | None = None
|
|
# Identity-keyed cache of every indexed ``source_path`` — see
|
|
# ``_index_source_paths``. Set before ``super().__init__`` because
|
|
# the eager walk consults it through ``_tool_file_on_disk``.
|
|
self._index_source_paths_cache: tuple[ToolIndex, set[str]] | None = None
|
|
# Versionless-guid → sibling versions map, keyed on index identity
|
|
# plus entry count — see ``_guid_sibling_versions``. Also consulted
|
|
# during ``super().__init__`` (panel views resolve lineages
|
|
# mid-walk).
|
|
self._guid_sibling_versions_cache: tuple[ToolIndex, int, dict[str, list[tuple[str, str]]]] | None = None
|
|
|
|
# Eager init — its ``_init_tools_from_configs`` is overridden so the
|
|
# walk goes through our ``create_tool`` seam and hands back LazyTool
|
|
# stubs for indexed sources.
|
|
super().__init__(
|
|
config_filenames=config_filenames,
|
|
tool_root_dir=tool_root_dir,
|
|
app=app,
|
|
save_integrated_tool_panel=save_integrated_tool_panel,
|
|
)
|
|
|
|
# Post-eager-walk: build the short-id lookup from whatever guids
|
|
# the panel pass registered. Section metadata, conf-level hidden,
|
|
# and labels are already on the index entries (the populator stamps
|
|
# them at discovery time), so no post-walk sync is needed.
|
|
self._rebuild_shed_short_id_map()
|
|
|
|
log.info(
|
|
"LazyToolBox initialized with %d tools (cache_size=%d, parsed=%d, from_store=%d)",
|
|
len(self._tools_by_id),
|
|
cache_size,
|
|
self._tools_parsed_from_file,
|
|
self._tools_loaded_from_store,
|
|
)
|
|
|
|
self._start_store_watcher()
|
|
|
|
def _start_store_watcher(self) -> None:
|
|
"""Poll externally-published stores for freshness-token changes.
|
|
|
|
Only read-only members with a probe are watched: writable stores
|
|
change through this process's own populate paths, which broadcast
|
|
their own reloads, and CVMFS (the read-only publishing model)
|
|
delivers no filesystem events to react to — polling one
|
|
extended-attribute read per store per tick is the whole cost.
|
|
"""
|
|
if not self.app.config.watch_tool_source_stores:
|
|
return
|
|
if not isinstance(self._store, CompositeToolSourceStore):
|
|
log.info("watch_tool_source_stores is enabled but no named tool source stores are configured")
|
|
return
|
|
members = [(n, m) for n, m in self._store.members if m.read_only and m.has_freshness_probe]
|
|
if not members:
|
|
log.info("watch_tool_source_stores is enabled but no read-only store declares a freshness probe")
|
|
return
|
|
self._store_watcher = ToolSourceStoreWatcher(
|
|
members=members,
|
|
interval=self.app.config.tool_source_store_watch_interval,
|
|
on_change=self._on_store_freshness_change,
|
|
)
|
|
self._store_watcher.start()
|
|
log.info(
|
|
"Watching tool source store(s) %s for freshness changes every %gs",
|
|
sorted(n for n, _ in members),
|
|
self.app.config.tool_source_store_watch_interval,
|
|
)
|
|
|
|
def _on_store_freshness_change(self, changed_names: list[str]) -> None:
|
|
"""A watched store was republished: reload index state and search.
|
|
|
|
``invalidate_index_cache`` handles the reload dance (it also
|
|
disposes read-only members' engines — see there). The whoosh
|
|
rebuild runs here rather than in the reload path because only a
|
|
republished store can grow the corpus without a local populate;
|
|
its corpus-signature check makes re-runs no-ops, and concurrent
|
|
rebuilds from peer processes degrade to one winner (whoosh lock,
|
|
errors swallowed and logged by ``build_whoosh_for_store``).
|
|
"""
|
|
self.invalidate_index_cache()
|
|
if not isinstance(self._store, CompositeToolSourceStore):
|
|
return
|
|
for name, member in self._store.members:
|
|
if name not in changed_names:
|
|
continue
|
|
index = member.load_index()
|
|
if index is not None:
|
|
build_whoosh_for_store(self.app.config, name, index)
|
|
|
|
def _init_tools_from_configs(self, config_filenames: list[str]) -> None:
|
|
"""Load the persistent ``ToolIndex`` before delegating to the eager walk.
|
|
|
|
Cold-start safety net: if the index doesn't yet cover every tool the
|
|
configs reference (fresh checkout, a new conf entry, a wiped store),
|
|
invoke the populator in-process to fill the gap. The populator is
|
|
content-addressed and idempotent, so re-runs on a warm store only
|
|
touch the new rows.
|
|
|
|
After this returns, the eager pipeline calls into ``create_tool`` for
|
|
every ``<tool>`` it walks. The seam short-circuits indexed sources to
|
|
a :class:`LazyTool` stub; misses raise — by contract the cold-start
|
|
populator below guarantees coverage.
|
|
"""
|
|
# Replace the plain ``LineageMap`` the base ``__init__`` just
|
|
# assigned: ``LazyLineageMap`` sources each lineage's version set
|
|
# from ``entries_by_version`` at lookup time, so post-boot lookups
|
|
# (peer installs surfaced by ``invalidate_index_cache``, reloads)
|
|
# see every indexed version instead of a memoised single-version
|
|
# lineage built from one Tool object.
|
|
self._lineage_map = LazyLineageMap(self.app, versions_for=self._index_versions_for)
|
|
self._tool_panel_loaded_from_index = False
|
|
if self._store is not None:
|
|
self._tool_index = self._store.load_index() or ToolIndex()
|
|
if self._index_needs_population():
|
|
self._run_inline_populator()
|
|
# The populator writes through its own store instances; drop
|
|
# this store's cached index so the re-load below reads the
|
|
# index the populator just persisted rather than the (possibly
|
|
# foreign, shared-database) index cached two lines up.
|
|
self._store.invalidate_index_cache()
|
|
self._tool_index = self._store.load_index() or ToolIndex()
|
|
else:
|
|
self._tool_index = ToolIndex()
|
|
if self._init_tools_from_index(config_filenames):
|
|
return
|
|
super()._init_tools_from_configs(config_filenames)
|
|
|
|
def _init_tools_from_index(self, config_filenames: list[str]) -> bool:
|
|
"""Register lazy stubs directly from a fresh index.
|
|
|
|
The generic toolbox walk still parses every tool-conf item and routes
|
|
each tool through ``load_item``. In lazy mode, a populated source-store
|
|
index already carries the panel placements (``ToolIndex.panel_items``)
|
|
the walk would produce, so replay those instead. If the index predates
|
|
placement recording, return ``False`` and let the parent
|
|
implementation take the conservative path.
|
|
"""
|
|
placements = self._index_panel_items()
|
|
if not placements:
|
|
return False
|
|
# ``_index_panel_items`` only returns placements when the index is
|
|
# populated, so it is non-None here.
|
|
assert self._tool_index is not None
|
|
missing = {p.tool_id for p in placements if p.tool_id not in self._tool_index.entries}
|
|
if missing:
|
|
log.info(
|
|
"LazyToolBox fast panel init found %d panel ids missing from index; running populator",
|
|
len(missing),
|
|
)
|
|
self._run_inline_populator()
|
|
if self._store is not None:
|
|
self._store.invalidate_index_cache()
|
|
self._tool_index = self._store.load_index() or ToolIndex()
|
|
placements = self._index_panel_items()
|
|
missing = {p.tool_id for p in placements if p.tool_id not in self._tool_index.entries}
|
|
if missing or not placements:
|
|
log.debug("LazyToolBox fast panel init disabled; %d panel ids still missing from index", len(missing))
|
|
return False
|
|
assert self._tool_index is not None
|
|
self._init_dynamic_tool_confs_without_loading(config_filenames)
|
|
stubs_by_id: dict[str, LazyTool] = {}
|
|
for entry in self._tool_index.entries.values():
|
|
stubs_by_id[entry.id] = self._register_lazy_entry(entry, place_in_panel=False)
|
|
placed = 0
|
|
for placement in placements:
|
|
stub = stubs_by_id.get(placement.tool_id)
|
|
if stub is None:
|
|
continue
|
|
self._place_stub(stub, placement.section_id, placement.section_name, hidden=placement.hidden)
|
|
placed += 1
|
|
log.debug(
|
|
"LazyToolBox registered %d indexed tools (%d panel placements) without walking tool conf items",
|
|
len(stubs_by_id),
|
|
placed,
|
|
)
|
|
self._tool_panel_loaded_from_index = True
|
|
return True
|
|
|
|
def _load_tool_panel(self) -> None:
|
|
if getattr(self, "_tool_panel_loaded_from_index", False):
|
|
return
|
|
super()._load_tool_panel()
|
|
|
|
def _index_panel_items(self) -> list[ToolPanelItem]:
|
|
"""The index's conf-ordered panel placements, latest version only.
|
|
|
|
Placements of non-newest versions of a lineage are dropped the same
|
|
way the old id-based fast path collapsed them — the panel shows the
|
|
latest version, older ones stay reachable through the lineage.
|
|
"""
|
|
if self._tool_index is None:
|
|
return []
|
|
placements = self._tool_index.panel_items
|
|
if not placements:
|
|
return []
|
|
ordered_ids: list[str] = []
|
|
seen: set[str] = set()
|
|
for placement in placements:
|
|
if placement.tool_id in seen:
|
|
continue
|
|
seen.add(placement.tool_id)
|
|
ordered_ids.append(placement.tool_id)
|
|
keep = set(self._latest_panel_tool_ids(ordered_ids))
|
|
return [placement for placement in placements if placement.tool_id in keep]
|
|
|
|
def _latest_panel_tool_ids(self, tool_ids: list[str]) -> list[str]:
|
|
if self._tool_index is None:
|
|
return tool_ids
|
|
ordered_lineages: list[tuple[str | None, str]] = []
|
|
latest_by_lineage: dict[tuple[str | None, str], ToolIndexEntry] = {}
|
|
for tool_id in tool_ids:
|
|
entry = self._tool_index.entries.get(tool_id)
|
|
if entry is None or entry.hidden:
|
|
continue
|
|
lineage_id = remove_version_from_guid(entry.id) or entry.id
|
|
lineage_key = (entry.panel_section_id, lineage_id)
|
|
current = latest_by_lineage.get(lineage_key)
|
|
if current is None:
|
|
ordered_lineages.append(lineage_key)
|
|
latest_by_lineage[lineage_key] = entry
|
|
elif self._index_entry_newer(entry, current):
|
|
latest_by_lineage[lineage_key] = entry
|
|
return [latest_by_lineage[lineage_key].id for lineage_key in ordered_lineages]
|
|
|
|
def _index_entry_newer(self, entry: ToolIndexEntry, other: ToolIndexEntry) -> bool:
|
|
try:
|
|
return parse_version(entry.version or "0") > parse_version(other.version or "0")
|
|
except Exception:
|
|
return (entry.version or "") > (other.version or "")
|
|
|
|
def _init_dynamic_tool_confs_without_loading(self, config_filenames: list[str]) -> None:
|
|
config_filenames = listify(config_filenames)
|
|
config_directories = [config_filename for config_filename in config_filenames if os.path.isdir(config_filename)]
|
|
config_filenames = [
|
|
config_filename for config_filename in config_filenames if config_filename not in config_directories
|
|
]
|
|
for config_directory in config_directories:
|
|
directory_contents = sorted(os.listdir(config_directory))
|
|
directory_config_files = [config_file for config_file in directory_contents if config_file.endswith(".xml")]
|
|
config_filenames.extend(directory_config_files)
|
|
for config_filename in config_filenames:
|
|
if not self.can_load_config_file(config_filename):
|
|
continue
|
|
self._init_dynamic_tool_conf_without_loading(config_filename)
|
|
|
|
def _init_dynamic_tool_conf_without_loading(self, config_filename: str) -> None:
|
|
try:
|
|
tool_conf_source = get_toolbox_parser(config_filename)
|
|
except OSError as exc:
|
|
dynamic_confs = (self.app.config.shed_tool_config_file, self.app.config.migrated_tools_config)
|
|
if config_filename in dynamic_confs and exc.errno == errno.ENOENT:
|
|
stcd = dict(
|
|
config_filename=config_filename,
|
|
tool_path=self.app.config.shed_tools_dir,
|
|
config_elems=[],
|
|
create=SHED_TOOL_CONF_XML.format(shed_tools_dir=self.app.config.shed_tools_dir),
|
|
)
|
|
self._dynamic_tool_confs.append(stcd)
|
|
return
|
|
raise
|
|
if not tool_conf_source.is_shed_tool_conf() or not os.access(config_filename, os.W_OK):
|
|
return
|
|
tool_path = resolve_tool_path(tool_conf_source.parse_tool_path(), config_filename, self._tool_root_dir)
|
|
config_elems = [item.elem for item in tool_conf_source.parse_items() if item.has_elem]
|
|
self._dynamic_tool_confs.append(
|
|
dict(
|
|
config_filename=config_filename,
|
|
tool_path=tool_path,
|
|
config_elems=config_elems,
|
|
)
|
|
)
|
|
|
|
def _index_needs_population(self) -> bool:
|
|
"""Return True when at least one config-discovered tool path is absent
|
|
from the store.
|
|
|
|
The stored paths are fetched as one bulk set up front
|
|
(``list_source_paths``) so the conf walk costs a set lookup per tool
|
|
instead of a store query per tool — on a CVMFS-scale deployment the
|
|
per-tool round trips dominated boot time.
|
|
|
|
Confs routed to read-only stores are not walked at all: the
|
|
populator can't write those stores, so a miss there could never be
|
|
healed — their coverage is the trust/schema gate's job
|
|
(``index_is_fresh``), and any genuinely missing tool falls through
|
|
to the eager parse in ``create_tool``.
|
|
"""
|
|
if self._store is None:
|
|
return False
|
|
if not self._tool_index or not self._tool_index.entries:
|
|
return True
|
|
# Freshness fast path: read-only members are trusted whenever their
|
|
# index loads (published together with their tools — the CVMFS
|
|
# model); writable members compare their probe against the token
|
|
# the populator stamped. When every member reports fresh, the store
|
|
# provably covers the current tree — skip the conf walk (and its
|
|
# per-file existence stats) entirely. See
|
|
# ``galaxy.tools.source_store.freshness``.
|
|
fresh = self._store.index_is_fresh()
|
|
if fresh is True:
|
|
if self._writable_store_index_needs_population():
|
|
return True
|
|
log.info("Tool source store index is fresh; skipping index coverage scan")
|
|
return False
|
|
if fresh is False:
|
|
log.info("Tool source store index is stale; running populator")
|
|
return True
|
|
try:
|
|
stored_paths = self._store.list_source_paths()
|
|
only_confs = None
|
|
if isinstance(self._store, CompositeToolSourceStore):
|
|
read_only_stores = self._store.read_only_member_names
|
|
if read_only_stores:
|
|
conf_to_store = conf_to_store_map(self.app.config)
|
|
only_confs = {c for c, name in conf_to_store.items() if name not in read_only_stores}
|
|
for d in discover_tools(self.app.config, only_confs=only_confs):
|
|
if d.path not in stored_paths:
|
|
return True
|
|
except Exception as e:
|
|
log.warning("Index coverage check raised; running populator defensively: %s", e)
|
|
return True
|
|
return False
|
|
|
|
def _writable_store_index_needs_population(self) -> bool:
|
|
"""Return True when an index entry references a source the store lost.
|
|
|
|
The invariant that matters is one-directional: every ``source_hash``
|
|
the index references must resolve to a stored row, or materialising
|
|
that tool would fail. The reverse is not staleness — the store is
|
|
append-only (only ``reconcile_index`` prunes), so rows orphaned by a
|
|
content change, an ad-hoc self-heal superseded by conf context, or an
|
|
uninstall accumulate legitimately. A symmetric comparison here turned
|
|
one such orphan into a full inline repopulate on every boot and
|
|
reload, permanently defeating the freshness-token scan skip.
|
|
"""
|
|
if self._store is None:
|
|
return False
|
|
stores = (
|
|
self._store.members if isinstance(self._store, CompositeToolSourceStore) else [("__default__", self._store)]
|
|
)
|
|
for store_name, store in stores:
|
|
if store.read_only:
|
|
continue
|
|
source_hashes = set(store.list_all())
|
|
index = store.load_index()
|
|
index_hashes: set[str] = set()
|
|
if index is not None:
|
|
index_hashes.update(entry.source_hash for entry in index.entries.values() if entry.source_hash)
|
|
for versions in index.entries_by_version.values():
|
|
index_hashes.update(entry.source_hash for entry in versions.values() if entry.source_hash)
|
|
dangling = index_hashes - source_hashes
|
|
if dangling:
|
|
log.info(
|
|
"Tool source store index for %s references %d source(s) missing from the store "
|
|
"(%d indexed, %d stored); running populator",
|
|
store_name,
|
|
len(dangling),
|
|
len(index_hashes),
|
|
len(source_hashes),
|
|
)
|
|
return True
|
|
orphaned = len(source_hashes - index_hashes)
|
|
if orphaned:
|
|
log.debug(
|
|
"Tool source store %s holds %d source row(s) no index entry references "
|
|
"(superseded content or ad-hoc rows; reconcile_index prunes them)",
|
|
store_name,
|
|
orphaned,
|
|
)
|
|
return False
|
|
|
|
def _run_inline_populator(self) -> None:
|
|
"""Cold-start hook: write the index + whoosh 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
|
|
reroute. Failures here surface to the operator: the eager walk that
|
|
follows calls ``create_tool``, which falls through to the parent
|
|
on miss — a broken populator surfaces immediately on first tool
|
|
load instead of degrading silently.
|
|
"""
|
|
log.info("LazyToolBox: running populator inline to backfill the index")
|
|
populate_store_inline(
|
|
self.app.config,
|
|
rebuild_whoosh=True,
|
|
)
|
|
|
|
def _index_versions_for(self, tool_id: str) -> list[str]:
|
|
"""Return every version present in the index for ``tool_id``.
|
|
|
|
Hooked into ``LazyLineageMap.versions_for`` so a lineage lookup
|
|
sources its data straight from ``_tool_index.entries_by_version``.
|
|
Empty list (no versions) tells the lineage map to fall through to
|
|
the standard ``LineageMap.get`` toolbox path.
|
|
|
|
For shed-installed tools the index keys each entry on the full
|
|
toolshed guid (e.g. ``toolshed.../fastp/0.20.1+galaxy0``), so a
|
|
single id maps to a single version. Lineage merging in
|
|
``ToolSection.copy(merge_tools=True)`` needs *every* version with
|
|
the same versionless guid to deduplicate older revisions out of
|
|
a panel view (e.g. ``test_only_latest_version_in_panel_fastp``
|
|
expects two installed fastp revisions to render as one
|
|
latest-version entry). Eager achieves this via
|
|
``_tools_by_old_id``; the lazy index needs to walk sibling
|
|
entries that share the versionless prefix.
|
|
"""
|
|
if self._tool_index is None:
|
|
return []
|
|
versions = list(self._tool_index.entries_by_version.get(tool_id, {}).keys())
|
|
result = [v for v in versions if v]
|
|
if "/repos/" in tool_id:
|
|
versionless = remove_version_from_guid(tool_id)
|
|
if versionless:
|
|
for entry_id, version in self._guid_sibling_versions().get(versionless, ()):
|
|
if entry_id != tool_id and version not in result:
|
|
result.append(version)
|
|
return result
|
|
|
|
def _guid_sibling_versions(self) -> dict[str, list[tuple[str, str]]]:
|
|
"""Versionless guid → ``(entry_id, version)`` of every indexed sibling.
|
|
|
|
``_index_versions_for`` used to prefix-scan all index entries per
|
|
lineage lookup; the EDAM panel views resolve a lineage per shed
|
|
tool, which made boot quadratic in installed-tool count (~11k
|
|
scans over ~9k entries profiled as the single largest toolbox
|
|
cost). Keyed on index identity *and* entry count — reloads swap
|
|
the ``ToolIndex`` object, while registrations and removals mutate
|
|
membership in place; in-place removals additionally reset the
|
|
cache explicitly (a pop-then-add could keep the count stable).
|
|
"""
|
|
index = self._tool_index
|
|
if index is None:
|
|
return {}
|
|
cached = self._guid_sibling_versions_cache
|
|
if cached is not None and cached[0] is index and cached[1] == len(index.entries):
|
|
return cached[2]
|
|
siblings: dict[str, list[tuple[str, str]]] = {}
|
|
for entry_id, entry in index.entries.items():
|
|
if "/repos/" not in entry_id or not entry.version:
|
|
continue
|
|
versionless = remove_version_from_guid(entry_id)
|
|
if versionless:
|
|
siblings.setdefault(versionless, []).append((entry_id, entry.version))
|
|
self._guid_sibling_versions_cache = (index, len(index.entries), siblings)
|
|
return siblings
|
|
|
|
def _rebuild_shed_short_id_map(self) -> None:
|
|
"""Walk the index and rebuild short-id → guid mappings for shed installs.
|
|
|
|
Called from ``__init__`` (after the index is loaded from the store)
|
|
and from ``_prune_orphaned_shed_entries`` (after pruning), so the
|
|
map stays consistent with whatever is currently in
|
|
``self._tool_index.entries``.
|
|
"""
|
|
self._shed_short_id_to_guids.clear()
|
|
if self._tool_index is None:
|
|
return
|
|
for entry_id in self._tool_index.entries.keys():
|
|
short_id = extract_short_id_from_guid(entry_id)
|
|
if short_id and short_id != entry_id:
|
|
self._shed_short_id_to_guids.setdefault(short_id, set()).add(entry_id)
|
|
|
|
# === Override get_tool for lazy loading ===
|
|
|
|
@overload
|
|
def get_tool(
|
|
self,
|
|
tool_id: str | None = None,
|
|
tool_version: str | None = None,
|
|
tool_uuid: UUID | str | None = None,
|
|
get_all_versions: Literal[False] = False,
|
|
exact: bool | None = False,
|
|
user: Optional["User"] = None,
|
|
) -> Optional["Tool"]: ...
|
|
|
|
@overload
|
|
def get_tool(
|
|
self,
|
|
tool_id: str | None = None,
|
|
tool_version: str | None = None,
|
|
tool_uuid: UUID | str | None = None,
|
|
get_all_versions: Literal[True] = True,
|
|
exact: bool | None = False,
|
|
user: Optional["User"] = None,
|
|
) -> list["Tool"]: ...
|
|
|
|
def get_tool(
|
|
self,
|
|
tool_id: str | None = None,
|
|
tool_version: str | None = None,
|
|
tool_uuid: UUID | str | None = None,
|
|
get_all_versions: bool | None = False,
|
|
exact: bool | None = False,
|
|
user: Optional["User"] = None,
|
|
) -> Optional["Tool"] | list["Tool"]:
|
|
"""
|
|
Get a tool, loading from store on-demand if needed.
|
|
|
|
Overrides ToolBox.get_tool to implement lazy loading.
|
|
"""
|
|
if tool_id is None and tool_uuid is None:
|
|
raise RequestParameterInvalidException("get_tool cannot be called with both tool_id and tool_uuid as None")
|
|
|
|
# Handle UUID lookup
|
|
if tool_uuid:
|
|
if user:
|
|
unprivileged_tool = self.get_unprivileged_tool_or_none(user, tool_uuid=tool_uuid)
|
|
if unprivileged_tool:
|
|
return unprivileged_tool
|
|
tool_uuid = tool_uuid if isinstance(tool_uuid, UUID) else UUID(tool_uuid)
|
|
tool_from_uuid = self._get_tool_by_uuid(tool_uuid)
|
|
if tool_from_uuid is None:
|
|
raise ObjectNotFound(f"Failed to find a tool with uuid [{tool_uuid}]")
|
|
tool_id = tool_from_uuid.id
|
|
|
|
assert tool_id
|
|
|
|
if tool_version:
|
|
tool_version = str(tool_version)
|
|
|
|
if get_all_versions and exact:
|
|
raise RequestParameterInvalidException(
|
|
"get_tool cannot be called with both get_all_versions and exact as True"
|
|
)
|
|
|
|
# Check if we have this tool in our index
|
|
if self._tool_index and tool_id in self._tool_index.entries:
|
|
if get_all_versions:
|
|
# Lazy-load every indexed version. Callers (e.g. workflow
|
|
# refactor's ``upgrade_all_steps``) need every version to
|
|
# determine the latest; returning only the requested version
|
|
# makes upgrades silently no-op.
|
|
def _ver_key(v: str):
|
|
# ``galaxy.tool_util.version.parse_version`` matches what
|
|
# the eager ToolLineage uses to order versions and
|
|
# tolerates non-numeric segments (e.g. ``"1.0.0+galaxy0"``)
|
|
# via ``LegacyVersion`` fallback.
|
|
try:
|
|
return (0, parse_version(v))
|
|
except Exception:
|
|
return (1, v)
|
|
|
|
versions = sorted(
|
|
self._tool_index.entries_by_version.get(tool_id, {}).keys(),
|
|
key=_ver_key,
|
|
)
|
|
tools: list[Tool] = []
|
|
for ver in versions:
|
|
loaded = self._load_tool_on_demand(tool_id, ver or None)
|
|
if loaded is not None:
|
|
tools.append(loaded)
|
|
if tools:
|
|
return tools
|
|
else:
|
|
tool = self._load_tool_on_demand(tool_id, tool_version)
|
|
if tool:
|
|
return tool
|
|
# Version not in the index. Fall back to the default (latest)
|
|
# entry's Tool — not the requested version, but a Tool for the
|
|
# same id. The eager toolbox does this after a
|
|
# ``_tool_versions_by_id`` miss when ``tool_id`` is in
|
|
# ``_tools_by_id`` and ``exact`` is False: the for-loop in
|
|
# ``AbstractToolBox.get_tool`` ``continue``s for ``exact``
|
|
# when the version doesn't match. Without this fallback,
|
|
# ``ToolModule.__init__`` invoked at workflow-upload time
|
|
# gets ``None`` for any workflow pinned to a tool_version we
|
|
# no longer ship — eager would have returned the
|
|
# lineage-newest Tool, then ``get_safe_version`` would have
|
|
# downgraded it to the safe-upgrade version
|
|
# (e.g. ``__BUILD_LIST__`` 1.0.0 → 1.1.0 via
|
|
# ``WORKFLOW_SAFE_TOOL_VERSION_UPDATES``). Returning ``None``
|
|
# here breaks that path and the workflow ends up bound to
|
|
# the latest version with state shaped for the old version,
|
|
# producing spurious upgrade-message 400s on invoke.
|
|
#
|
|
# Honor ``exact`` though: callers like the workflow
|
|
# missing-tool check pass ``exact=True`` specifically to
|
|
# ask "is THIS exact version installed", and silently
|
|
# substituting the latest makes the missing-tools list
|
|
# under-report (``test_run_workflow_with_missing_tool``
|
|
# asserts both ``nonexistent_tool`` and a known-absent
|
|
# ``compose_text_param 0.0.1`` show up as missing).
|
|
if tool_version and not exact:
|
|
default_entry = self._tool_index.entries.get(tool_id)
|
|
if default_entry is not None:
|
|
default_version = default_entry.version or None
|
|
tool = self._load_tool_on_demand(tool_id, default_version)
|
|
if tool:
|
|
return tool
|
|
|
|
# Short-id fallback for shed installs. The eager toolbox resolves
|
|
# ``get_tool("collection_column_join")`` via ``_tools_by_old_id``,
|
|
# which is populated at tool-registration time. The lazy install
|
|
# path doesn't materialise the Tool, so we maintain
|
|
# ``_shed_short_id_to_guids`` separately and consult it here. Each
|
|
# shed install lives under a distinct guid in the index, so we
|
|
# walk every guid mapped to the short id and sort the
|
|
# successfully-loaded Tools by version — matching the eager path's
|
|
# ``rval.sort(key=lambda t: t.version_object)`` over
|
|
# ``_tools_by_old_id[tool_id]``.
|
|
if self._tool_index and self._shed_short_id_to_guids and tool_id in self._shed_short_id_to_guids:
|
|
candidates: list[tuple[tuple[int, Any], Tool]] = []
|
|
for guid in sorted(self._shed_short_id_to_guids[tool_id]):
|
|
loaded = self._load_tool_on_demand(guid, tool_version)
|
|
if loaded is None and tool_version:
|
|
# Unknown version on this guid — try its default.
|
|
default_entry = self._tool_index.entries.get(guid)
|
|
if default_entry is not None:
|
|
loaded = self._load_tool_on_demand(guid, default_entry.version or None)
|
|
if loaded is not None:
|
|
ver_key: tuple[int, Any]
|
|
try:
|
|
ver_key = (0, parse_version(loaded.version or "0"))
|
|
except Exception:
|
|
ver_key = (1, loaded.version or "")
|
|
candidates.append((ver_key, loaded))
|
|
if candidates:
|
|
candidates.sort(key=lambda pair: pair[0])
|
|
if get_all_versions:
|
|
return [t for _, t in candidates]
|
|
return candidates[-1][1]
|
|
|
|
# Fall back to parent implementation for tools not in our index
|
|
# (dynamic tools, data manager tools, etc.)
|
|
if get_all_versions:
|
|
return super().get_tool(
|
|
tool_id=tool_id,
|
|
tool_version=tool_version,
|
|
tool_uuid=tool_uuid,
|
|
get_all_versions=True,
|
|
exact=exact,
|
|
user=user,
|
|
)
|
|
return super().get_tool(
|
|
tool_id=tool_id,
|
|
tool_version=tool_version,
|
|
tool_uuid=tool_uuid,
|
|
get_all_versions=False,
|
|
exact=exact,
|
|
user=user,
|
|
)
|
|
|
|
# === create_tool seam: return LazyTool stub when the index already has the source ===
|
|
|
|
def create_tool(self, config_file, tool_shed_repository=None, guid=None, **kwds) -> "Tool":
|
|
"""Return a :class:`LazyTool` for every indexed tool source.
|
|
|
|
The populator (cold-start in :meth:`_init_tools_from_configs`, shed
|
|
installs via ``tool_panel_manager.add_to_tool_panel``) is the single
|
|
writer of the index. Hidden lib tools (``set_metadata_tool.xml``,
|
|
``data_fetch``, history import/export) are indexed via
|
|
``galaxy.tools.special_tools.hidden_lib_tool_paths``, so the
|
|
post-boot ``load_hidden_lib_tool`` calls resolve through the seam
|
|
too. A miss for a file that exists on disk self-heals through a
|
|
single-path populate (:meth:`_populate_adhoc_path`) — shed installs
|
|
load cloned tools during metadata generation, before any conf is
|
|
persisted. A miss for anything else is a contract failure and
|
|
raises.
|
|
"""
|
|
entry = self._resolve_index_entry(config_file, guid)
|
|
if entry is None and config_file is not None and os.path.exists(str(config_file)):
|
|
# The file is real but the index doesn't know it. The main
|
|
# legitimate path here is a shed install: metadata generation
|
|
# (``installed_repository_metadata_manager.get_repository_tools_tups``)
|
|
# loads the freshly cloned tools *before* ``add_to_tool_panel``
|
|
# persists the conf and populates. Populate this one path —
|
|
# still through the single-writer populator — and retry.
|
|
entry = self._populate_adhoc_path(str(config_file), guid)
|
|
if entry is None:
|
|
if config_file is not None and os.path.exists(str(config_file)):
|
|
log.warning(
|
|
"LazyToolBox.create_tool: no index entry for %s after ad-hoc populate; parsing eagerly",
|
|
config_file,
|
|
)
|
|
return super().create_tool(config_file, tool_shed_repository=tool_shed_repository, guid=guid, **kwds)
|
|
raise RuntimeError(
|
|
"LazyToolBox.create_tool: no index entry for "
|
|
f"(config_file={config_file!r}, guid={guid!r}). The populator "
|
|
"owns the index — run scripts/tool_source/populate_store.py "
|
|
"or, for a new Galaxy-internal lib tool, add it to "
|
|
"galaxy.tools.special_tools.hidden_lib_tool_paths()."
|
|
)
|
|
data_manager_id = kwds.get("data_manager_id")
|
|
if data_manager_id and not entry.data_manager_id:
|
|
# ``DataManager._load_tool`` hands the ``<data_manager id>`` conf
|
|
# id through ``load_hidden_tool``. Entries minted before any data
|
|
# manager conf covered this tool (install-time self-heal) don't
|
|
# carry it, and the materialise path must restore it or
|
|
# ``DataManagerTool.exec_after_process`` falls back to the tool
|
|
# id and misses the registry. Persist so job-handler processes
|
|
# materialising from the shared index see it too.
|
|
entry.data_manager_id = data_manager_id
|
|
if self._tool_index is not None:
|
|
# After a from_dict reload the default and per-version maps
|
|
# hold distinct objects; the job-time materialise resolves
|
|
# through the per-version map, so stamp its twin too.
|
|
twin = self._tool_index.entries_by_version.get(entry.id, {}).get(entry.version or "")
|
|
if twin is not None and twin is not entry:
|
|
twin.data_manager_id = data_manager_id
|
|
if self._store is not None:
|
|
try:
|
|
self._store.update_index_entry(entry)
|
|
except Exception as e:
|
|
log.warning("Persisting data_manager_id for %s raised: %s", entry.id, e)
|
|
# LazyTool is duck-typed against Tool — the eager pipeline only
|
|
# consults attributes the stub forwards from ToolIndexEntry, with
|
|
# mutations stored on ``_overrides``.
|
|
return LazyTool( # type: ignore[return-value]
|
|
entry,
|
|
materialize_callback=self._materialize_for_lazy_tool,
|
|
is_admin_user=self.app.config.is_admin_user,
|
|
)
|
|
|
|
def load_tool_from_cache(self, config_file, recover_tool: bool = False):
|
|
"""Skip Galaxy's disk-backed ``ToolCache``.
|
|
|
|
The index + LRU + content-addressed store already plays that role.
|
|
Returning ``None`` keeps ``load_tool`` honest — every call lands in
|
|
``create_tool``, where the seam can decide stub vs real.
|
|
"""
|
|
return None
|
|
|
|
def add_tool_to_cache(self, tool, config_file) -> None:
|
|
"""Bypass the disk ``ToolCache`` — see :meth:`load_tool_from_cache`."""
|
|
return None
|
|
|
|
def _populate_adhoc_path(self, config_file: str, guid: str | None) -> ToolIndexEntry | None:
|
|
"""Index a single on-disk tool file that no conf covers yet.
|
|
|
|
Runs the partial populator for the path (threading the guid so the
|
|
entry is keyed like its eventual conf-driven replacement), reloads
|
|
the index, and retries resolution. Returns the entry, or ``None``
|
|
when the populator couldn't index the file either.
|
|
"""
|
|
path = os.path.abspath(config_file)
|
|
log.info("LazyToolBox: index miss for existing file %s — populating ad hoc (guid=%s)", path, guid)
|
|
try:
|
|
populate_for_paths(
|
|
self.app.config,
|
|
[path],
|
|
path_guids={path: guid},
|
|
app=self.app,
|
|
)
|
|
except Exception as e:
|
|
log.warning("Ad-hoc populate for %s raised: %s", path, e)
|
|
return None
|
|
if self._store is not None:
|
|
self._store.invalidate_index_cache()
|
|
self._tool_index = self._store.load_index() or ToolIndex()
|
|
self._rebuild_shed_short_id_map()
|
|
return self._resolve_index_entry(config_file, guid)
|
|
|
|
def _index_source_paths(self) -> set[str]:
|
|
"""Every ``source_path`` the current in-memory index covers.
|
|
|
|
Cached against the identity of ``_tool_index``, so any reload
|
|
(which always assigns a fresh ``ToolIndex``) refreshes it without
|
|
reset plumbing. In-place entry removals can leave a stale extra
|
|
path here — that only skips one stat for a tool ``create_tool``
|
|
will then resolve or self-heal, so it is harmless.
|
|
"""
|
|
index = self._tool_index
|
|
if index is None:
|
|
return set()
|
|
cached = self._index_source_paths_cache
|
|
if cached is not None and cached[0] is index:
|
|
return cached[1]
|
|
paths: set[str] = set()
|
|
for versions in index.entries_by_version.values():
|
|
for entry in versions.values():
|
|
if entry.source_path:
|
|
paths.add(entry.source_path)
|
|
for entry in index.entries.values():
|
|
if entry.source_path:
|
|
paths.add(entry.source_path)
|
|
self._index_source_paths_cache = (index, paths)
|
|
return paths
|
|
|
|
def _tool_file_on_disk(self, path: str) -> bool:
|
|
"""Answer the eager walk's existence gate from the index.
|
|
|
|
The populator established existence when it stored the source, and
|
|
materialisation reads the raw source from the store, not the file —
|
|
so for an index-covered tool the per-tool stat proves nothing and,
|
|
on a CVMFS-resident shed conf, costs minutes of boot time.
|
|
"""
|
|
if os.path.abspath(path) in self._index_source_paths():
|
|
return True
|
|
return os.path.exists(path)
|
|
|
|
def _missing_repository_log_level(self, path: str) -> int:
|
|
"""Index-covered shed tools expectedly lack install-DB rows.
|
|
|
|
A conf-provided repository (CVMFS shed conf) is never installed
|
|
through the install database, so the eager warning would repeat
|
|
for every one of its tools on every boot.
|
|
"""
|
|
if os.path.abspath(path) in self._index_source_paths():
|
|
return logging.DEBUG
|
|
return logging.WARNING
|
|
|
|
def _resolve_index_entry(self, config_file, guid: str | None) -> ToolIndexEntry | None:
|
|
"""Find a matching index entry for the (config_file, guid) pair, or ``None``."""
|
|
if self._tool_index is None:
|
|
return None
|
|
# Shed install: guid is authoritative — keyed identically in the index.
|
|
if guid:
|
|
entry = self._tool_index.entries.get(guid)
|
|
if entry is not None:
|
|
return entry
|
|
if config_file is None:
|
|
return None
|
|
config_file_str = os.path.abspath(str(config_file))
|
|
# Exact source-path lookup against the store; ``source_path`` is set
|
|
# by the bootstrap and the shed-install persistence hook.
|
|
if self._store is not None:
|
|
try:
|
|
stored = self._store.get_by_source_path(config_file_str)
|
|
except Exception as e:
|
|
log.debug("get_by_source_path raised for %s: %s", config_file_str, e)
|
|
stored = None
|
|
if stored is not None and stored.tool_id:
|
|
# Honor the stored source's *version* — multi-version tools
|
|
# (same ``<tool id=foo>`` across two files at different
|
|
# ``<tool version=...>``) have one StoredToolSource per file,
|
|
# so ``entries.get(tool_id)`` would always hand back the
|
|
# latest and both files would collapse to one LazyTool.
|
|
entry = self._tool_index.get(stored.tool_id, stored.tool_version)
|
|
if entry is not None:
|
|
return entry
|
|
# No entry: do NOT fall back to parsing the id out of the file —
|
|
# ``entries[id]`` holds only the latest version, which would collapse
|
|
# multi-version conf entries onto one LazyTool. The store's
|
|
# ``source_path`` index is the authoritative key; a miss means the
|
|
# source genuinely isn't persisted yet, so let ``create_tool``'s
|
|
# fallthrough parse and ``_persist_tool_source`` write it.
|
|
return None
|
|
|
|
def _materialize_for_lazy_tool(self, entry: ToolIndexEntry) -> "Tool":
|
|
"""Promote a stub to a real ``Tool`` via the existing store-backed loader.
|
|
|
|
Routed through :meth:`_register_loaded_tool` so ``_tools_by_id`` /
|
|
``_tool_versions_by_id`` / ``_tools_by_old_id`` / lineage all agree
|
|
with the eager toolbox bookkeeping.
|
|
"""
|
|
if self._store is None:
|
|
raise RuntimeError(f"LazyTool materialise needs a tool source store (id={entry.id!r})")
|
|
stored = self._stored_source_for_entry(entry)
|
|
if stored is None:
|
|
raise RuntimeError(
|
|
"LazyTool materialise: indexed source missing from store "
|
|
f"(id={entry.id!r}, path={entry.source_path!r}, hash={entry.source_hash!r})"
|
|
)
|
|
tool = self._create_tool_from_stored_source(stored, entry=entry)
|
|
self._register_loaded_tool(tool)
|
|
return tool
|
|
|
|
def _stored_source_for_entry(self, entry: ToolIndexEntry) -> StoredToolSource | None:
|
|
"""Resolve path-specific source metadata without ambiguous hash lookup."""
|
|
if self._store is None:
|
|
return None
|
|
if entry.source_path is None:
|
|
return self._store.get(entry.source_hash)
|
|
stored = self._store.get_by_source_path(entry.source_path)
|
|
if stored is None or stored.hash != entry.source_hash:
|
|
return None
|
|
return stored
|
|
|
|
def _load_tool_on_demand(self, tool_id: str, tool_version: str | None = None) -> Optional["Tool"]:
|
|
"""
|
|
Load a tool from the store on-demand.
|
|
|
|
Uses LRU cache to avoid reloading frequently used tools.
|
|
"""
|
|
cache_key = f"{tool_id}:{tool_version or 'latest'}"
|
|
|
|
# Check cache first
|
|
with self._cache_lock:
|
|
if cache_key in self._tool_object_cache:
|
|
return self._tool_object_cache[cache_key]
|
|
|
|
# Check if already loaded in _tools_by_id — only safe when no specific
|
|
# version was requested. ``_tools_by_id`` keys by id and stores the
|
|
# latest-loaded version, so honoring ``tool_version`` requires going
|
|
# through the index to pick the right ``source_hash``.
|
|
if tool_version is None:
|
|
existing = self._tools_by_id.get(tool_id)
|
|
if existing is not None:
|
|
with self._cache_lock:
|
|
self._tool_object_cache[cache_key] = existing
|
|
return existing
|
|
|
|
# Get entry from index
|
|
if self._tool_index is None or self._store is None:
|
|
return None
|
|
|
|
entry = self._tool_index.get(tool_id, tool_version)
|
|
if not entry:
|
|
return None
|
|
|
|
# Load source from store
|
|
stored = self._stored_source_for_entry(entry)
|
|
if not stored:
|
|
log.warning(
|
|
"Indexed tool source not found for %s (path: %s, hash: %s)",
|
|
tool_id,
|
|
entry.source_path,
|
|
entry.source_hash,
|
|
)
|
|
return None
|
|
|
|
# Create Tool object
|
|
try:
|
|
tool = self._create_tool_from_stored_source(stored, entry=entry)
|
|
log.debug(f"Lazy-loaded tool: {tool_id}")
|
|
except Exception as e:
|
|
log.error(f"Error creating tool {tool_id}: {e}")
|
|
return None
|
|
|
|
# Register the tool
|
|
self._register_loaded_tool(tool)
|
|
|
|
# Add to cache
|
|
with self._cache_lock:
|
|
self._tool_object_cache[cache_key] = tool
|
|
|
|
return tool
|
|
|
|
def _create_tool_from_stored_source(self, stored: StoredToolSource, entry: ToolIndexEntry | None = None) -> "Tool":
|
|
"""Create a Tool object from stored source.
|
|
|
|
The single chokepoint for promoting a stub to a real ``Tool`` at
|
|
runtime — bump ``_lazy_materialize_count`` here so the budget test
|
|
can assert batch endpoints don't materialise.
|
|
"""
|
|
self._lazy_materialize_count += 1
|
|
tool_source = get_tool_source(
|
|
raw_tool_source=stored.raw_source,
|
|
tool_source_class=stored.tool_source_class,
|
|
)
|
|
# When the stored source's ``tool_id`` is a toolshed guid (set by
|
|
# the lazy shed install path), pass it as ``guid`` to the Tool
|
|
# constructor so ``Tool.id`` becomes the guid — matching what the
|
|
# eager toolbox does and what callers consult via ``has_tool``
|
|
# / ``get_tool`` and the ``_tools_by_id`` registry.
|
|
kwds: dict[str, Any] = {"tool_dir": stored.tool_dir}
|
|
if entry and entry.data_manager_id:
|
|
# The registry is keyed by the ``<data_manager id>`` conf id,
|
|
# which may differ from the tool XML id.
|
|
# ``DataManagerTool.__init__`` falls back to the tool id when
|
|
# the kwd is missing and ``exec_after_process`` then can't find
|
|
# the manager — eager threads this through ``load_hidden_tool``.
|
|
kwds["data_manager_id"] = entry.data_manager_id
|
|
if stored.tool_id and "/repos/" in stored.tool_id:
|
|
kwds["guid"] = stored.tool_id
|
|
# ``ToolConfRepository`` (lib/galaxy/tool_util/toolbox/base.py:87)
|
|
# is the same namedtuple stub the eager pipeline hands the Tool
|
|
# ctor for shed tools whose install-DB row hasn't materialised
|
|
# yet — see ``AbstractToolBox.get_tool_repository_from_xml_item``.
|
|
# ``Tool.populate_tool_shed_info`` only reads the scalar fields,
|
|
# all of which are on ``ToolIndexEntry``; building the stub
|
|
# directly avoids a per-materialise install-DB round-trip.
|
|
# ``installed_tool_dependencies`` readers get ``[]`` — same
|
|
# shape the eager pre-install code path produces.
|
|
if entry and not entry.is_local and entry.tool_shed:
|
|
kwds["tool_shed_repository"] = ToolConfRepository(
|
|
tool_shed=entry.tool_shed,
|
|
name=entry.repository_name,
|
|
owner=entry.repository_owner,
|
|
installed_changeset_revision=entry.changeset_revision,
|
|
changeset_revision=entry.changeset_revision,
|
|
tool_dependencies_installed_or_in_error=[],
|
|
repository_path=None,
|
|
tool_path=stored.tool_dir,
|
|
)
|
|
return create_tool_from_source(self.app, tool_source, **kwds)
|
|
|
|
def invalidate_index_cache(self) -> None:
|
|
"""Drop cached tool index so the next read picks up out-of-band updates.
|
|
|
|
Wired to the ``reload_tool_source_cache`` queue-worker control
|
|
message: a populator on another host (or another Galaxy process)
|
|
writes new sources/index to the shared store, then publishes
|
|
the message — every process calls this method to re-read the
|
|
index. The per-process LRU of materialized ``Tool`` objects
|
|
stays warm because content-addressed sources can't go stale
|
|
for a given hash.
|
|
"""
|
|
if self._store is None:
|
|
return
|
|
# Serialize against ``remove_tool_by_id``: an uninstall pops the
|
|
# tool from every in-memory registry and then persists the index
|
|
# removal. Every populate broadcasts an invalidation, so without
|
|
# the lock a queue-worker invalidation can interleave — it reloads
|
|
# the pre-removal index and re-registers the just-removed tool as
|
|
# a stub (observed as test_repository_uninstall resurrecting the
|
|
# tool right after the install's own broadcast).
|
|
with self.app._toolbox_lock:
|
|
try:
|
|
# Read-only members are published externally (CVMFS); a
|
|
# descriptor opened before the publish keeps serving the old
|
|
# snapshot forever, so dropping pooled connections — not just
|
|
# the cached index — is what makes the re-read below actually
|
|
# see the new file. Writable stores were updated through this
|
|
# process group's own connections and only need the cache drop.
|
|
if isinstance(self._store, CompositeToolSourceStore):
|
|
for _name, member in self._store.members:
|
|
if member.read_only:
|
|
member.dispose()
|
|
self._store.invalidate_index_cache()
|
|
except Exception as e:
|
|
log.debug(f"Store invalidate_index_cache raised: {e}")
|
|
previous_ids = set(self._tool_index.entries) if self._tool_index is not None else set()
|
|
loaded = self._store.load_index()
|
|
self._tool_index = loaded if loaded is not None else ToolIndex()
|
|
# Index just changed under us — refresh the short-id map so
|
|
# peer-process installs (which only update the persisted index)
|
|
# are reachable via short-id lookups in this process.
|
|
self._rebuild_shed_short_id_map()
|
|
# Wire newly-indexed entries (e.g. shed-install partial updates
|
|
# from a peer process) into this process's in-memory registries
|
|
# as ``LazyTool`` stubs. Without this, ``/api/tools`` would
|
|
# return the new ids only after the next full toolbox boot.
|
|
self._register_new_index_entries_as_stubs()
|
|
# Reconcile removals: an id the previous index carried but the
|
|
# reloaded one doesn't was removed by a peer process (uninstall).
|
|
# Registration above is add/update-only, so without this pop the
|
|
# stale stub would keep serving via the eager ``get_tool``
|
|
# fall-through. Diffing the two indexes keeps this scoped to
|
|
# store-backed tools — internal and dynamic tools never enter
|
|
# the index, so they are untouched.
|
|
for tool_id in previous_ids - set(self._tool_index.entries):
|
|
if tool_id not in self._tools_by_id:
|
|
continue
|
|
try:
|
|
self._remove_tool_in_memory(tool_id)
|
|
except Exception as e:
|
|
log.warning("Reconciling peer-removed index entry %s raised: %s", tool_id, e)
|
|
|
|
def _register_new_index_entries_as_stubs(self) -> None:
|
|
"""For every index entry not yet in ``_tools_by_id``, build a
|
|
``LazyTool`` stub, slot it into the toolbox registries, and place it
|
|
under its declared panel section.
|
|
|
|
Mirrors what the eager walk does at boot for each ``<tool>`` element,
|
|
but for a single entry already fully described by the populator.
|
|
"""
|
|
if self._tool_index is None:
|
|
return
|
|
for tool_id, entry in self._tool_index.entries.items():
|
|
# ``Any``: the registry is typed for real Tools but holds
|
|
# LazyTool stubs on this toolbox (same duck-typing as create_tool).
|
|
existing: Any = self._tools_by_id.get(tool_id)
|
|
if existing is not None:
|
|
if isinstance(existing, LazyTool) and existing._entry is not entry:
|
|
# Refresh the stub in place — the panel and registries
|
|
# hold this object, and the reloaded index may carry an
|
|
# enriched entry (e.g. the conf-driven populate after a
|
|
# shed install adds repository metadata the install-time
|
|
# ad-hoc entry lacked). ``_overrides`` survive.
|
|
existing._entry = entry
|
|
continue
|
|
try:
|
|
self._register_lazy_entry(entry)
|
|
except Exception as e:
|
|
log.warning("Failed to register new index entry %s: %s", tool_id, e)
|
|
|
|
def _register_lazy_entry(self, entry: ToolIndexEntry, place_in_panel: bool = True) -> "LazyTool":
|
|
"""Construct + slot a ``LazyTool`` stub for ``entry``.
|
|
|
|
Inverse of :meth:`_register_loaded_tool`: same bookkeeping, but for
|
|
the stub side. Used by :meth:`invalidate_index_cache` when a
|
|
peer-process populator run added new entries that this process should
|
|
surface immediately.
|
|
"""
|
|
stub = LazyTool(
|
|
entry,
|
|
materialize_callback=self._materialize_for_lazy_tool,
|
|
is_admin_user=self.app.config.is_admin_user,
|
|
)
|
|
tool_id = entry.id
|
|
self._tools_by_id[tool_id] = stub # type: ignore[assignment]
|
|
version = entry.version
|
|
self._tool_versions_by_id.setdefault(tool_id, {})[version or ""] = stub # type: ignore[assignment]
|
|
old_id = stub.old_id
|
|
# Register the old-id bucket even when ``old_id == tool_id`` — the
|
|
# eager ``__add_tool`` does, and ``remove_tool_by_id`` unconditionally
|
|
# removes from the bucket, so skipping it here would KeyError a later
|
|
# removal of this stub.
|
|
if old_id:
|
|
bucket = self._tools_by_old_id.setdefault(old_id, [])
|
|
if not any(getattr(t, "id", None) == tool_id for t in bucket):
|
|
bucket.append(stub) # type: ignore[arg-type]
|
|
if entry.uuid:
|
|
self._tools_by_uuid[UUID(entry.uuid)] = stub # type: ignore[assignment]
|
|
# Lineage: LazyLineageMap builds it from entries_by_version, which the
|
|
# populator wrote on the previous step, so .get() returns the right
|
|
# ToolLineage; register() is the fallback for an as-yet-unseen id.
|
|
stub._lineage = self._lineage_map.get(tool_id) or self._lineage_map.register(stub) # type: ignore[arg-type]
|
|
if not place_in_panel:
|
|
return stub
|
|
# Place into the panel. The populator stamped panel_section_id /
|
|
# panel_section_name onto the entry; create the ToolSection if it
|
|
# doesn't already exist (peer-process install of the first tool in
|
|
# a new section).
|
|
self._place_stub(stub, entry.panel_section_id, entry.panel_section_name, hidden=False)
|
|
return stub
|
|
|
|
def _place_stub(
|
|
self,
|
|
stub: "LazyTool",
|
|
section_id: str | None,
|
|
section_name: str | None,
|
|
hidden: bool,
|
|
) -> None:
|
|
"""Slot ``stub`` into the live and integrated tool panels.
|
|
|
|
The integrated panel gets every placement, hidden included — the
|
|
eager ``__add_tool`` always records walked conf tools there. It is
|
|
the panel ``_load_tool_panel_views`` renders EDAM/static views from
|
|
and ``_save_integrated_tool_panel`` persists, so a boot that skips
|
|
the conf walk must fill it or every panel view renders empty.
|
|
"""
|
|
tool_id = stub.id
|
|
if section_id:
|
|
integrated_section = self._integrated_tool_panel.get(section_id)
|
|
if not isinstance(integrated_section, ToolSection):
|
|
integrated_section = ToolSection({"id": section_id, "name": section_name or section_id})
|
|
self._integrated_tool_panel[section_id] = integrated_section
|
|
integrated_section.elems[f"tool_{tool_id}"] = stub
|
|
else:
|
|
self._integrated_tool_panel[f"tool_{tool_id}"] = stub
|
|
if hidden:
|
|
return
|
|
if section_id:
|
|
section = self._tool_panel.get(section_id)
|
|
if not isinstance(section, ToolSection):
|
|
section = ToolSection({"id": section_id, "name": section_name or section_id})
|
|
self._tool_panel[section_id] = section
|
|
section.elems.append_tool(stub)
|
|
self._tool_panel.record_section_for_tool_id(tool_id, section_id, section.name)
|
|
else:
|
|
self._tool_panel[f"tool_{tool_id}"] = stub
|
|
|
|
def _register_loaded_tool(self, tool: "Tool") -> None:
|
|
"""Register a lazily-loaded tool in the toolbox registries."""
|
|
tool_id = tool.id
|
|
if not tool_id:
|
|
return
|
|
|
|
self._tools_by_id[tool_id] = tool
|
|
|
|
version = tool.version
|
|
if tool_id not in self._tool_versions_by_id:
|
|
self._tool_versions_by_id[tool_id] = {}
|
|
self._tool_versions_by_id[tool_id][version] = tool
|
|
|
|
# The eager ``__add_tool`` also tracks tools by their pre-shed
|
|
# ``old_id`` so callers like ``remove_tool_by_id``
|
|
# (``self._tools_by_old_id[tool.old_id].remove(tool)``) can find
|
|
# them. Without this, removing a lazy-loaded shed tool raises
|
|
# ``KeyError`` on ``_tools_by_old_id``.
|
|
old_id = getattr(tool, "old_id", None)
|
|
if old_id:
|
|
bucket = self._tools_by_old_id.setdefault(old_id, [])
|
|
# The eager pipeline registers a LazyTool stub at boot via
|
|
# ``register_tool``; on materialise we drop the stub so the
|
|
# bucket doesn't end up with two entries for the same tool id
|
|
# (``get_tool`` lineage walk would otherwise return both).
|
|
bucket[:] = [t for t in bucket if t is not tool and getattr(t, "id", None) != tool.id]
|
|
bucket.append(tool)
|
|
|
|
# Tool uses 'guid' not 'uuid'
|
|
if hasattr(tool, "uuid") and tool.uuid:
|
|
self._tools_by_uuid[tool.uuid] = tool
|
|
|
|
# Update lineage. ``LazyLineageMap.get`` builds the lineage from
|
|
# ``entries_by_version`` (cached after first call); for a shed tool
|
|
# that arrived after boot and isn't in the index yet,
|
|
# ``LineageMap.register`` is the right fallback. Either way the
|
|
# eager ToolBox assigns it to ``tool._lineage`` (see
|
|
# AbstractToolBox.__add_tool) — without that assignment,
|
|
# ``tool.lineage`` is ``None`` and ``tool.tool_versions`` returns
|
|
# ``[]``, breaking /api/tools/{id}'s ``versions`` /
|
|
# ``hidden_versions`` fields.
|
|
tool._lineage = self._lineage_map.get(tool_id) or self._lineage_map.register(tool)
|
|
|
|
# Conf-level ``hidden="true"`` (from the ``<tool>`` directive in the
|
|
# tool conf) is applied here. The eager toolbox does this in
|
|
# ``_load_tool_tag_set``; the lazy path's ``_create_tool_from_stored_source``
|
|
# only sees the parsed XML body, so we lift the flag from the index
|
|
# entry. Note: never *clear* an XML-body hidden flag — only set it.
|
|
if self._tool_index is not None:
|
|
entry = self._tool_index.get(tool_id, version)
|
|
if entry and entry.hidden:
|
|
tool.hidden = True
|
|
|
|
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.
|
|
"""
|
|
if self._store_watcher is not None:
|
|
self._store_watcher.shutdown()
|
|
self._store_watcher = None
|
|
with self._cache_lock:
|
|
self._tool_object_cache.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 ``LazyLineageMap.get`` rebuilds from the freshly-loaded
|
|
# 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
|
|
# parent's shutdown sequence may still iterate them.
|
|
|
|
def remove_tool_by_id(self, tool_id: str, remove_from_panel: bool = True):
|
|
"""Also drop the tool from the lazy index + LRU cache.
|
|
|
|
``AbstractToolBox.remove_tool_by_id`` only deletes from
|
|
``_tools_by_id``. In the lazy path that's not enough — ``get_tool``
|
|
re-loads the tool from ``_tool_index`` on the next request, so the
|
|
tool effectively comes back. The eager toolbox doesn't have this
|
|
problem because the tool object isn't created from a serialised
|
|
store. Mirror the deletion across the two backing stores.
|
|
|
|
Wrapped in ``app._toolbox_lock`` so the cleanup is atomic
|
|
against concurrent ``_load_tool_on_demand`` calls — without
|
|
the lock, an in-flight HTTP request thread that's mid-way
|
|
through ``_register_loaded_tool`` can re-populate
|
|
``_tool_versions_by_id`` / ``_lineage_map.lineage_map`` /
|
|
``_tools_by_id`` after this method has already cleared them,
|
|
leaving the tool resurrectable via the eager super().get_tool
|
|
fall-through path that walks lineage versions via
|
|
``_tool_from_lineage_version``.
|
|
"""
|
|
with self.app._toolbox_lock:
|
|
# Force-materialise so the eager parent's bookkeeping (``_tools_by_old_id``,
|
|
# panel removal, lineage, tool cache expiry) gets a real Tool to work
|
|
# against.
|
|
if self._tools_by_id.get(tool_id) is None:
|
|
self.get_tool(tool_id=tool_id)
|
|
result = self._remove_tool_in_memory(tool_id, remove_from_panel=remove_from_panel)
|
|
if self._store is not None:
|
|
# The pops above are in-memory. The persisted singleton index
|
|
# still carries the entry, and any later cache invalidation
|
|
# (every populate broadcasts one) would reload it —
|
|
# resurrecting an uninstalled tool.
|
|
try:
|
|
self._store.remove_index_entry(tool_id)
|
|
except Exception as e:
|
|
log.warning("Persisting index removal of %s raised: %s", tool_id, e)
|
|
# Installs converge across processes because every populate
|
|
# broadcasts an invalidation; removals must broadcast too or
|
|
# peer web workers keep serving the uninstalled tool until an
|
|
# unrelated populate happens to run.
|
|
# Local import: genuine circularity — galaxy.queue_worker
|
|
# imports LazyToolBox at module level.
|
|
from galaxy.queue_worker import send_control_task
|
|
|
|
try:
|
|
send_control_task(self.app, "reload_tool_source_cache", noop_self=True)
|
|
except Exception as e:
|
|
log.warning("Broadcasting index removal of %s raised: %s", tool_id, e)
|
|
# A toolbox reload queued before this removal (the conf write of
|
|
# the preceding install) may already have swapped a NEW toolbox
|
|
# into ``app.toolbox``, built from the index as it stood before
|
|
# ``remove_index_entry`` above — with the tool still registered.
|
|
# ``self`` is then the superseded instance and cleaning it alone
|
|
# leaves ``app.toolbox`` serving the uninstalled tool. The swap
|
|
# holds the same lock, so the current object is stable here.
|
|
current = getattr(self.app, "toolbox", None)
|
|
if current is not self and isinstance(current, LazyToolBox):
|
|
current._remove_tool_in_memory(tool_id, remove_from_panel=remove_from_panel)
|
|
return result
|
|
|
|
def _remove_tool_in_memory(self, tool_id: str, remove_from_panel: bool = True):
|
|
"""Pop ``tool_id`` from every in-memory registry, panel slot and cache.
|
|
|
|
Shared by :meth:`remove_tool_by_id` (which additionally persists the
|
|
index removal and notifies peer processes) and by
|
|
:meth:`invalidate_index_cache`'s reconciliation of entries a peer
|
|
process removed from the persisted index. Caller must hold
|
|
``app._toolbox_lock``.
|
|
"""
|
|
result = super().remove_tool_by_id(tool_id, remove_from_panel=remove_from_panel)
|
|
if self._tool_index is not None:
|
|
self._tool_index.remove_entry(tool_id)
|
|
# In-place membership change on the same ToolIndex object: the
|
|
# identity-keyed sibling-versions cache would otherwise keep
|
|
# serving the removed version to lineage lookups.
|
|
self._guid_sibling_versions_cache = None
|
|
# ``super().remove_tool_by_id`` clears ``_tools_by_id`` but leaves
|
|
# ``_tool_versions_by_id`` and the lineage map intact. ``get_tool``'s
|
|
# fall-through walks lineage versions via ``_tool_from_lineage_version``
|
|
# which reads ``_tool_versions_by_id``, so the tool would otherwise
|
|
# come back from there even after removal.
|
|
self._tool_versions_by_id.pop(tool_id, None)
|
|
if hasattr(self, "_lineage_map"):
|
|
self._lineage_map.lineage_map.pop(tool_id, None)
|
|
versionless = remove_version_from_guid(tool_id)
|
|
if versionless:
|
|
self._lineage_map.lineage_map.pop(versionless, None)
|
|
# ``super().remove_tool_by_id`` removes a single Tool object
|
|
# from ``_tools_by_old_id[old_id]``, but if a concurrent
|
|
# ``_register_loaded_tool`` (e.g. an in-flight HTTP request
|
|
# that beat us to the lock) appended a sibling Tool to the
|
|
# same bucket, the sibling survives and the eager
|
|
# super().get_tool fall-through returns it via
|
|
# ``rval.extend(self._tools_by_old_id[tool_id])``. Drop the
|
|
# whole bucket for this tool_id to match the index removal.
|
|
self._tools_by_old_id.pop(tool_id, None)
|
|
# The short-id bucket is keyed by ``old_id`` — for a shed guid
|
|
# that's the short tool id, not ``tool_id``. ``super()`` removes
|
|
# from it by object identity only, which misses when the bucket
|
|
# holds an earlier registration (stub or materialised instance)
|
|
# while ``_tools_by_id`` held a fresher one; the leftover then
|
|
# resurrects the uninstalled tool via the eager get_tool
|
|
# fall-through. Scrub every object belonging to this guid, but
|
|
# leave sibling installs (other guids, other versions) alone.
|
|
short_id = extract_short_id_from_guid(tool_id)
|
|
if short_id and short_id != tool_id:
|
|
bucket = self._tools_by_old_id.get(short_id)
|
|
if bucket:
|
|
survivors = [
|
|
t for t in bucket if getattr(t, "id", None) != tool_id and getattr(t, "guid", None) != tool_id
|
|
]
|
|
if survivors:
|
|
self._tools_by_old_id[short_id] = survivors
|
|
else:
|
|
del self._tools_by_old_id[short_id]
|
|
# Mirror the cleanup in our short-id → guid map so a
|
|
# subsequent ``has_tool``/``get_tool`` for the short id
|
|
# doesn't resurrect a removed shed install. Both directions
|
|
# need cleanup: ``tool_id`` may itself be a short id (drop
|
|
# the entry), or it may be a guid (drop it from any short
|
|
# id's set, and remove that short id if its set becomes
|
|
# empty).
|
|
self._shed_short_id_to_guids.pop(tool_id, None)
|
|
for _short, _guids in list(self._shed_short_id_to_guids.items()):
|
|
_guids.discard(tool_id)
|
|
if not _guids:
|
|
del self._shed_short_id_to_guids[_short]
|
|
with self._cache_lock:
|
|
# Purge by cached identity, not by key prefix: the LRU is
|
|
# keyed by whatever id the caller resolved with, so the same
|
|
# tool can sit under both its guid and its short id
|
|
# ("collection_column_join:latest"). A guid-prefix purge
|
|
# leaves the short-id entry behind and get_tool serves the
|
|
# uninstalled tool straight from cache.
|
|
for key, cached in list(self._tool_object_cache.items()):
|
|
if (
|
|
key.startswith(f"{tool_id}:")
|
|
or getattr(cached, "id", None) == tool_id
|
|
or getattr(cached, "guid", None) == tool_id
|
|
):
|
|
self._tool_object_cache.pop(key, None)
|
|
return result
|
|
|
|
# === Override has_tool to check index ===
|
|
|
|
def has_tool(
|
|
self,
|
|
tool_id: str | None,
|
|
tool_version: str | None = None,
|
|
tool_uuid: UUID | str | None = None,
|
|
exact: bool = False,
|
|
user: Optional["User"] = None,
|
|
) -> bool:
|
|
"""Check if tool exists, using index for fast lookup.
|
|
|
|
Honors ``tool_version`` + ``exact`` the same way the eager
|
|
``AbstractToolBox.has_tool`` does — without that, the workflow
|
|
missing-tools check (``services/workflows.py``: ``has_tool(...,
|
|
exact=require_exact_tool_versions)``) silently passes any tool
|
|
whose id is in the index regardless of which version was
|
|
requested. ``test_run_workflow_with_missing_tool`` exercises
|
|
that surface by installing ``compose_text_param 0.1.0`` from
|
|
the toolshed and then asking the workflow runner to invoke a
|
|
workflow pinned to ``compose_text_param 0.0.1``: with the
|
|
version-blind lookup, ``has_tool`` answers ``True`` for 0.0.1,
|
|
the tool drops out of the missing-tools list, and the assertion
|
|
on the error message under-reports.
|
|
"""
|
|
if tool_id and self._tool_index:
|
|
entries = self._tool_index.entries_by_version.get(tool_id)
|
|
if entries is not None:
|
|
if tool_version is None:
|
|
return bool(entries) or tool_id in self._tool_index.entries
|
|
if str(tool_version) in entries:
|
|
return True
|
|
if exact:
|
|
# Exact version requested and not present — say so.
|
|
# Don't fall through to the parent which would walk
|
|
# lineage and return ``True`` for any version.
|
|
return False
|
|
# Non-exact: fall through to parent for lineage walk.
|
|
elif tool_version is None and tool_id in self._tool_index.entries:
|
|
return True
|
|
# Short-id alias for shed installs (see ``get_tool``'s short-id
|
|
# fallback for the rationale).
|
|
if tool_id and tool_id in self._shed_short_id_to_guids:
|
|
if tool_version is None:
|
|
return True
|
|
# For version-specific short-id lookups, check if any guid
|
|
# mapped to this short id has the requested version.
|
|
if self._tool_index is not None:
|
|
for guid in self._shed_short_id_to_guids[tool_id]:
|
|
versions = self._tool_index.entries_by_version.get(guid, {})
|
|
if str(tool_version) in versions:
|
|
return True
|
|
if exact:
|
|
return False
|
|
# Fall back to parent for UUID lookups and edge cases
|
|
return super().has_tool(
|
|
tool_id=tool_id,
|
|
tool_version=tool_version,
|
|
tool_uuid=tool_uuid,
|
|
exact=exact,
|
|
user=user,
|
|
)
|
|
|
|
# === Index access methods ===
|
|
|
|
@property
|
|
def tool_index(self) -> ToolIndex | None:
|
|
"""Get the tool index."""
|
|
return self._tool_index
|
|
|
|
def get_tool_ids(self) -> list[str]:
|
|
"""Get all tool IDs from index."""
|
|
if self._tool_index:
|
|
return list(self._tool_index.entries.keys())
|
|
return []
|
|
|
|
def get_index_entry(self, tool_id: str) -> ToolIndexEntry | None:
|
|
"""Get index entry for a tool without loading it."""
|
|
if self._tool_index:
|
|
return self._tool_index.get(tool_id)
|
|
return None
|
|
|
|
def resolve_search_hit(self, tool_id: str) -> Optional["Tool"]:
|
|
"""Resolve a search hit to a registered stub, never materialising.
|
|
|
|
``ToolsService.search_tools`` only needs the tool id plus an
|
|
``allow_user_access`` check to filter results — parsing the tool
|
|
would be pure waste. Going through :meth:`get_tool` instead
|
|
materialises every hit, and the populator-owned whoosh index can
|
|
carry ids that were never loaded into this toolbox
|
|
(``tool_conf.xml.sample`` alone lists ~150 legacy tools whose files
|
|
aren't present in most deployments).
|
|
|
|
Return the already-registered stub / Tool from ``_tools_by_id`` (or
|
|
via the shed short-id map), or ``None`` for a hit that isn't part of
|
|
this toolbox. That matches eager search, whose ``_tools_by_id``
|
|
lookup returns ``None`` for un-loaded ids so they're skipped — the
|
|
lazy path must not surface (or parse) tools the eager path wouldn't.
|
|
"""
|
|
tool = self._tools_by_id.get(tool_id)
|
|
if tool is not None:
|
|
return tool
|
|
if self._shed_short_id_to_guids and tool_id in self._shed_short_id_to_guids:
|
|
for guid in sorted(self._shed_short_id_to_guids[tool_id]):
|
|
candidate = self._tools_by_id.get(guid)
|
|
if candidate is not None:
|
|
return candidate
|
|
return None
|
|
|
|
def latest_search_hits(self, tool_ids: list[str]) -> list[str]:
|
|
if self._tool_index is None:
|
|
return tool_ids
|
|
ordered_lineages: list[str] = []
|
|
latest_by_lineage: dict[str, ToolIndexEntry] = {}
|
|
for tool_id in tool_ids:
|
|
entry = self._tool_index.entries.get(tool_id)
|
|
if entry is None:
|
|
continue
|
|
lineage_id = remove_version_from_guid(entry.id) or entry.id
|
|
current = latest_by_lineage.get(lineage_id)
|
|
if current is None:
|
|
ordered_lineages.append(lineage_id)
|
|
latest_by_lineage[lineage_id] = entry
|
|
elif self._index_entry_newer(entry, current):
|
|
latest_by_lineage[lineage_id] = entry
|
|
return [latest_by_lineage[lineage_id].id for lineage_id in ordered_lineages]
|
|
|
|
# === Required property overrides ===
|
|
|
|
@property
|
|
def all_requirements(self):
|
|
"""Get all tool requirements from index (no tool loading needed)."""
|
|
if self._tool_index:
|
|
requirements = set()
|
|
for entry in self._tool_index.entries.values():
|
|
for req in entry.requirements:
|
|
# Convert dict to hashable tuple
|
|
req_tuple = (req.get("name"), req.get("version"), req.get("type"))
|
|
requirements.add(req_tuple)
|
|
return [
|
|
{"name": r[0], "version": r[1], "type": r[2]} for r in requirements if r[0] # Filter out empty names
|
|
]
|
|
return []
|
|
|
|
# ``to_dict`` is NOT overridden: ``AbstractToolBox.to_dict`` runs the
|
|
# ``FilterFactory`` pass for both the panel and the flat listing, and
|
|
# its ``get_tool_to_dict`` serves ``LazyTool`` stubs via
|
|
# ``to_panel_entry`` — filtered AND non-materialising.
|