switch-api 0.6.17__tar.gz → 0.6.18__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.
Files changed (78) hide show
  1. {switch_api-0.6.17 → switch_api-0.6.18}/HISTORY.md +13 -0
  2. {switch_api-0.6.17 → switch_api-0.6.18}/PKG-INFO +37 -6
  3. switch_api-0.6.18/pyproject.toml +6 -0
  4. {switch_api-0.6.17 → switch_api-0.6.18}/setup.py +2 -2
  5. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/__init__.py +4 -2
  6. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/realtime/__init__.py +2 -1
  7. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/realtime/_mqtt.py +27 -8
  8. switch_api-0.6.18/switch_api/realtime/_redis.py +429 -0
  9. switch_api-0.6.18/switch_api/realtime/realtime.py +397 -0
  10. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/PKG-INFO +38 -7
  11. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/SOURCES.txt +7 -1
  12. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/requires.txt +1 -0
  13. switch_api-0.6.18/tests/test_dataset.py +7 -0
  14. switch_api-0.6.18/tests/test_error_handlers.py +196 -0
  15. switch_api-0.6.18/tests/test_initialize.py +5 -0
  16. switch_api-0.6.18/tests/test_integration.py +37 -0
  17. switch_api-0.6.18/tests/test_pipeline.py +6 -0
  18. switch_api-0.6.17/pyproject.toml +0 -6
  19. switch_api-0.6.17/switch_api/realtime/realtime.py +0 -235
  20. {switch_api-0.6.17 → switch_api-0.6.18}/AUTHORS.rst +0 -0
  21. {switch_api-0.6.17 → switch_api-0.6.18}/LICENCE +0 -0
  22. {switch_api-0.6.17 → switch_api-0.6.18}/MANIFEST.in +0 -0
  23. {switch_api-0.6.17 → switch_api-0.6.18}/README.md +0 -0
  24. {switch_api-0.6.17 → switch_api-0.6.18}/setup.cfg +0 -0
  25. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/__init__.py +0 -0
  26. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_authentication.py +0 -0
  27. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_credentials_store/__init__.py +0 -0
  28. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_credentials_store/_credentials_store.py +0 -0
  29. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_msal/__init__.py +0 -0
  30. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_authentication/_msal/_custom_application.py +0 -0
  31. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/__init__.py +0 -0
  32. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_commands.py +0 -0
  33. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_compute.py +0 -0
  34. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_iotservice.py +0 -0
  35. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_models.py +0 -0
  36. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_compute/_utils.py +0 -0
  37. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/__init__.py +0 -0
  38. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/main.py +0 -0
  39. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_guide/processor.py +0 -0
  40. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/__init__.py +0 -0
  41. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_constants.py +0 -0
  42. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_marketplace.py +0 -0
  43. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_mqtt.py +0 -0
  44. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_platform.py +0 -0
  45. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/_utils/_utils.py +0 -0
  46. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/analytics/__init__.py +0 -0
  47. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/analytics/analytics.py +0 -0
  48. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/cache/__init__.py +0 -0
  49. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/cache/cache.py +0 -0
  50. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/__init__.py +0 -0
  51. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_constants.py +0 -0
  52. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_enums.py +0 -0
  53. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/_mqtt.py +0 -0
  54. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/controls/controls.py +0 -0
  55. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/dataset/__init__.py +0 -0
  56. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/dataset/dataset.py +0 -0
  57. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/email/__init__.py +0 -0
  58. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/email/email_sender.py +0 -0
  59. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/error_handlers/__init__.py +0 -0
  60. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/error_handlers/error_handlers.py +0 -0
  61. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/__init__.py +0 -0
  62. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/extensions.py +0 -0
  63. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/field_meta.py +0 -0
  64. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/helpers.py +0 -0
  65. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/extensions/pipeline.py +0 -0
  66. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/initialize.py +0 -0
  67. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/__init__.py +0 -0
  68. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/_utils.py +0 -0
  69. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/helpers.py +0 -0
  70. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/integration/integration.py +0 -0
  71. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/__init__.py +0 -0
  72. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/automation.py +0 -0
  73. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/definitions.py +0 -0
  74. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/pipeline/pipeline.py +0 -0
  75. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/platform_insights/__init__.py +0 -0
  76. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api/platform_insights/platform_insights.py +0 -0
  77. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/dependency_links.txt +0 -0
  78. {switch_api-0.6.17 → switch_api-0.6.18}/switch_api.egg-info/top_level.txt +0 -0
@@ -1,5 +1,18 @@
1
1
  # History
2
2
 
3
+ #0.6.18
4
+ ### Changed
5
+ In the `realtime` module:
6
+ - Updated `submit_realtime_updates()` to use the passed `timeout` value when waiting for a retained MQTT message.
7
+ - Added Redis-backed optimistic concurrency support for realtime dataset updates using WATCH/MULTI/EXEC.
8
+ - Added `set_realtime_redis_config()` to configure Redis as the authoritative state store before publishing updates to MQTT.
9
+ - Added `redis` package dependency for Redis-backed realtime state updates.
10
+ - Added a maximum of 3 retained-message subscription attempts for connection or subscribe failures in the MQTT fallback path.
11
+ - Preserved new-topic behavior: when no retained message exists and Redis is not configured, the provided dataframe is used and published as the initial retained dataset.
12
+ - Updated `subscribe_with_retained_sync()` to apply the passed timeout to the MQTT connection, check subscribe failures, and log when no retained topics are received within the wait period.
13
+ - Added Redis timeout retry handling with a default maximum of 3 attempts for Redis realtime update operations.
14
+
15
+
3
16
  #0.6.17
4
17
  ### Changed
5
18
  In the `integration` module:
@@ -1,11 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: switch_api
3
- Version: 0.6.17
3
+ Version: 0.6.18
4
4
  Summary: A complete package for data ingestion into the Switch Automation Platform.
5
- Home-page: UNKNOWN
6
5
  Author: Switch Automation Pty Ltd.
7
6
  License: MIT License
8
- Platform: UNKNOWN
9
7
  Classifier: Development Status :: 2 - Pre-Alpha
10
8
  Classifier: License :: OSI Approved :: MIT License
11
9
  Classifier: Intended Audience :: Other Audience
@@ -17,6 +15,28 @@ Requires-Python: >=3.8.0
17
15
  Description-Content-Type: text/markdown
18
16
  License-File: LICENCE
19
17
  License-File: AUTHORS.rst
18
+ Requires-Dist: pandas==1.5.3
19
+ Requires-Dist: requests
20
+ Requires-Dist: azure-storage-blob
21
+ Requires-Dist: pandera[io]==0.7.1
22
+ Requires-Dist: azure-servicebus
23
+ Requires-Dist: msal>=1.11.0
24
+ Requires-Dist: paho-mqtt==1.6.1
25
+ Requires-Dist: uvicorn==0.22.0
26
+ Requires-Dist: fastapi==0.98.0
27
+ Requires-Dist: pyodbc==4.0.39
28
+ Requires-Dist: cachetools==5.5.0
29
+ Requires-Dist: numexpr==2.10.2
30
+ Requires-Dist: redis<6,>=4.5.5
31
+ Dynamic: author
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: license
36
+ Dynamic: license-file
37
+ Dynamic: requires-dist
38
+ Dynamic: requires-python
39
+ Dynamic: summary
20
40
 
21
41
  # Switch Automation library for Python
22
42
  This is a package for data ingestion into the Switch Automation software platform.
@@ -38,6 +58,19 @@ pip install switch_api
38
58
 
39
59
  # History
40
60
 
61
+ #0.6.18
62
+ ### Changed
63
+ In the `realtime` module:
64
+ - Updated `submit_realtime_updates()` to use the passed `timeout` value when waiting for a retained MQTT message.
65
+ - Added Redis-backed optimistic concurrency support for realtime dataset updates using WATCH/MULTI/EXEC.
66
+ - Added `set_realtime_redis_config()` to configure Redis as the authoritative state store before publishing updates to MQTT.
67
+ - Added `redis` package dependency for Redis-backed realtime state updates.
68
+ - Added a maximum of 3 retained-message subscription attempts for connection or subscribe failures in the MQTT fallback path.
69
+ - Preserved new-topic behavior: when no retained message exists and Redis is not configured, the provided dataframe is used and published as the initial retained dataset.
70
+ - Updated `subscribe_with_retained_sync()` to apply the passed timeout to the MQTT connection, check subscribe failures, and log when no retained topics are received within the wait period.
71
+ - Added Redis timeout retry handling with a default maximum of 3 attempts for Redis realtime update operations.
72
+
73
+
41
74
  #0.6.17
42
75
  ### Changed
43
76
  In the `integration` module:
@@ -1196,5 +1229,3 @@ Removed the `name_as_filename` and `treat_as_timeseries` parameter from the foll
1196
1229
  - `switch.integration.replace_data()`
1197
1230
  - `switch.integration.append_data()`
1198
1231
  - `switch.integration.upload_data()`
1199
-
1200
-
@@ -0,0 +1,6 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=64",
4
+ "wheel"
5
+ ]
6
+ build-backend = "setuptools.build_meta"
@@ -16,11 +16,11 @@ setup(
16
16
  long_description_content_type='text/markdown',
17
17
  license='MIT License',
18
18
  name="switch_api",
19
- version="0.6.17",
19
+ version="0.6.18",
20
20
  packages=find_packages(include=["switch_api", "switch_api.*"]),
21
21
  install_requires=['pandas==1.5.3', 'requests', 'azure-storage-blob', 'pandera[io]==0.7.1', 'azure-servicebus',
22
22
  'msal>=1.11.0', 'paho-mqtt==1.6.1', 'uvicorn==0.22.0', 'fastapi==0.98.0', 'pyodbc==4.0.39', 'cachetools==5.5.0',
23
- 'numexpr==2.10.2'],
23
+ 'numexpr==2.10.2', 'redis>=4.5.5,<6'],
24
24
  python_requires=">=3.8.0",
25
25
  classifiers=[
26
26
  'Development Status :: 2 - Pre-Alpha',
@@ -10,7 +10,8 @@ Data Ingestion into the Switch Automation
10
10
  Complete package for ingestion data into the Switch Automation Platform.
11
11
  """
12
12
  __all__ = ['dataset', 'analytics', 'pipeline', 'error_handlers', 'integration',
13
- 'initialize', 'cache', 'extensions', 'email', 'controls', 'realtime', '_compute', 'generate_filepath', 'set_mqtt_broker_config']
13
+ 'initialize', 'cache', 'extensions', 'email', 'controls', 'realtime', '_compute', 'generate_filepath',
14
+ 'set_mqtt_broker_config', 'set_realtime_redis_config']
14
15
 
15
16
  from .initialize import initialize
16
17
  from . import dataset
@@ -27,6 +28,7 @@ from . import realtime
27
28
  from . import _compute
28
29
  from ._utils._utils import generate_filepath
29
30
  from ._utils._mqtt import set_mqtt_broker_config
31
+ from .realtime import set_realtime_redis_config
30
32
  from . import _guide
31
33
 
32
34
 
@@ -42,4 +44,4 @@ from . import _guide
42
44
  # log.addHandler(_ch)
43
45
  # https://dev.to/joaomcteixeira/setting-up-python-logging-for-a-library-app-6ml
44
46
 
45
- __version__ = "0.6.17"
47
+ __version__ = "0.6.18"
@@ -8,5 +8,6 @@ A module for sending control request of sensors.
8
8
  """
9
9
 
10
10
  from .realtime import submit_realtime_updates
11
+ from ._redis import set_realtime_redis_config
11
12
 
12
- __all__ = ['submit_realtime_updates']
13
+ __all__ = ['submit_realtime_updates', 'set_realtime_redis_config']
@@ -7,7 +7,6 @@
7
7
  A module containing the SwithRealtime class and associated methods referenced by the controls module in the package. This
8
8
  module is not directly referenced by end users.
9
9
  """
10
- import json
11
10
  import logging
12
11
  import sys
13
12
  import threading
@@ -109,8 +108,6 @@ class SwitchRealtime:
109
108
  if topics is None:
110
109
  raise ValueError("topics cannot be None")
111
110
 
112
- self.connect()
113
-
114
111
  topics = list(topics)
115
112
 
116
113
  if not topics:
@@ -122,6 +119,11 @@ class SwitchRealtime:
122
119
  if not isinstance(timeout, int) or timeout <= 0:
123
120
  raise ValueError("timeout must be a positive integer")
124
121
 
122
+ if not self.connect(timeout=timeout):
123
+ raise TimeoutError(
124
+ f"Could not connect to MQTT Broker within {timeout} seconds."
125
+ )
126
+
125
127
  # --- Initialize tracking ---
126
128
  self._expected_topics = set(topics)
127
129
  self._received_retained = set()
@@ -129,19 +131,32 @@ class SwitchRealtime:
129
131
 
130
132
  # --- Subscribe ---
131
133
  for topic in topics:
132
- self.client.subscribe(topic)
134
+ result, _ = self.client.subscribe(topic)
135
+ if result != mqtt.MQTT_ERR_SUCCESS:
136
+ raise RuntimeError(
137
+ f"Failed to subscribe to {topic}. MQTT result code: {result}"
138
+ )
133
139
  logger.info(f"Subscribed to = {topic}")
134
140
 
135
141
  # --- Wait until all retained received OR timeout ---
136
- self._retained_event.wait(timeout=timeout)
142
+ received_all = self._retained_event.wait(timeout=timeout)
137
143
 
138
144
  # --- Return only retained dataset ---
139
- return {
145
+ retained_dataset = {
140
146
  topic: self.topic_state[topic]["payload"]
141
147
  for topic in self._received_retained
142
148
  if topic in self.topic_state
143
149
  }
144
150
 
151
+ if not received_all:
152
+ missing_topics = self._expected_topics - self._received_retained
153
+ logger.info(
154
+ f"No retained messages received within {timeout} seconds "
155
+ f"for topics: {sorted(missing_topics)}"
156
+ )
157
+
158
+ return retained_dataset
159
+
145
160
 
146
161
  def publish(self, topic: str, payload: str, qos: int = 1, retain: bool = True):
147
162
  """
@@ -151,7 +166,11 @@ class SwitchRealtime:
151
166
  result = self.client.publish(topic, payload, qos=qos, retain=retain)
152
167
 
153
168
  if result.rc != mqtt.MQTT_ERR_SUCCESS:
154
- logger.error(f"Failed to publish to {topic} : {json.loads(payload)}")
169
+ message = f"Failed to publish to {topic}. MQTT result code: {result.rc}"
170
+ logger.error(message)
171
+ raise RuntimeError(message)
172
+
173
+ return result
155
174
 
156
175
 
157
176
 
@@ -202,4 +221,4 @@ class SwitchRealtime:
202
221
  self._retained_event.set()
203
222
 
204
223
  else:
205
- logger.info(f'MQTT Message - {topic} : {payload}')
224
+ logger.info(f'MQTT Message - {topic} : {payload}')
@@ -0,0 +1,429 @@
1
+ # -------------------------------------------------------------------------
2
+ # Copyright (c) Switch Automation Pty Ltd. All rights reserved.
3
+ # Licensed under the MIT License. See License.txt in the project root for
4
+ # license information.
5
+ # --------------------------------------------------------------------------
6
+ """
7
+ Redis state helpers for concurrency-safe realtime dataset updates.
8
+ """
9
+ import logging
10
+ import random
11
+ import sys
12
+ import time
13
+ from typing import Callable
14
+
15
+ # -----------------------------------------------------------------------------
16
+ # Logging Setup
17
+ # -----------------------------------------------------------------------------
18
+ logger = logging.getLogger(__name__)
19
+ logger.setLevel(logging.DEBUG)
20
+
21
+ if not logger.handlers:
22
+ console_handler = logging.StreamHandler(stream=sys.stdout)
23
+ console_handler.setLevel(logging.INFO)
24
+
25
+ formatter = logging.Formatter(
26
+ "%(asctime)s %(name)s.%(funcName)s %(levelname)s: %(message)s",
27
+ datefmt="%Y-%m-%dT%H:%M:%S",
28
+ )
29
+ console_handler.setFormatter(formatter)
30
+ logger.addHandler(console_handler)
31
+
32
+
33
+ global _redis_config
34
+ global _redis_client
35
+
36
+ _redis_config = {
37
+ "host": "",
38
+ "port": 6380,
39
+ "username": None,
40
+ "password": "",
41
+ "ssl": True,
42
+ "db": 0,
43
+ "key_prefix": "mqtt-state",
44
+ "max_retries": 10,
45
+ "timeout_max_retries": 3,
46
+ "retry_min_delay": 0.01,
47
+ "retry_max_delay": 0.05,
48
+ "socket_timeout": None,
49
+ "socket_connect_timeout": None,
50
+ }
51
+ _redis_client = None
52
+
53
+
54
+ def _log_json_value(label: str, value):
55
+ if value is None:
56
+ logger.info(f"{label}: <None>")
57
+ else:
58
+ logger.info(f"{label}: {value}")
59
+
60
+
61
+ def _get_redis_modules():
62
+ try:
63
+ import redis
64
+ from redis.exceptions import TimeoutError as RedisTimeoutError
65
+ from redis.exceptions import WatchError
66
+ except ImportError as e:
67
+ raise ImportError(
68
+ "Redis realtime state support requires the 'redis' package."
69
+ ) from e
70
+
71
+ return redis, WatchError, RedisTimeoutError
72
+
73
+
74
+ def _is_redis_timeout_error(error: Exception):
75
+ _, _, RedisTimeoutError = _get_redis_modules()
76
+ return isinstance(error, (RedisTimeoutError, TimeoutError))
77
+
78
+
79
+ def _reset_redis_client():
80
+ global _redis_client
81
+
82
+ if _redis_client is not None:
83
+ try:
84
+ _redis_client.close()
85
+ except Exception as e:
86
+ logger.warning(f"Error closing Redis realtime client after timeout: {e}")
87
+
88
+ _redis_client = None
89
+
90
+
91
+ def set_realtime_redis_config(
92
+ host: str,
93
+ password: str = "",
94
+ port: int = 6380,
95
+ username: str = None,
96
+ ssl: bool = True,
97
+ db: int = 0,
98
+ key_prefix: str = "mqtt-state",
99
+ max_retries: int = 10,
100
+ timeout_max_retries: int = 3,
101
+ retry_min_delay: float = 0.01,
102
+ retry_max_delay: float = 0.05,
103
+ socket_timeout: int = None,
104
+ socket_connect_timeout: int = None,
105
+ ):
106
+ """
107
+ Configure Redis state storage for realtime MQTT dataset updates.
108
+
109
+ This method enables Redis-backed concurrency protection for
110
+ `submit_realtime_updates()`.
111
+
112
+ When Redis is configured, `submit_realtime_updates()` treats Redis as the
113
+ authoritative dataset store. Dataset updates are applied using Redis
114
+ optimistic concurrency with WATCH/MULTI/EXEC, then the final serialized
115
+ dataset is published back to MQTT.
116
+
117
+ When Redis is not configured, `submit_realtime_updates()` falls back to
118
+ reading the retained MQTT message before publishing the updated dataset.
119
+
120
+ Redis Key Format
121
+ ----------------
122
+ A single Redis key is constructed in the format:
123
+
124
+ {key_prefix}:{topic}
125
+
126
+ The default key prefix is:
127
+
128
+ mqtt-state
129
+
130
+ Example:
131
+ If key_prefix = "mqtt-state"
132
+ and topic = "Realtime/123/temperature"
133
+
134
+ The resulting Redis key will be:
135
+ mqtt-state:Realtime/123/temperature
136
+
137
+ Parameters
138
+ ----------
139
+ host : str
140
+ Redis host name.
141
+ For Azure Redis, this is typically the cache host name, such as:
142
+ your-cache.redis.cache.windows.net
143
+ Must be a non-empty string.
144
+
145
+ password : str, optional
146
+ Redis access key or password.
147
+ Defaults to an empty string.
148
+
149
+ port : int, optional
150
+ Redis port.
151
+ Defaults to 6380, which is the SSL port commonly used by Azure Redis.
152
+ Must be greater than 0.
153
+
154
+ username : str, optional
155
+ Redis username.
156
+ Defaults to None.
157
+
158
+ ssl : bool, optional
159
+ Indicates whether SSL should be used for the Redis connection.
160
+ Defaults to True.
161
+
162
+ db : int, optional
163
+ Redis database number.
164
+ Defaults to 0.
165
+ Must be zero or a positive integer.
166
+
167
+ key_prefix : str, optional
168
+ Prefix used when constructing Redis dataset keys.
169
+ Defaults to "mqtt-state".
170
+ Must be a non-empty string.
171
+
172
+ max_retries : int, optional
173
+ Maximum number of optimistic concurrency retry attempts when Redis
174
+ detects that another service updated the dataset during the current
175
+ update attempt.
176
+ Defaults to 10.
177
+ Must be greater than 0.
178
+
179
+ timeout_max_retries : int, optional
180
+ Maximum number of Redis timeout retry attempts when a Redis operation
181
+ exceeds the configured socket timeout.
182
+ Defaults to 3.
183
+ Must be greater than 0.
184
+
185
+ retry_min_delay : float, optional
186
+ Minimum randomized delay, in seconds, before retrying after a Redis
187
+ WATCH conflict or Redis timeout.
188
+ Defaults to 0.01.
189
+
190
+ retry_max_delay : float, optional
191
+ Maximum randomized delay, in seconds, before retrying after a Redis
192
+ WATCH conflict or Redis timeout.
193
+ Defaults to 0.05.
194
+
195
+ socket_timeout : int, optional
196
+ Redis socket read timeout in seconds.
197
+ Defaults to None.
198
+
199
+ socket_connect_timeout : int, optional
200
+ Redis socket connection timeout in seconds.
201
+ Defaults to None.
202
+
203
+ Returns
204
+ -------
205
+ None
206
+ The Redis configuration is stored globally for realtime dataset
207
+ updates.
208
+
209
+ Raises
210
+ ------
211
+ ValueError
212
+ If required Redis configuration values are invalid.
213
+ """
214
+ global _redis_config
215
+ global _redis_client
216
+
217
+ if not isinstance(host, str) or not host.strip():
218
+ raise ValueError("host must be a non-empty string.")
219
+
220
+ if not isinstance(port, int) or port < 1:
221
+ raise ValueError("port must be a positive integer.")
222
+
223
+ if not isinstance(db, int) or db < 0:
224
+ raise ValueError("db must be zero or a positive integer.")
225
+
226
+ if not isinstance(key_prefix, str) or not key_prefix.strip():
227
+ raise ValueError("key_prefix must be a non-empty string.")
228
+
229
+ if not isinstance(max_retries, int) or max_retries < 1:
230
+ raise ValueError("max_retries must be a positive integer.")
231
+
232
+ if not isinstance(timeout_max_retries, int) or timeout_max_retries < 1:
233
+ raise ValueError("timeout_max_retries must be a positive integer.")
234
+
235
+ if not isinstance(retry_min_delay, (int, float)) or not isinstance(retry_max_delay, (int, float)):
236
+ raise ValueError("retry delays must be numeric.")
237
+
238
+ if retry_min_delay < 0 or retry_max_delay < 0:
239
+ raise ValueError("retry delays must be zero or positive.")
240
+
241
+ if retry_min_delay > retry_max_delay:
242
+ raise ValueError("retry_min_delay cannot be greater than retry_max_delay.")
243
+
244
+ _redis_config = {
245
+ "host": host,
246
+ "port": port,
247
+ "username": username,
248
+ "password": password,
249
+ "ssl": ssl,
250
+ "db": db,
251
+ "key_prefix": key_prefix,
252
+ "max_retries": max_retries,
253
+ "timeout_max_retries": timeout_max_retries,
254
+ "retry_min_delay": retry_min_delay,
255
+ "retry_max_delay": retry_max_delay,
256
+ "socket_timeout": socket_timeout,
257
+ "socket_connect_timeout": socket_connect_timeout,
258
+ }
259
+ _redis_client = None
260
+ logger.info(
261
+ f"Configured Redis realtime state. host={host}, port={port}, "
262
+ f"ssl={ssl}, db={db}, key_prefix={key_prefix}, "
263
+ f"max_retries={max_retries}, timeout_max_retries={timeout_max_retries}, "
264
+ f"retry_min_delay={retry_min_delay}, retry_max_delay={retry_max_delay}"
265
+ )
266
+
267
+
268
+ def get_realtime_redis_config():
269
+ """
270
+ Return the current Redis realtime state configuration.
271
+ """
272
+ return dict(_redis_config)
273
+
274
+
275
+ def is_realtime_redis_configured():
276
+ """
277
+ Return True when Redis realtime state has been configured.
278
+ """
279
+ return bool(_redis_config["host"])
280
+
281
+
282
+ def get_realtime_redis_client():
283
+ """
284
+ Return a cached redis-py client using the configured realtime state settings.
285
+ """
286
+ global _redis_client
287
+
288
+ if not is_realtime_redis_configured():
289
+ raise ValueError("Redis realtime state is not configured.")
290
+
291
+ if _redis_client is None:
292
+ redis, _, _ = _get_redis_modules()
293
+
294
+ logger.info(
295
+ f"Creating Redis realtime client. host={_redis_config['host']}, "
296
+ f"port={_redis_config['port']}, ssl={_redis_config['ssl']}, "
297
+ f"db={_redis_config['db']}"
298
+ )
299
+ _redis_client = redis.Redis(
300
+ host=_redis_config["host"],
301
+ port=_redis_config["port"],
302
+ username=_redis_config["username"],
303
+ password=_redis_config["password"],
304
+ ssl=_redis_config["ssl"],
305
+ db=_redis_config["db"],
306
+ decode_responses=True,
307
+ socket_timeout=_redis_config["socket_timeout"],
308
+ socket_connect_timeout=_redis_config["socket_connect_timeout"],
309
+ )
310
+
311
+ return _redis_client
312
+
313
+
314
+ def realtime_state_key(topic: str):
315
+ """
316
+ Build the Redis key used to store the authoritative dataset for a topic.
317
+ """
318
+ if not isinstance(topic, str) or not topic.strip():
319
+ raise ValueError("topic must be a non-empty string.")
320
+
321
+ return f"{_redis_config['key_prefix']}:{topic}"
322
+
323
+
324
+ def _update_realtime_dataset_once(topic: str, update_func: Callable[[str], str]):
325
+ """
326
+ Atomically update the Redis dataset for a topic using WATCH/MULTI/EXEC.
327
+ """
328
+ _, WatchError, _ = _get_redis_modules()
329
+
330
+ client = get_realtime_redis_client()
331
+ key = realtime_state_key(topic)
332
+ max_retries = _redis_config["max_retries"]
333
+ logger.info(
334
+ f"Starting Redis realtime dataset update. topic={topic}, key={key}, "
335
+ f"max_retries={max_retries}"
336
+ )
337
+
338
+ for attempt in range(1, max_retries + 1):
339
+ try:
340
+ with client.pipeline() as pipe:
341
+ logger.info(
342
+ f"Watching Redis realtime key={key} "
343
+ f"(attempt {attempt}/{max_retries})"
344
+ )
345
+ pipe.watch(key)
346
+ existing_json = pipe.get(key)
347
+ _log_json_value(f"Redis current cache for key={key}", existing_json)
348
+ current_json = existing_json if existing_json else "[]"
349
+ updated_json = update_func(current_json)
350
+ if not isinstance(updated_json, str):
351
+ raise ValueError("update_func must return a JSON string.")
352
+
353
+ _log_json_value(f"Redis value to set for key={key}", updated_json)
354
+ pipe.multi()
355
+ pipe.set(key, updated_json)
356
+ pipe.execute()
357
+
358
+ latest_json = client.get(key)
359
+ logger.info(f"Updated Redis realtime state for key={key}")
360
+ _log_json_value(f"Redis cache after update for key={key}", latest_json)
361
+ return updated_json
362
+
363
+ except WatchError:
364
+ if attempt >= max_retries:
365
+ break
366
+
367
+ delay = random.uniform(
368
+ _redis_config["retry_min_delay"],
369
+ _redis_config["retry_max_delay"],
370
+ )
371
+ logger.info(
372
+ f"Redis realtime state changed during update for {key}. "
373
+ f"Retrying attempt {attempt + 1}/{max_retries} after {delay:.3f}s."
374
+ )
375
+ time.sleep(delay)
376
+
377
+ raise RuntimeError(
378
+ f"Failed to update Redis realtime state after {max_retries} retries "
379
+ f"for topic: {topic}"
380
+ )
381
+
382
+
383
+ def update_realtime_dataset(
384
+ topic: str,
385
+ update_func: Callable[[str], str],
386
+ ):
387
+ """
388
+ Retry Redis realtime updates when redis-py raises a timeout error.
389
+ """
390
+ key = realtime_state_key(topic)
391
+ timeout_max_retries = _redis_config["timeout_max_retries"]
392
+ last_error = None
393
+
394
+ for attempt in range(1, timeout_max_retries + 1):
395
+ try:
396
+ logger.info(
397
+ f"Starting Redis timeout-protected update for key={key} "
398
+ f"(attempt {attempt}/{timeout_max_retries})"
399
+ )
400
+ return _update_realtime_dataset_once(topic=topic, update_func=update_func)
401
+
402
+ except Exception as e:
403
+ if not _is_redis_timeout_error(e):
404
+ raise
405
+
406
+ last_error = e
407
+ logger.error(
408
+ f"Redis timeout during realtime update for key={key} "
409
+ f"(attempt {attempt}/{timeout_max_retries}): {e}"
410
+ )
411
+ _reset_redis_client()
412
+
413
+ if attempt >= timeout_max_retries:
414
+ break
415
+
416
+ delay = random.uniform(
417
+ _redis_config["retry_min_delay"],
418
+ _redis_config["retry_max_delay"],
419
+ )
420
+ logger.info(
421
+ f"Retrying Redis realtime update for key={key} after "
422
+ f"{delay:.3f}s due to timeout."
423
+ )
424
+ time.sleep(delay)
425
+
426
+ raise RuntimeError(
427
+ f"Redis realtime update timed out after {timeout_max_retries} attempts "
428
+ f"for topic: {topic}. Last error: {last_error}"
429
+ ) from last_error