smartinno 1.0.0__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.
- redis_sdk/__init__.py +15 -0
- redis_sdk/clients/__init__.py +0 -0
- redis_sdk/clients/base.py +405 -0
- redis_sdk/clients/cluster.py +65 -0
- redis_sdk/clients/hybrid.py +51 -0
- redis_sdk/clients/sentinel.py +59 -0
- redis_sdk/clients/smart_router.py +119 -0
- redis_sdk/communication_service.py +512 -0
- redis_sdk/config.py +50 -0
- redis_sdk/factory.py +104 -0
- redis_sdk/interfaces.py +124 -0
- redis_sdk/test_sdk.py +321 -0
- smartinno-1.0.0.dist-info/METADATA +275 -0
- smartinno-1.0.0.dist-info/RECORD +16 -0
- smartinno-1.0.0.dist-info/WHEEL +5 -0
- smartinno-1.0.0.dist-info/top_level.txt +1 -0
redis_sdk/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .factory import RedisClientFactory
|
|
2
|
+
from .config import Architecture, UseCase, RedisConfig
|
|
3
|
+
from .clients.base import CircuitBreaker, CircuitState
|
|
4
|
+
from .communication_service import CommunicationService, communication_service
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"RedisClientFactory",
|
|
8
|
+
"Architecture",
|
|
9
|
+
"UseCase",
|
|
10
|
+
"RedisConfig",
|
|
11
|
+
"CircuitBreaker",
|
|
12
|
+
"CircuitState",
|
|
13
|
+
"CommunicationService",
|
|
14
|
+
"communication_service",
|
|
15
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import functools
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, List
|
|
9
|
+
from redis_sdk.interfaces import IRedisClient
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CircuitState(Enum):
|
|
15
|
+
CLOSED = "closed" # Normal operation — requests pass through
|
|
16
|
+
OPEN = "open" # Failing — all requests are rejected immediately
|
|
17
|
+
HALF_OPEN = "half_open" # Recovery probe — one request allowed to test
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitBreaker:
|
|
21
|
+
"""
|
|
22
|
+
Built-in circuit breaker for Redis operations.
|
|
23
|
+
|
|
24
|
+
Tracks consecutive failures. When the failure threshold is exceeded the circuit
|
|
25
|
+
opens and all subsequent calls are rejected immediately for `recovery_timeout`
|
|
26
|
+
seconds. After that window a single probe request is allowed through; if it
|
|
27
|
+
succeeds the circuit closes again, otherwise it re-opens.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
failure_threshold: int = 5,
|
|
33
|
+
recovery_timeout: float = 30.0,
|
|
34
|
+
name: str = "redis",
|
|
35
|
+
):
|
|
36
|
+
self.failure_threshold = failure_threshold
|
|
37
|
+
self.recovery_timeout = recovery_timeout
|
|
38
|
+
self.name = name
|
|
39
|
+
|
|
40
|
+
self._state = CircuitState.CLOSED
|
|
41
|
+
self._failure_count = 0
|
|
42
|
+
self._last_failure_time: float = 0.0
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def state(self) -> CircuitState:
|
|
46
|
+
if self._state == CircuitState.OPEN:
|
|
47
|
+
# Check if enough time has passed to move to HALF_OPEN
|
|
48
|
+
if time.monotonic() - self._last_failure_time >= self.recovery_timeout:
|
|
49
|
+
self._state = CircuitState.HALF_OPEN
|
|
50
|
+
logger.info(
|
|
51
|
+
f"Circuit breaker [{self.name}] transitioning OPEN -> HALF_OPEN"
|
|
52
|
+
)
|
|
53
|
+
return self._state
|
|
54
|
+
|
|
55
|
+
def record_success(self) -> None:
|
|
56
|
+
self._failure_count = 0
|
|
57
|
+
if self._state != CircuitState.CLOSED:
|
|
58
|
+
logger.info(f"Circuit breaker [{self.name}] closing (recovered)")
|
|
59
|
+
self._state = CircuitState.CLOSED
|
|
60
|
+
|
|
61
|
+
def record_failure(self) -> None:
|
|
62
|
+
self._failure_count += 1
|
|
63
|
+
self._last_failure_time = time.monotonic()
|
|
64
|
+
if self._failure_count >= self.failure_threshold:
|
|
65
|
+
self._state = CircuitState.OPEN
|
|
66
|
+
logger.warning(
|
|
67
|
+
f"Circuit breaker [{self.name}] OPEN after "
|
|
68
|
+
f"{self._failure_count} consecutive failures"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def allow_request(self) -> bool:
|
|
72
|
+
current = self.state
|
|
73
|
+
if current == CircuitState.CLOSED:
|
|
74
|
+
return True
|
|
75
|
+
if current == CircuitState.HALF_OPEN:
|
|
76
|
+
return True # Allow one probe
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _circuit_protected(method):
|
|
81
|
+
"""Decorator that wraps a Redis command with circuit breaker logic."""
|
|
82
|
+
|
|
83
|
+
@functools.wraps(method)
|
|
84
|
+
def wrapper(self, *args, **kwargs):
|
|
85
|
+
if not self._circuit_breaker.allow_request():
|
|
86
|
+
raise ConnectionError(
|
|
87
|
+
f"Circuit breaker [{self._circuit_breaker.name}] is OPEN — "
|
|
88
|
+
f"request rejected. Will retry after "
|
|
89
|
+
f"{self._circuit_breaker.recovery_timeout}s."
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
result = method(self, *args, **kwargs)
|
|
93
|
+
self._circuit_breaker.record_success()
|
|
94
|
+
return result
|
|
95
|
+
except Exception:
|
|
96
|
+
self._circuit_breaker.record_failure()
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
return wrapper
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BaseRedisAdapter(IRedisClient):
|
|
103
|
+
"""
|
|
104
|
+
Base implementation that wraps standard redis commands with an integrated
|
|
105
|
+
circuit breaker. Requires self.client to be initialized by subclasses.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
failure_threshold: int = 5,
|
|
111
|
+
recovery_timeout: float = 30.0,
|
|
112
|
+
breaker_name: str = "redis",
|
|
113
|
+
service_name: str = "safari_pro",
|
|
114
|
+
):
|
|
115
|
+
self.client = None
|
|
116
|
+
self.service_name = service_name
|
|
117
|
+
self._circuit_breaker = CircuitBreaker(
|
|
118
|
+
failure_threshold=failure_threshold,
|
|
119
|
+
recovery_timeout=recovery_timeout,
|
|
120
|
+
name=breaker_name,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# --- Data Standardization Utilities ---
|
|
124
|
+
|
|
125
|
+
def _format_key(self, key: str, action: str = "data") -> str:
|
|
126
|
+
"""Enforces a standardized key naming convention: {service}:{action}:{key}"""
|
|
127
|
+
if not isinstance(key, str):
|
|
128
|
+
return key
|
|
129
|
+
if key.startswith(f"{self.service_name}:"):
|
|
130
|
+
return key
|
|
131
|
+
return f"{self.service_name}:{action}:{key}"
|
|
132
|
+
|
|
133
|
+
def _wrap_payload(self, payload: Any, event: str = "update", type_action: str = "cache") -> str:
|
|
134
|
+
"""Wraps a payload into a standardized JSON envelope."""
|
|
135
|
+
data = {
|
|
136
|
+
"request_id": str(uuid.uuid4()),
|
|
137
|
+
"event": event,
|
|
138
|
+
"type": type_action,
|
|
139
|
+
"service": self.service_name,
|
|
140
|
+
"payload": payload,
|
|
141
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
142
|
+
}
|
|
143
|
+
return json.dumps(data)
|
|
144
|
+
|
|
145
|
+
def _unwrap_payload(self, data: Any) -> Any:
|
|
146
|
+
"""Unwraps a standardized JSON envelope, returning the original payload."""
|
|
147
|
+
if data is None:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
data_str = data
|
|
151
|
+
if isinstance(data, bytes):
|
|
152
|
+
try:
|
|
153
|
+
data_str = data.decode('utf-8')
|
|
154
|
+
except UnicodeDecodeError:
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
if not isinstance(data_str, str):
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
obj = json.loads(data_str)
|
|
162
|
+
if isinstance(obj, dict) and "payload" in obj and "service" in obj:
|
|
163
|
+
return obj["payload"]
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
return data
|
|
168
|
+
|
|
169
|
+
# --- Explicitly wrapped commands (circuit-protected) ---
|
|
170
|
+
|
|
171
|
+
def _apply_ttl(self, key: str, ttl: int = None):
|
|
172
|
+
"""Helper to apply the TTL to collections if explicitly requested."""
|
|
173
|
+
if ttl is not None:
|
|
174
|
+
try:
|
|
175
|
+
self.client.expire(key, ttl)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.warning(f"Failed to apply TTL to {key}: {e}")
|
|
178
|
+
|
|
179
|
+
@_circuit_protected
|
|
180
|
+
def set(self, key: str, value: Any, ex: int = None, **kwargs) -> bool:
|
|
181
|
+
if ex is None:
|
|
182
|
+
ex = 259200 # Default 3 days
|
|
183
|
+
f_key = self._format_key(key, "cache")
|
|
184
|
+
wrapped_val = self._wrap_payload(value, "set", "cache")
|
|
185
|
+
return self.client.set(f_key, wrapped_val, ex=ex, **kwargs)
|
|
186
|
+
|
|
187
|
+
@_circuit_protected
|
|
188
|
+
def get(self, key: str) -> Any:
|
|
189
|
+
f_key = self._format_key(key, "cache")
|
|
190
|
+
val = self.client.get(f_key)
|
|
191
|
+
return self._unwrap_payload(val)
|
|
192
|
+
|
|
193
|
+
@_circuit_protected
|
|
194
|
+
def mget(self, keys: List[str]) -> List[Any]:
|
|
195
|
+
f_keys = [self._format_key(k, "cache") for k in keys]
|
|
196
|
+
vals = self.client.mget(f_keys)
|
|
197
|
+
return [self._unwrap_payload(v) for v in vals]
|
|
198
|
+
|
|
199
|
+
@_circuit_protected
|
|
200
|
+
def publish(self, channel: str, message: Any) -> int:
|
|
201
|
+
f_channel = self._format_key(channel, "pubsub")
|
|
202
|
+
wrapped_msg = self._wrap_payload(message, "publish", "pubsub")
|
|
203
|
+
return self.client.publish(f_channel, wrapped_msg)
|
|
204
|
+
|
|
205
|
+
@_circuit_protected
|
|
206
|
+
def pipeline(self, transaction: bool = True) -> Any:
|
|
207
|
+
# Pipeline requires special handling at the caller level to wrap/unwrap
|
|
208
|
+
# since it queues commands. For now we return the native pipeline.
|
|
209
|
+
return self.client.pipeline(transaction=transaction)
|
|
210
|
+
|
|
211
|
+
@_circuit_protected
|
|
212
|
+
def xadd(self, name: str, fields: dict, maxlen: int = 10000, approximate: bool = True) -> str:
|
|
213
|
+
"""
|
|
214
|
+
Adds a message to a stream. Ensures the stream is created if it does not exist.
|
|
215
|
+
Caps the stream length using maxlen (defaults to 10000) to prevent OOM.
|
|
216
|
+
"""
|
|
217
|
+
f_name = self._format_key(name, "stream")
|
|
218
|
+
# For streams, wrap the entire fields dict into a payload field
|
|
219
|
+
wrapped_fields = {
|
|
220
|
+
"request_id": str(uuid.uuid4()),
|
|
221
|
+
"event": "update",
|
|
222
|
+
"type": "stream",
|
|
223
|
+
"service": self.service_name,
|
|
224
|
+
"payload": json.dumps(fields),
|
|
225
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
226
|
+
}
|
|
227
|
+
res = self.client.xadd(
|
|
228
|
+
f_name,
|
|
229
|
+
wrapped_fields,
|
|
230
|
+
maxlen=maxlen,
|
|
231
|
+
approximate=approximate,
|
|
232
|
+
nomkstream=False # Ensures stream is created if it doesn't exist
|
|
233
|
+
)
|
|
234
|
+
return res
|
|
235
|
+
|
|
236
|
+
@_circuit_protected
|
|
237
|
+
def xread(self, streams: dict, count: int = None, block: int = None) -> List:
|
|
238
|
+
f_streams = {self._format_key(k, "stream"): v for k, v in streams.items()}
|
|
239
|
+
result = self.client.xread(f_streams, count=count, block=block)
|
|
240
|
+
|
|
241
|
+
# Unwrap
|
|
242
|
+
unwrapped = []
|
|
243
|
+
for stream_name, messages in result:
|
|
244
|
+
u_messages = []
|
|
245
|
+
for msg_id, fields in messages:
|
|
246
|
+
payload_val = fields.get(b"payload") or fields.get("payload")
|
|
247
|
+
if payload_val:
|
|
248
|
+
try:
|
|
249
|
+
u_fields = json.loads(payload_val)
|
|
250
|
+
except Exception:
|
|
251
|
+
u_fields = fields
|
|
252
|
+
else:
|
|
253
|
+
u_fields = fields
|
|
254
|
+
u_messages.append((msg_id, u_fields))
|
|
255
|
+
|
|
256
|
+
# Restore original stream name from input
|
|
257
|
+
original_stream_name = stream_name
|
|
258
|
+
prefix_b = f"{self.service_name}:stream:".encode('utf-8')
|
|
259
|
+
prefix_s = f"{self.service_name}:stream:"
|
|
260
|
+
if isinstance(stream_name, bytes) and stream_name.startswith(prefix_b):
|
|
261
|
+
original_stream_name = stream_name[len(prefix_b):].decode('utf-8')
|
|
262
|
+
elif isinstance(stream_name, str) and stream_name.startswith(prefix_s):
|
|
263
|
+
original_stream_name = stream_name[len(prefix_s):]
|
|
264
|
+
|
|
265
|
+
unwrapped.append((original_stream_name, u_messages))
|
|
266
|
+
return unwrapped
|
|
267
|
+
|
|
268
|
+
# --- Hashes ---
|
|
269
|
+
@_circuit_protected
|
|
270
|
+
def hset(self, name: str, key: str = None, value: str = None, mapping: dict = None, ttl: int = None) -> int:
|
|
271
|
+
f_name = self._format_key(name, "hash")
|
|
272
|
+
|
|
273
|
+
if mapping:
|
|
274
|
+
# Wrap all values in the mapping
|
|
275
|
+
wrapped_mapping = {k: self._wrap_payload(v, "hset", "hash") for k, v in mapping.items()}
|
|
276
|
+
res = self.client.hset(f_name, mapping=wrapped_mapping)
|
|
277
|
+
else:
|
|
278
|
+
wrapped_value = self._wrap_payload(value, "hset", "hash")
|
|
279
|
+
res = self.client.hset(f_name, key=key, value=wrapped_value)
|
|
280
|
+
|
|
281
|
+
self._apply_ttl(f_name, ttl)
|
|
282
|
+
return res
|
|
283
|
+
|
|
284
|
+
@_circuit_protected
|
|
285
|
+
def hget(self, name: str, key: str) -> Any:
|
|
286
|
+
f_name = self._format_key(name, "hash")
|
|
287
|
+
val = self.client.hget(f_name, key)
|
|
288
|
+
return self._unwrap_payload(val)
|
|
289
|
+
|
|
290
|
+
# --- Lists ---
|
|
291
|
+
@_circuit_protected
|
|
292
|
+
def lpush(self, name: str, *values, ttl: int = None) -> int:
|
|
293
|
+
f_name = self._format_key(name, "list")
|
|
294
|
+
wrapped_values = [self._wrap_payload(v, "lpush", "list") for v in values]
|
|
295
|
+
res = self.client.lpush(f_name, *wrapped_values)
|
|
296
|
+
self._apply_ttl(f_name, ttl)
|
|
297
|
+
return res
|
|
298
|
+
|
|
299
|
+
@_circuit_protected
|
|
300
|
+
def rpop(self, name: str) -> Any:
|
|
301
|
+
f_name = self._format_key(name, "list")
|
|
302
|
+
val = self.client.rpop(f_name)
|
|
303
|
+
return self._unwrap_payload(val)
|
|
304
|
+
|
|
305
|
+
# --- Sets ---
|
|
306
|
+
@_circuit_protected
|
|
307
|
+
def sadd(self, name: str, *values, ttl: int = None) -> int:
|
|
308
|
+
f_name = self._format_key(name, "set")
|
|
309
|
+
wrapped_values = [self._wrap_payload(v, "sadd", "set") for v in values]
|
|
310
|
+
res = self.client.sadd(f_name, *wrapped_values)
|
|
311
|
+
self._apply_ttl(f_name, ttl)
|
|
312
|
+
return res
|
|
313
|
+
|
|
314
|
+
@_circuit_protected
|
|
315
|
+
def smembers(self, name: str) -> set:
|
|
316
|
+
f_name = self._format_key(name, "set")
|
|
317
|
+
members = self.client.smembers(f_name)
|
|
318
|
+
return {self._unwrap_payload(m) for m in members}
|
|
319
|
+
|
|
320
|
+
# --- Sorted Sets ---
|
|
321
|
+
@_circuit_protected
|
|
322
|
+
def zadd(self, name: str, mapping: dict, ttl: int = None) -> int:
|
|
323
|
+
f_name = self._format_key(name, "zset")
|
|
324
|
+
# mapping is {member: score}
|
|
325
|
+
wrapped_mapping = {self._wrap_payload(k, "zadd", "zset"): v for k, v in mapping.items()}
|
|
326
|
+
res = self.client.zadd(f_name, wrapped_mapping)
|
|
327
|
+
self._apply_ttl(f_name, ttl)
|
|
328
|
+
return res
|
|
329
|
+
|
|
330
|
+
@_circuit_protected
|
|
331
|
+
def zrange(self, name: str, start: int, end: int, desc: bool = False, withscores: bool = False) -> List:
|
|
332
|
+
f_name = self._format_key(name, "zset")
|
|
333
|
+
res = self.client.zrange(f_name, start, end, desc=desc, withscores=withscores)
|
|
334
|
+
if withscores:
|
|
335
|
+
return [(self._unwrap_payload(member), score) for member, score in res]
|
|
336
|
+
return [self._unwrap_payload(member) for member in res]
|
|
337
|
+
|
|
338
|
+
# --- Geospatial ---
|
|
339
|
+
@_circuit_protected
|
|
340
|
+
def geoadd(self, name: str, values: tuple, ttl: int = None) -> int:
|
|
341
|
+
f_name = self._format_key(name, "geo")
|
|
342
|
+
# values is typically (longitude, latitude, member)
|
|
343
|
+
# We will format the key but NOT wrap the member to preserve geo exactness
|
|
344
|
+
res = self.client.geoadd(f_name, values)
|
|
345
|
+
self._apply_ttl(f_name, ttl)
|
|
346
|
+
return res
|
|
347
|
+
|
|
348
|
+
@_circuit_protected
|
|
349
|
+
def geosearch(self, name: str, longitude: float, latitude: float, radius: float, unit: str = 'km') -> List:
|
|
350
|
+
f_name = self._format_key(name, "geo")
|
|
351
|
+
return self.client.geosearch(f_name, longitude=longitude, latitude=latitude, radius=radius, unit=unit)
|
|
352
|
+
|
|
353
|
+
# --- Bitmaps & HyperLogLog ---
|
|
354
|
+
@_circuit_protected
|
|
355
|
+
def setbit(self, name: str, offset: int, value: int, ttl: int = None) -> int:
|
|
356
|
+
f_name = self._format_key(name, "bitmap")
|
|
357
|
+
res = self.client.setbit(f_name, offset, value)
|
|
358
|
+
self._apply_ttl(f_name, ttl)
|
|
359
|
+
return res
|
|
360
|
+
|
|
361
|
+
@_circuit_protected
|
|
362
|
+
def pfadd(self, name: str, *values, ttl: int = None) -> int:
|
|
363
|
+
f_name = self._format_key(name, "hll")
|
|
364
|
+
wrapped_values = [self._wrap_payload(v, "pfadd", "hll") for v in values]
|
|
365
|
+
res = self.client.pfadd(f_name, *wrapped_values)
|
|
366
|
+
self._apply_ttl(f_name, ttl)
|
|
367
|
+
return res
|
|
368
|
+
|
|
369
|
+
@_circuit_protected
|
|
370
|
+
def close(self) -> None:
|
|
371
|
+
self.client.close()
|
|
372
|
+
|
|
373
|
+
# --- Health / Status helpers ---
|
|
374
|
+
|
|
375
|
+
def get_circuit_state(self) -> str:
|
|
376
|
+
"""Return the current circuit breaker state as a string."""
|
|
377
|
+
return self._circuit_breaker.state.value
|
|
378
|
+
|
|
379
|
+
def __getattr__(self, item):
|
|
380
|
+
"""
|
|
381
|
+
Proxy any unknown method calls to the underlying Redis client.
|
|
382
|
+
This allows the adapter to seamlessly support all native Redis commands
|
|
383
|
+
(like hset, publish, expire, etc.) without having to explicitly wrap them.
|
|
384
|
+
Proxied calls are also circuit-breaker protected.
|
|
385
|
+
"""
|
|
386
|
+
if hasattr(self, 'client') and self.client is not None and hasattr(self.client, item):
|
|
387
|
+
attr = getattr(self.client, item)
|
|
388
|
+
if callable(attr):
|
|
389
|
+
@functools.wraps(attr)
|
|
390
|
+
def _protected_proxy(*args, **kwargs):
|
|
391
|
+
if not self._circuit_breaker.allow_request():
|
|
392
|
+
raise ConnectionError(
|
|
393
|
+
f"Circuit breaker [{self._circuit_breaker.name}] is OPEN — "
|
|
394
|
+
f"request rejected."
|
|
395
|
+
)
|
|
396
|
+
try:
|
|
397
|
+
result = attr(*args, **kwargs)
|
|
398
|
+
self._circuit_breaker.record_success()
|
|
399
|
+
return result
|
|
400
|
+
except Exception:
|
|
401
|
+
self._circuit_breaker.record_failure()
|
|
402
|
+
raise
|
|
403
|
+
return _protected_proxy
|
|
404
|
+
return attr
|
|
405
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from redis.cluster import RedisCluster, ClusterNode
|
|
2
|
+
from redis.backoff import ExponentialBackoff
|
|
3
|
+
from redis.retry import Retry
|
|
4
|
+
from redis_sdk.clients.base import BaseRedisAdapter
|
|
5
|
+
from redis_sdk.config import RedisConfig
|
|
6
|
+
|
|
7
|
+
class ClusterClientAdapter(BaseRedisAdapter):
|
|
8
|
+
"""
|
|
9
|
+
Adapter for Native Redis Cluster Architecture.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, config: RedisConfig):
|
|
12
|
+
super().__init__(breaker_name="cluster", service_name=config.service_name)
|
|
13
|
+
|
|
14
|
+
nodes = []
|
|
15
|
+
if config.startup_nodes:
|
|
16
|
+
nodes = [ClusterNode(host=n['host'], port=n['port']) for n in config.startup_nodes]
|
|
17
|
+
else:
|
|
18
|
+
nodes = [ClusterNode(host=config.host, port=config.port)]
|
|
19
|
+
|
|
20
|
+
# Transparently remap internal Docker hostnames to Windows localhost with mapped ports
|
|
21
|
+
def remap_host_port(host, port):
|
|
22
|
+
node_map = {
|
|
23
|
+
'redis-node-1': ('127.0.0.1', 7001),
|
|
24
|
+
'redis-node-2': ('127.0.0.1', 7002),
|
|
25
|
+
'redis-node-3': ('127.0.0.1', 7003),
|
|
26
|
+
'redis-node-4': ('127.0.0.1', 7004),
|
|
27
|
+
'redis-node-5': ('127.0.0.1', 7005),
|
|
28
|
+
'redis-node-6': ('127.0.0.1', 7006),
|
|
29
|
+
'redis-cluster-node-1': ('127.0.0.1', 7001),
|
|
30
|
+
'redis-cluster-node-2': ('127.0.0.1', 7002),
|
|
31
|
+
'redis-cluster-node-3': ('127.0.0.1', 7003),
|
|
32
|
+
'redis-cluster-node-4': ('127.0.0.1', 7004),
|
|
33
|
+
'redis-cluster-node-5': ('127.0.0.1', 7005),
|
|
34
|
+
'redis-cluster-node-6': ('127.0.0.1', 7006),
|
|
35
|
+
}
|
|
36
|
+
return node_map.get(host, (host, port))
|
|
37
|
+
|
|
38
|
+
retry_strategy = None
|
|
39
|
+
if config.retry_backoff:
|
|
40
|
+
retry_strategy = Retry(
|
|
41
|
+
ExponentialBackoff(cap=config.retry_backoff_max, base=config.retry_backoff_min),
|
|
42
|
+
config.retry_backoff_retries
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self.client = RedisCluster(
|
|
46
|
+
startup_nodes=nodes,
|
|
47
|
+
password=config.password,
|
|
48
|
+
decode_responses=True,
|
|
49
|
+
host_port_remap=remap_host_port,
|
|
50
|
+
ssl=config.ssl,
|
|
51
|
+
ssl_cert_reqs=config.ssl_cert_reqs if config.ssl else None,
|
|
52
|
+
max_connections=config.max_connections,
|
|
53
|
+
socket_timeout=config.socket_timeout,
|
|
54
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
55
|
+
socket_keepalive=config.socket_keepalive,
|
|
56
|
+
health_check_interval=config.health_check_interval,
|
|
57
|
+
retry_on_timeout=config.retry_on_timeout,
|
|
58
|
+
retry=retry_strategy
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def pipeline(self, transaction: bool = False):
|
|
62
|
+
"""
|
|
63
|
+
Native Cluster supports pipelines, but cross-slot transactions are complex.
|
|
64
|
+
"""
|
|
65
|
+
return self.client.pipeline(transaction=transaction)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import redis
|
|
2
|
+
from redis.backoff import ExponentialBackoff
|
|
3
|
+
from redis.retry import Retry
|
|
4
|
+
from redis_sdk.clients.base import BaseRedisAdapter
|
|
5
|
+
from redis_sdk.config import RedisConfig
|
|
6
|
+
|
|
7
|
+
class HybridClientAdapter(BaseRedisAdapter):
|
|
8
|
+
"""
|
|
9
|
+
Adapter for Hybrid (Predixy Proxy + Cluster) Architecture.
|
|
10
|
+
Acts as a standard Redis client, but overrides pipeline behavior.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, config: RedisConfig):
|
|
13
|
+
super().__init__(breaker_name="hybrid", service_name=config.service_name)
|
|
14
|
+
|
|
15
|
+
retry_strategy = None
|
|
16
|
+
if config.retry_backoff:
|
|
17
|
+
retry_strategy = Retry(
|
|
18
|
+
ExponentialBackoff(cap=config.retry_backoff_max, base=config.retry_backoff_min),
|
|
19
|
+
config.retry_backoff_retries
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
self.client = redis.Redis(
|
|
23
|
+
host=config.host,
|
|
24
|
+
port=config.port,
|
|
25
|
+
password=config.password,
|
|
26
|
+
decode_responses=True,
|
|
27
|
+
protocol=2, # Predixy operates reliably on RESP2
|
|
28
|
+
ssl=config.ssl,
|
|
29
|
+
ssl_cert_reqs=config.ssl_cert_reqs if config.ssl else None,
|
|
30
|
+
max_connections=config.max_connections,
|
|
31
|
+
socket_timeout=config.socket_timeout,
|
|
32
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
33
|
+
socket_keepalive=config.socket_keepalive,
|
|
34
|
+
health_check_interval=config.health_check_interval,
|
|
35
|
+
retry_on_timeout=config.retry_on_timeout,
|
|
36
|
+
retry=retry_strategy
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def pipeline(self, transaction: bool = False):
|
|
40
|
+
"""
|
|
41
|
+
Predixy Proxy DOES NOT support MULTI/EXEC transactions efficiently across a cluster via python pipelines.
|
|
42
|
+
We MUST force transaction=False to ensure Predixy routes pipelines sequentially based on hash tags.
|
|
43
|
+
"""
|
|
44
|
+
original_pipeline = self.client.pipeline
|
|
45
|
+
|
|
46
|
+
def predixy_pipeline(transaction=False, shard_hint=None):
|
|
47
|
+
if transaction:
|
|
48
|
+
print("Notice: Hybrid Predixy Proxy does not support transactions in pipelines. Forcing transaction=False.")
|
|
49
|
+
return original_pipeline(transaction=False, shard_hint=shard_hint)
|
|
50
|
+
|
|
51
|
+
return predixy_pipeline(transaction=transaction)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from redis.sentinel import Sentinel
|
|
2
|
+
from redis.backoff import ExponentialBackoff
|
|
3
|
+
from redis.retry import Retry
|
|
4
|
+
from redis_sdk.clients.base import BaseRedisAdapter
|
|
5
|
+
from redis_sdk.config import RedisConfig
|
|
6
|
+
|
|
7
|
+
class SentinelClientAdapter(BaseRedisAdapter):
|
|
8
|
+
"""
|
|
9
|
+
Adapter for Redis Sentinel Architecture.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, config: RedisConfig):
|
|
12
|
+
super().__init__(breaker_name="sentinel", service_name=config.service_name)
|
|
13
|
+
|
|
14
|
+
retry_strategy = None
|
|
15
|
+
if config.retry_backoff:
|
|
16
|
+
retry_strategy = Retry(
|
|
17
|
+
ExponentialBackoff(cap=config.retry_backoff_max, base=config.retry_backoff_min),
|
|
18
|
+
config.retry_backoff_retries
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if config.sentinel_nodes:
|
|
22
|
+
# Traditional Sentinel connection (connects to individuals to find master)
|
|
23
|
+
master_name = config.sentinel_master_name or 'mymaster'
|
|
24
|
+
self.sentinel = Sentinel(
|
|
25
|
+
config.sentinel_nodes,
|
|
26
|
+
socket_timeout=config.socket_timeout,
|
|
27
|
+
password=config.password,
|
|
28
|
+
sentinel_kwargs={'password': config.password}
|
|
29
|
+
)
|
|
30
|
+
self.client = self.sentinel.master_for(
|
|
31
|
+
master_name,
|
|
32
|
+
decode_responses=True,
|
|
33
|
+
ssl=config.ssl,
|
|
34
|
+
ssl_cert_reqs=config.ssl_cert_reqs if config.ssl else None,
|
|
35
|
+
max_connections=config.max_connections,
|
|
36
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
37
|
+
socket_keepalive=config.socket_keepalive,
|
|
38
|
+
health_check_interval=config.health_check_interval,
|
|
39
|
+
retry_on_timeout=config.retry_on_timeout,
|
|
40
|
+
retry=retry_strategy
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
# Connect via HAProxy Load Balancer directly
|
|
44
|
+
import redis
|
|
45
|
+
self.client = redis.Redis(
|
|
46
|
+
host=config.host,
|
|
47
|
+
port=config.port,
|
|
48
|
+
password=config.password,
|
|
49
|
+
decode_responses=True,
|
|
50
|
+
ssl=config.ssl,
|
|
51
|
+
ssl_cert_reqs=config.ssl_cert_reqs if config.ssl else None,
|
|
52
|
+
max_connections=config.max_connections,
|
|
53
|
+
socket_timeout=config.socket_timeout,
|
|
54
|
+
socket_connect_timeout=config.socket_connect_timeout,
|
|
55
|
+
socket_keepalive=config.socket_keepalive,
|
|
56
|
+
health_check_interval=config.health_check_interval,
|
|
57
|
+
retry_on_timeout=config.retry_on_timeout,
|
|
58
|
+
retry=retry_strategy
|
|
59
|
+
)
|