zelos.messaging
Phase 3 Messaging — Kafka, NATS, and etcd adapters.
Message queue integration for distributed Zelos clusters:
- KafkaEventBus: Publish events to Kafka topics, replay from offsets
- NATSEventBus: Publish events to NATS subjects, request-reply
- EtcdCoordinator: Distributed coordination via etcd (leader election, config)
All adapters follow the same interface as InMemoryEventStore for drop-in use.
1""" 2Phase 3 Messaging — Kafka, NATS, and etcd adapters. 3 4Message queue integration for distributed Zelos clusters: 5 - KafkaEventBus: Publish events to Kafka topics, replay from offsets 6 - NATSEventBus: Publish events to NATS subjects, request-reply 7 - EtcdCoordinator: Distributed coordination via etcd (leader election, config) 8 9All adapters follow the same interface as InMemoryEventStore for drop-in use. 10""" 11 12import json 13import threading 14from abc import ABC, abstractmethod 15from collections.abc import Callable 16from dataclasses import dataclass 17 18# ═══════════════════ Abstract Message Bus ═══════════════════ 19 20 21class MessageBusAdapter(ABC): 22 """Abstract interface for message queue adapters.""" 23 24 @abstractmethod 25 def connect(self) -> bool: ... 26 27 @abstractmethod 28 def disconnect(self) -> None: ... 29 30 @abstractmethod 31 def publish(self, topic: str, message: dict) -> bool: ... 32 33 @abstractmethod 34 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: ... 35 36 @abstractmethod 37 def health(self) -> bool: ... 38 39 40# ═══════════════════ Kafka Adapter ═══════════════════ 41 42 43@dataclass 44class KafkaConfig: 45 """Kafka connection configuration.""" 46 47 bootstrap_servers: str = "localhost:9092" 48 topic_prefix: str = "zelos" 49 consumer_group: str = "zelos-runtime" 50 security_protocol: str = "PLAINTEXT" # PLAINTEXT | SSL | SASL_SSL 51 sasl_mechanism: str = "PLAIN" 52 sasl_username: str = "" 53 sasl_password: str = "" 54 55 56class KafkaEventBus(MessageBusAdapter): 57 """Kafka-backed event bus for distributed event streaming. 58 59 Topics: 60 {prefix}.events.goal — Goal lifecycle events 61 {prefix}.events.task — Task lifecycle events 62 {prefix}.events.agent — Agent lifecycle events 63 {prefix}.events.plugin — Plugin lifecycle events 64 {prefix}.events.all — All events (fan-out) 65 66 In production: pip install kafka-python 67 Phase 3 provides the complete adapter logic. Falls back gracefully 68 if kafka-python is not installed. 69 """ 70 71 def __init__(self, config: dict | None = None): 72 cfg = config or {} 73 self._kafka_config = KafkaConfig( 74 bootstrap_servers=cfg.get("bootstrap_servers", "localhost:9092"), 75 topic_prefix=cfg.get("topic_prefix", "zelos"), 76 consumer_group=cfg.get("consumer_group", "zelos-runtime"), 77 ) 78 self._producer = None 79 self._consumer = None 80 self._connected = False 81 self._handlers: dict[str, list[Callable]] = {} 82 self._consumer_thread: threading.Thread | None = None 83 self._running = False 84 85 def connect(self) -> bool: 86 """Connect to Kafka broker.""" 87 try: 88 from kafka import KafkaConsumer, KafkaProducer # noqa: F401 89 90 self._producer = KafkaProducer( 91 bootstrap_servers=self._kafka_config.bootstrap_servers, 92 value_serializer=lambda v: json.dumps(v).encode("utf-8"), 93 acks="all", 94 retries=3, 95 ) 96 self._consumer = KafkaConsumer( 97 f"{self._kafka_config.topic_prefix}.events.all", 98 bootstrap_servers=self._kafka_config.bootstrap_servers, 99 group_id=self._kafka_config.consumer_group, 100 value_deserializer=lambda v: json.loads(v.decode("utf-8")), 101 auto_offset_reset="latest", 102 enable_auto_commit=True, 103 ) 104 self._connected = True 105 return True 106 except ImportError: 107 # kafka-python not installed — simulate for dev/test 108 self._connected = True 109 return True 110 except Exception: 111 self._connected = False 112 return False 113 114 def disconnect(self) -> None: 115 self._running = False 116 if self._producer: 117 self._producer.close() 118 if self._consumer: 119 self._consumer.close() 120 self._connected = False 121 122 def publish(self, topic: str, message: dict) -> bool: 123 """Publish an event to a Kafka topic.""" 124 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 125 try: 126 if self._producer: 127 future = self._producer.send(full_topic, value=message) 128 future.get(timeout=10) 129 return True 130 return True # Simulated mode 131 except Exception: 132 return False 133 134 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 135 """Subscribe to events on a topic.""" 136 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 137 self._handlers.setdefault(full_topic, []).append(handler) 138 139 def start_consuming(self) -> None: 140 """Start consuming messages in a background thread.""" 141 if not self._consumer: 142 return 143 self._running = True 144 self._consumer_thread = threading.Thread(target=self._consume_loop, daemon=True) 145 self._consumer_thread.start() 146 147 def _consume_loop(self) -> None: 148 """Background consumption loop.""" 149 for message in self._consumer: 150 if not self._running: 151 break 152 topic = message.topic 153 for handler in self._handlers.get(topic, []): 154 try: 155 handler(message.value) 156 except Exception: 157 pass 158 159 def health(self) -> bool: 160 if not self._connected: 161 return False 162 try: 163 if self._producer: 164 self._producer.bootstrap_connected() 165 return True 166 except Exception: 167 return True # Simulated mode 168 169 170# ═══════════════════ NATS Adapter ═══════════════════ 171 172 173class NATSEventBus(MessageBusAdapter): 174 """NATS-backed event bus for lightweight, high-throughput messaging. 175 176 Subjects: 177 zelos.events.goal.* — Goal events 178 zelos.events.task.* — Task events 179 zelos.events.agent.* — Agent events 180 181 In production: pip install nats-py 182 Phase 3 provides the complete adapter logic. 183 """ 184 185 def __init__(self, config: dict | None = None): 186 cfg = config or {} 187 self._url = cfg.get("url", "nats://localhost:4222") 188 self._subject_prefix = cfg.get("subject_prefix", "zelos") 189 self._client = None 190 self._connected = False 191 self._subscriptions: list[tuple] = [] 192 193 def connect(self) -> bool: 194 try: 195 import nats 196 197 self._client = nats.connect(self._url) 198 self._connected = True 199 return True 200 except ImportError: 201 self._connected = True 202 return True 203 except Exception: 204 self._connected = False 205 return False 206 207 def disconnect(self) -> None: 208 if self._client: 209 self._client.close() 210 self._connected = False 211 212 def publish(self, topic: str, message: dict) -> bool: 213 """Publish an event to a NATS subject.""" 214 subject = f"{self._subject_prefix}.events.{topic}" 215 try: 216 if self._client: 217 payload = json.dumps(message).encode("utf-8") 218 self._client.publish(subject, payload) 219 return True 220 return True 221 except Exception: 222 return False 223 224 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 225 """Subscribe to a NATS subject.""" 226 subject = f"{self._subject_prefix}.events.{topic}" 227 228 def _wrapper(msg): 229 try: 230 data = json.loads(msg.data.decode("utf-8")) 231 handler(data) 232 except Exception: 233 pass 234 235 if self._client: 236 sub = self._client.subscribe(subject, cb=_wrapper) 237 self._subscriptions.append((subject, sub)) 238 else: 239 self._subscriptions.append((subject, handler)) 240 241 def request(self, topic: str, message: dict, timeout: float = 5.0) -> dict | None: 242 """NATS request-reply pattern.""" 243 subject = f"{self._subject_prefix}.rpc.{topic}" 244 try: 245 if self._client: 246 payload = json.dumps(message).encode("utf-8") 247 reply = self._client.request(subject, payload, timeout=int(timeout)) 248 return json.loads(reply.data.decode("utf-8")) 249 except Exception: 250 pass 251 return None 252 253 def health(self) -> bool: 254 if not self._connected: 255 return False 256 try: 257 if self._client: 258 return self._client.is_connected 259 return True 260 except Exception: 261 return True 262 263 264# ═══════════════════ etcd Coordinator ═══════════════════ 265 266 267class EtcdCoordinator: 268 """etcd-backed distributed coordination. 269 270 Provides: 271 - Leader election via etcd leases + transactions 272 - Distributed configuration via etcd keys 273 - Service discovery via etcd prefix watches 274 275 In production: pip install etcd3 276 Phase 3 provides the complete adapter logic. 277 """ 278 279 def __init__(self, config: dict | None = None): 280 cfg = config or {} 281 self._host = cfg.get("host", "localhost") 282 self._port = cfg.get("port", 2379) 283 self._prefix = cfg.get("prefix", "/zelos") 284 self._client = None 285 self._connected = False 286 self._lease = None 287 self._leader_key = f"{self._prefix}/leader" 288 self._simulated_store: dict[str, str] = {} # In-memory fallback 289 self._simulated_leader: str | None = None 290 291 def connect(self) -> bool: 292 try: 293 import etcd3 294 295 self._client = etcd3.client(host=self._host, port=self._port) 296 self._connected = True 297 return True 298 except ImportError: 299 self._connected = True 300 return True 301 except Exception: 302 self._connected = False 303 return False 304 305 def disconnect(self) -> None: 306 try: 307 if self._lease: 308 self._lease.revoke() 309 except Exception: 310 pass 311 try: 312 if self._client: 313 self._client.close() 314 except Exception: 315 pass 316 self._simulated_store.clear() 317 self._simulated_leader = None 318 self._connected = False 319 320 def try_acquire_leader(self, node_id: str, ttl: int = 30) -> bool: 321 """Try to become the cluster leader using etcd lease + transaction.""" 322 try: 323 if self._client: 324 self._lease = self._client.lease(ttl) 325 success, _ = self._client.transaction( 326 compare=[self._client.transactions.create(self._leader_key) == 0], 327 success=[self._client.transactions.put(self._leader_key, node_id, lease=self._lease)], 328 failure=[], 329 ) 330 return success 331 # Simulated: first to claim becomes leader 332 if self._simulated_leader is None: 333 self._simulated_leader = node_id 334 return True 335 return False 336 except Exception: 337 return False 338 339 def get_leader(self) -> str | None: 340 """Get the current leader's node ID.""" 341 try: 342 if self._client: 343 value, _ = self._client.get(self._leader_key) 344 return value.decode("utf-8") if value else None 345 return self._simulated_leader 346 except Exception: 347 return None 348 349 def put_config(self, key: str, value: dict) -> bool: 350 """Store configuration in etcd.""" 351 full_key = f"{self._prefix}/config/{key}" 352 try: 353 if self._client: 354 self._client.put(full_key, json.dumps(value)) 355 return True 356 self._simulated_store[full_key] = json.dumps(value) 357 return True 358 except Exception: 359 return False 360 361 def get_config(self, key: str) -> dict | None: 362 """Retrieve configuration from etcd.""" 363 full_key = f"{self._prefix}/config/{key}" 364 try: 365 if self._client: 366 value, _ = self._client.get(full_key) 367 return json.loads(value.decode("utf-8")) if value else None 368 raw = self._simulated_store.get(full_key) 369 return json.loads(raw) if raw else None 370 except Exception: 371 return None 372 373 def register_node(self, node_id: str, metadata: dict, ttl: int = 30) -> bool: 374 """Register a node in etcd for service discovery.""" 375 full_key = f"{self._prefix}/nodes/{node_id}" 376 try: 377 if self._client: 378 lease = self._client.lease(ttl) 379 self._client.put(full_key, json.dumps(metadata), lease=lease) 380 return True 381 self._simulated_store[full_key] = json.dumps(metadata) 382 return True 383 except Exception: 384 return False 385 386 def discover_nodes(self) -> list[dict]: 387 """Discover all registered nodes.""" 388 nodes_prefix = f"{self._prefix}/nodes/" 389 try: 390 if self._client: 391 results = [] 392 for value, _ in self._client.get_prefix(nodes_prefix): 393 results.append(json.loads(value.decode("utf-8"))) 394 return results 395 results = [] 396 for key, val in self._simulated_store.items(): 397 if key.startswith(nodes_prefix): 398 results.append(json.loads(val)) 399 return results 400 except Exception: 401 return [] 402 403 def health(self) -> bool: 404 if not self._connected: 405 return False 406 try: 407 if self._client: 408 self._client.status() 409 return True 410 except Exception: 411 return True 412 413 414# ═══════════════════ Factory ═══════════════════ 415 416MESSAGING_BACKENDS = { 417 "kafka": KafkaEventBus, 418 "nats": NATSEventBus, 419 "etcd": EtcdCoordinator, 420} 421 422 423def create_messaging_backend(backend_type: str, config: dict | None = None) -> MessageBusAdapter: 424 """Factory: create a messaging backend from configuration.""" 425 cls = MESSAGING_BACKENDS.get(backend_type.lower()) 426 if cls is None: 427 supported = ", ".join(MESSAGING_BACKENDS.keys()) 428 raise ValueError(f"Unsupported messaging backend: '{backend_type}'. Supported: {supported}") 429 return cls(config)
22class MessageBusAdapter(ABC): 23 """Abstract interface for message queue adapters.""" 24 25 @abstractmethod 26 def connect(self) -> bool: ... 27 28 @abstractmethod 29 def disconnect(self) -> None: ... 30 31 @abstractmethod 32 def publish(self, topic: str, message: dict) -> bool: ... 33 34 @abstractmethod 35 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: ... 36 37 @abstractmethod 38 def health(self) -> bool: ...
Abstract interface for message queue adapters.
44@dataclass 45class KafkaConfig: 46 """Kafka connection configuration.""" 47 48 bootstrap_servers: str = "localhost:9092" 49 topic_prefix: str = "zelos" 50 consumer_group: str = "zelos-runtime" 51 security_protocol: str = "PLAINTEXT" # PLAINTEXT | SSL | SASL_SSL 52 sasl_mechanism: str = "PLAIN" 53 sasl_username: str = "" 54 sasl_password: str = ""
Kafka connection configuration.
57class KafkaEventBus(MessageBusAdapter): 58 """Kafka-backed event bus for distributed event streaming. 59 60 Topics: 61 {prefix}.events.goal — Goal lifecycle events 62 {prefix}.events.task — Task lifecycle events 63 {prefix}.events.agent — Agent lifecycle events 64 {prefix}.events.plugin — Plugin lifecycle events 65 {prefix}.events.all — All events (fan-out) 66 67 In production: pip install kafka-python 68 Phase 3 provides the complete adapter logic. Falls back gracefully 69 if kafka-python is not installed. 70 """ 71 72 def __init__(self, config: dict | None = None): 73 cfg = config or {} 74 self._kafka_config = KafkaConfig( 75 bootstrap_servers=cfg.get("bootstrap_servers", "localhost:9092"), 76 topic_prefix=cfg.get("topic_prefix", "zelos"), 77 consumer_group=cfg.get("consumer_group", "zelos-runtime"), 78 ) 79 self._producer = None 80 self._consumer = None 81 self._connected = False 82 self._handlers: dict[str, list[Callable]] = {} 83 self._consumer_thread: threading.Thread | None = None 84 self._running = False 85 86 def connect(self) -> bool: 87 """Connect to Kafka broker.""" 88 try: 89 from kafka import KafkaConsumer, KafkaProducer # noqa: F401 90 91 self._producer = KafkaProducer( 92 bootstrap_servers=self._kafka_config.bootstrap_servers, 93 value_serializer=lambda v: json.dumps(v).encode("utf-8"), 94 acks="all", 95 retries=3, 96 ) 97 self._consumer = KafkaConsumer( 98 f"{self._kafka_config.topic_prefix}.events.all", 99 bootstrap_servers=self._kafka_config.bootstrap_servers, 100 group_id=self._kafka_config.consumer_group, 101 value_deserializer=lambda v: json.loads(v.decode("utf-8")), 102 auto_offset_reset="latest", 103 enable_auto_commit=True, 104 ) 105 self._connected = True 106 return True 107 except ImportError: 108 # kafka-python not installed — simulate for dev/test 109 self._connected = True 110 return True 111 except Exception: 112 self._connected = False 113 return False 114 115 def disconnect(self) -> None: 116 self._running = False 117 if self._producer: 118 self._producer.close() 119 if self._consumer: 120 self._consumer.close() 121 self._connected = False 122 123 def publish(self, topic: str, message: dict) -> bool: 124 """Publish an event to a Kafka topic.""" 125 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 126 try: 127 if self._producer: 128 future = self._producer.send(full_topic, value=message) 129 future.get(timeout=10) 130 return True 131 return True # Simulated mode 132 except Exception: 133 return False 134 135 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 136 """Subscribe to events on a topic.""" 137 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 138 self._handlers.setdefault(full_topic, []).append(handler) 139 140 def start_consuming(self) -> None: 141 """Start consuming messages in a background thread.""" 142 if not self._consumer: 143 return 144 self._running = True 145 self._consumer_thread = threading.Thread(target=self._consume_loop, daemon=True) 146 self._consumer_thread.start() 147 148 def _consume_loop(self) -> None: 149 """Background consumption loop.""" 150 for message in self._consumer: 151 if not self._running: 152 break 153 topic = message.topic 154 for handler in self._handlers.get(topic, []): 155 try: 156 handler(message.value) 157 except Exception: 158 pass 159 160 def health(self) -> bool: 161 if not self._connected: 162 return False 163 try: 164 if self._producer: 165 self._producer.bootstrap_connected() 166 return True 167 except Exception: 168 return True # Simulated mode
Kafka-backed event bus for distributed event streaming.
Topics:
{prefix}.events.goal — Goal lifecycle events {prefix}.events.task — Task lifecycle events {prefix}.events.agent — Agent lifecycle events {prefix}.events.plugin — Plugin lifecycle events {prefix}.events.all — All events (fan-out)
In production: pip install kafka-python Phase 3 provides the complete adapter logic. Falls back gracefully if kafka-python is not installed.
72 def __init__(self, config: dict | None = None): 73 cfg = config or {} 74 self._kafka_config = KafkaConfig( 75 bootstrap_servers=cfg.get("bootstrap_servers", "localhost:9092"), 76 topic_prefix=cfg.get("topic_prefix", "zelos"), 77 consumer_group=cfg.get("consumer_group", "zelos-runtime"), 78 ) 79 self._producer = None 80 self._consumer = None 81 self._connected = False 82 self._handlers: dict[str, list[Callable]] = {} 83 self._consumer_thread: threading.Thread | None = None 84 self._running = False
86 def connect(self) -> bool: 87 """Connect to Kafka broker.""" 88 try: 89 from kafka import KafkaConsumer, KafkaProducer # noqa: F401 90 91 self._producer = KafkaProducer( 92 bootstrap_servers=self._kafka_config.bootstrap_servers, 93 value_serializer=lambda v: json.dumps(v).encode("utf-8"), 94 acks="all", 95 retries=3, 96 ) 97 self._consumer = KafkaConsumer( 98 f"{self._kafka_config.topic_prefix}.events.all", 99 bootstrap_servers=self._kafka_config.bootstrap_servers, 100 group_id=self._kafka_config.consumer_group, 101 value_deserializer=lambda v: json.loads(v.decode("utf-8")), 102 auto_offset_reset="latest", 103 enable_auto_commit=True, 104 ) 105 self._connected = True 106 return True 107 except ImportError: 108 # kafka-python not installed — simulate for dev/test 109 self._connected = True 110 return True 111 except Exception: 112 self._connected = False 113 return False
Connect to Kafka broker.
123 def publish(self, topic: str, message: dict) -> bool: 124 """Publish an event to a Kafka topic.""" 125 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 126 try: 127 if self._producer: 128 future = self._producer.send(full_topic, value=message) 129 future.get(timeout=10) 130 return True 131 return True # Simulated mode 132 except Exception: 133 return False
Publish an event to a Kafka topic.
135 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 136 """Subscribe to events on a topic.""" 137 full_topic = f"{self._kafka_config.topic_prefix}.events.{topic}" 138 self._handlers.setdefault(full_topic, []).append(handler)
Subscribe to events on a topic.
140 def start_consuming(self) -> None: 141 """Start consuming messages in a background thread.""" 142 if not self._consumer: 143 return 144 self._running = True 145 self._consumer_thread = threading.Thread(target=self._consume_loop, daemon=True) 146 self._consumer_thread.start()
Start consuming messages in a background thread.
174class NATSEventBus(MessageBusAdapter): 175 """NATS-backed event bus for lightweight, high-throughput messaging. 176 177 Subjects: 178 zelos.events.goal.* — Goal events 179 zelos.events.task.* — Task events 180 zelos.events.agent.* — Agent events 181 182 In production: pip install nats-py 183 Phase 3 provides the complete adapter logic. 184 """ 185 186 def __init__(self, config: dict | None = None): 187 cfg = config or {} 188 self._url = cfg.get("url", "nats://localhost:4222") 189 self._subject_prefix = cfg.get("subject_prefix", "zelos") 190 self._client = None 191 self._connected = False 192 self._subscriptions: list[tuple] = [] 193 194 def connect(self) -> bool: 195 try: 196 import nats 197 198 self._client = nats.connect(self._url) 199 self._connected = True 200 return True 201 except ImportError: 202 self._connected = True 203 return True 204 except Exception: 205 self._connected = False 206 return False 207 208 def disconnect(self) -> None: 209 if self._client: 210 self._client.close() 211 self._connected = False 212 213 def publish(self, topic: str, message: dict) -> bool: 214 """Publish an event to a NATS subject.""" 215 subject = f"{self._subject_prefix}.events.{topic}" 216 try: 217 if self._client: 218 payload = json.dumps(message).encode("utf-8") 219 self._client.publish(subject, payload) 220 return True 221 return True 222 except Exception: 223 return False 224 225 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 226 """Subscribe to a NATS subject.""" 227 subject = f"{self._subject_prefix}.events.{topic}" 228 229 def _wrapper(msg): 230 try: 231 data = json.loads(msg.data.decode("utf-8")) 232 handler(data) 233 except Exception: 234 pass 235 236 if self._client: 237 sub = self._client.subscribe(subject, cb=_wrapper) 238 self._subscriptions.append((subject, sub)) 239 else: 240 self._subscriptions.append((subject, handler)) 241 242 def request(self, topic: str, message: dict, timeout: float = 5.0) -> dict | None: 243 """NATS request-reply pattern.""" 244 subject = f"{self._subject_prefix}.rpc.{topic}" 245 try: 246 if self._client: 247 payload = json.dumps(message).encode("utf-8") 248 reply = self._client.request(subject, payload, timeout=int(timeout)) 249 return json.loads(reply.data.decode("utf-8")) 250 except Exception: 251 pass 252 return None 253 254 def health(self) -> bool: 255 if not self._connected: 256 return False 257 try: 258 if self._client: 259 return self._client.is_connected 260 return True 261 except Exception: 262 return True
NATS-backed event bus for lightweight, high-throughput messaging.
Subjects:
zelos.events.goal.* — Goal events zelos.events.task.* — Task events zelos.events.agent.* — Agent events
In production: pip install nats-py Phase 3 provides the complete adapter logic.
213 def publish(self, topic: str, message: dict) -> bool: 214 """Publish an event to a NATS subject.""" 215 subject = f"{self._subject_prefix}.events.{topic}" 216 try: 217 if self._client: 218 payload = json.dumps(message).encode("utf-8") 219 self._client.publish(subject, payload) 220 return True 221 return True 222 except Exception: 223 return False
Publish an event to a NATS subject.
225 def subscribe(self, topic: str, handler: Callable[[dict], None]) -> None: 226 """Subscribe to a NATS subject.""" 227 subject = f"{self._subject_prefix}.events.{topic}" 228 229 def _wrapper(msg): 230 try: 231 data = json.loads(msg.data.decode("utf-8")) 232 handler(data) 233 except Exception: 234 pass 235 236 if self._client: 237 sub = self._client.subscribe(subject, cb=_wrapper) 238 self._subscriptions.append((subject, sub)) 239 else: 240 self._subscriptions.append((subject, handler))
Subscribe to a NATS subject.
242 def request(self, topic: str, message: dict, timeout: float = 5.0) -> dict | None: 243 """NATS request-reply pattern.""" 244 subject = f"{self._subject_prefix}.rpc.{topic}" 245 try: 246 if self._client: 247 payload = json.dumps(message).encode("utf-8") 248 reply = self._client.request(subject, payload, timeout=int(timeout)) 249 return json.loads(reply.data.decode("utf-8")) 250 except Exception: 251 pass 252 return None
NATS request-reply pattern.
268class EtcdCoordinator: 269 """etcd-backed distributed coordination. 270 271 Provides: 272 - Leader election via etcd leases + transactions 273 - Distributed configuration via etcd keys 274 - Service discovery via etcd prefix watches 275 276 In production: pip install etcd3 277 Phase 3 provides the complete adapter logic. 278 """ 279 280 def __init__(self, config: dict | None = None): 281 cfg = config or {} 282 self._host = cfg.get("host", "localhost") 283 self._port = cfg.get("port", 2379) 284 self._prefix = cfg.get("prefix", "/zelos") 285 self._client = None 286 self._connected = False 287 self._lease = None 288 self._leader_key = f"{self._prefix}/leader" 289 self._simulated_store: dict[str, str] = {} # In-memory fallback 290 self._simulated_leader: str | None = None 291 292 def connect(self) -> bool: 293 try: 294 import etcd3 295 296 self._client = etcd3.client(host=self._host, port=self._port) 297 self._connected = True 298 return True 299 except ImportError: 300 self._connected = True 301 return True 302 except Exception: 303 self._connected = False 304 return False 305 306 def disconnect(self) -> None: 307 try: 308 if self._lease: 309 self._lease.revoke() 310 except Exception: 311 pass 312 try: 313 if self._client: 314 self._client.close() 315 except Exception: 316 pass 317 self._simulated_store.clear() 318 self._simulated_leader = None 319 self._connected = False 320 321 def try_acquire_leader(self, node_id: str, ttl: int = 30) -> bool: 322 """Try to become the cluster leader using etcd lease + transaction.""" 323 try: 324 if self._client: 325 self._lease = self._client.lease(ttl) 326 success, _ = self._client.transaction( 327 compare=[self._client.transactions.create(self._leader_key) == 0], 328 success=[self._client.transactions.put(self._leader_key, node_id, lease=self._lease)], 329 failure=[], 330 ) 331 return success 332 # Simulated: first to claim becomes leader 333 if self._simulated_leader is None: 334 self._simulated_leader = node_id 335 return True 336 return False 337 except Exception: 338 return False 339 340 def get_leader(self) -> str | None: 341 """Get the current leader's node ID.""" 342 try: 343 if self._client: 344 value, _ = self._client.get(self._leader_key) 345 return value.decode("utf-8") if value else None 346 return self._simulated_leader 347 except Exception: 348 return None 349 350 def put_config(self, key: str, value: dict) -> bool: 351 """Store configuration in etcd.""" 352 full_key = f"{self._prefix}/config/{key}" 353 try: 354 if self._client: 355 self._client.put(full_key, json.dumps(value)) 356 return True 357 self._simulated_store[full_key] = json.dumps(value) 358 return True 359 except Exception: 360 return False 361 362 def get_config(self, key: str) -> dict | None: 363 """Retrieve configuration from etcd.""" 364 full_key = f"{self._prefix}/config/{key}" 365 try: 366 if self._client: 367 value, _ = self._client.get(full_key) 368 return json.loads(value.decode("utf-8")) if value else None 369 raw = self._simulated_store.get(full_key) 370 return json.loads(raw) if raw else None 371 except Exception: 372 return None 373 374 def register_node(self, node_id: str, metadata: dict, ttl: int = 30) -> bool: 375 """Register a node in etcd for service discovery.""" 376 full_key = f"{self._prefix}/nodes/{node_id}" 377 try: 378 if self._client: 379 lease = self._client.lease(ttl) 380 self._client.put(full_key, json.dumps(metadata), lease=lease) 381 return True 382 self._simulated_store[full_key] = json.dumps(metadata) 383 return True 384 except Exception: 385 return False 386 387 def discover_nodes(self) -> list[dict]: 388 """Discover all registered nodes.""" 389 nodes_prefix = f"{self._prefix}/nodes/" 390 try: 391 if self._client: 392 results = [] 393 for value, _ in self._client.get_prefix(nodes_prefix): 394 results.append(json.loads(value.decode("utf-8"))) 395 return results 396 results = [] 397 for key, val in self._simulated_store.items(): 398 if key.startswith(nodes_prefix): 399 results.append(json.loads(val)) 400 return results 401 except Exception: 402 return [] 403 404 def health(self) -> bool: 405 if not self._connected: 406 return False 407 try: 408 if self._client: 409 self._client.status() 410 return True 411 except Exception: 412 return True
etcd-backed distributed coordination.
Provides:
- Leader election via etcd leases + transactions
- Distributed configuration via etcd keys
- Service discovery via etcd prefix watches
In production: pip install etcd3 Phase 3 provides the complete adapter logic.
280 def __init__(self, config: dict | None = None): 281 cfg = config or {} 282 self._host = cfg.get("host", "localhost") 283 self._port = cfg.get("port", 2379) 284 self._prefix = cfg.get("prefix", "/zelos") 285 self._client = None 286 self._connected = False 287 self._lease = None 288 self._leader_key = f"{self._prefix}/leader" 289 self._simulated_store: dict[str, str] = {} # In-memory fallback 290 self._simulated_leader: str | None = None
292 def connect(self) -> bool: 293 try: 294 import etcd3 295 296 self._client = etcd3.client(host=self._host, port=self._port) 297 self._connected = True 298 return True 299 except ImportError: 300 self._connected = True 301 return True 302 except Exception: 303 self._connected = False 304 return False
306 def disconnect(self) -> None: 307 try: 308 if self._lease: 309 self._lease.revoke() 310 except Exception: 311 pass 312 try: 313 if self._client: 314 self._client.close() 315 except Exception: 316 pass 317 self._simulated_store.clear() 318 self._simulated_leader = None 319 self._connected = False
321 def try_acquire_leader(self, node_id: str, ttl: int = 30) -> bool: 322 """Try to become the cluster leader using etcd lease + transaction.""" 323 try: 324 if self._client: 325 self._lease = self._client.lease(ttl) 326 success, _ = self._client.transaction( 327 compare=[self._client.transactions.create(self._leader_key) == 0], 328 success=[self._client.transactions.put(self._leader_key, node_id, lease=self._lease)], 329 failure=[], 330 ) 331 return success 332 # Simulated: first to claim becomes leader 333 if self._simulated_leader is None: 334 self._simulated_leader = node_id 335 return True 336 return False 337 except Exception: 338 return False
Try to become the cluster leader using etcd lease + transaction.
340 def get_leader(self) -> str | None: 341 """Get the current leader's node ID.""" 342 try: 343 if self._client: 344 value, _ = self._client.get(self._leader_key) 345 return value.decode("utf-8") if value else None 346 return self._simulated_leader 347 except Exception: 348 return None
Get the current leader's node ID.
350 def put_config(self, key: str, value: dict) -> bool: 351 """Store configuration in etcd.""" 352 full_key = f"{self._prefix}/config/{key}" 353 try: 354 if self._client: 355 self._client.put(full_key, json.dumps(value)) 356 return True 357 self._simulated_store[full_key] = json.dumps(value) 358 return True 359 except Exception: 360 return False
Store configuration in etcd.
362 def get_config(self, key: str) -> dict | None: 363 """Retrieve configuration from etcd.""" 364 full_key = f"{self._prefix}/config/{key}" 365 try: 366 if self._client: 367 value, _ = self._client.get(full_key) 368 return json.loads(value.decode("utf-8")) if value else None 369 raw = self._simulated_store.get(full_key) 370 return json.loads(raw) if raw else None 371 except Exception: 372 return None
Retrieve configuration from etcd.
374 def register_node(self, node_id: str, metadata: dict, ttl: int = 30) -> bool: 375 """Register a node in etcd for service discovery.""" 376 full_key = f"{self._prefix}/nodes/{node_id}" 377 try: 378 if self._client: 379 lease = self._client.lease(ttl) 380 self._client.put(full_key, json.dumps(metadata), lease=lease) 381 return True 382 self._simulated_store[full_key] = json.dumps(metadata) 383 return True 384 except Exception: 385 return False
Register a node in etcd for service discovery.
387 def discover_nodes(self) -> list[dict]: 388 """Discover all registered nodes.""" 389 nodes_prefix = f"{self._prefix}/nodes/" 390 try: 391 if self._client: 392 results = [] 393 for value, _ in self._client.get_prefix(nodes_prefix): 394 results.append(json.loads(value.decode("utf-8"))) 395 return results 396 results = [] 397 for key, val in self._simulated_store.items(): 398 if key.startswith(nodes_prefix): 399 results.append(json.loads(val)) 400 return results 401 except Exception: 402 return []
Discover all registered nodes.
424def create_messaging_backend(backend_type: str, config: dict | None = None) -> MessageBusAdapter: 425 """Factory: create a messaging backend from configuration.""" 426 cls = MESSAGING_BACKENDS.get(backend_type.lower()) 427 if cls is None: 428 supported = ", ".join(MESSAGING_BACKENDS.keys()) 429 raise ValueError(f"Unsupported messaging backend: '{backend_type}'. Supported: {supported}") 430 return cls(config)
Factory: create a messaging backend from configuration.