Skip to content

Commit 257dacb

Browse files
authored
Merge pull request #421 from mvdbeek/implement_retries_and_since
Implement pulsar-relay retry handling, improve message resume
2 parents c006f29 + 5ed02ec commit 257dacb

7 files changed

Lines changed: 664 additions & 69 deletions

File tree

app.yml.sample

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@
8383
## Python library is available and start the Pulsar normally.
8484
#message_queue_url: amqp://guest:guest@localhost:5672//
8585

86+
## Alternatively, use pulsar-relay (experimental) with HTTP/HTTPS instead of AMQP.
87+
## This does not require kombu or RabbitMQ.
88+
#message_queue_url: https://relay-server.example.org:9000
89+
#message_queue_username: admin
90+
#message_queue_password: changeme
91+
92+
## Optional topic prefix for relay messages. This allows multiple independent
93+
## Galaxy/Pulsar instance pairs to share the same relay server by using different
94+
## prefixes (e.g., "production", "staging"). Must match the relay_topic_prefix
95+
## configured in Galaxy's job_conf.yml if set.
96+
#relay_topic_prefix: production
97+
8698
## Pulsar loops over waiting for queue messages for a short time before checking
8799
## to see if it has been instructed to shut down. By default this is 0.2
88100
## seconds. This value is used as the value of the 'timeout' parameter to

docs/configure.rst

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ To configure Pulsar to use pulsar-relay, set the ``message_queue_url`` in
267267
The ``http://`` / ``https://`` prefix tells Pulsar to use the proxy communication mode instead
268268
of AMQP.
269269

270+
**Optional Topic Prefix**
271+
272+
You can optionally set a ``relay_topic_prefix`` to namespace your topics. This is useful
273+
when multiple independent Galaxy/Pulsar instance pairs share the same relay::
274+
275+
message_queue_url: http://proxy-server.example.org:9000
276+
message_queue_username: admin
277+
message_queue_password: your_secure_password
278+
relay_topic_prefix: production
279+
270280
.. note::
271281

272282
Unlike AMQP mode, the pulsar-relay mode does **not** require the ``kombu``
@@ -286,6 +296,8 @@ with proxy parameters::
286296
proxy_url: http://proxy-server.example.org:9000
287297
proxy_username: your_username
288298
proxy_password: your_secure_password
299+
# Optional topic prefix (must match Pulsar configuration)
300+
# relay_topic_prefix: production
289301

290302

291303
execution:
@@ -298,6 +310,11 @@ with proxy parameters::
298310
# Remote job staging directory
299311
jobs_directory: /data/pulsar/staging
300312

313+
.. note::
314+
315+
The ``relay_topic_prefix`` must match on both Galaxy and Pulsar sides.
316+
If set on one side but not the other, messages will not be routed correctly.
317+
301318

302319
Authentication
303320
``````````````
@@ -368,18 +385,105 @@ In Galaxy's job configuration, route jobs to specific clusters using the
368385
manager: cluster_b
369386
# ... other settings
370387

388+
Multiple Galaxy/Pulsar Instance Pairs
389+
``````````````````````````````````````
390+
391+
You can have multiple independent Galaxy and Pulsar instance pairs all sharing
392+
the same relay by using different topic prefixes. This is useful for:
393+
394+
* Running separate production and staging environments
395+
* Supporting multiple research groups with isolated instances
396+
* Multi-tenant deployments
397+
398+
**Example: Production and Staging Environments**
399+
400+
**Production Pulsar** (``app.yml``)::
401+
402+
message_queue_url: https://shared-relay:9000
403+
message_queue_username: admin
404+
message_queue_password: password
405+
relay_topic_prefix: production
406+
managers:
407+
cluster_a:
408+
type: queued_slurm
409+
410+
**Staging Pulsar** (``app.yml``)::
411+
412+
message_queue_url: https://shared-relay:9000
413+
message_queue_username: admin
414+
message_queue_password: password
415+
relay_topic_prefix: staging
416+
managers:
417+
cluster_a:
418+
type: queued_slurm
419+
420+
**Production Galaxy** (``job_conf.yml``)::
421+
422+
runners:
423+
pulsar:
424+
load: galaxy.jobs.runners.pulsar:PulsarMQJobRunner
425+
proxy_url: https://shared-relay:9000
426+
proxy_username: admin
427+
proxy_password: password
428+
relay_topic_prefix: production
429+
430+
execution:
431+
environments:
432+
pulsar_jobs:
433+
runner: pulsar
434+
manager: cluster_a
435+
# ... other settings
436+
437+
**Staging Galaxy** (``job_conf.yml``)::
438+
439+
runners:
440+
pulsar:
441+
load: galaxy.jobs.runners.pulsar:PulsarMQJobRunner
442+
proxy_url: https://shared-relay:9000
443+
proxy_username: admin
444+
proxy_password: password
445+
relay_topic_prefix: staging
446+
447+
execution:
448+
environments:
449+
pulsar_jobs:
450+
runner: pulsar
451+
manager: cluster_a
452+
# ... other settings
453+
454+
In this setup, the topics will be completely isolated:
455+
456+
* **Production**: ``production_job_setup_cluster_a``, ``production_job_status_update_cluster_a``
457+
* **Staging**: ``staging_job_setup_cluster_a``, ``staging_job_status_update_cluster_a``
458+
371459
Topic Naming
372460
````````````
373461

374-
Messages are organized by topic with automatic naming based on the manager name:
462+
Messages are organized by topic with automatic naming based on the optional prefix
463+
and manager name:
464+
465+
* Job setup: ``job_setup`` (default manager, no prefix)
466+
* Job setup: ``job_setup_{manager_name}`` (named manager, no prefix)
467+
* Job setup: ``{prefix}_job_setup`` (default manager, with prefix)
468+
* Job setup: ``{prefix}_job_setup_{manager_name}`` (named manager, with prefix)
469+
470+
The same pattern applies to other message types:
471+
472+
* Status requests: ``job_status_request``, ``job_status_request_{manager_name}``
473+
* Kill commands: ``job_kill``, ``job_kill_{manager_name}``
474+
* Status updates: ``job_status_update``, ``job_status_update_{manager_name}``
475+
476+
When a ``relay_topic_prefix`` is configured, it is prepended to all topic names:
477+
478+
* ``production_job_setup``
479+
* ``production_job_setup_cluster_a``
480+
* ``production_job_status_update_cluster_a``
375481

376-
* Job setup: ``job_setup_{manager_name}`` or ``job_setup`` (for default manager)
377-
* Status requests: ``job_status_request_{manager_name}``
378-
* Kill commands: ``job_kill_{manager_name}``
379-
* Status updates: ``job_status_update_{manager_name}``
482+
This allows:
380483

381-
This allows multiple Pulsar instances to share the same proxy without message
382-
conflicts.
484+
* Multiple Pulsar instances to share the same relay (using different manager names)
485+
* Multiple independent Galaxy/Pulsar instance pairs to share the same relay
486+
(using different topic prefixes)
383487

384488
Comparison with AMQP Mode
385489
``````````````````````````

pulsar/client/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ def launch(self, command_line, dependencies_description=None, env=None, remote_s
604604

605605
# Determine topic name based on manager
606606
manager_name = self.client_manager.manager_name
607-
topic = f"job_setup_{manager_name}" if manager_name != "_default_" else "job_setup"
607+
topic = self.client_manager._make_topic_name("job_setup", manager_name)
608608

609609
# Post message to relay
610610
self.client_manager.relay_transport.post_message(topic, launch_params)
@@ -618,7 +618,7 @@ def get_status(self):
618618
Cached status if available, None otherwise
619619
"""
620620
manager_name = self.client_manager.manager_name
621-
topic = f"job_status_request_{manager_name}" if manager_name != "_default_" else "job_status_request"
621+
topic = self.client_manager._make_topic_name("job_status_request", manager_name)
622622

623623
status_params = {
624624
'job_id': self.job_id,
@@ -633,7 +633,7 @@ def get_status(self):
633633
def kill(self):
634634
"""Kill a job by posting a kill message to the relay."""
635635
manager_name = self.client_manager.manager_name
636-
topic = f"job_kill_{manager_name}" if manager_name != "_default_" else "job_kill"
636+
topic = self.client_manager._make_topic_name("job_kill", manager_name)
637637

638638
kill_params = {'job_id': self.job_id}
639639
self.client_manager.relay_transport.post_message(topic, kill_params)

pulsar/client/manager.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import functools
99
import threading
10-
import time
1110
from logging import getLogger
1211
from os import getenv
1312
from queue import Queue
@@ -264,18 +263,20 @@ class RelayClientManager(BaseRemoteConfiguredJobClientManager):
264263
"""
265264
status_cache: Dict[str, Any]
266265

267-
def __init__(self, relay_url: str, relay_username: str, relay_password: str, **kwds: Dict[str, Any]):
266+
def __init__(self, relay_url: str, relay_username: str, relay_password: str, relay_topic_prefix: str = '', **kwds: Dict[str, Any]):
268267
super().__init__(**kwds)
269268

270269
if not relay_url:
271270
raise Exception("relay_url is required for RelayClientManager")
272271

273272
# Initialize relay transport
274273
self.relay_transport = RelayTransport(relay_url, relay_username, relay_password)
274+
self.relay_topic_prefix = relay_topic_prefix
275275
self.status_cache = {}
276276
self.callback_lock = threading.Lock()
277277
self.callback_thread = None
278278
self.active = True
279+
self.shutdown_event = threading.Event()
279280

280281
def callback_wrapper(self, callback, message_data):
281282
"""Process status update messages from the relay."""
@@ -298,7 +299,7 @@ def callback_wrapper(self, callback, message_data):
298299
def status_consumer(self, callback_wrapper):
299300
"""Long-poll the relay for status update messages."""
300301
manager_name = self.manager_name
301-
topic = f"job_status_update_{manager_name}" if manager_name != "_default_" else "job_status_update"
302+
topic = self._make_topic_name("job_status_update", manager_name)
302303

303304
log.info("Starting relay status consumer for topic '%s'", topic)
304305

@@ -314,12 +315,14 @@ def status_consumer(self, callback_wrapper):
314315
if self.active:
315316
log.exception("Exception while polling for status updates from relay, will retry.")
316317
# Brief sleep before retrying to avoid tight loop on persistent errors
317-
time.sleep(5)
318+
# Use wait() instead of sleep() to allow immediate interruption on shutdown
319+
if self.shutdown_event.wait(timeout=5):
320+
break
318321
else:
319322
log.debug("Exception during shutdown, ignoring.")
320323
break
321324

322-
log.debug("Leaving Pulsar client relay status consumer, no additional updates will be processed.")
325+
log.info("Done consuming relay status updates for topic %s", topic)
323326

324327
def ensure_has_status_update_callback(self, callback):
325328
"""Start a thread to poll for status updates if not already running."""
@@ -333,17 +336,47 @@ def ensure_has_status_update_callback(self, callback):
333336
name="pulsar_client_%s_relay_status_consumer" % self.manager_name,
334337
target=run
335338
)
336-
thread.daemon = False # Don't interrupt processing
339+
# Make daemon so Python can exit even if thread is blocked in HTTP request.
340+
# Unlike MessageQueueClientManager which uses AMQP connections that can be
341+
# interrupted cleanly, HTTP long-poll requests block until timeout.
342+
thread.daemon = True
337343
thread.start()
338344
self.callback_thread = thread
339345

340346
def ensure_has_ack_consumers(self):
341347
"""No-op for relay client manager, as acknowledgements are handled via HTTP."""
342348
pass
343349

350+
def _make_topic_name(self, base_topic: str, manager_name: str) -> str:
351+
"""Create a topic name with optional prefix and manager suffix.
352+
353+
Args:
354+
base_topic: Base topic name (e.g., 'job_setup', 'job_status_update')
355+
manager_name: Manager name (e.g., '_default_', 'cluster_a')
356+
357+
Returns:
358+
Fully qualified topic name
359+
"""
360+
parts = []
361+
362+
# Add prefix if provided
363+
if self.relay_topic_prefix:
364+
parts.append(self.relay_topic_prefix)
365+
366+
# Add base topic
367+
parts.append(base_topic)
368+
369+
# Add manager name if not default
370+
if manager_name != "_default_":
371+
parts.append(manager_name)
372+
373+
return "_".join(parts)
374+
344375
def shutdown(self, ensure_cleanup: bool = False):
345376
"""Shutdown the client manager and cleanup resources."""
346377
self.active = False
378+
# Signal the shutdown event to interrupt any waiting threads
379+
self.shutdown_event.set()
347380
if ensure_cleanup:
348381
if self.callback_thread is not None:
349382
self.callback_thread.join()
@@ -391,6 +424,7 @@ def build_client_manager(
391424
relay_url: Optional[str] = None,
392425
relay_username: Optional[str] = None,
393426
relay_password: Optional[str] = None,
427+
relay_topic_prefix: Optional[str] = None,
394428
amqp_url: Optional[str] = None,
395429
k8s_enabled: Optional[bool] = None,
396430
tes_enabled: Optional[bool] = None,
@@ -401,7 +435,13 @@ def build_client_manager(
401435
return ClientManager(job_manager=job_manager, **kwargs) # TODO: Consider more separation here.
402436
elif relay_url:
403437
assert relay_password and relay_username, "relay_url set, but relay_username and relay_password must also be set"
404-
return RelayClientManager(relay_url=relay_url, relay_username=relay_username, relay_password=relay_password, **kwargs)
438+
return RelayClientManager(
439+
relay_url=relay_url,
440+
relay_username=relay_username,
441+
relay_password=relay_password,
442+
relay_topic_prefix=relay_topic_prefix or '',
443+
**kwargs
444+
)
405445
elif amqp_url:
406446
return MessageQueueClientManager(amqp_url=amqp_url, **kwargs)
407447
elif k8s_enabled or tes_enabled or gcp_batch_enabled:

0 commit comments

Comments
 (0)