Skip to content

Commit 6b51584

Browse files
committed
Publish pulsar capability snapshot to relay on startup
Adds a static config + host-probe snapshot (staging dirs, dependency resolvers, container runtimes, manager type) collected once during PulsarApp init and POSTed once to a per-manager relay topic from messaging.bind_app. Galaxy can fetch the latest snapshot synchronously via the relay's existing /api/v1/topics/{topic}/messages?limit=1&order=desc endpoint and use it to auto-fill destination params on BYOC bootstrap and to downgrade requests at job-build time when the remote pulsar doesn't actually offer what the destination asks for. The publish is fire-and-forget and gated by message_queue_publish_capabilities (default True). Failures are logged and swallowed — capabilities are advisory and must not block manager bind.
1 parent 28a76f2 commit 6b51584

9 files changed

Lines changed: 829 additions & 28 deletions

File tree

app.yml.sample

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@
104104
## configured in Galaxy's job_conf.yml if set.
105105
#relay_topic_prefix: production
106106

107+
## On startup, publish a static capability snapshot (staging dirs, dependency
108+
## resolvers, container runtimes available, manager type) to the relay topic
109+
## "pulsar_capabilities[_<manager>]". Galaxy can read this to auto-fill
110+
## destination params and to refuse jobs that ask for capabilities this Pulsar
111+
## doesn't have. Advisory: a publish failure does not block startup, and
112+
## Galaxy keeps using operator-supplied params if no snapshot is available.
113+
## Set to false to disable the publish entirely.
114+
#message_queue_publish_capabilities: true
115+
107116
## Pulsar loops over waiting for queue messages for a short time before checking
108117
## to see if it has been instructed to shut down. By default this is 0.2
109118
## seconds. This value is used as the value of the 'timeout' parameter to

pulsar/capabilities.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Static capability snapshot collection for a Pulsar app.
2+
3+
A ``PulsarCapabilities`` is a serializable summary of what a Pulsar
4+
server is configured to do — staging paths, dependency resolvers,
5+
container runtimes available on the host — so that Galaxy can read it
6+
at job-build time and adjust (or refuse) requests that the remote does
7+
not actually support.
8+
9+
The data is collected once at app startup (in ``PulsarApp.__init__``),
10+
cached on the app, and published once to a relay topic from
11+
``messaging.bind_app``. It is never recomputed. Consumers fetch the
12+
latest snapshot via the relay's REST topic-messages endpoint.
13+
14+
Schema is versioned (``SCHEMA_VERSION``); bumping it is a wire-breaking
15+
change for consumers, so add new optional fields rather than reshape
16+
existing ones.
17+
"""
18+
from __future__ import annotations
19+
20+
import logging
21+
import os
22+
import shutil
23+
from dataclasses import (
24+
asdict,
25+
dataclass,
26+
field,
27+
)
28+
from typing import (
29+
TYPE_CHECKING,
30+
Dict,
31+
List,
32+
Optional,
33+
Union,
34+
)
35+
36+
from galaxy.tool_util.deps.resolvers.conda import CondaDependencyResolver
37+
38+
from pulsar import __version__ as pulsar_version
39+
40+
if TYPE_CHECKING:
41+
# Imported lazily to avoid the runtime cycle: pulsar.core imports this
42+
# module to populate the cache.
43+
from pulsar.core import PulsarApp
44+
from pulsar.managers.stateful import StatefulManagerProxy
45+
46+
log = logging.getLogger(__name__)
47+
48+
SCHEMA_VERSION = 1
49+
50+
51+
@dataclass
52+
class DependencyResolverInfo:
53+
type: str
54+
disabled: bool = False
55+
versionless: bool = False
56+
auto_init: Optional[bool] = None
57+
auto_install: Optional[bool] = None
58+
prefix: Optional[str] = None
59+
60+
61+
@dataclass
62+
class ContainerRuntimeInfo:
63+
docker_available: bool = False
64+
singularity_available: bool = False
65+
apptainer_available: bool = False
66+
67+
68+
@dataclass
69+
class ManagerCapabilities:
70+
name: str
71+
type: str
72+
num_concurrent_jobs: Union[int, str, None] = None
73+
74+
75+
@dataclass
76+
class PulsarCapabilities:
77+
schema_version: int
78+
manager_name: str
79+
pulsar_version: str
80+
staging_directory: str
81+
persistence_directory: Optional[str]
82+
tool_dependency_dir: Optional[str]
83+
dependency_resolvers: List[DependencyResolverInfo] = field(default_factory=list)
84+
conda_available: bool = False
85+
container_runtime: ContainerRuntimeInfo = field(default_factory=ContainerRuntimeInfo)
86+
manager: Optional[ManagerCapabilities] = None
87+
88+
def to_dict(self) -> dict:
89+
return asdict(self)
90+
91+
92+
def collect_capabilities(app: "PulsarApp", manager: "StatefulManagerProxy") -> PulsarCapabilities:
93+
"""Build the capabilities snapshot for a single manager on this app."""
94+
resolvers = _collect_dependency_resolvers(app)
95+
manager_caps = _collect_manager(manager)
96+
return PulsarCapabilities(
97+
schema_version=SCHEMA_VERSION,
98+
manager_name=manager_caps.name,
99+
pulsar_version=pulsar_version,
100+
staging_directory=app.staging_directory,
101+
persistence_directory=app.persistence_directory,
102+
tool_dependency_dir=_tool_dependency_dir(app),
103+
dependency_resolvers=resolvers,
104+
conda_available=_conda_available(resolvers),
105+
container_runtime=_detect_container_runtime(),
106+
manager=manager_caps,
107+
)
108+
109+
110+
def collect_all_capabilities(app: "PulsarApp") -> Dict[str, PulsarCapabilities]:
111+
"""Map ``manager_name -> PulsarCapabilities`` for every manager on the app.
112+
113+
Used by ``PulsarApp.__init__`` to populate the cache. Failures on
114+
individual managers are logged and skipped so a misconfigured
115+
manager cannot prevent the app from coming up.
116+
"""
117+
out: Dict[str, PulsarCapabilities] = {}
118+
for name, manager in app.managers.items():
119+
try:
120+
out[name] = collect_capabilities(app, manager)
121+
except Exception:
122+
log.exception("Failed to collect capabilities for manager %s", name)
123+
return out
124+
125+
126+
def _collect_dependency_resolvers(app: "PulsarApp") -> List[DependencyResolverInfo]:
127+
out: List[DependencyResolverInfo] = []
128+
for r in app.dependency_manager.dependency_resolvers:
129+
# ``resolver_type`` and ``versionless`` are class attributes on
130+
# concrete subclasses, not the base — probe defensively rather
131+
# than narrow the loop type to every known subclass.
132+
rt = getattr(r, "resolver_type", None)
133+
if not rt:
134+
continue
135+
info = DependencyResolverInfo(
136+
type=str(rt),
137+
disabled=r.disabled,
138+
versionless=bool(getattr(r, "versionless", False)),
139+
)
140+
if isinstance(r, CondaDependencyResolver):
141+
info.auto_init = bool(r.auto_init)
142+
info.auto_install = bool(r.auto_install)
143+
info.prefix = str(r.prefix) if r.prefix else None
144+
out.append(info)
145+
return out
146+
147+
148+
def _conda_available(resolvers: List[DependencyResolverInfo]) -> bool:
149+
"""A conda resolver is enabled and conda is reachable on this host.
150+
151+
"Reachable" means either the conda binary is on PATH or the resolver's
152+
configured prefix exists on disk — Galaxy on the requesting side wants
153+
to know whether ``dependency_resolution=remote`` will actually find
154+
something to run.
155+
"""
156+
for r in resolvers:
157+
if r.type != "conda" or r.disabled:
158+
continue
159+
if shutil.which("conda"):
160+
return True
161+
if r.prefix and os.path.isdir(r.prefix):
162+
return True
163+
return False
164+
165+
166+
def _detect_container_runtime() -> ContainerRuntimeInfo:
167+
return ContainerRuntimeInfo(
168+
docker_available=bool(shutil.which("docker")),
169+
singularity_available=bool(shutil.which("singularity")),
170+
apptainer_available=bool(shutil.which("apptainer")),
171+
)
172+
173+
174+
def _collect_manager(manager: "StatefulManagerProxy") -> ManagerCapabilities:
175+
underlying = manager._proxied_manager
176+
# ``manager_type`` is a class attribute set on each concrete manager
177+
# implementation but not on the proxy or its base. The ``work_threads``
178+
# attribute is queued_python-only — falling back to a public
179+
# ``num_concurrent_jobs`` covers any future manager that exposes one.
180+
mtype = getattr(type(underlying), "manager_type", "unknown")
181+
work_threads = getattr(underlying, "work_threads", None)
182+
num: Union[int, str, None]
183+
if work_threads is not None:
184+
try:
185+
num = len(work_threads)
186+
except TypeError:
187+
num = None
188+
else:
189+
num = getattr(underlying, "num_concurrent_jobs", None)
190+
return ManagerCapabilities(name=manager.name, type=str(mtype), num_concurrent_jobs=num)
191+
192+
193+
def _tool_dependency_dir(app: "PulsarApp") -> Optional[str]:
194+
base = getattr(app.dependency_manager, "default_base_path", None)
195+
return str(base) if base else None

pulsar/core.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
messaging,
1616
)
1717
from pulsar.cache import Cache
18+
from pulsar.capabilities import collect_all_capabilities
1819
from pulsar.manager_factory import build_managers
1920
from pulsar.tools import ToolBox
2021
from pulsar.tools.authorization import get_authorizer
@@ -50,6 +51,7 @@ def __init__(self, **conf):
5051
self.__setup_job_metrics(conf)
5152
self.__setup_user_auth_manager(conf)
5253
self.__setup_managers(conf)
54+
self.__setup_capabilities()
5355
self.__setup_file_cache(conf)
5456
self.__setup_bind_to_message_queue(conf)
5557
self.__recover_jobs()
@@ -122,6 +124,12 @@ def __setup_staging_directory(self, staging_directory):
122124
def __setup_managers(self, conf):
123125
self.managers = build_managers(self, conf)
124126

127+
def __setup_capabilities(self):
128+
# Snapshot of static config + host probes that get_capabilities
129+
# publishes to the relay. Computed once; stays valid for this
130+
# process's lifetime since pulsar doesn't reload app.yml.
131+
self.capabilities_by_manager = collect_all_capabilities(self)
132+
125133
def __recover_jobs(self):
126134
for manager in self.managers.values():
127135
manager.recover_active_jobs()

pulsar/messaging/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,19 @@ def bind_app(app, queue_id, conf=None):
2323
log.info("Detected relay connection string, binding to pulsar-relay at %s", relay_url)
2424

2525
relay_state = RelayState()
26+
merged_conf = conf or {}
2627
for manager in app.managers.values():
27-
bind_relay.bind_manager_to_relay(manager, relay_state, relay_url, conf or {})
28+
# Build the transport once per manager and reuse for both the
29+
# control-message bind and the capabilities publish, so the
30+
# initial token fetch + cursor file open happen exactly once.
31+
relay_transport = bind_relay.build_relay_transport(manager, relay_url, merged_conf)
32+
bind_relay.bind_manager_to_relay(
33+
manager, relay_state, relay_url, merged_conf,
34+
relay_transport=relay_transport,
35+
)
36+
bind_relay.publish_manager_capabilities_to_relay(
37+
app, manager, relay_transport, merged_conf,
38+
)
2839
return relay_state
2940
else:
3041
# Use AMQP binding

0 commit comments

Comments
 (0)