nost-tools 2.0.5__py3-none-any.whl → 2.1.1__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,11 +1,10 @@
1
- __version__ = "2.0.5"
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
nost_tools/application.py CHANGED
@@ -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.error(f"Failed to refresh access token: {e}")
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
- # Create an SSL context
279
- context = ssl.create_default_context()
280
- context.check_hostname = False
281
- context.verify_mode = ssl.CERT_NONE
282
- # Set SSL options
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
- logger.info(f"Reconnect delay: {self._reconnect_delay}")
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.info("Adding channel close callback")
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
- Channels are usually closed if you attempt to do something that
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 loss of connection
402
+ reason (Exception): exception representing reason for channel closure
372
403
  """
373
- logger.warning(f"Channel {channel} was closed: {reason}")
374
- self.close_connection()
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
- This method is invoked by pika when the connection to RabbitMQ is
403
- closed unexpectedly. Since it is unexpected, we will reconnect to
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
- logger.warning(
415
- f"Connection closed, reconnecting in {self._reconnect_delay} seconds: {reason}"
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
- if self.connection:
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
- # Stop the token refresh thread
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
- logger.info(f"Application {self.app_name} successfully shut down.")
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. The message is sent to the exchange using the routing key. The routing key is created using the application name and topic. The message is published with an expiration of 60 seconds.
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
- if not self.predefined_exchanges_queues:
491
- routing_key, queue_name = self.yamless_declare_bind_queue(
492
- routing_key=routing_key
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
- self.channel.basic_publish(
495
- exchange=self.prefix,
496
- routing_key=routing_key,
497
- body=payload,
498
- properties=pika.BasicProperties(
499
- expiration=self.config.rc.server_configuration.servers.rabbitmq.message_expiration,
500
- delivery_mode=self.config.rc.server_configuration.servers.rabbitmq.delivery_mode,
501
- content_type=self.config.rc.server_configuration.servers.rabbitmq.content_type,
502
- app_id=self.app_name,
503
- ),
504
- )
505
- logger.debug(
506
- f"Successfully sent message '{payload}' to topic '{routing_key}'."
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=False, auto_delete=True
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
- self.channel.basic_qos(prefetch_count=1)
604
- self._consumer_tag = self.channel.basic_consume(
605
- queue=queue_name,
606
- on_message_callback=self._handle_message,
607
- auto_ack=False,
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=False, auto_delete=True
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.info(f"Deleting queue: {config['address']}")
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 delete_all_queues_and_exchanges(self):
1089
+ def _delete_queues_with_callback(self, completion_event):
754
1090
  """
755
- Deletes all declared queues and exchanges from RabbitMQ.
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
- for queue_name in list(self.declared_queues):
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
- # self.channel.queue_purge(queue=queue_name)
760
- self.channel.queue_delete(queue=queue_name)
761
- logger.info(f"Deleted queue: {queue_name}")
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
- for exchange_name in list(self.declared_exchanges):
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
- self.channel.exchange_delete(exchange=exchange_name)
768
- logger.info(f"Deleted exchange: {exchange_name}")
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.error(f"Failed to delete exchange {exchange_name}: {e}")
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.info("Sending a Basic.Cancel RPC command to RabbitMQ")
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. When RabbitMQ confirms the cancellation, on_cancelok
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
- if self._consuming:
830
- self.stop_consuming()
831
- # Signal the thread to stop
832
- if hasattr(self, "stop_event"):
833
- self.stop_event.set()
834
- if hasattr(self, "_should_stop"):
835
- self._should_stop.set()
836
- if hasattr(self, "io_thread"):
837
- self._io_thread.join()
838
- sys.exit()
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
- self.connection.ioloop.stop()
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
nost_tools/entity.py CHANGED
@@ -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
- ).start()
208
+ )
209
+ self._simulation_thread.start()
209
210
 
210
211
  except Exception as e:
211
212
  logger.error(
nost_tools/manager.py CHANGED
@@ -94,7 +94,7 @@ class Manager(Application):
94
94
  self.channel.exchange_declare(
95
95
  exchange=self.prefix,
96
96
  exchange_type="topic",
97
- durable=False,
97
+ durable=True,
98
98
  auto_delete=True,
99
99
  )
100
100
 
nost_tools/observer.py CHANGED
@@ -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 datetime import datetime, timezone
7
- from typing import List, Optional, Union
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
nost_tools/schemas.py CHANGED
@@ -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
- virtual_host: str = Field("/", description="RabbitMQ virtual host.")
226
- message_expiration: str = Field(
227
- None, description="RabbitMQ expiration, in milliseconds."
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
- content_type: str = Field(
233
- None,
234
- description="RabbitMQ MIME content type (application/json, text/plain, etc.).",
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.0.5
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
@@ -0,0 +1,18 @@
1
+ nost_tools/__init__.py,sha256=bNVX1vszl3KMnBrd1x53pAeZgUUwlPQCF0FuLotlc3Y,870
2
+ nost_tools/application.py,sha256=bPuRMBifppJ-qkDKqOqp13rW_LOjbM7JaXIozMyxmlw,60914
3
+ nost_tools/application_utils.py,sha256=_R39D26FYxgaO4uyTON24KXc4UQ4zAEDBZfEkHbEw64,9386
4
+ nost_tools/configuration.py,sha256=ikNpZi8aofhZzJRbJf4x46afbAnp8r5C7Yr50Rnn1Nc,11639
5
+ nost_tools/entity.py,sha256=JrSN5rz7h-N9zIQsaJOQVBIYPDfygacedks55fsq_QQ,2802
6
+ nost_tools/errors.py,sha256=0JcDlMEkZAya3-5c0rRozLuxp8qF58StG4JgRsaxfKU,344
7
+ nost_tools/logger_application.py,sha256=rxPBfyA7Zym5b_EsoSJvT9JWNIVWZX1a-4czFwCqaQ4,7217
8
+ nost_tools/managed_application.py,sha256=7393kCF9FeXlf_pSP_C-tAeNSW9_-smQH6pQgyHC6Cw,11576
9
+ nost_tools/manager.py,sha256=QSOcFqA3xpa84AuxvXV6134PZp8iQndNCq1w3P4tHUU,21315
10
+ nost_tools/observer.py,sha256=D64V0KTvHRPEqbB8q3BosJhoAlpBah2vyBlVbxWQR44,8161
11
+ nost_tools/publisher.py,sha256=omU8tb0AXnA6RfhYSh0vnXbJtrRo4ukx1J5ANl4bDLQ,5291
12
+ nost_tools/schemas.py,sha256=nlMG4gZH-hUdmvz5a2294wGVN9Km7yE80x40vRJJRu8,17670
13
+ nost_tools/simulator.py,sha256=ALnGDmnA_ga-1Lq-bVWi2vcrspgjS4vtuDE0jWsI7fE,20191
14
+ nost_tools-2.1.1.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
+ nost_tools-2.1.1.dist-info/METADATA,sha256=fNbyKb9l5zQyeYZfYL9bfV5bfGHV1rF4z__FJ2leKfA,4256
16
+ nost_tools-2.1.1.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
17
+ nost_tools-2.1.1.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
+ nost_tools-2.1.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (79.0.1)
2
+ Generator: setuptools (80.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,18 +0,0 @@
1
- nost_tools/__init__.py,sha256=wtJclxK6ZfF7GpmfBfOz2YKp-zFw-kO9B49LJeTb1Vw,873
2
- nost_tools/application.py,sha256=1mFCw6b5BCAlaof-ijzjFYCvVB-WCn9llpHHijxeIik,37027
3
- nost_tools/application_utils.py,sha256=_R39D26FYxgaO4uyTON24KXc4UQ4zAEDBZfEkHbEw64,9386
4
- nost_tools/configuration.py,sha256=ikNpZi8aofhZzJRbJf4x46afbAnp8r5C7Yr50Rnn1Nc,11639
5
- nost_tools/entity.py,sha256=AwbZMP3_H4RQuyU4voyQwYFkETxG0mfD-0BMHxrRFf8,2064
6
- nost_tools/errors.py,sha256=0JcDlMEkZAya3-5c0rRozLuxp8qF58StG4JgRsaxfKU,344
7
- nost_tools/logger_application.py,sha256=rxPBfyA7Zym5b_EsoSJvT9JWNIVWZX1a-4czFwCqaQ4,7217
8
- nost_tools/managed_application.py,sha256=jjS-URl4D_-VwKNYnqwYg_myFugKgMd_Gy1aAxxw0SU,11514
9
- nost_tools/manager.py,sha256=i0lNPlUrj3Cm-F_kFOa0vL4dH95PTomsLvESvMrDrUA,21316
10
- nost_tools/observer.py,sha256=w66jZQ11Fr7XSCcvcc2f5ISce2n8Ba7cXqheSTuyrmw,5519
11
- nost_tools/publisher.py,sha256=omU8tb0AXnA6RfhYSh0vnXbJtrRo4ukx1J5ANl4bDLQ,5291
12
- nost_tools/schemas.py,sha256=m6Np7DGrBIgtswpnrTqSowTb_niC4NY59BTQFBYfkZc,15332
13
- nost_tools/simulator.py,sha256=ALnGDmnA_ga-1Lq-bVWi2vcrspgjS4vtuDE0jWsI7fE,20191
14
- nost_tools-2.0.5.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
- nost_tools-2.0.5.dist-info/METADATA,sha256=rJzfSSuz40c1VYpDojXIcZoYgLv9twTxG8oSt4URP3g,4256
16
- nost_tools-2.0.5.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
17
- nost_tools-2.0.5.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
- nost_tools-2.0.5.dist-info/RECORD,,