nost-tools 2.0.5__tar.gz → 2.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nost-tools might be problematic. Click here for more details.
- {nost_tools-2.0.5 → nost_tools-2.1.1}/PKG-INFO +1 -1
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/__init__.py +2 -3
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/application.py +608 -96
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/entity.py +17 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/managed_application.py +3 -2
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/manager.py +1 -1
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/observer.py +79 -2
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/schemas.py +55 -12
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools.egg-info/PKG-INFO +1 -1
- {nost_tools-2.0.5 → nost_tools-2.1.1}/LICENSE +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/README.md +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/application_utils.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/configuration.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/errors.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/logger_application.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/publisher.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools/simulator.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools.egg-info/SOURCES.txt +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools.egg-info/dependency_links.txt +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools.egg-info/requires.txt +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/nost_tools.egg-info/top_level.txt +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/pyproject.toml +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/setup.cfg +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/tests/test_entity.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/tests/test_observer.py +0 -0
- {nost_tools-2.0.5 → nost_tools-2.1.1}/tests/test_simulator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nost_tools
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Tools for Novel Observing Strategies Testbed (NOS-T) Applications
|
|
5
5
|
Author-email: "Paul T. Grogan" <paul.grogan@asu.edu>, "Emmanuel M. Gonzalez" <emmanuelgonzalez@asu.edu>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
__version__ = "2.
|
|
1
|
+
__version__ = "2.1.1"
|
|
2
2
|
|
|
3
3
|
from .application import Application
|
|
4
4
|
from .application_utils import ConnectionConfig, ModeStatusObserver, TimeStatusPublisher
|
|
5
5
|
from .configuration import ConnectionConfig
|
|
6
6
|
from .entity import Entity
|
|
7
|
-
|
|
8
|
-
# from .logger_application import LoggerApplication
|
|
7
|
+
from .logger_application import LoggerApplication
|
|
9
8
|
from .managed_application import ManagedApplication
|
|
10
9
|
from .manager import Manager, TimeScaleUpdate
|
|
11
10
|
from .observer import Observable, Observer
|
|
@@ -4,8 +4,9 @@ Provides a base application that publishes messages from a simulator to a broker
|
|
|
4
4
|
|
|
5
5
|
import functools
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
7
9
|
import ssl
|
|
8
|
-
import sys
|
|
9
10
|
import threading
|
|
10
11
|
import time
|
|
11
12
|
from datetime import datetime, timedelta
|
|
@@ -28,7 +29,7 @@ from .simulator import Simulator
|
|
|
28
29
|
|
|
29
30
|
logging.captureWarnings(True)
|
|
30
31
|
logger = logging.getLogger(__name__)
|
|
31
|
-
urllib3.disable_warnings()
|
|
32
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class Application:
|
|
@@ -84,6 +85,21 @@ class Application:
|
|
|
84
85
|
self._token_refresh_thread = None
|
|
85
86
|
self.token_refresh_interval = None
|
|
86
87
|
self._reconnect_delay = None
|
|
88
|
+
# Set up signal handlers for graceful shutdown
|
|
89
|
+
self._setup_signal_handlers()
|
|
90
|
+
|
|
91
|
+
def _setup_signal_handlers(self):
|
|
92
|
+
"""
|
|
93
|
+
Sets up signal handlers for graceful shutdown on SIGINT (CTRL+C) and SIGTERM.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def signal_handler(sig, frame):
|
|
97
|
+
logger.info(f"Received signal {sig}, shutting down...")
|
|
98
|
+
self.shut_down()
|
|
99
|
+
|
|
100
|
+
# Register the signal handler for CTRL+C (SIGINT) and SIGTERM
|
|
101
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
102
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
87
103
|
|
|
88
104
|
def ready(self) -> None:
|
|
89
105
|
"""
|
|
@@ -172,7 +188,7 @@ class Application:
|
|
|
172
188
|
self.refresh_token = refresh_token
|
|
173
189
|
self.update_connection_credentials(access_token)
|
|
174
190
|
except Exception as e:
|
|
175
|
-
logger.
|
|
191
|
+
logger.debug(f"Failed to refresh access token: {e}")
|
|
176
192
|
|
|
177
193
|
self._token_refresh_thread = threading.Thread(target=refresh_token_periodically)
|
|
178
194
|
self._token_refresh_thread.start()
|
|
@@ -261,33 +277,39 @@ class Application:
|
|
|
261
277
|
# Set up connection parameters
|
|
262
278
|
parameters = pika.ConnectionParameters(
|
|
263
279
|
host=self.config.rc.server_configuration.servers.rabbitmq.host,
|
|
264
|
-
virtual_host=self.config.rc.server_configuration.servers.rabbitmq.virtual_host,
|
|
265
280
|
port=self.config.rc.server_configuration.servers.rabbitmq.port,
|
|
281
|
+
virtual_host=self.config.rc.server_configuration.servers.rabbitmq.virtual_host,
|
|
266
282
|
credentials=credentials,
|
|
283
|
+
channel_max=config.rc.server_configuration.servers.rabbitmq.channel_max,
|
|
284
|
+
frame_max=config.rc.server_configuration.servers.rabbitmq.frame_max,
|
|
267
285
|
heartbeat=config.rc.server_configuration.servers.rabbitmq.heartbeat,
|
|
268
286
|
connection_attempts=config.rc.server_configuration.servers.rabbitmq.connection_attempts,
|
|
269
287
|
retry_delay=config.rc.server_configuration.servers.rabbitmq.retry_delay,
|
|
270
288
|
socket_timeout=config.rc.server_configuration.servers.rabbitmq.socket_timeout,
|
|
271
289
|
stack_timeout=config.rc.server_configuration.servers.rabbitmq.stack_timeout,
|
|
272
290
|
locale=config.rc.server_configuration.servers.rabbitmq.locale,
|
|
291
|
+
blocked_connection_timeout=config.rc.server_configuration.servers.rabbitmq.blocked_connection_timeout,
|
|
273
292
|
)
|
|
293
|
+
logger.info(parameters)
|
|
274
294
|
|
|
275
295
|
# Configure transport layer security (TLS) if needed
|
|
276
296
|
if self.config.rc.server_configuration.servers.rabbitmq.tls:
|
|
277
297
|
logger.info("Using TLS/SSL.")
|
|
278
|
-
#
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
parameters.ssl_options = pika.SSLOptions(context)
|
|
298
|
+
# SSL Context for TLS configuration of Amazon MQ for RabbitMQ
|
|
299
|
+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
|
|
300
|
+
ssl_context.check_hostname = False
|
|
301
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
302
|
+
ssl_context.set_ciphers("ECDHE+AESGCM:!ECDSA")
|
|
303
|
+
parameters.ssl_options = pika.SSLOptions(context=ssl_context)
|
|
284
304
|
|
|
285
305
|
# Save connection parameters for reconnection
|
|
286
306
|
self._connection_parameters = parameters
|
|
287
307
|
self._reconnect_delay = (
|
|
288
308
|
self.config.rc.server_configuration.servers.rabbitmq.reconnect_delay
|
|
289
309
|
)
|
|
290
|
-
|
|
310
|
+
self._queue_max_size = (
|
|
311
|
+
self.config.rc.server_configuration.servers.rabbitmq.queue_max_size
|
|
312
|
+
)
|
|
291
313
|
|
|
292
314
|
# Establish non-blocking connection to RabbitMQ
|
|
293
315
|
self.connection = pika.SelectConnection(
|
|
@@ -351,27 +373,60 @@ class Application:
|
|
|
351
373
|
# Signal that connection is established
|
|
352
374
|
self._is_connected.set()
|
|
353
375
|
|
|
376
|
+
# Re-establish callbacks if this is a reconnection
|
|
377
|
+
if hasattr(self, "_saved_callbacks") and self._saved_callbacks:
|
|
378
|
+
logger.info(f"Restoring {len(self._saved_callbacks)} message callbacks")
|
|
379
|
+
for app_name, app_topic, user_callback in self._saved_callbacks:
|
|
380
|
+
# Pass through existing add_message_callback to handle all logic consistently
|
|
381
|
+
self.add_message_callback(app_name, app_topic, user_callback)
|
|
382
|
+
|
|
383
|
+
# Process any queued messages now that we're connected
|
|
384
|
+
if hasattr(self, "_message_queue") and self._message_queue:
|
|
385
|
+
# Schedule message processing to happen after all initialization
|
|
386
|
+
self.connection.ioloop.call_later(0.1, self._process_message_queue)
|
|
387
|
+
|
|
354
388
|
def add_on_channel_close_callback(self):
|
|
355
389
|
"""This method tells pika to call the on_channel_closed method if
|
|
356
390
|
RabbitMQ unexpectedly closes the channel.
|
|
357
391
|
"""
|
|
358
|
-
logger.
|
|
392
|
+
logger.debug("Adding channel close callback")
|
|
359
393
|
self.channel.add_on_close_callback(self.on_channel_closed)
|
|
360
394
|
|
|
361
395
|
def on_channel_closed(self, channel, reason):
|
|
362
396
|
"""
|
|
363
397
|
Invoked by pika when RabbitMQ unexpectedly closes the channel.
|
|
364
|
-
|
|
365
|
-
violates the protocol, such as re-declare an exchange or queue with
|
|
366
|
-
different parameters. In this case, we'll close the connection
|
|
367
|
-
to shutdown the object.
|
|
398
|
+
Determines whether to close the connection or just prepare for reconnection.
|
|
368
399
|
|
|
369
400
|
Args:
|
|
370
401
|
channel (:obj:`pika.channel.Channel`): channel object
|
|
371
|
-
reason (Exception): exception representing reason for
|
|
402
|
+
reason (Exception): exception representing reason for channel closure
|
|
372
403
|
"""
|
|
373
|
-
|
|
374
|
-
|
|
404
|
+
reply_code = 0
|
|
405
|
+
if hasattr(reason, "reply_code"):
|
|
406
|
+
reply_code = reason.reply_code
|
|
407
|
+
|
|
408
|
+
logger.debug(f"Channel was closed: {reason} (code: {reply_code})")
|
|
409
|
+
|
|
410
|
+
# Clear channel reference
|
|
411
|
+
self.channel = None
|
|
412
|
+
|
|
413
|
+
# # Clear consumer tag reference
|
|
414
|
+
# if hasattr(self, "_consumer_tag"):
|
|
415
|
+
# self._consumer_tag = None
|
|
416
|
+
|
|
417
|
+
# Check if this is part of an intentional shutdown
|
|
418
|
+
if self._closing:
|
|
419
|
+
logger.info(
|
|
420
|
+
"Connection closed intentionally. Proceeding with connection closure."
|
|
421
|
+
)
|
|
422
|
+
# During intentional shutdown, proceed with connection closure
|
|
423
|
+
self.close_connection()
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# If unexpected closure, wait for reconnection
|
|
427
|
+
logger.info(
|
|
428
|
+
f"Connection closed unexpectedly. Reconnecting in {self._reconnect_delay} seconds."
|
|
429
|
+
)
|
|
375
430
|
|
|
376
431
|
def close_connection(self):
|
|
377
432
|
"""
|
|
@@ -399,21 +454,29 @@ class Application:
|
|
|
399
454
|
|
|
400
455
|
def on_connection_closed(self, connection, reason):
|
|
401
456
|
"""
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
RabbitMQ if it disconnects.
|
|
457
|
+
Invoked by pika when RabbitMQ unexpectedly closes the connection.
|
|
458
|
+
Determines whether to close the connection or just prepare for reconnection.
|
|
405
459
|
|
|
406
460
|
Args:
|
|
407
461
|
connection (:obj:`pika.connection.Connection`): connection object
|
|
408
462
|
reason (Exception): exception representing reason for loss of connection
|
|
409
463
|
"""
|
|
464
|
+
# First clear the channel reference regardless of reason
|
|
410
465
|
self.channel = None
|
|
466
|
+
|
|
467
|
+
# Check if this is an intentional closure (self._closing is True)
|
|
411
468
|
if self._closing:
|
|
469
|
+
# Resources already cleaned up in stop_application()
|
|
470
|
+
logger.debug(
|
|
471
|
+
"Connection closed after intentional stop, cleanup already performed."
|
|
472
|
+
)
|
|
412
473
|
self.connection.ioloop.stop()
|
|
413
474
|
else:
|
|
414
|
-
|
|
415
|
-
|
|
475
|
+
# This is an unexpected connection drop - don't delete queues or exchanges
|
|
476
|
+
logger.debug(
|
|
477
|
+
f"Connection closed unexpectedly, reconnecting in {self._reconnect_delay} seconds: {reason}."
|
|
416
478
|
)
|
|
479
|
+
# Schedule reconnection
|
|
417
480
|
self.connection.ioloop.call_later(self._reconnect_delay, self.reconnect)
|
|
418
481
|
|
|
419
482
|
def on_connection_open(self, connection):
|
|
@@ -427,15 +490,43 @@ class Application:
|
|
|
427
490
|
"""
|
|
428
491
|
self.connection = connection
|
|
429
492
|
self.connection.channel(on_open_callback=self.on_channel_open)
|
|
430
|
-
logger.info("Connection established successfully.")
|
|
493
|
+
# logger.info("Connection established successfully.")
|
|
431
494
|
|
|
432
495
|
def reconnect(self):
|
|
433
496
|
"""
|
|
434
|
-
Reconnect to RabbitMQ by reinitializing the connection.
|
|
497
|
+
Reconnect to RabbitMQ by reinitializing the connection with refreshed credentials.
|
|
435
498
|
"""
|
|
436
499
|
if not self._closing:
|
|
437
500
|
try:
|
|
438
|
-
logger.info("Attempting to reconnect to RabbitMQ
|
|
501
|
+
logger.info("Attempting to reconnect to RabbitMQ.")
|
|
502
|
+
|
|
503
|
+
# Reset callback tracking dictionary but keep saved callbacks
|
|
504
|
+
self._callbacks_per_topic = {}
|
|
505
|
+
|
|
506
|
+
# Refresh the token if Keycloak authentication is enabled
|
|
507
|
+
if (
|
|
508
|
+
self.config.rc.server_configuration.servers.rabbitmq.keycloak_authentication
|
|
509
|
+
):
|
|
510
|
+
try:
|
|
511
|
+
logger.debug("Refreshing access token before reconnection...")
|
|
512
|
+
access_token, refresh_token = self.new_access_token(
|
|
513
|
+
self.refresh_token
|
|
514
|
+
)
|
|
515
|
+
self.refresh_token = refresh_token
|
|
516
|
+
|
|
517
|
+
# Update connection parameters with new credentials
|
|
518
|
+
self._connection_parameters.credentials = pika.PlainCredentials(
|
|
519
|
+
"", access_token
|
|
520
|
+
)
|
|
521
|
+
logger.debug(
|
|
522
|
+
"Access token refreshed successfully for reconnection"
|
|
523
|
+
)
|
|
524
|
+
except Exception as e:
|
|
525
|
+
logger.error(
|
|
526
|
+
f"Failed to refresh token during reconnection: {e}"
|
|
527
|
+
)
|
|
528
|
+
# Continue with existing token, it might still work
|
|
529
|
+
|
|
439
530
|
self.connection = pika.SelectConnection(
|
|
440
531
|
parameters=self._connection_parameters,
|
|
441
532
|
on_open_callback=self.on_connection_open,
|
|
@@ -459,51 +550,280 @@ class Application:
|
|
|
459
550
|
"""
|
|
460
551
|
Shuts down the application by stopping the background event loop and disconnecting from the broker.
|
|
461
552
|
"""
|
|
553
|
+
logger.info(f"Initiating shutdown of {self.app_name}")
|
|
554
|
+
|
|
555
|
+
# Clean up simulator-related resources
|
|
462
556
|
if self._time_status_publisher is not None:
|
|
463
557
|
self.simulator.remove_observer(self._time_status_publisher)
|
|
464
558
|
self._time_status_publisher = None
|
|
465
559
|
|
|
466
|
-
|
|
560
|
+
# Clean up connection-related resources
|
|
561
|
+
if self.connection and not self._closing:
|
|
562
|
+
logger.info(f"Shutting down {self.app_name} connection.")
|
|
467
563
|
self.stop_application()
|
|
468
564
|
self._consuming = False
|
|
469
565
|
|
|
470
|
-
#
|
|
566
|
+
# Signal all threads to stop
|
|
567
|
+
if hasattr(self, "stop_event"):
|
|
568
|
+
self.stop_event.set()
|
|
471
569
|
if hasattr(self, "_should_stop"):
|
|
472
570
|
self._should_stop.set()
|
|
473
571
|
|
|
474
|
-
|
|
572
|
+
# Comprehensive resource cleanup
|
|
573
|
+
self._cleanup_resources()
|
|
574
|
+
|
|
575
|
+
logger.info(f"Shutdown of {self.app_name} completed successfully.")
|
|
576
|
+
|
|
577
|
+
# Exit the process
|
|
578
|
+
os._exit(0)
|
|
579
|
+
|
|
580
|
+
def _cleanup_resources(self):
|
|
581
|
+
"""
|
|
582
|
+
Comprehensive cleanup of both multiprocessing resource tracker and joblib resources.
|
|
583
|
+
Handles cleanup in the correct order to prevent resource leaks and warnings.
|
|
584
|
+
"""
|
|
585
|
+
try:
|
|
586
|
+
import gc
|
|
587
|
+
import sys
|
|
588
|
+
import warnings
|
|
589
|
+
|
|
590
|
+
# Suppress resource tracker warnings
|
|
591
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="joblib")
|
|
592
|
+
warnings.filterwarnings(
|
|
593
|
+
"ignore",
|
|
594
|
+
category=UserWarning,
|
|
595
|
+
message="resource_tracker: There appear to be.*leaked.*objects",
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# 1. First cleanup joblib resources (if joblib is used)
|
|
599
|
+
joblib_used = "joblib" in sys.modules
|
|
600
|
+
if joblib_used:
|
|
601
|
+
logger.debug("Cleaning up joblib resources")
|
|
602
|
+
try:
|
|
603
|
+
import joblib
|
|
604
|
+
from joblib.externals.loky import process_executor
|
|
605
|
+
|
|
606
|
+
# Force garbage collection to help break reference cycles
|
|
607
|
+
gc.collect()
|
|
608
|
+
|
|
609
|
+
# Clear any joblib Memory caches
|
|
610
|
+
memory_locations = []
|
|
611
|
+
for obj in gc.get_objects():
|
|
612
|
+
if isinstance(obj, joblib.Memory):
|
|
613
|
+
memory_locations.append(obj.location)
|
|
614
|
+
obj.clear()
|
|
615
|
+
|
|
616
|
+
if memory_locations:
|
|
617
|
+
logger.debug(
|
|
618
|
+
f"Cleared {len(memory_locations)} joblib memory caches"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Find and terminate any active Parallel instances
|
|
622
|
+
terminated_count = 0
|
|
623
|
+
for obj in gc.get_objects():
|
|
624
|
+
try:
|
|
625
|
+
if (
|
|
626
|
+
hasattr(obj, "_backend")
|
|
627
|
+
and hasattr(obj, "n_jobs")
|
|
628
|
+
and hasattr(obj, "_terminate")
|
|
629
|
+
):
|
|
630
|
+
obj._terminate()
|
|
631
|
+
terminated_count += 1
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
if terminated_count:
|
|
636
|
+
logger.debug(
|
|
637
|
+
f"Terminated {terminated_count} joblib Parallel instances"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Reset the process executor state
|
|
641
|
+
process_executor._CURRENT_DEPTH = 0
|
|
642
|
+
if hasattr(process_executor, "_INITIALIZER"):
|
|
643
|
+
process_executor._INITIALIZER = None
|
|
644
|
+
if hasattr(process_executor, "_INITARGS"):
|
|
645
|
+
process_executor._INITARGS = ()
|
|
646
|
+
|
|
647
|
+
except Exception as e:
|
|
648
|
+
logger.debug(f"Error during joblib cleanup: {e}")
|
|
649
|
+
|
|
650
|
+
# 2. Then clean up multiprocessing resource tracker
|
|
651
|
+
try:
|
|
652
|
+
import multiprocessing.resource_tracker as resource_tracker
|
|
653
|
+
|
|
654
|
+
# Force resource tracker to clean up resources
|
|
655
|
+
if (
|
|
656
|
+
hasattr(resource_tracker, "_resource_tracker")
|
|
657
|
+
and resource_tracker._resource_tracker is not None
|
|
658
|
+
):
|
|
659
|
+
|
|
660
|
+
logger.debug("Cleaning up resource tracker")
|
|
661
|
+
tracker = resource_tracker._resource_tracker
|
|
662
|
+
|
|
663
|
+
if hasattr(tracker, "_resources"):
|
|
664
|
+
for resource_type in list(tracker._resources.keys()):
|
|
665
|
+
resources = tracker._resources.get(resource_type, set())
|
|
666
|
+
if resources:
|
|
667
|
+
logger.debug(
|
|
668
|
+
f"Cleaning up {len(resources)} {resource_type} resources"
|
|
669
|
+
)
|
|
670
|
+
resources_copy = resources.copy()
|
|
671
|
+
for resource in resources_copy:
|
|
672
|
+
try:
|
|
673
|
+
resources.discard(resource)
|
|
674
|
+
except Exception as e:
|
|
675
|
+
logger.debug(
|
|
676
|
+
f"Error discarding {resource_type} resource: {e}"
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# Final safety check - clear all remaining resources
|
|
680
|
+
tracker._resources.clear()
|
|
681
|
+
|
|
682
|
+
# Force garbage collection after cleanup
|
|
683
|
+
gc.collect()
|
|
684
|
+
|
|
685
|
+
except (ImportError, AttributeError, Exception) as e:
|
|
686
|
+
logger.debug(f"Error during resource tracker cleanup: {e}")
|
|
687
|
+
|
|
688
|
+
except Exception as e:
|
|
689
|
+
logger.warning(f"Error during resource cleanup: {e}")
|
|
475
690
|
|
|
476
691
|
def send_message(self, app_name, app_topics, payload: str) -> None:
|
|
477
692
|
"""
|
|
478
|
-
Sends a message to the broker.
|
|
693
|
+
Sends a message to the broker. If the connection is down, the message is queued
|
|
694
|
+
for later delivery when the connection is restored.
|
|
479
695
|
|
|
480
696
|
Args:
|
|
481
697
|
app_name (str): application name
|
|
482
698
|
app_topics (str or list): topic name or list of topic names
|
|
483
699
|
payload (str): message payload
|
|
484
700
|
"""
|
|
701
|
+
# Initialize message queue if it doesn't exist
|
|
702
|
+
if not hasattr(self, "_message_queue"):
|
|
703
|
+
self._message_queue = []
|
|
704
|
+
# self._queue_max_size = 1000 # Limit queue size to prevent memory issues
|
|
705
|
+
|
|
485
706
|
if isinstance(app_topics, str):
|
|
486
707
|
app_topics = [app_topics]
|
|
487
708
|
|
|
709
|
+
# Check if channel is available
|
|
710
|
+
if self.channel is None or not self._is_connected.is_set():
|
|
711
|
+
logger.warning(f"Connection down, queueing message for later delivery")
|
|
712
|
+
|
|
713
|
+
# Queue the message if there's space available
|
|
714
|
+
if len(self._message_queue) < self._queue_max_size:
|
|
715
|
+
# Add timestamp to each message for FIFO ordering
|
|
716
|
+
timestamp = time.time()
|
|
717
|
+
for app_topic in app_topics:
|
|
718
|
+
self._message_queue.append(
|
|
719
|
+
(timestamp, app_name, app_topic, payload)
|
|
720
|
+
)
|
|
721
|
+
logger.info(
|
|
722
|
+
f"Queued message for topic {app_topic} (queue size: {len(self._message_queue)})"
|
|
723
|
+
)
|
|
724
|
+
else:
|
|
725
|
+
logger.error(f"Message queue full, dropping message for {app_topics}")
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
# Try to send any queued messages first
|
|
729
|
+
self._process_message_queue()
|
|
730
|
+
|
|
731
|
+
# Now send the current message
|
|
488
732
|
for app_topic in app_topics:
|
|
489
733
|
routing_key = self.create_routing_key(app_name=app_name, topic=app_topic)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
734
|
+
try:
|
|
735
|
+
self.channel.basic_publish(
|
|
736
|
+
exchange=self.prefix,
|
|
737
|
+
routing_key=routing_key,
|
|
738
|
+
body=payload,
|
|
739
|
+
properties=pika.BasicProperties(
|
|
740
|
+
content_type=self.config.rc.server_configuration.servers.rabbitmq.content_type,
|
|
741
|
+
content_encoding=self.config.rc.server_configuration.servers.rabbitmq.content_encoding,
|
|
742
|
+
headers=self.config.rc.server_configuration.servers.rabbitmq.headers,
|
|
743
|
+
delivery_mode=self.config.rc.server_configuration.servers.rabbitmq.delivery_mode,
|
|
744
|
+
priority=self.config.rc.server_configuration.servers.rabbitmq.priority,
|
|
745
|
+
correlation_id=self.config.rc.server_configuration.servers.rabbitmq.correlation_id,
|
|
746
|
+
reply_to=self.config.rc.server_configuration.servers.rabbitmq.reply_to,
|
|
747
|
+
expiration=self.config.rc.server_configuration.servers.rabbitmq.message_expiration,
|
|
748
|
+
message_id=self.config.rc.server_configuration.servers.rabbitmq.message_id,
|
|
749
|
+
timestamp=self.config.rc.server_configuration.servers.rabbitmq.timestamp,
|
|
750
|
+
type=self.config.rc.server_configuration.servers.rabbitmq.type,
|
|
751
|
+
user_id=self.config.rc.server_configuration.servers.rabbitmq.user_id,
|
|
752
|
+
app_id=self.config.rc.server_configuration.servers.rabbitmq.app_id,
|
|
753
|
+
cluster_id=self.config.rc.server_configuration.servers.rabbitmq.cluster_id,
|
|
754
|
+
),
|
|
493
755
|
)
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
756
|
+
logger.debug(
|
|
757
|
+
f"Successfully sent message '{payload}' to topic '{routing_key}'."
|
|
758
|
+
)
|
|
759
|
+
except Exception as e:
|
|
760
|
+
logger.warning(f"Failed to publish message to {routing_key}: {e}")
|
|
761
|
+
# Queue the failed message if there's space available
|
|
762
|
+
if len(self._message_queue) < self._queue_max_size:
|
|
763
|
+
timestamp = time.time()
|
|
764
|
+
self._message_queue.append(
|
|
765
|
+
(timestamp, app_name, app_topic, payload)
|
|
766
|
+
)
|
|
767
|
+
logger.info(
|
|
768
|
+
f"Queued failed message for retry (queue size: {len(self._message_queue)})"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
def _process_message_queue(self):
|
|
772
|
+
"""
|
|
773
|
+
Process queued messages when connection is available.
|
|
774
|
+
Attempts to send all queued messages in order of oldest first.
|
|
775
|
+
"""
|
|
776
|
+
if not hasattr(self, "_message_queue") or not self._message_queue:
|
|
777
|
+
return # No messages to process
|
|
778
|
+
|
|
779
|
+
if self.channel is None or not self._is_connected.is_set():
|
|
780
|
+
return # Still no connection
|
|
781
|
+
|
|
782
|
+
# Process the queue in timestamp order (oldest first)
|
|
783
|
+
logger.info(f"Processing message queue ({len(self._message_queue)} messages)")
|
|
784
|
+
|
|
785
|
+
# Sort messages by timestamp (oldest first)
|
|
786
|
+
sorted_messages = sorted(self._message_queue, key=lambda x: x[0])
|
|
787
|
+
self._message_queue.clear()
|
|
788
|
+
|
|
789
|
+
success_count = 0
|
|
790
|
+
for timestamp, app_name, app_topic, payload in sorted_messages:
|
|
791
|
+
routing_key = self.create_routing_key(app_name=app_name, topic=app_topic)
|
|
792
|
+
try:
|
|
793
|
+
self.channel.basic_publish(
|
|
794
|
+
exchange=self.prefix,
|
|
795
|
+
routing_key=routing_key,
|
|
796
|
+
body=payload,
|
|
797
|
+
properties=pika.BasicProperties(
|
|
798
|
+
content_type=self.config.rc.server_configuration.servers.rabbitmq.content_type,
|
|
799
|
+
content_encoding=self.config.rc.server_configuration.servers.rabbitmq.content_encoding,
|
|
800
|
+
headers=self.config.rc.server_configuration.servers.rabbitmq.headers,
|
|
801
|
+
delivery_mode=self.config.rc.server_configuration.servers.rabbitmq.delivery_mode,
|
|
802
|
+
priority=self.config.rc.server_configuration.servers.rabbitmq.priority,
|
|
803
|
+
correlation_id=self.config.rc.server_configuration.servers.rabbitmq.correlation_id,
|
|
804
|
+
reply_to=self.config.rc.server_configuration.servers.rabbitmq.reply_to,
|
|
805
|
+
expiration=self.config.rc.server_configuration.servers.rabbitmq.message_expiration,
|
|
806
|
+
message_id=self.config.rc.server_configuration.servers.rabbitmq.message_id,
|
|
807
|
+
timestamp=self.config.rc.server_configuration.servers.rabbitmq.timestamp,
|
|
808
|
+
type=self.config.rc.server_configuration.servers.rabbitmq.type,
|
|
809
|
+
user_id=self.config.rc.server_configuration.servers.rabbitmq.user_id,
|
|
810
|
+
app_id=self.config.rc.server_configuration.servers.rabbitmq.app_id,
|
|
811
|
+
cluster_id=self.config.rc.server_configuration.servers.rabbitmq.cluster_id,
|
|
812
|
+
),
|
|
813
|
+
)
|
|
814
|
+
success_count += 1
|
|
815
|
+
except Exception as e:
|
|
816
|
+
# If sending still fails, put it back in the queue with original timestamp
|
|
817
|
+
# to preserve ordering
|
|
818
|
+
logger.warning(f"Failed to resend queued message to {routing_key}: {e}")
|
|
819
|
+
self._message_queue.append((timestamp, app_name, app_topic, payload))
|
|
820
|
+
|
|
821
|
+
if success_count > 0:
|
|
822
|
+
logger.info(f"Successfully sent {success_count} queued messages")
|
|
823
|
+
|
|
824
|
+
if self._message_queue:
|
|
825
|
+
logger.info(
|
|
826
|
+
f"{len(self._message_queue)} messages remain queued for later delivery"
|
|
507
827
|
)
|
|
508
828
|
|
|
509
829
|
def routing_key_matches_pattern(self, routing_key, pattern):
|
|
@@ -566,6 +886,15 @@ class Application:
|
|
|
566
886
|
self.was_consuming = True
|
|
567
887
|
self._consuming = True
|
|
568
888
|
|
|
889
|
+
# Store callback for reconnection
|
|
890
|
+
if not hasattr(self, "_saved_callbacks"):
|
|
891
|
+
self._saved_callbacks = []
|
|
892
|
+
|
|
893
|
+
# Don't duplicate callbacks in saved list
|
|
894
|
+
callback_info = (app_name, app_topic, user_callback)
|
|
895
|
+
if callback_info not in self._saved_callbacks:
|
|
896
|
+
self._saved_callbacks.append(callback_info)
|
|
897
|
+
|
|
569
898
|
routing_key = self.create_routing_key(app_name=app_name, topic=app_topic)
|
|
570
899
|
|
|
571
900
|
# Check if this is the first callback for this routing key pattern
|
|
@@ -576,6 +905,7 @@ class Application:
|
|
|
576
905
|
if not self.predefined_exchanges_queues:
|
|
577
906
|
# For wildcard subscriptions, use the app_name as queue suffix to ensure uniqueness
|
|
578
907
|
queue_suffix = self.app_name
|
|
908
|
+
queue_name = None
|
|
579
909
|
|
|
580
910
|
# If using wildcards, bind to the wildcard pattern
|
|
581
911
|
if "*" in routing_key or "#" in routing_key:
|
|
@@ -584,7 +914,7 @@ class Application:
|
|
|
584
914
|
|
|
585
915
|
# Declare a new queue
|
|
586
916
|
self.channel.queue_declare(
|
|
587
|
-
queue=queue_name, durable=
|
|
917
|
+
queue=queue_name, durable=True, auto_delete=False
|
|
588
918
|
)
|
|
589
919
|
|
|
590
920
|
# Bind queue to the exchange with the wildcard pattern
|
|
@@ -592,20 +922,26 @@ class Application:
|
|
|
592
922
|
exchange=self.prefix, queue=queue_name, routing_key=routing_key
|
|
593
923
|
)
|
|
594
924
|
|
|
595
|
-
# Track the declared queue
|
|
925
|
+
# Track the declared queue and exchange
|
|
596
926
|
self.declared_queues.add(queue_name)
|
|
927
|
+
self.declared_exchanges.add(self.prefix.strip())
|
|
928
|
+
|
|
929
|
+
# # Also track the routing key if it's used for binding
|
|
930
|
+
# if routing_key != queue_name:
|
|
931
|
+
# self.declared_queues.add(routing_key.strip())
|
|
597
932
|
else:
|
|
598
933
|
# For non-wildcard keys, use the standard approach
|
|
599
934
|
routing_key, queue_name = self.yamless_declare_bind_queue(
|
|
600
935
|
routing_key=routing_key, app_specific_extender=queue_suffix
|
|
601
936
|
)
|
|
602
937
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
938
|
+
if queue_name:
|
|
939
|
+
self.channel.basic_qos(prefetch_count=1)
|
|
940
|
+
self._consumer_tag = self.channel.basic_consume(
|
|
941
|
+
queue=queue_name,
|
|
942
|
+
on_message_callback=self._handle_message,
|
|
943
|
+
auto_ack=False,
|
|
944
|
+
)
|
|
609
945
|
|
|
610
946
|
# Add the callback to the list for this routing key
|
|
611
947
|
self._callbacks_per_topic[routing_key].append(user_callback)
|
|
@@ -706,14 +1042,14 @@ class Application:
|
|
|
706
1042
|
else:
|
|
707
1043
|
queue_name = routing_key
|
|
708
1044
|
self.channel.queue_declare(
|
|
709
|
-
queue=queue_name, durable=
|
|
1045
|
+
queue=queue_name, durable=True, auto_delete=False
|
|
710
1046
|
)
|
|
711
1047
|
self.channel.queue_bind(
|
|
712
1048
|
exchange=self.prefix, queue=queue_name, routing_key=routing_key
|
|
713
1049
|
)
|
|
714
1050
|
# Create list of declared queues and exchanges
|
|
715
1051
|
self.declared_queues.add(queue_name.strip())
|
|
716
|
-
self.declared_queues.add(routing_key.strip())
|
|
1052
|
+
# self.declared_queues.add(routing_key.strip())
|
|
717
1053
|
self.declared_exchanges.add(self.prefix.strip())
|
|
718
1054
|
|
|
719
1055
|
logger.debug(f"Bound queue '{queue_name}' to topic '{routing_key}'.")
|
|
@@ -735,7 +1071,7 @@ class Application:
|
|
|
735
1071
|
"""
|
|
736
1072
|
for config in configs:
|
|
737
1073
|
if config["app"] == app_name:
|
|
738
|
-
logger.
|
|
1074
|
+
logger.debug(f"Deleting queue: {config['address']}")
|
|
739
1075
|
self.channel.queue_delete(queue=config["address"])
|
|
740
1076
|
logger.info("Successfully deleted queues.")
|
|
741
1077
|
|
|
@@ -750,31 +1086,169 @@ class Application:
|
|
|
750
1086
|
self.channel.exchange_delete(exchange=exchange_name)
|
|
751
1087
|
logger.info("Successfully deleted exchanges.")
|
|
752
1088
|
|
|
753
|
-
def
|
|
1089
|
+
def _delete_queues_with_callback(self, completion_event):
|
|
754
1090
|
"""
|
|
755
|
-
Deletes all declared queues
|
|
1091
|
+
Deletes all declared queues from RabbitMQ with proper callbacks,
|
|
1092
|
+
and signals the completion_event when done.
|
|
1093
|
+
Does NOT delete exchanges - those are managed exclusively by the Manager class.
|
|
1094
|
+
|
|
1095
|
+
Args:
|
|
1096
|
+
completion_event (threading.Event): Event to signal when deletion is complete
|
|
756
1097
|
"""
|
|
757
|
-
|
|
1098
|
+
if not self.channel or self.channel.is_closed:
|
|
1099
|
+
logger.warning("Cannot delete queues: channel is closed")
|
|
1100
|
+
completion_event.set() # Signal completion since we can't proceed
|
|
1101
|
+
return
|
|
1102
|
+
|
|
1103
|
+
# Create copies to avoid modification during iteration
|
|
1104
|
+
queues_to_delete = list(self.declared_queues)
|
|
1105
|
+
|
|
1106
|
+
# If nothing to delete, signal completion immediately
|
|
1107
|
+
if not queues_to_delete:
|
|
1108
|
+
logger.info("No queues to delete")
|
|
1109
|
+
completion_event.set()
|
|
1110
|
+
return
|
|
1111
|
+
|
|
1112
|
+
# Track how many queues are still pending deletion
|
|
1113
|
+
pending_deletions = len(queues_to_delete)
|
|
1114
|
+
|
|
1115
|
+
# Callback functions
|
|
1116
|
+
def on_queue_purged(method_frame, queue_name):
|
|
1117
|
+
"""Callback for queue purge"""
|
|
1118
|
+
logger.debug(f"Successfully purged queue: {queue_name}")
|
|
1119
|
+
# After purging, unbind the queue
|
|
1120
|
+
unbind_queue_from_exchanges(queue_name)
|
|
1121
|
+
|
|
1122
|
+
def on_queue_unbind_ok(
|
|
1123
|
+
method_frame, queue_name, current_exchange, remaining_exchanges
|
|
1124
|
+
):
|
|
1125
|
+
"""Callback for queue unbind"""
|
|
1126
|
+
logger.debug(
|
|
1127
|
+
f"Successfully unbound queue {queue_name} from exchange {current_exchange}"
|
|
1128
|
+
)
|
|
1129
|
+
if remaining_exchanges:
|
|
1130
|
+
# Continue unbinding from next exchange
|
|
1131
|
+
next_exchange = remaining_exchanges[0]
|
|
1132
|
+
try:
|
|
1133
|
+
self.channel.queue_unbind(
|
|
1134
|
+
queue=queue_name,
|
|
1135
|
+
exchange=next_exchange,
|
|
1136
|
+
routing_key=queue_name,
|
|
1137
|
+
callback=lambda method_frame: on_queue_unbind_ok(
|
|
1138
|
+
method_frame,
|
|
1139
|
+
queue_name,
|
|
1140
|
+
next_exchange,
|
|
1141
|
+
remaining_exchanges[1:],
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
logger.error(
|
|
1146
|
+
f"Failed to unbind queue {queue_name} from exchange {next_exchange}: {e}"
|
|
1147
|
+
)
|
|
1148
|
+
# Continue with queue deletion even if unbinding fails
|
|
1149
|
+
delete_queue(queue_name)
|
|
1150
|
+
else:
|
|
1151
|
+
# All unbinds complete, delete queue
|
|
1152
|
+
delete_queue(queue_name)
|
|
1153
|
+
|
|
1154
|
+
def on_queue_deleted(method_frame, queue_name):
|
|
1155
|
+
"""Callback for queue delete"""
|
|
1156
|
+
nonlocal pending_deletions
|
|
1157
|
+
logger.debug(f"Successfully deleted queue: {queue_name}")
|
|
1158
|
+
self.declared_queues.discard(queue_name)
|
|
1159
|
+
|
|
1160
|
+
# Decrement pending deletions counter
|
|
1161
|
+
pending_deletions -= 1
|
|
1162
|
+
|
|
1163
|
+
# When all queues are deleted, signal completion
|
|
1164
|
+
if pending_deletions == 0:
|
|
1165
|
+
logger.debug("All queues have been deleted.")
|
|
1166
|
+
completion_event.set()
|
|
1167
|
+
|
|
1168
|
+
def unbind_queue_from_exchanges(queue_name):
|
|
1169
|
+
"""Unbind queue from each exchange"""
|
|
1170
|
+
exchanges_to_unbind_from = list(self.declared_exchanges)
|
|
1171
|
+
if exchanges_to_unbind_from:
|
|
1172
|
+
first_exchange = exchanges_to_unbind_from[0]
|
|
1173
|
+
try:
|
|
1174
|
+
if first_exchange and first_exchange != "":
|
|
1175
|
+
logger.debug(
|
|
1176
|
+
f"Unbinding queue {queue_name} from exchange {first_exchange}"
|
|
1177
|
+
)
|
|
1178
|
+
self.channel.queue_unbind(
|
|
1179
|
+
queue=queue_name,
|
|
1180
|
+
exchange=first_exchange,
|
|
1181
|
+
routing_key=queue_name,
|
|
1182
|
+
callback=lambda method_frame: on_queue_unbind_ok(
|
|
1183
|
+
method_frame,
|
|
1184
|
+
queue_name,
|
|
1185
|
+
first_exchange,
|
|
1186
|
+
exchanges_to_unbind_from[1:],
|
|
1187
|
+
),
|
|
1188
|
+
)
|
|
1189
|
+
else:
|
|
1190
|
+
# Skip empty exchange, move to next
|
|
1191
|
+
on_queue_unbind_ok(
|
|
1192
|
+
None,
|
|
1193
|
+
queue_name,
|
|
1194
|
+
first_exchange,
|
|
1195
|
+
exchanges_to_unbind_from[1:],
|
|
1196
|
+
)
|
|
1197
|
+
except Exception as e:
|
|
1198
|
+
logger.error(
|
|
1199
|
+
f"Failed to unbind queue {queue_name} from exchange {first_exchange}: {e}"
|
|
1200
|
+
)
|
|
1201
|
+
# Continue with queue deletion even if unbinding fails
|
|
1202
|
+
delete_queue(queue_name)
|
|
1203
|
+
else:
|
|
1204
|
+
# No exchanges to unbind from, proceed with delete
|
|
1205
|
+
delete_queue(queue_name)
|
|
1206
|
+
|
|
1207
|
+
def delete_queue(queue_name):
|
|
1208
|
+
"""Delete a queue with callback"""
|
|
758
1209
|
try:
|
|
759
|
-
|
|
760
|
-
self.channel.queue_delete(
|
|
761
|
-
|
|
1210
|
+
logger.debug(f"Deleting queue: {queue_name}")
|
|
1211
|
+
self.channel.queue_delete(
|
|
1212
|
+
queue=queue_name,
|
|
1213
|
+
if_unused=False,
|
|
1214
|
+
if_empty=False,
|
|
1215
|
+
callback=lambda method_frame: on_queue_deleted(
|
|
1216
|
+
method_frame, queue_name
|
|
1217
|
+
),
|
|
1218
|
+
)
|
|
762
1219
|
except Exception as e:
|
|
763
1220
|
logger.error(f"Failed to delete queue {queue_name}: {e}")
|
|
764
|
-
|
|
765
|
-
|
|
1221
|
+
# Remove from tracking even if deletion fails and update counter
|
|
1222
|
+
self.declared_queues.discard(queue_name)
|
|
1223
|
+
nonlocal pending_deletions
|
|
1224
|
+
pending_deletions -= 1
|
|
1225
|
+
|
|
1226
|
+
# Check if this was the last queue
|
|
1227
|
+
if pending_deletions == 0:
|
|
1228
|
+
# All queues processed (or failed), signal completion
|
|
1229
|
+
completion_event.set()
|
|
1230
|
+
|
|
1231
|
+
# Start the deletion process for each queue
|
|
1232
|
+
for queue_name in queues_to_delete:
|
|
766
1233
|
try:
|
|
767
|
-
|
|
768
|
-
|
|
1234
|
+
logger.debug(f"Attempting to purge queue: {queue_name}")
|
|
1235
|
+
self.channel.queue_purge(
|
|
1236
|
+
queue=queue_name,
|
|
1237
|
+
callback=lambda method_frame, q=queue_name: on_queue_purged(
|
|
1238
|
+
method_frame, q
|
|
1239
|
+
),
|
|
1240
|
+
)
|
|
769
1241
|
except Exception as e:
|
|
770
|
-
logger.
|
|
1242
|
+
logger.debug(f"Failed to purge queue {queue_name}: {e}")
|
|
1243
|
+
# Continue with unbinding even if purge fails
|
|
1244
|
+
unbind_queue_from_exchanges(queue_name)
|
|
771
1245
|
|
|
772
1246
|
def stop_consuming(self):
|
|
773
1247
|
"""Tell RabbitMQ that you would like to stop consuming by sending the
|
|
774
1248
|
Basic.Cancel RPC command.
|
|
775
1249
|
"""
|
|
776
1250
|
if self.channel:
|
|
777
|
-
logger.
|
|
1251
|
+
logger.debug("Sending a Basic.Cancel RPC command to RabbitMQ")
|
|
778
1252
|
cb = functools.partial(self.on_cancelok, userdata=self._consumer_tag)
|
|
779
1253
|
self.channel.basic_cancel(self._consumer_tag, cb)
|
|
780
1254
|
|
|
@@ -799,14 +1273,6 @@ class Application:
|
|
|
799
1273
|
"""Call to close the channel with RabbitMQ cleanly by issuing the
|
|
800
1274
|
Channel.Close RPC command.
|
|
801
1275
|
"""
|
|
802
|
-
logger.info("Deleting queues and exchanges.")
|
|
803
|
-
|
|
804
|
-
if self.predefined_exchanges_queues:
|
|
805
|
-
self.delete_queue(self.channel_configs, self.app_name)
|
|
806
|
-
self.delete_exchange(self.unique_exchanges)
|
|
807
|
-
else:
|
|
808
|
-
self.delete_all_queues_and_exchanges()
|
|
809
|
-
|
|
810
1276
|
logger.info("Closing channel")
|
|
811
1277
|
self.channel.close()
|
|
812
1278
|
|
|
@@ -816,28 +1282,74 @@ class Application:
|
|
|
816
1282
|
|
|
817
1283
|
def stop_application(self):
|
|
818
1284
|
"""Cleanly shutdown the connection to RabbitMQ by stopping the consumer
|
|
819
|
-
with RabbitMQ
|
|
820
|
-
will be invoked by pika, which will then closing the channel and
|
|
821
|
-
connection. The IOLoop is started again because this method is invoked
|
|
822
|
-
when CTRL-C is pressed raising a KeyboardInterrupt exception. This
|
|
823
|
-
exception stops the IOLoop which needs to be running for pika to
|
|
824
|
-
communicate with RabbitMQ. All of the commands issued prior to starting
|
|
825
|
-
the IOLoop will be buffered but not processed.
|
|
1285
|
+
with RabbitMQ, cleaning up resources, and stopping all background threads.
|
|
826
1286
|
"""
|
|
827
1287
|
if not self._closing:
|
|
828
1288
|
self._closing = True
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1289
|
+
logger.debug("Initiating application shutdown sequence")
|
|
1290
|
+
|
|
1291
|
+
# Create a threading Event to signal when cleanup is complete
|
|
1292
|
+
cleanup_complete_event = threading.Event()
|
|
1293
|
+
|
|
1294
|
+
# First clean up RabbitMQ resources if channel is available
|
|
1295
|
+
if (
|
|
1296
|
+
self.channel
|
|
1297
|
+
and not self.channel.is_closing
|
|
1298
|
+
and not self.channel.is_closed
|
|
1299
|
+
):
|
|
1300
|
+
try:
|
|
1301
|
+
# Clean up resources before stopping the loop
|
|
1302
|
+
if self.predefined_exchanges_queues:
|
|
1303
|
+
self.delete_queue(self.channel_configs, self.app_name)
|
|
1304
|
+
self.delete_exchange(self.unique_exchanges)
|
|
1305
|
+
# Signal completion immediately for simple delete operations
|
|
1306
|
+
cleanup_complete_event.set()
|
|
1307
|
+
else:
|
|
1308
|
+
# Delete all queues and exchanges with a callback for completion
|
|
1309
|
+
self._delete_queues_with_callback(cleanup_complete_event)
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
logger.error(f"Error during cleanup: {e}")
|
|
1312
|
+
# Set the event even if there's an error
|
|
1313
|
+
cleanup_complete_event.set()
|
|
1314
|
+
else:
|
|
1315
|
+
# No channel available, so cleanup is "complete"
|
|
1316
|
+
cleanup_complete_event.set()
|
|
1317
|
+
|
|
1318
|
+
# Wait for cleanup to complete with a reasonable timeout (10 seconds)
|
|
1319
|
+
logger.info("Cleaning up queues.")
|
|
1320
|
+
cleanup_result = cleanup_complete_event.wait(timeout=10)
|
|
1321
|
+
if cleanup_result:
|
|
1322
|
+
logger.info("Cleaning up queues completed successfully.")
|
|
839
1323
|
else:
|
|
840
|
-
|
|
1324
|
+
logger.warning("Cleaning up queues timed out after 10 seconds.")
|
|
1325
|
+
|
|
1326
|
+
# Stop consuming messages if we were consuming
|
|
1327
|
+
if self._consuming:
|
|
1328
|
+
try:
|
|
1329
|
+
self.stop_consuming()
|
|
1330
|
+
except Exception as e:
|
|
1331
|
+
logger.error(f"Error stopping consumer: {e}")
|
|
1332
|
+
|
|
1333
|
+
# Also stop token refresh thread if it exists
|
|
1334
|
+
if hasattr(self, "_should_stop"):
|
|
1335
|
+
self._should_stop.set()
|
|
1336
|
+
if (
|
|
1337
|
+
hasattr(self, "_token_refresh_thread")
|
|
1338
|
+
and self._token_refresh_thread
|
|
1339
|
+
and self._token_refresh_thread.is_alive()
|
|
1340
|
+
):
|
|
1341
|
+
logger.info("Closing token refresh thread.")
|
|
1342
|
+
# Set a timeout to avoid hanging indefinitely
|
|
1343
|
+
self._token_refresh_thread.join(timeout=60.0)
|
|
1344
|
+
# Check if it's still alive after timeout
|
|
1345
|
+
if self._token_refresh_thread.is_alive():
|
|
1346
|
+
logger.warning(
|
|
1347
|
+
"Closing token refresh thread timed out after 60 seconds. "
|
|
1348
|
+
)
|
|
1349
|
+
else:
|
|
1350
|
+
logger.info("Closing token refresh thread completed successfully")
|
|
1351
|
+
|
|
1352
|
+
logger.debug("Stop_application completed successfully.")
|
|
841
1353
|
|
|
842
1354
|
def set_wallclock_offset(
|
|
843
1355
|
self, host="pool.ntp.org", retry_delay_s: int = 5, max_retry: int = 5
|
|
@@ -43,13 +43,30 @@ class Entity(Observable):
|
|
|
43
43
|
"""
|
|
44
44
|
self._init_time = self._time = self._next_time = init_time
|
|
45
45
|
|
|
46
|
+
# def tick(self, time_step: timedelta) -> None:
|
|
47
|
+
# """
|
|
48
|
+
# Computes the next state transition following an elapsed scenario duration (time step).
|
|
49
|
+
|
|
50
|
+
# Args:
|
|
51
|
+
# time_step (:obj:`timedelta`): elapsed scenario duration
|
|
52
|
+
# """
|
|
53
|
+
# self._next_time = self._time + time_step
|
|
46
54
|
def tick(self, time_step: timedelta) -> None:
|
|
47
55
|
"""
|
|
48
56
|
Computes the next state transition following an elapsed scenario duration (time step).
|
|
57
|
+
If the entity hasn't been initialized yet, the time_step will be stored but no
|
|
58
|
+
time advancement will occur until the entity is initialized.
|
|
49
59
|
|
|
50
60
|
Args:
|
|
51
61
|
time_step (:obj:`timedelta`): elapsed scenario duration
|
|
52
62
|
"""
|
|
63
|
+
if self._time is None:
|
|
64
|
+
logger.debug(
|
|
65
|
+
f"Entity {self.name} not yet initialized, waiting for initialization."
|
|
66
|
+
)
|
|
67
|
+
# Don't try to calculate next_time yet, just maintain the current None state
|
|
68
|
+
return
|
|
69
|
+
|
|
53
70
|
self._next_time = self._time + time_step
|
|
54
71
|
|
|
55
72
|
def tock(self) -> None:
|
|
@@ -196,7 +196,7 @@ class ManagedApplication(Application):
|
|
|
196
196
|
self._sim_stop_time = params.sim_stop_time
|
|
197
197
|
logger.info(f"Sim stop time: {params.sim_stop_time}")
|
|
198
198
|
|
|
199
|
-
threading.Thread(
|
|
199
|
+
self._simulation_thread = threading.Thread(
|
|
200
200
|
target=self.simulator.execute,
|
|
201
201
|
kwargs={
|
|
202
202
|
"init_time": self._sim_start_time,
|
|
@@ -205,7 +205,8 @@ class ManagedApplication(Application):
|
|
|
205
205
|
"wallclock_epoch": params.start_time,
|
|
206
206
|
"time_scale_factor": params.time_scaling_factor,
|
|
207
207
|
},
|
|
208
|
-
)
|
|
208
|
+
)
|
|
209
|
+
self._simulation_thread.start()
|
|
209
210
|
|
|
210
211
|
except Exception as e:
|
|
211
212
|
logger.error(
|
|
@@ -3,8 +3,12 @@ Provides base classes that implement the observer pattern to loosely couple an o
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
7
|
-
from
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from nost_tools.simulator import Mode, Simulator
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
class Observer(ABC):
|
|
@@ -179,3 +183,76 @@ class MessageObservable(Observable):
|
|
|
179
183
|
"""
|
|
180
184
|
for observer in self._message_observers:
|
|
181
185
|
observer.on_message(ch, method, properties, body)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PropertyChangeCallback(Observer):
|
|
189
|
+
"""
|
|
190
|
+
Triggers a provided callback basedwhen a named property changes.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, property_name: str, callback: Callable[[object, object], None]):
|
|
194
|
+
self.callback = callback
|
|
195
|
+
self.property_name = property_name
|
|
196
|
+
|
|
197
|
+
def on_change(
|
|
198
|
+
self, source: object, property_name: str, old_value: object, new_value: object
|
|
199
|
+
) -> None:
|
|
200
|
+
if self.property_name == property_name:
|
|
201
|
+
self.callback(source, new_value)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ScenarioTimeIntervalCallback(Observer):
|
|
205
|
+
"""
|
|
206
|
+
Triggers a provided callback at a fixed interval in scenario time.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self, callback: Callable[[object, datetime], None], time_inteval: timedelta
|
|
211
|
+
):
|
|
212
|
+
self.callback = callback
|
|
213
|
+
self.time_interval = time_inteval
|
|
214
|
+
self._next_time = None
|
|
215
|
+
|
|
216
|
+
def on_change(
|
|
217
|
+
self, source: object, property_name: str, old_value: object, new_value: object
|
|
218
|
+
):
|
|
219
|
+
if property_name == source.PROPERTY_TIME:
|
|
220
|
+
if self._next_time is None:
|
|
221
|
+
self._next_time = old_value + self.time_interval
|
|
222
|
+
while self._next_time <= new_value:
|
|
223
|
+
self.callback(source, self._next_time)
|
|
224
|
+
self._next_time = self._next_time + self.time_interval
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class WallclockTimeIntervalCallback(Observer):
|
|
228
|
+
"""
|
|
229
|
+
Triggers a provided callback at a fixed interval in wallclock time.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
simulator: "Simulator",
|
|
235
|
+
callback: Callable[[datetime], None],
|
|
236
|
+
time_inteval: timedelta,
|
|
237
|
+
time_init: timedelta = None,
|
|
238
|
+
):
|
|
239
|
+
self.simulator = simulator
|
|
240
|
+
self.callback = callback
|
|
241
|
+
self.time_interval = time_inteval
|
|
242
|
+
self.time_init = time_init
|
|
243
|
+
self._next_time = None
|
|
244
|
+
|
|
245
|
+
def on_change(
|
|
246
|
+
self, source: object, property_name: str, old_value: object, new_value: object
|
|
247
|
+
):
|
|
248
|
+
from nost_tools.simulator import Mode, Simulator
|
|
249
|
+
|
|
250
|
+
if property_name == Simulator.PROPERTY_MODE and new_value == Mode.INITIALIZED:
|
|
251
|
+
self._next_time = self.time_init
|
|
252
|
+
elif property_name == Simulator.PROPERTY_TIME:
|
|
253
|
+
wallclock_time = self.simulator.get_wallclock_time()
|
|
254
|
+
if self._next_time is None:
|
|
255
|
+
self._next_time = wallclock_time + self.time_interval
|
|
256
|
+
while self._next_time <= wallclock_time:
|
|
257
|
+
self.callback(self._next_time)
|
|
258
|
+
self._next_time = self._next_time + self.time_interval
|
|
@@ -219,20 +219,48 @@ class RabbitMQConfig(BaseModel):
|
|
|
219
219
|
keycloak_authentication: bool = Field(
|
|
220
220
|
False, description="Keycloak authentication for RabbitMQ."
|
|
221
221
|
)
|
|
222
|
-
host: str = Field("localhost", description="RabbitMQ host.")
|
|
223
|
-
port: int = Field(5672, description="RabbitMQ port.")
|
|
224
222
|
tls: bool = Field(False, description="RabbitMQ TLS/SSL.")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
223
|
+
reconnect_delay: int = Field(10, description="Reconnection delay, in seconds.")
|
|
224
|
+
queue_max_size: int = Field(5000, description="Maximum size of the RabbitMQ queue.")
|
|
225
|
+
# BasicProperties
|
|
226
|
+
content_type: str = Field(
|
|
227
|
+
None,
|
|
228
|
+
description="RabbitMQ MIME content type (application/json, text/plain, etc.).",
|
|
229
|
+
)
|
|
230
|
+
content_encoding: str = Field(
|
|
231
|
+
None,
|
|
232
|
+
description="RabbitMQ MIME content encoding (gzip, deflate, etc.).",
|
|
233
|
+
)
|
|
234
|
+
headers: Dict[str, str] = Field(
|
|
235
|
+
None, description="RabbitMQ message headers (key-value pairs)."
|
|
228
236
|
)
|
|
229
237
|
delivery_mode: int = Field(
|
|
230
238
|
None, description="RabbitMQ delivery mode (1: non-persistent, 2: durable)."
|
|
231
239
|
)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
description="RabbitMQ
|
|
240
|
+
priority: int = Field(None, description="RabbitMQ message priority (0-255).")
|
|
241
|
+
correlation_id: str = Field(
|
|
242
|
+
None, description="RabbitMQ correlation ID for message tracking."
|
|
235
243
|
)
|
|
244
|
+
reply_to: str = Field(
|
|
245
|
+
None, description="RabbitMQ reply-to queue for response messages."
|
|
246
|
+
)
|
|
247
|
+
message_expiration: str = Field(
|
|
248
|
+
None, description="RabbitMQ expiration, in milliseconds."
|
|
249
|
+
)
|
|
250
|
+
message_id: str = Field(None, description="RabbitMQ message ID for tracking.")
|
|
251
|
+
timestamp: datetime = Field(None, description="RabbitMQ message timestamp.")
|
|
252
|
+
type: str = Field(None, description="RabbitMQ message type (e.g., 'text', 'json').")
|
|
253
|
+
user_id: str = Field(None, description="RabbitMQ user ID for authentication.")
|
|
254
|
+
app_id: str = Field(None, description="RabbitMQ application ID for tracking.")
|
|
255
|
+
cluster_id: str = Field(None, description="RabbitMQ cluster ID for tracking.")
|
|
256
|
+
# ConnectionParameters
|
|
257
|
+
host: str = Field("localhost", description="RabbitMQ host.")
|
|
258
|
+
port: int = Field(5672, description="RabbitMQ port.")
|
|
259
|
+
virtual_host: str = Field("/", description="RabbitMQ virtual host.")
|
|
260
|
+
channel_max: int = Field(
|
|
261
|
+
65535, description="RabbitMQ maximum number of channels per connection."
|
|
262
|
+
)
|
|
263
|
+
frame_max: int = Field(131072, description="RabbitMQ maximum frame size in bytes.")
|
|
236
264
|
heartbeat: int = Field(None, description="RabbitMQ heartbeat interval, in seconds.")
|
|
237
265
|
connection_attempts: int = Field(
|
|
238
266
|
1, description="RabbitMQ connection attempts before giving up."
|
|
@@ -244,7 +272,6 @@ class RabbitMQConfig(BaseModel):
|
|
|
244
272
|
blocked_connection_timeout: int = Field(
|
|
245
273
|
None, description="Timeout for blocked connections."
|
|
246
274
|
)
|
|
247
|
-
reconnect_delay: int = Field(10, description="Reconnect delay, in seconds.")
|
|
248
275
|
|
|
249
276
|
|
|
250
277
|
class KeycloakConfig(BaseModel):
|
|
@@ -312,12 +339,18 @@ class ManagerConfig(BaseModel):
|
|
|
312
339
|
shut_down_when_terminated: bool = Field(
|
|
313
340
|
False, description="Shut down when terminated."
|
|
314
341
|
)
|
|
342
|
+
is_scenario_time_status_step: bool = Field(
|
|
343
|
+
True,
|
|
344
|
+
description="If True, time_status_step is in scenario time and won't be scaled. If False, time_status_step is in wallclock time and will be scaled by the time scale factor.",
|
|
345
|
+
)
|
|
315
346
|
|
|
316
347
|
@model_validator(mode="before")
|
|
317
348
|
def scale_time(cls, values):
|
|
318
349
|
time_scale_factor = values.get("time_scale_factor", 1.0)
|
|
319
350
|
|
|
320
|
-
if "time_status_step" in values
|
|
351
|
+
if "time_status_step" in values and not values.get(
|
|
352
|
+
"is_scenario_time_status_step", True
|
|
353
|
+
):
|
|
321
354
|
time_status_step = values["time_status_step"]
|
|
322
355
|
if isinstance(time_status_step, str):
|
|
323
356
|
hours, minutes, seconds = map(int, time_status_step.split(":"))
|
|
@@ -344,12 +377,20 @@ class ManagedApplicationConfig(BaseModel):
|
|
|
344
377
|
False, description="Shut down when terminated."
|
|
345
378
|
)
|
|
346
379
|
manager_app_name: str = Field("manager", description="Manager application name.")
|
|
380
|
+
is_scenario_time_step: bool = Field(
|
|
381
|
+
True,
|
|
382
|
+
description="If True, time_step is in scenario time and won't be scaled. If False, time_step is in wallclock time and will be scaled by the time scale factor.",
|
|
383
|
+
)
|
|
384
|
+
is_scenario_time_status_step: bool = Field(
|
|
385
|
+
True,
|
|
386
|
+
description="If True, time_status_step is in scenario time and won't be scaled. If False, time_status_step is in wallclock time and will be scaled by the time scale factor.",
|
|
387
|
+
)
|
|
347
388
|
|
|
348
389
|
@model_validator(mode="before")
|
|
349
390
|
def scale_time(cls, values):
|
|
350
391
|
time_scale_factor = values.get("time_scale_factor", 1.0)
|
|
351
392
|
|
|
352
|
-
if "time_step" in values:
|
|
393
|
+
if "time_step" in values and not values.get("is_scenario_time_step", True):
|
|
353
394
|
time_step = values["time_step"]
|
|
354
395
|
if isinstance(time_step, str):
|
|
355
396
|
hours, minutes, seconds = map(int, time_step.split(":"))
|
|
@@ -359,7 +400,9 @@ class ManagedApplicationConfig(BaseModel):
|
|
|
359
400
|
seconds=time_step.total_seconds() * time_scale_factor
|
|
360
401
|
)
|
|
361
402
|
|
|
362
|
-
if "time_status_step" in values
|
|
403
|
+
if "time_status_step" in values and not values.get(
|
|
404
|
+
"is_scenario_time_status_step", True
|
|
405
|
+
):
|
|
363
406
|
time_status_step = values["time_status_step"]
|
|
364
407
|
if isinstance(time_status_step, str):
|
|
365
408
|
hours, minutes, seconds = map(int, time_status_step.split(":"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nost_tools
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Tools for Novel Observing Strategies Testbed (NOS-T) Applications
|
|
5
5
|
Author-email: "Paul T. Grogan" <paul.grogan@asu.edu>, "Emmanuel M. Gonzalez" <emmanuelgonzalez@asu.edu>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|