resilient-circuit 0.4.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.
- resilient_circuit/__init__.py +16 -0
- resilient_circuit/backoff.py +37 -0
- resilient_circuit/buffer.py +55 -0
- resilient_circuit/circuit_breaker.py +311 -0
- resilient_circuit/cli.py +261 -0
- resilient_circuit/exceptions.py +10 -0
- resilient_circuit/failsafe.py +34 -0
- resilient_circuit/policy.py +13 -0
- resilient_circuit/py.typed +0 -0
- resilient_circuit/retry.py +47 -0
- resilient_circuit/storage.py +252 -0
- resilient_circuit-0.4.1.dist-info/METADATA +443 -0
- resilient_circuit-0.4.1.dist-info/RECORD +17 -0
- resilient_circuit-0.4.1.dist-info/WHEEL +5 -0
- resilient_circuit-0.4.1.dist-info/entry_points.txt +2 -0
- resilient_circuit-0.4.1.dist-info/licenses/LICENSE +201 -0
- resilient_circuit-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from resilient_circuit.backoff import ExponentialDelay, FixedDelay
|
|
2
|
+
from resilient_circuit.circuit_breaker import CircuitProtectorPolicy
|
|
3
|
+
from resilient_circuit.circuit_breaker import CircuitStatus as CircuitState
|
|
4
|
+
from resilient_circuit.failsafe import SafetyNet
|
|
5
|
+
from resilient_circuit.policy import ProtectionPolicy
|
|
6
|
+
from resilient_circuit.retry import RetryWithBackoffPolicy
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"ExponentialDelay",
|
|
10
|
+
"CircuitProtectorPolicy",
|
|
11
|
+
"CircuitState",
|
|
12
|
+
"FixedDelay",
|
|
13
|
+
"SafetyNet",
|
|
14
|
+
"ProtectionPolicy",
|
|
15
|
+
"RetryWithBackoffPolicy",
|
|
16
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ExponentialDelay:
|
|
9
|
+
min_delay: timedelta
|
|
10
|
+
max_delay: timedelta
|
|
11
|
+
factor: int = 2
|
|
12
|
+
jitter: Optional[float] = None
|
|
13
|
+
|
|
14
|
+
def __post_init__(self) -> None:
|
|
15
|
+
if self.jitter is not None and (self.jitter < 0 or self.jitter > 1):
|
|
16
|
+
raise ValueError("`jitter` must be in range [0, 1].")
|
|
17
|
+
|
|
18
|
+
def for_attempt(self, attempt: int) -> float:
|
|
19
|
+
"""Compute delay in seconds for a given attempt."""
|
|
20
|
+
|
|
21
|
+
if attempt < 1:
|
|
22
|
+
raise ValueError("`attempt` must be positive.")
|
|
23
|
+
|
|
24
|
+
delay = self.min_delay.total_seconds() * pow(self.factor, attempt - 1)
|
|
25
|
+
|
|
26
|
+
if self.jitter is not None:
|
|
27
|
+
offset = delay * self.jitter
|
|
28
|
+
delay += random.uniform(-offset, offset)
|
|
29
|
+
|
|
30
|
+
return min(delay, self.max_delay.total_seconds())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FixedDelay(ExponentialDelay):
|
|
34
|
+
"""Special case of ExponentialDelay when delay between calls is constant."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, delay: timedelta) -> None:
|
|
37
|
+
super().__init__(min_delay=delay, max_delay=delay, factor=1)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from fractions import Fraction
|
|
2
|
+
from typing import Generic, List, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GenericCircularBuffer(Generic[T]):
|
|
8
|
+
"""Buffer that keeps last N items."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, size: int) -> None:
|
|
11
|
+
if size < 1:
|
|
12
|
+
raise ValueError("`size` must be positive.")
|
|
13
|
+
|
|
14
|
+
self.size = size
|
|
15
|
+
self._items: List[T] = []
|
|
16
|
+
|
|
17
|
+
def __len__(self) -> int:
|
|
18
|
+
return len(self._items)
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return str(self._items)
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"<{self.__class__.__name__}(size={self.size}): {self}>"
|
|
25
|
+
|
|
26
|
+
def add(self, item: T) -> None:
|
|
27
|
+
self._items.append(item)
|
|
28
|
+
self._items = self._items[-self.size :]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_full(self) -> bool:
|
|
32
|
+
return len(self) >= self.size
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BinaryCircularBuffer(GenericCircularBuffer[bool]):
|
|
36
|
+
"""GenericCircularBuffer of boolean items.
|
|
37
|
+
|
|
38
|
+
Introduces properties to get success/failures and their respective ratios.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def success_count(self) -> int:
|
|
43
|
+
return self._items.count(True)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def failure_count(self) -> int:
|
|
47
|
+
return self._items.count(False)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def success_rate(self) -> Fraction:
|
|
51
|
+
return Fraction(self.success_count, len(self))
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def failure_rate(self) -> Fraction:
|
|
55
|
+
return Fraction(self.failure_count, len(self))
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import enum
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from fractions import Fraction
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Callable, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from typing_extensions import ParamSpec
|
|
10
|
+
|
|
11
|
+
from resilient_circuit.buffer import BinaryCircularBuffer
|
|
12
|
+
from resilient_circuit.exceptions import ProtectedCallError
|
|
13
|
+
from resilient_circuit.policy import ProtectionPolicy
|
|
14
|
+
from resilient_circuit.storage import CircuitBreakerStorage, InMemoryStorage, create_storage
|
|
15
|
+
|
|
16
|
+
R = TypeVar("R")
|
|
17
|
+
P = ParamSpec("P")
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CircuitStatus(enum.Enum):
|
|
23
|
+
CLOSED = "CLOSED"
|
|
24
|
+
OPEN = "OPEN"
|
|
25
|
+
HALF_OPEN = "HALF_OPEN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CircuitProtectorPolicy(ProtectionPolicy):
|
|
29
|
+
DEFAULT_THRESHOLD = Fraction(1, 1)
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
resource_key: Optional[str] = None,
|
|
35
|
+
storage: Optional[CircuitBreakerStorage] = None,
|
|
36
|
+
namespace: Optional[str] = None,
|
|
37
|
+
cooldown: timedelta = timedelta(0),
|
|
38
|
+
failure_limit: Fraction = DEFAULT_THRESHOLD,
|
|
39
|
+
success_limit: Fraction = DEFAULT_THRESHOLD,
|
|
40
|
+
should_handle: Callable[[Exception], bool] = lambda e: True,
|
|
41
|
+
on_status_change: Optional[
|
|
42
|
+
Callable[["CircuitProtectorPolicy", CircuitStatus, CircuitStatus], None]
|
|
43
|
+
] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
# Generate a default resource key if not provided for backward compatibility
|
|
46
|
+
self.resource_key = resource_key or f"anonymous_{id(self)}"
|
|
47
|
+
# Create storage with namespace support if not provided
|
|
48
|
+
self.storage = storage or create_storage(namespace=namespace)
|
|
49
|
+
self.cooldown = cooldown
|
|
50
|
+
self.success_limit = success_limit
|
|
51
|
+
self.failure_limit = failure_limit
|
|
52
|
+
self.should_consider_failure = should_handle
|
|
53
|
+
self._on_status_change = on_status_change
|
|
54
|
+
|
|
55
|
+
# Load state from storage
|
|
56
|
+
self._load_state()
|
|
57
|
+
|
|
58
|
+
def _load_state(self) -> None:
|
|
59
|
+
"""Load circuit breaker state from storage."""
|
|
60
|
+
try:
|
|
61
|
+
state_data = self.storage.get_state(self.resource_key)
|
|
62
|
+
if state_data:
|
|
63
|
+
# Restore state from storage
|
|
64
|
+
state = CircuitStatus(state_data["state"])
|
|
65
|
+
failure_count = int(state_data.get("failure_count", 0))
|
|
66
|
+
open_until = float(state_data.get("open_until", 0))
|
|
67
|
+
|
|
68
|
+
# Initialize status based on stored state
|
|
69
|
+
status: CircuitStatusBase
|
|
70
|
+
if state == CircuitStatus.CLOSED:
|
|
71
|
+
status = StatusClosed(policy=self, failure_count=failure_count)
|
|
72
|
+
elif state == CircuitStatus.OPEN:
|
|
73
|
+
# For OPEN state, we need a previous status to pass to the constructor
|
|
74
|
+
# We'll use a temporary closed status with same failure count
|
|
75
|
+
temp_status = StatusClosed(policy=self, failure_count=failure_count)
|
|
76
|
+
status = StatusOpen(policy=self, previous_status=temp_status, open_until=open_until)
|
|
77
|
+
else: # HALF_OPEN
|
|
78
|
+
status = StatusHalfOpen(policy=self, failure_count=failure_count)
|
|
79
|
+
|
|
80
|
+
self._status = status
|
|
81
|
+
logger.debug(f"Loaded circuit breaker state for {self.resource_key}: {state.value}")
|
|
82
|
+
else:
|
|
83
|
+
# No state found, start with CLOSED
|
|
84
|
+
self._status = StatusClosed(policy=self)
|
|
85
|
+
logger.debug(f"No stored state found for {self.resource_key}, starting with CLOSED")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Failed to load state for {self.resource_key}: {e}")
|
|
88
|
+
# Fallback to default state
|
|
89
|
+
self._status = StatusClosed(policy=self)
|
|
90
|
+
|
|
91
|
+
def _save_state(self) -> None:
|
|
92
|
+
"""Save circuit breaker state to storage."""
|
|
93
|
+
try:
|
|
94
|
+
state_value: str = self._status.status_type.value
|
|
95
|
+
failure_count_val: int = int(getattr(self._status, 'failure_count', 0))
|
|
96
|
+
# For StatusOpen, use open_until_timestamp; for others, default to 0
|
|
97
|
+
open_until_val: float = float(
|
|
98
|
+
getattr(self._status, 'open_until_timestamp', 0)
|
|
99
|
+
if hasattr(self._status, 'open_until_timestamp') else 0
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self.storage.set_state(
|
|
103
|
+
self.resource_key,
|
|
104
|
+
state_value,
|
|
105
|
+
failure_count_val,
|
|
106
|
+
open_until_val
|
|
107
|
+
)
|
|
108
|
+
logger.debug(f"Saved circuit breaker state for {self.resource_key}: {state_value}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Failed to save state for {self.resource_key}: {e}")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def execution_log(self) -> BinaryCircularBuffer:
|
|
114
|
+
return self._status.execution_log
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def status(self) -> CircuitStatus:
|
|
118
|
+
return self._status.status_type
|
|
119
|
+
|
|
120
|
+
@status.setter
|
|
121
|
+
def status(self, new_status: CircuitStatus) -> None:
|
|
122
|
+
old_status = self.status
|
|
123
|
+
new_status_obj: CircuitStatusBase
|
|
124
|
+
if new_status is CircuitStatus.CLOSED:
|
|
125
|
+
# When transitioning to CLOSED, reset failure count
|
|
126
|
+
new_status_obj = StatusClosed(policy=self, failure_count=0)
|
|
127
|
+
elif new_status is CircuitStatus.OPEN:
|
|
128
|
+
# When transitioning to OPEN, keep the failure count from current status
|
|
129
|
+
current_failure_count = getattr(self._status, 'failure_count', 0)
|
|
130
|
+
# Calculate the open_until timestamp based on current time and cooldown
|
|
131
|
+
from datetime import datetime
|
|
132
|
+
open_until = (datetime.now() + self.cooldown).timestamp()
|
|
133
|
+
new_status_obj = StatusOpen(policy=self, previous_status=self._status, open_until=open_until)
|
|
134
|
+
else: # HALF_OPEN
|
|
135
|
+
# When transitioning to HALF_OPEN, reset failure count
|
|
136
|
+
new_status_obj = StatusHalfOpen(policy=self, failure_count=0)
|
|
137
|
+
|
|
138
|
+
self._status = new_status_obj
|
|
139
|
+
self.on_status_change(old_status, new_status)
|
|
140
|
+
self._save_state() # Persist state change
|
|
141
|
+
|
|
142
|
+
def on_status_change(self, current: CircuitStatus, new: CircuitStatus) -> None:
|
|
143
|
+
"""This method is called whenever protector changes its status."""
|
|
144
|
+
if self._on_status_change is not None:
|
|
145
|
+
self._on_status_change(self, current, new)
|
|
146
|
+
|
|
147
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
148
|
+
@wraps(func)
|
|
149
|
+
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
150
|
+
self._status.validate_execution()
|
|
151
|
+
try:
|
|
152
|
+
result = func(*args, **kwargs)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
if self.should_consider_failure(e):
|
|
155
|
+
self._status.mark_failure()
|
|
156
|
+
else:
|
|
157
|
+
self._status.mark_success()
|
|
158
|
+
self._save_state() # Persist state after exception
|
|
159
|
+
raise
|
|
160
|
+
else:
|
|
161
|
+
self._status.mark_success()
|
|
162
|
+
self._save_state() # Persist state after success
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
return decorated
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CircuitStatusBase(abc.ABC):
|
|
169
|
+
"""Interface describing common methods of CircuitProtector's status."""
|
|
170
|
+
|
|
171
|
+
execution_log: BinaryCircularBuffer
|
|
172
|
+
|
|
173
|
+
def __init__(self, policy: CircuitProtectorPolicy):
|
|
174
|
+
self.policy = policy
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
@abc.abstractmethod
|
|
178
|
+
def status_type(self) -> CircuitStatus:
|
|
179
|
+
"""Defines type of the status."""
|
|
180
|
+
|
|
181
|
+
@abc.abstractmethod
|
|
182
|
+
def validate_execution(self) -> None:
|
|
183
|
+
"""Override this method to raise an exception to prevent execution."""
|
|
184
|
+
|
|
185
|
+
@abc.abstractmethod
|
|
186
|
+
def mark_failure(self) -> None:
|
|
187
|
+
"""This method is called whenever execution fails."""
|
|
188
|
+
|
|
189
|
+
@abc.abstractmethod
|
|
190
|
+
def mark_success(self) -> None:
|
|
191
|
+
"""This method is called whenever execution succeeds."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class StatusClosed(CircuitStatusBase):
|
|
195
|
+
status_type = CircuitStatus.CLOSED
|
|
196
|
+
|
|
197
|
+
def __init__(self, policy: CircuitProtectorPolicy, failure_count: int = 0):
|
|
198
|
+
super().__init__(policy)
|
|
199
|
+
# Initialize failure_count for StatusClosed
|
|
200
|
+
self.failure_count = failure_count
|
|
201
|
+
self.execution_log = BinaryCircularBuffer(size=policy.failure_limit.denominator)
|
|
202
|
+
|
|
203
|
+
def validate_execution(self) -> None:
|
|
204
|
+
# In the CLOSED status, execution is allowed
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
def mark_failure(self) -> None:
|
|
208
|
+
self.failure_count += 1 # Increment failure count
|
|
209
|
+
self.execution_log.add(False)
|
|
210
|
+
if (
|
|
211
|
+
self.execution_log.is_full
|
|
212
|
+
and self.execution_log.failure_rate >= self.policy.failure_limit
|
|
213
|
+
):
|
|
214
|
+
self.policy.status = CircuitStatus.OPEN
|
|
215
|
+
|
|
216
|
+
def mark_success(self) -> None:
|
|
217
|
+
self.failure_count = 0 # Reset failure count on success
|
|
218
|
+
self.execution_log.add(True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class StatusOpen(CircuitStatusBase):
|
|
222
|
+
status_type = CircuitStatus.OPEN
|
|
223
|
+
|
|
224
|
+
def __init__(self, policy: CircuitProtectorPolicy, previous_status: CircuitStatusBase, open_until: float = 0) -> None:
|
|
225
|
+
super().__init__(policy)
|
|
226
|
+
self.execution_log = previous_status.execution_log
|
|
227
|
+
# Handle setting failure_count from previous_status if it exists
|
|
228
|
+
if hasattr(previous_status, 'failure_count'):
|
|
229
|
+
self.failure_count = getattr(previous_status, 'failure_count', 0)
|
|
230
|
+
else:
|
|
231
|
+
self.failure_count = 0
|
|
232
|
+
|
|
233
|
+
# Store the timestamp when the OPEN state should end (cooldown period)
|
|
234
|
+
# If open_until is 0, circuit should be blocked for the full cooldown period
|
|
235
|
+
from datetime import datetime
|
|
236
|
+
if open_until and open_until > 0:
|
|
237
|
+
self.open_until_timestamp = open_until # This is when cooldown ends
|
|
238
|
+
else:
|
|
239
|
+
# Calculate when cooldown should end based on current time and policy cooldown
|
|
240
|
+
self.open_until_timestamp = (datetime.now() + policy.cooldown).timestamp()
|
|
241
|
+
|
|
242
|
+
def validate_execution(self) -> None:
|
|
243
|
+
from datetime import datetime
|
|
244
|
+
# Check if cooldown period has expired
|
|
245
|
+
if datetime.now().timestamp() >= self.open_until_timestamp:
|
|
246
|
+
# Cooldown expired, transition to HALF_OPEN to allow test requests
|
|
247
|
+
self.policy.status = CircuitStatus.HALF_OPEN
|
|
248
|
+
return # Allow execution in HALF_OPEN state
|
|
249
|
+
|
|
250
|
+
# Still in cooldown period, block execution
|
|
251
|
+
raise ProtectedCallError
|
|
252
|
+
|
|
253
|
+
def mark_failure(self) -> None:
|
|
254
|
+
# In OPEN status, errors are not recorded because execution is blocked
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def mark_success(self) -> None:
|
|
258
|
+
self.policy.status = CircuitStatus.HALF_OPEN
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class StatusHalfOpen(CircuitStatusBase):
|
|
262
|
+
status_type = CircuitStatus.HALF_OPEN
|
|
263
|
+
|
|
264
|
+
def __init__(self, policy: CircuitProtectorPolicy, failure_count: int = 0):
|
|
265
|
+
super().__init__(policy)
|
|
266
|
+
self.failure_count = failure_count
|
|
267
|
+
self.use_success = policy.success_limit != policy.DEFAULT_THRESHOLD
|
|
268
|
+
self.execution_log = BinaryCircularBuffer(
|
|
269
|
+
size=(
|
|
270
|
+
policy.success_limit.denominator
|
|
271
|
+
if self.use_success
|
|
272
|
+
else policy.failure_limit.denominator
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def validate_execution(self) -> None:
|
|
277
|
+
# In HALF_OPEN status, execution is allowed
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def mark_failure(self) -> None:
|
|
281
|
+
self.failure_count += 1
|
|
282
|
+
self.execution_log.add(False)
|
|
283
|
+
self._check_limit()
|
|
284
|
+
|
|
285
|
+
def mark_success(self) -> None:
|
|
286
|
+
self.failure_count = 0 # Reset on success
|
|
287
|
+
self.execution_log.add(True)
|
|
288
|
+
self._check_limit()
|
|
289
|
+
|
|
290
|
+
def _check_limit(self) -> None:
|
|
291
|
+
"""Determine whether a limit has been met and the circuit should be opened or closed.
|
|
292
|
+
|
|
293
|
+
The circuit changes status only after the expected number of executions take place.
|
|
294
|
+
If configured, success ratio has precedence over failure ratio.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
if not self.execution_log.is_full:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if self.use_success:
|
|
301
|
+
self.policy.status = (
|
|
302
|
+
CircuitStatus.CLOSED
|
|
303
|
+
if self.execution_log.success_rate >= self.policy.success_limit
|
|
304
|
+
else CircuitStatus.OPEN
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
self.policy.status = (
|
|
308
|
+
CircuitStatus.OPEN
|
|
309
|
+
if self.execution_log.failure_rate >= self.policy.failure_limit
|
|
310
|
+
else CircuitStatus.CLOSED
|
|
311
|
+
)
|
resilient_circuit/cli.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI module for Highway Circuit Breaker
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import psycopg
|
|
12
|
+
except ImportError:
|
|
13
|
+
psycopg = None
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
except ImportError:
|
|
18
|
+
load_dotenv = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_env_vars():
|
|
22
|
+
"""Load environment variables from .env file if available."""
|
|
23
|
+
if load_dotenv:
|
|
24
|
+
load_dotenv()
|
|
25
|
+
else:
|
|
26
|
+
print("Warning: python-dotenv not found, skipping .env file loading")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_db_config_from_env() -> dict:
|
|
30
|
+
"""Get database configuration from environment variables."""
|
|
31
|
+
return {
|
|
32
|
+
'host': os.getenv('RC_DB_HOST', 'localhost'),
|
|
33
|
+
'port': int(os.getenv('RC_DB_PORT', '5432')),
|
|
34
|
+
'dbname': os.getenv('RC_DB_NAME', 'resilient_circuit_db'),
|
|
35
|
+
'user': os.getenv('RC_DB_USER', 'postgres'),
|
|
36
|
+
'password': os.getenv('RC_DB_PASSWORD', 'postgres')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_postgres_table(config: dict) -> bool:
|
|
41
|
+
"""Create the circuit breaker table in PostgreSQL database."""
|
|
42
|
+
if not psycopg:
|
|
43
|
+
print("Error: psycopg is required for PostgreSQL setup. Install with: pip install resilient_circuit[postgres]")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Connect to the database
|
|
48
|
+
conn = psycopg.connect(
|
|
49
|
+
host=config['host'],
|
|
50
|
+
port=config['port'],
|
|
51
|
+
dbname=config['dbname'],
|
|
52
|
+
user=config['user'],
|
|
53
|
+
password=config['password']
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
with conn:
|
|
57
|
+
with conn.cursor() as cur:
|
|
58
|
+
# Check if table already exists
|
|
59
|
+
cur.execute("""
|
|
60
|
+
SELECT EXISTS (
|
|
61
|
+
SELECT FROM information_schema.tables
|
|
62
|
+
WHERE table_schema = 'public'
|
|
63
|
+
AND table_name = 'rc_circuit_breakers'
|
|
64
|
+
);
|
|
65
|
+
""")
|
|
66
|
+
|
|
67
|
+
table_exists = cur.fetchone()[0]
|
|
68
|
+
if table_exists:
|
|
69
|
+
print(f"ā¹ļø Table 'rc_circuit_breakers' already exists, checking for updates...")
|
|
70
|
+
|
|
71
|
+
# Create the circuit breaker table
|
|
72
|
+
cur.execute("""
|
|
73
|
+
CREATE TABLE IF NOT EXISTS rc_circuit_breakers (
|
|
74
|
+
resource_key VARCHAR(255) NOT NULL,
|
|
75
|
+
state VARCHAR(20) NOT NULL CHECK (state IN ('CLOSED', 'OPEN', 'HALF_OPEN')),
|
|
76
|
+
failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0 AND failure_count <= 2147483647),
|
|
77
|
+
open_until TIMESTAMPTZ,
|
|
78
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
79
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
80
|
+
PRIMARY KEY (resource_key)
|
|
81
|
+
);
|
|
82
|
+
""")
|
|
83
|
+
|
|
84
|
+
# Create optimized indexes
|
|
85
|
+
cur.execute("""
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_state
|
|
87
|
+
ON rc_circuit_breakers (state);
|
|
88
|
+
""")
|
|
89
|
+
|
|
90
|
+
cur.execute("""
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_open_until
|
|
92
|
+
ON rc_circuit_breakers (open_until)
|
|
93
|
+
WHERE open_until IS NOT NULL;
|
|
94
|
+
""")
|
|
95
|
+
|
|
96
|
+
cur.execute("""
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_key_state
|
|
98
|
+
ON rc_circuit_breakers (resource_key, state);
|
|
99
|
+
""")
|
|
100
|
+
|
|
101
|
+
cur.execute("""
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_state_updated
|
|
103
|
+
ON rc_circuit_breakers (state, updated_at DESC);
|
|
104
|
+
""")
|
|
105
|
+
|
|
106
|
+
# Create trigger function
|
|
107
|
+
cur.execute("""
|
|
108
|
+
CREATE OR REPLACE FUNCTION update_rc_circuit_breakers_updated_at_column()
|
|
109
|
+
RETURNS TRIGGER AS $$
|
|
110
|
+
BEGIN
|
|
111
|
+
NEW.updated_at = NOW();
|
|
112
|
+
RETURN NEW;
|
|
113
|
+
END;
|
|
114
|
+
$$ language 'plpgsql' SET search_path = public;
|
|
115
|
+
""")
|
|
116
|
+
|
|
117
|
+
# Create trigger
|
|
118
|
+
cur.execute("""
|
|
119
|
+
DROP TRIGGER IF EXISTS update_rc_circuit_breakers_updated_at
|
|
120
|
+
ON rc_circuit_breakers;
|
|
121
|
+
""")
|
|
122
|
+
|
|
123
|
+
cur.execute("""
|
|
124
|
+
CREATE TRIGGER update_rc_circuit_breakers_updated_at
|
|
125
|
+
BEFORE UPDATE ON rc_circuit_breakers
|
|
126
|
+
FOR EACH ROW
|
|
127
|
+
WHEN (OLD IS DISTINCT FROM NEW)
|
|
128
|
+
EXECUTE FUNCTION update_rc_circuit_breakers_updated_at_column();
|
|
129
|
+
""")
|
|
130
|
+
|
|
131
|
+
# Add table comments
|
|
132
|
+
cur.execute("""
|
|
133
|
+
COMMENT ON TABLE rc_circuit_breakers IS
|
|
134
|
+
'Circuit breaker state storage with performance optimizations';
|
|
135
|
+
""")
|
|
136
|
+
|
|
137
|
+
cur.execute("""
|
|
138
|
+
COMMENT ON COLUMN rc_circuit_breakers.state IS
|
|
139
|
+
'Current state of the circuit breaker: CLOSED, OPEN, or HALF_OPEN';
|
|
140
|
+
""")
|
|
141
|
+
|
|
142
|
+
cur.execute("""
|
|
143
|
+
COMMENT ON COLUMN rc_circuit_breakers.open_until IS
|
|
144
|
+
'Timestamp when the circuit breaker should transition from OPEN to HALF_OPEN';
|
|
145
|
+
""")
|
|
146
|
+
|
|
147
|
+
cur.execute("""
|
|
148
|
+
COMMENT ON COLUMN rc_circuit_breakers.failure_count IS
|
|
149
|
+
'Number of consecutive failures since last reset';
|
|
150
|
+
""")
|
|
151
|
+
|
|
152
|
+
conn.commit()
|
|
153
|
+
|
|
154
|
+
if table_exists:
|
|
155
|
+
print(f"ā
Successfully updated table in database: {config['dbname']}")
|
|
156
|
+
else:
|
|
157
|
+
print(f"ā
Successfully created table in database: {config['dbname']}")
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
except psycopg.OperationalError as e:
|
|
161
|
+
if "database" in str(e) and "does not exist" in str(e):
|
|
162
|
+
print(f"ā Error: Database '{config['dbname']}' does not exist.")
|
|
163
|
+
print("š” Please create the database first or update your RC_DB_NAME in the .env file.")
|
|
164
|
+
print(f" You can create it with: createdb -h {config['host']} -p {config['port']} -U {config['user']} {config['dbname']}")
|
|
165
|
+
else:
|
|
166
|
+
print(f"ā Database connection error: {e}")
|
|
167
|
+
return False
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print(f"ā Error creating table: {e}")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_pg_setup(args: argparse.Namespace) -> int:
|
|
174
|
+
"""Run the PostgreSQL setup command."""
|
|
175
|
+
print("š Highway Circuit Breaker PostgreSQL Setup")
|
|
176
|
+
print()
|
|
177
|
+
|
|
178
|
+
load_env_vars()
|
|
179
|
+
|
|
180
|
+
# Get config from environment
|
|
181
|
+
config = get_db_config_from_env()
|
|
182
|
+
|
|
183
|
+
print(f"š§ Using database configuration from environment:")
|
|
184
|
+
print(f" Host: {config['host']}")
|
|
185
|
+
print(f" Port: {config['port']}")
|
|
186
|
+
print(f" Database: {config['dbname']}")
|
|
187
|
+
print(f" User: {config['user']}")
|
|
188
|
+
|
|
189
|
+
if config['dbname'] == 'resilient_circuit_db':
|
|
190
|
+
print(f"\nā ļø Note: Using default database name '{config['dbname']}'.")
|
|
191
|
+
print(" You can customize this by setting RC_DB_NAME in your .env file.")
|
|
192
|
+
|
|
193
|
+
if args.dry_run:
|
|
194
|
+
print("\nš DRY RUN MODE - No changes will be made to the database")
|
|
195
|
+
print("This command would create the required tables and indexes in your PostgreSQL database.")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
# Confirm before proceeding
|
|
199
|
+
if not args.yes:
|
|
200
|
+
response = input(f"\nā ļø This will create/update the circuit breaker table in '{config['dbname']}'. Continue? [y/N]: ")
|
|
201
|
+
if response.lower() not in ['y', 'yes']:
|
|
202
|
+
print("ā Setup cancelled by user.")
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
print("\nš¦ Creating PostgreSQL table and indexes...")
|
|
206
|
+
success = create_postgres_table(config)
|
|
207
|
+
|
|
208
|
+
if success:
|
|
209
|
+
print("\nā
PostgreSQL setup completed successfully!")
|
|
210
|
+
print("\nš The following have been created/updated:")
|
|
211
|
+
print(" - Table: rc_circuit_breakers")
|
|
212
|
+
print(" - Primary key index: rc_circuit_breakers_pkey")
|
|
213
|
+
print(" - Index: idx_rc_circuit_breakers_state")
|
|
214
|
+
print(" - Index: idx_rc_circuit_breakers_open_until")
|
|
215
|
+
print(" - Index: idx_rc_circuit_breakers_key_state")
|
|
216
|
+
print(" - Index: idx_rc_circuit_breakers_state_updated")
|
|
217
|
+
print(" - Trigger: update_rc_circuit_breakers_updated_at")
|
|
218
|
+
print(" - Function: update_rc_circuit_breakers_updated_at_column")
|
|
219
|
+
|
|
220
|
+
print(f"\nš” The database '{config['dbname']}' is now ready for use with Highway Circuit Breaker!")
|
|
221
|
+
return 0
|
|
222
|
+
else:
|
|
223
|
+
print("\nā PostgreSQL setup failed!")
|
|
224
|
+
return 1
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main():
|
|
228
|
+
"""Main CLI entry point."""
|
|
229
|
+
parser = argparse.ArgumentParser(
|
|
230
|
+
prog='highway-circutbreaker-cli',
|
|
231
|
+
description='Highway Circuit Breaker CLI tools'
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
235
|
+
|
|
236
|
+
# PostgreSQL setup command
|
|
237
|
+
pg_setup_parser = subparsers.add_parser(
|
|
238
|
+
'pg-setup',
|
|
239
|
+
help='Setup PostgreSQL table for circuit breaker state storage'
|
|
240
|
+
)
|
|
241
|
+
pg_setup_parser.add_argument(
|
|
242
|
+
'--yes',
|
|
243
|
+
action='store_true',
|
|
244
|
+
help='Skip confirmation prompt'
|
|
245
|
+
)
|
|
246
|
+
pg_setup_parser.add_argument(
|
|
247
|
+
'--dry-run',
|
|
248
|
+
action='store_true',
|
|
249
|
+
help='Show what would be done without making changes'
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
args = parser.parse_args()
|
|
253
|
+
|
|
254
|
+
if args.command == 'pg-setup':
|
|
255
|
+
return run_pg_setup(args)
|
|
256
|
+
else:
|
|
257
|
+
parser.print_help()
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
if __name__ == '__main__':
|
|
261
|
+
sys.exit(main())
|