mcp-hangar 0.2.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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Health tracking entity for providers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class HealthTracker:
|
|
10
|
+
"""Tracks health metrics for a provider.
|
|
11
|
+
|
|
12
|
+
This is a mutable entity (not a value object) that encapsulates
|
|
13
|
+
health-related business logic including:
|
|
14
|
+
- Failure counting and threshold detection
|
|
15
|
+
- Backoff calculation for retry logic
|
|
16
|
+
- Success/failure recording with timestamps
|
|
17
|
+
|
|
18
|
+
Note:
|
|
19
|
+
This class is intentionally mutable as it tracks state over time.
|
|
20
|
+
It is not a value object and should not be compared by value.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
max_consecutive_failures: Threshold for triggering degradation.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> tracker = HealthTracker(max_consecutive_failures=3)
|
|
27
|
+
>>> tracker.record_failure()
|
|
28
|
+
>>> tracker.consecutive_failures
|
|
29
|
+
1
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
max_consecutive_failures: int = 3
|
|
33
|
+
_consecutive_failures: int = field(default=0, init=False)
|
|
34
|
+
_last_success_at: Optional[float] = field(default=None, init=False)
|
|
35
|
+
_last_failure_at: Optional[float] = field(default=None, init=False)
|
|
36
|
+
_total_invocations: int = field(default=0, init=False)
|
|
37
|
+
_total_failures: int = field(default=0, init=False)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def consecutive_failures(self) -> int:
|
|
41
|
+
"""Get the current consecutive failure count.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Number of consecutive failures since last success.
|
|
45
|
+
"""
|
|
46
|
+
return self._consecutive_failures
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def last_success_at(self) -> Optional[float]:
|
|
50
|
+
"""Get the timestamp of last successful operation.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Unix timestamp of last success, or None if never succeeded.
|
|
54
|
+
"""
|
|
55
|
+
return self._last_success_at
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def last_failure_at(self) -> Optional[float]:
|
|
59
|
+
"""Get the timestamp of last failed operation.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Unix timestamp of last failure, or None if never failed.
|
|
63
|
+
"""
|
|
64
|
+
return self._last_failure_at
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def total_invocations(self) -> int:
|
|
68
|
+
"""Get the total number of invocations.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Total count of success + failure invocations.
|
|
72
|
+
"""
|
|
73
|
+
return self._total_invocations
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def total_failures(self) -> int:
|
|
77
|
+
"""Get the total number of failures.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Total count of failed invocations.
|
|
81
|
+
"""
|
|
82
|
+
return self._total_failures
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def success_rate(self) -> float:
|
|
86
|
+
"""Calculate the success rate as a percentage.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Success rate from 0.0 to 1.0. Returns 1.0 if no invocations yet.
|
|
90
|
+
"""
|
|
91
|
+
if self._total_invocations == 0:
|
|
92
|
+
return 1.0
|
|
93
|
+
return (self._total_invocations - self._total_failures) / self._total_invocations
|
|
94
|
+
|
|
95
|
+
def record_success(self) -> None:
|
|
96
|
+
"""Record a successful operation.
|
|
97
|
+
|
|
98
|
+
Resets the consecutive failure counter and updates timestamps.
|
|
99
|
+
"""
|
|
100
|
+
self._consecutive_failures = 0
|
|
101
|
+
self._last_success_at = time.time()
|
|
102
|
+
self._total_invocations += 1
|
|
103
|
+
|
|
104
|
+
def record_failure(self) -> None:
|
|
105
|
+
"""Record a failed operation.
|
|
106
|
+
|
|
107
|
+
Increments both consecutive and total failure counters.
|
|
108
|
+
"""
|
|
109
|
+
self._consecutive_failures += 1
|
|
110
|
+
self._last_failure_at = time.time()
|
|
111
|
+
self._total_failures += 1
|
|
112
|
+
self._total_invocations += 1
|
|
113
|
+
|
|
114
|
+
def record_invocation_failure(self) -> None:
|
|
115
|
+
"""Record a failed tool invocation.
|
|
116
|
+
|
|
117
|
+
Increments total_failures but not consecutive failures.
|
|
118
|
+
Use this for application-level errors that shouldn't trigger degradation.
|
|
119
|
+
"""
|
|
120
|
+
self._total_failures += 1
|
|
121
|
+
self._total_invocations += 1
|
|
122
|
+
|
|
123
|
+
def should_degrade(self) -> bool:
|
|
124
|
+
"""Check if provider should transition to DEGRADED state.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True when consecutive failures reach the threshold.
|
|
128
|
+
"""
|
|
129
|
+
return self._consecutive_failures >= self.max_consecutive_failures
|
|
130
|
+
|
|
131
|
+
def can_retry(self) -> bool:
|
|
132
|
+
"""Check if enough time has passed for a retry attempt.
|
|
133
|
+
|
|
134
|
+
Uses exponential backoff: min(60, 2^consecutive_failures) seconds.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if retry is allowed, False if still in backoff period.
|
|
138
|
+
"""
|
|
139
|
+
if self._last_failure_at is None:
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
backoff = self._calculate_backoff()
|
|
143
|
+
elapsed = time.time() - self._last_failure_at
|
|
144
|
+
return elapsed >= backoff
|
|
145
|
+
|
|
146
|
+
def time_until_retry(self) -> float:
|
|
147
|
+
"""Calculate time remaining until retry is allowed.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Seconds until retry is allowed. Returns 0 if retry is already allowed.
|
|
151
|
+
"""
|
|
152
|
+
if self._last_failure_at is None:
|
|
153
|
+
return 0.0
|
|
154
|
+
|
|
155
|
+
backoff = self._calculate_backoff()
|
|
156
|
+
elapsed = time.time() - self._last_failure_at
|
|
157
|
+
remaining = backoff - elapsed
|
|
158
|
+
return max(0.0, remaining)
|
|
159
|
+
|
|
160
|
+
def _calculate_backoff(self) -> float:
|
|
161
|
+
"""Calculate backoff duration based on consecutive failures."""
|
|
162
|
+
return min(60.0, 2**self._consecutive_failures)
|
|
163
|
+
|
|
164
|
+
def reset(self) -> None:
|
|
165
|
+
"""Reset health tracker to initial state."""
|
|
166
|
+
self._consecutive_failures = 0
|
|
167
|
+
self._last_success_at = None
|
|
168
|
+
self._last_failure_at = None
|
|
169
|
+
self._total_invocations = 0
|
|
170
|
+
self._total_failures = 0
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict:
|
|
173
|
+
"""Convert to dictionary representation."""
|
|
174
|
+
return {
|
|
175
|
+
"consecutive_failures": self._consecutive_failures,
|
|
176
|
+
"last_success_at": self._last_success_at,
|
|
177
|
+
"last_failure_at": self._last_failure_at,
|
|
178
|
+
"total_invocations": self._total_invocations,
|
|
179
|
+
"total_failures": self._total_failures,
|
|
180
|
+
"success_rate": self.success_rate,
|
|
181
|
+
"can_retry": self.can_retry(),
|
|
182
|
+
"time_until_retry": self.time_until_retry(),
|
|
183
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Load balancing strategies for provider groups.
|
|
2
|
+
|
|
3
|
+
This module implements various load balancing strategies for distributing
|
|
4
|
+
requests across group members.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
import random
|
|
9
|
+
import threading
|
|
10
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from ..value_objects import LoadBalancerStrategy
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .provider_group import GroupMember
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseStrategy(ABC):
|
|
19
|
+
"""Abstract base class for load balancing strategies."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
23
|
+
"""
|
|
24
|
+
Select a member from available members.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
members: List of available (in_rotation) group members
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Selected member or None if no members available
|
|
31
|
+
"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def reset(self) -> None:
|
|
36
|
+
"""Reset strategy state (e.g., after rebalancing)."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RoundRobinStrategy(BaseStrategy):
|
|
41
|
+
"""Simple round-robin selection.
|
|
42
|
+
|
|
43
|
+
Each member gets selected in order, cycling through the list.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self._index = 0
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
|
|
50
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
51
|
+
if not members:
|
|
52
|
+
return None
|
|
53
|
+
with self._lock:
|
|
54
|
+
selected = members[self._index % len(members)]
|
|
55
|
+
self._index += 1
|
|
56
|
+
return selected
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._index = 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WeightedRoundRobinStrategy(BaseStrategy):
|
|
64
|
+
"""
|
|
65
|
+
Weighted round-robin using smooth weighted algorithm.
|
|
66
|
+
|
|
67
|
+
Members with higher weights get proportionally more requests.
|
|
68
|
+
Uses the Nginx smooth weighted round-robin algorithm for even distribution.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
self._current_weights: dict = {}
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
76
|
+
if not members:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
with self._lock:
|
|
80
|
+
total_weight = sum(m.weight for m in members)
|
|
81
|
+
|
|
82
|
+
# Initialize or update current weights
|
|
83
|
+
for m in members:
|
|
84
|
+
mid = id(m)
|
|
85
|
+
if mid not in self._current_weights:
|
|
86
|
+
self._current_weights[mid] = 0
|
|
87
|
+
self._current_weights[mid] += m.weight
|
|
88
|
+
|
|
89
|
+
# Select member with highest current weight
|
|
90
|
+
best = max(members, key=lambda m: self._current_weights.get(id(m), 0))
|
|
91
|
+
|
|
92
|
+
# Reduce selected member's weight by total weight
|
|
93
|
+
self._current_weights[id(best)] -= total_weight
|
|
94
|
+
|
|
95
|
+
return best
|
|
96
|
+
|
|
97
|
+
def reset(self) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._current_weights.clear()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LeastConnectionsStrategy(BaseStrategy):
|
|
103
|
+
"""Select member with fewest recent selections.
|
|
104
|
+
|
|
105
|
+
Uses last_selected_at timestamp as a proxy for "connections".
|
|
106
|
+
Prefers members that haven't been selected recently.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
110
|
+
if not members:
|
|
111
|
+
return None
|
|
112
|
+
# Select member with oldest last_selected_at (least recently used)
|
|
113
|
+
return min(members, key=lambda m: m.last_selected_at)
|
|
114
|
+
|
|
115
|
+
def reset(self) -> None:
|
|
116
|
+
# No state to reset
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RandomStrategy(BaseStrategy):
|
|
121
|
+
"""Random selection with optional weights.
|
|
122
|
+
|
|
123
|
+
Uses weighted random selection where higher weight = higher probability.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
127
|
+
if not members:
|
|
128
|
+
return None
|
|
129
|
+
weights = [m.weight for m in members]
|
|
130
|
+
return random.choices(members, weights=weights, k=1)[0]
|
|
131
|
+
|
|
132
|
+
def reset(self) -> None:
|
|
133
|
+
# No state to reset
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PriorityStrategy(BaseStrategy):
|
|
138
|
+
"""Always select lowest priority member that's healthy.
|
|
139
|
+
|
|
140
|
+
Priority 1 is highest priority. Falls back to next priority if 1 is unavailable.
|
|
141
|
+
Useful for primary/backup scenarios.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
145
|
+
if not members:
|
|
146
|
+
return None
|
|
147
|
+
# Select member with lowest priority number (highest priority)
|
|
148
|
+
return min(members, key=lambda m: m.priority)
|
|
149
|
+
|
|
150
|
+
def reset(self) -> None:
|
|
151
|
+
# No state to reset
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class LoadBalancer:
|
|
156
|
+
"""
|
|
157
|
+
Load balancer that selects members based on configured strategy.
|
|
158
|
+
|
|
159
|
+
Thread-safe - each strategy implementation handles its own locking.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
_STRATEGIES = {
|
|
163
|
+
LoadBalancerStrategy.ROUND_ROBIN: RoundRobinStrategy,
|
|
164
|
+
LoadBalancerStrategy.WEIGHTED_ROUND_ROBIN: WeightedRoundRobinStrategy,
|
|
165
|
+
LoadBalancerStrategy.LEAST_CONNECTIONS: LeastConnectionsStrategy,
|
|
166
|
+
LoadBalancerStrategy.RANDOM: RandomStrategy,
|
|
167
|
+
LoadBalancerStrategy.PRIORITY: PriorityStrategy,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def __init__(self, strategy: LoadBalancerStrategy):
|
|
171
|
+
self._strategy_type = strategy
|
|
172
|
+
self._impl = self._STRATEGIES[strategy]()
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def strategy(self) -> LoadBalancerStrategy:
|
|
176
|
+
"""Get the current strategy type."""
|
|
177
|
+
return self._strategy_type
|
|
178
|
+
|
|
179
|
+
def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
|
|
180
|
+
"""Select a member from available members."""
|
|
181
|
+
return self._impl.select(members)
|
|
182
|
+
|
|
183
|
+
def reset(self) -> None:
|
|
184
|
+
"""Reset strategy state."""
|
|
185
|
+
self._impl.reset()
|