diff --git a/lib/galaxy/tools/lazy_toolbox.py b/lib/galaxy/tools/lazy_toolbox.py index f673a66b0ad..68a8a4a40d5 100644 --- a/lib/galaxy/tools/lazy_toolbox.py +++ b/lib/galaxy/tools/lazy_toolbox.py @@ -826,6 +826,17 @@ class LazyToolBox(ToolBox): 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 = ( @@ -841,15 +852,25 @@ class LazyToolBox(ToolBox): 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) - if source_hashes != index_hashes: + dangling = index_hashes - source_hashes + if dangling: log.info( - "Tool source store index/source mismatch for %s (%d indexed sources, %d stored sources); " - "running populator", + "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: diff --git a/test/unit/app/tools/test_lazy_tool.py b/test/unit/app/tools/test_lazy_tool.py index ae9b5f5ce0c..6c786dad64e 100644 --- a/test/unit/app/tools/test_lazy_tool.py +++ b/test/unit/app/tools/test_lazy_tool.py @@ -333,6 +333,27 @@ def test_resolve_index_entry_returns_none_when_nothing_matches(): assert box._resolve_index_entry(None, None) is None +def _reconcile_box(index_hashes, store_hashes): + box = _seam_box() + box._store.read_only = False + box._store.list_all.return_value = list(store_hashes) + index = ToolIndex() + for position, source_hash in enumerate(index_hashes): + index.add_entry(_entry(id=f"tool_{position}", source_hash=source_hash)) + box._store.load_index.return_value = index + return box + + +def test_index_reconcile_ignores_orphaned_store_rows(): + box = _reconcile_box(index_hashes=["a", "b"], store_hashes=["a", "b", "orphaned"]) + assert box._writable_store_index_needs_population() is False + + +def test_index_reconcile_repopulates_on_dangling_index_reference(): + box = _reconcile_box(index_hashes=["a", "b"], store_hashes=["a"]) + assert box._writable_store_index_needs_population() is True + + def test_create_tool_populates_adhoc_for_existing_file(tmp_path, monkeypatch): # Shed installs load cloned tools during metadata generation, before # any conf is persisted — a miss for an on-disk file populates that