zelos.storage
Pluggable Storage Backends — Event persistence + State storage.
Phase 2: InMemory, Redis, PostgreSQL, MySQL backends. All share a common interface: connect / disconnect / append / read / state / snapshot.
1""" 2Pluggable Storage Backends — Event persistence + State storage. 3 4Phase 2: InMemory, Redis, PostgreSQL, MySQL backends. 5All share a common interface: connect / disconnect / append / read / state / snapshot. 6""" 7 8import json 9import threading 10import time 11from abc import ABC, abstractmethod 12 13# ═══════════════════ Common Interface ═══════════════════ 14 15 16class StorageBackend(ABC): 17 """Abstract storage backend for events and state.""" 18 19 def __init__(self, config: dict | None = None): 20 self.config = config or {} 21 self._connected = False 22 23 @abstractmethod 24 def connect(self) -> bool: ... 25 26 @abstractmethod 27 def disconnect(self) -> None: ... 28 29 @abstractmethod 30 def health(self) -> bool: ... 31 32 @abstractmethod 33 def append(self, stream: str, events: list[dict]) -> int: ... 34 35 @abstractmethod 36 def read(self, stream: str, from_position: int, count: int) -> list[dict]: ... 37 38 @abstractmethod 39 def set_state(self, key: str, value: dict) -> None: ... 40 41 @abstractmethod 42 def get_state(self, key: str) -> dict | None: ... 43 44 @abstractmethod 45 def delete_state(self, key: str) -> None: ... 46 47 def create_snapshot(self, key: str, events_position: int, state: dict) -> None: 48 self.set_state( 49 f"snapshot:{key}", {"events_position": events_position, "state": state, "timestamp": time.time()} 50 ) 51 52 def get_snapshot(self, key: str) -> dict | None: 53 return self.get_state(f"snapshot:{key}") 54 55 @property 56 def is_connected(self) -> bool: 57 return self._connected 58 59 60# ═══════════════════ InMemory ═══════════════════ 61 62 63class InMemoryStorageBackend(StorageBackend): 64 """Phase 1 compatible — stores everything in memory.""" 65 66 def __init__(self, config: dict | None = None): 67 super().__init__(config) 68 self._streams: dict[str, list[dict]] = {} 69 self._state: dict[str, dict] = {} 70 self._lock = threading.Lock() 71 72 def connect(self) -> bool: 73 self._connected = True 74 return True 75 76 def disconnect(self) -> None: 77 self._connected = False 78 79 def health(self) -> bool: 80 return self._connected 81 82 def append(self, stream: str, events: list[dict]) -> int: 83 with self._lock: 84 if stream not in self._streams: 85 self._streams[stream] = [] 86 self._streams[stream].extend(events) 87 return len(self._streams[stream]) 88 89 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 90 with self._lock: 91 events = self._streams.get(stream, []) 92 return events[from_position : from_position + count] 93 94 def set_state(self, key: str, value: dict) -> None: 95 with self._lock: 96 self._state[key] = value 97 98 def get_state(self, key: str) -> dict | None: 99 return self._state.get(key) 100 101 def delete_state(self, key: str) -> None: 102 self._state.pop(key, None) 103 104 105# ═══════════════════ Redis ═══════════════════ 106 107 108class RedisStorageBackend(StorageBackend): 109 """Redis-backed storage. Events stored as lists, state as hash. 110 111 Config: 112 url: redis://localhost:6379/0 113 prefix: "zelos" (key namespace) 114 """ 115 116 def __init__(self, config: dict | None = None): 117 super().__init__(config) 118 self._url = (config or {}).get("url", "redis://localhost:6379/0") 119 self._prefix = (config or {}).get("prefix", "zelos") 120 self._client = None 121 122 def connect(self) -> bool: 123 try: 124 import redis 125 126 self._client = redis.Redis.from_url(self._url, decode_responses=True) 127 self._client.ping() 128 self._connected = True 129 return True 130 except Exception: 131 self._connected = False 132 return False 133 134 def disconnect(self) -> None: 135 if self._client: 136 self._client.close() 137 self._connected = False 138 139 def health(self) -> bool: 140 if not self._client: 141 return False 142 try: 143 self._client.ping() 144 return True 145 except Exception: 146 return False 147 148 def _stream_key(self, stream: str) -> str: 149 return f"{self._prefix}:stream:{stream}" 150 151 def _state_key(self, key: str) -> str: 152 return f"{self._prefix}:state:{key}" 153 154 def append(self, stream: str, events: list[dict]) -> int: 155 if not self._client: 156 return -1 157 pipe = self._client.pipeline() 158 for e in events: 159 pipe.rpush(self._stream_key(stream), json.dumps(e)) 160 pipe.execute() 161 return self._client.llen(self._stream_key(stream)) 162 163 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 164 if not self._client: 165 return [] 166 raw = self._client.lrange(self._stream_key(stream), from_position, from_position + count - 1) 167 return [json.loads(r) for r in raw] 168 169 def set_state(self, key: str, value: dict) -> None: 170 if self._client: 171 self._client.set(self._state_key(key), json.dumps(value)) 172 173 def get_state(self, key: str) -> dict | None: 174 if not self._client: 175 return None 176 raw = self._client.get(self._state_key(key)) 177 return json.loads(raw) if raw else None 178 179 def delete_state(self, key: str) -> None: 180 if self._client: 181 self._client.delete(self._state_key(key)) 182 183 184# ═══════════════════ PostgreSQL ═══════════════════ 185 186 187class PostgreSQLStorageBackend(StorageBackend): 188 """PostgreSQL-backed storage. Events table + state table. 189 190 Config: 191 url: postgresql://user:pass@localhost:5432/zelos 192 """ 193 194 def __init__(self, config: dict | None = None): 195 super().__init__(config) 196 self._url = (config or {}).get("url", "postgresql://localhost:5432/zelos") 197 self._conn = None 198 199 def connect(self) -> bool: 200 try: 201 import psycopg2 202 203 self._conn = psycopg2.connect(self._url) 204 self._conn.autocommit = True 205 self._create_tables() 206 self._connected = True 207 return True 208 except Exception: 209 self._connected = False 210 return False 211 212 def disconnect(self) -> None: 213 if self._conn: 214 self._conn.close() 215 self._connected = False 216 217 def health(self) -> bool: 218 if not self._conn: 219 return False 220 try: 221 cur = self._conn.cursor() 222 cur.execute("SELECT 1") 223 cur.close() 224 return True 225 except Exception: 226 return False 227 228 def _create_tables(self) -> None: 229 cur = self._conn.cursor() 230 cur.execute(""" 231 CREATE TABLE IF NOT EXISTS zelos_events ( 232 id SERIAL PRIMARY KEY, 233 stream VARCHAR(255) NOT NULL, 234 position INTEGER NOT NULL, 235 event_data JSONB NOT NULL, 236 created_at TIMESTAMP DEFAULT NOW() 237 ); 238 CREATE INDEX IF NOT EXISTS idx_zelos_events_stream_pos 239 ON zelos_events(stream, position); 240 CREATE TABLE IF NOT EXISTS zelos_state ( 241 key VARCHAR(255) PRIMARY KEY, 242 value JSONB NOT NULL, 243 updated_at TIMESTAMP DEFAULT NOW() 244 ); 245 """) 246 cur.close() 247 248 def append(self, stream: str, events: list[dict]) -> int: 249 if not self._conn: 250 return -1 251 cur = self._conn.cursor() 252 # Get current max position 253 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 254 pos = cur.fetchone()[0] 255 for e in events: 256 pos += 1 257 cur.execute( 258 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 259 (stream, pos, json.dumps(e)), 260 ) 261 cur.close() 262 return pos + 1 263 264 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 265 if not self._conn: 266 return [] 267 cur = self._conn.cursor() 268 cur.execute( 269 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 270 (stream, from_position, count), 271 ) 272 rows = cur.fetchall() 273 cur.close() 274 return [r[0] for r in rows] 275 276 def set_state(self, key: str, value: dict) -> None: 277 if not self._conn: 278 return 279 cur = self._conn.cursor() 280 cur.execute( 281 "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) " 282 "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", 283 (key, json.dumps(value)), 284 ) 285 cur.close() 286 287 def get_state(self, key: str) -> dict | None: 288 if not self._conn: 289 return None 290 cur = self._conn.cursor() 291 cur.execute("SELECT value FROM zelos_state WHERE key = %s", (key,)) 292 row = cur.fetchone() 293 cur.close() 294 return row[0] if row else None 295 296 def delete_state(self, key: str) -> None: 297 if not self._conn: 298 return 299 cur = self._conn.cursor() 300 cur.execute("DELETE FROM zelos_state WHERE key = %s", (key,)) 301 cur.close() 302 303 304# ═══════════════════ MySQL ═══════════════════ 305 306 307class MySQLStorageBackend(StorageBackend): 308 """MySQL-backed storage. Same schema as PostgreSQL. 309 310 Config: 311 url: mysql://user:pass@localhost:3306/zelos 312 """ 313 314 def __init__(self, config: dict | None = None): 315 super().__init__(config) 316 self._url = (config or {}).get("url", "mysql://localhost:3306/zelos") 317 self._conn = None 318 319 def connect(self) -> bool: 320 try: 321 # Parse URL 322 from urllib.parse import urlparse 323 324 import mysql.connector 325 326 parsed = urlparse(self._url) 327 self._conn = mysql.connector.connect( 328 host=parsed.hostname or "localhost", 329 port=parsed.port or 3306, 330 user=parsed.username or "root", 331 password=parsed.password or "", 332 database=parsed.path.lstrip("/") or "zelos", 333 autocommit=True, 334 ) 335 self._create_tables() 336 self._connected = True 337 return True 338 except Exception: 339 self._connected = False 340 return False 341 342 def disconnect(self) -> None: 343 if self._conn: 344 self._conn.close() 345 self._connected = False 346 347 def health(self) -> bool: 348 if not self._conn: 349 return False 350 try: 351 cur = self._conn.cursor() 352 cur.execute("SELECT 1") 353 cur.close() 354 return True 355 except Exception: 356 return False 357 358 def _create_tables(self) -> None: 359 cur = self._conn.cursor() 360 cur.execute(""" 361 CREATE TABLE IF NOT EXISTS zelos_events ( 362 id INT AUTO_INCREMENT PRIMARY KEY, 363 stream VARCHAR(255) NOT NULL, 364 position INT NOT NULL, 365 event_data JSON NOT NULL, 366 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 367 INDEX idx_stream_pos (stream, position) 368 ) 369 """) 370 cur.execute(""" 371 CREATE TABLE IF NOT EXISTS zelos_state ( 372 `key` VARCHAR(255) PRIMARY KEY, 373 value JSON NOT NULL, 374 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 375 ) 376 """) 377 cur.close() 378 379 def append(self, stream: str, events: list[dict]) -> int: 380 if not self._conn: 381 return -1 382 cur = self._conn.cursor() 383 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 384 pos = cur.fetchone()[0] 385 for e in events: 386 pos += 1 387 cur.execute( 388 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 389 (stream, pos, json.dumps(e)), 390 ) 391 cur.close() 392 return pos + 1 393 394 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 395 if not self._conn: 396 return [] 397 cur = self._conn.cursor() 398 cur.execute( 399 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 400 (stream, from_position, count), 401 ) 402 rows = cur.fetchall() 403 cur.close() 404 # JSON type returns str in MySQL connector 405 return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows] 406 407 def set_state(self, key: str, value: dict) -> None: 408 if not self._conn: 409 return 410 cur = self._conn.cursor() 411 cur.execute( 412 "INSERT INTO zelos_state (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)", 413 (key, json.dumps(value)), 414 ) 415 cur.close() 416 417 def get_state(self, key: str) -> dict | None: 418 if not self._conn: 419 return None 420 cur = self._conn.cursor() 421 cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,)) 422 row = cur.fetchone() 423 cur.close() 424 if row: 425 return json.loads(row[0]) if isinstance(row[0], str) else row[0] 426 return None 427 428 def delete_state(self, key: str) -> None: 429 if not self._conn: 430 return 431 cur = self._conn.cursor() 432 cur.execute("DELETE FROM zelos_state WHERE `key` = %s", (key,)) 433 cur.close() 434 435 436# ═══════════════════ Factory ═══════════════════ 437 438BACKENDS = { 439 "memory": InMemoryStorageBackend, 440 "redis": RedisStorageBackend, 441 "postgresql": PostgreSQLStorageBackend, 442 "postgres": PostgreSQLStorageBackend, 443 "pgsql": PostgreSQLStorageBackend, 444 "mysql": MySQLStorageBackend, 445} 446 447 448def create_storage_backend(config: dict) -> StorageBackend: 449 """Factory: create a storage backend from configuration.""" 450 backend_type = config.get("type", "memory").lower() 451 cls = BACKENDS.get(backend_type) 452 if cls is None: 453 raise ValueError(f"Unsupported storage backend: '{backend_type}'. Supported: {', '.join(BACKENDS.keys())}") 454 return cls(config)
class
StorageBackend(abc.ABC):
17class StorageBackend(ABC): 18 """Abstract storage backend for events and state.""" 19 20 def __init__(self, config: dict | None = None): 21 self.config = config or {} 22 self._connected = False 23 24 @abstractmethod 25 def connect(self) -> bool: ... 26 27 @abstractmethod 28 def disconnect(self) -> None: ... 29 30 @abstractmethod 31 def health(self) -> bool: ... 32 33 @abstractmethod 34 def append(self, stream: str, events: list[dict]) -> int: ... 35 36 @abstractmethod 37 def read(self, stream: str, from_position: int, count: int) -> list[dict]: ... 38 39 @abstractmethod 40 def set_state(self, key: str, value: dict) -> None: ... 41 42 @abstractmethod 43 def get_state(self, key: str) -> dict | None: ... 44 45 @abstractmethod 46 def delete_state(self, key: str) -> None: ... 47 48 def create_snapshot(self, key: str, events_position: int, state: dict) -> None: 49 self.set_state( 50 f"snapshot:{key}", {"events_position": events_position, "state": state, "timestamp": time.time()} 51 ) 52 53 def get_snapshot(self, key: str) -> dict | None: 54 return self.get_state(f"snapshot:{key}") 55 56 @property 57 def is_connected(self) -> bool: 58 return self._connected
Abstract storage backend for events and state.
64class InMemoryStorageBackend(StorageBackend): 65 """Phase 1 compatible — stores everything in memory.""" 66 67 def __init__(self, config: dict | None = None): 68 super().__init__(config) 69 self._streams: dict[str, list[dict]] = {} 70 self._state: dict[str, dict] = {} 71 self._lock = threading.Lock() 72 73 def connect(self) -> bool: 74 self._connected = True 75 return True 76 77 def disconnect(self) -> None: 78 self._connected = False 79 80 def health(self) -> bool: 81 return self._connected 82 83 def append(self, stream: str, events: list[dict]) -> int: 84 with self._lock: 85 if stream not in self._streams: 86 self._streams[stream] = [] 87 self._streams[stream].extend(events) 88 return len(self._streams[stream]) 89 90 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 91 with self._lock: 92 events = self._streams.get(stream, []) 93 return events[from_position : from_position + count] 94 95 def set_state(self, key: str, value: dict) -> None: 96 with self._lock: 97 self._state[key] = value 98 99 def get_state(self, key: str) -> dict | None: 100 return self._state.get(key) 101 102 def delete_state(self, key: str) -> None: 103 self._state.pop(key, None)
Phase 1 compatible — stores everything in memory.
Inherited Members
109class RedisStorageBackend(StorageBackend): 110 """Redis-backed storage. Events stored as lists, state as hash. 111 112 Config: 113 url: redis://localhost:6379/0 114 prefix: "zelos" (key namespace) 115 """ 116 117 def __init__(self, config: dict | None = None): 118 super().__init__(config) 119 self._url = (config or {}).get("url", "redis://localhost:6379/0") 120 self._prefix = (config or {}).get("prefix", "zelos") 121 self._client = None 122 123 def connect(self) -> bool: 124 try: 125 import redis 126 127 self._client = redis.Redis.from_url(self._url, decode_responses=True) 128 self._client.ping() 129 self._connected = True 130 return True 131 except Exception: 132 self._connected = False 133 return False 134 135 def disconnect(self) -> None: 136 if self._client: 137 self._client.close() 138 self._connected = False 139 140 def health(self) -> bool: 141 if not self._client: 142 return False 143 try: 144 self._client.ping() 145 return True 146 except Exception: 147 return False 148 149 def _stream_key(self, stream: str) -> str: 150 return f"{self._prefix}:stream:{stream}" 151 152 def _state_key(self, key: str) -> str: 153 return f"{self._prefix}:state:{key}" 154 155 def append(self, stream: str, events: list[dict]) -> int: 156 if not self._client: 157 return -1 158 pipe = self._client.pipeline() 159 for e in events: 160 pipe.rpush(self._stream_key(stream), json.dumps(e)) 161 pipe.execute() 162 return self._client.llen(self._stream_key(stream)) 163 164 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 165 if not self._client: 166 return [] 167 raw = self._client.lrange(self._stream_key(stream), from_position, from_position + count - 1) 168 return [json.loads(r) for r in raw] 169 170 def set_state(self, key: str, value: dict) -> None: 171 if self._client: 172 self._client.set(self._state_key(key), json.dumps(value)) 173 174 def get_state(self, key: str) -> dict | None: 175 if not self._client: 176 return None 177 raw = self._client.get(self._state_key(key)) 178 return json.loads(raw) if raw else None 179 180 def delete_state(self, key: str) -> None: 181 if self._client: 182 self._client.delete(self._state_key(key))
Redis-backed storage. Events stored as lists, state as hash.
Config:
url: redis://localhost:6379/0 prefix: "zelos" (key namespace)
Inherited Members
188class PostgreSQLStorageBackend(StorageBackend): 189 """PostgreSQL-backed storage. Events table + state table. 190 191 Config: 192 url: postgresql://user:pass@localhost:5432/zelos 193 """ 194 195 def __init__(self, config: dict | None = None): 196 super().__init__(config) 197 self._url = (config or {}).get("url", "postgresql://localhost:5432/zelos") 198 self._conn = None 199 200 def connect(self) -> bool: 201 try: 202 import psycopg2 203 204 self._conn = psycopg2.connect(self._url) 205 self._conn.autocommit = True 206 self._create_tables() 207 self._connected = True 208 return True 209 except Exception: 210 self._connected = False 211 return False 212 213 def disconnect(self) -> None: 214 if self._conn: 215 self._conn.close() 216 self._connected = False 217 218 def health(self) -> bool: 219 if not self._conn: 220 return False 221 try: 222 cur = self._conn.cursor() 223 cur.execute("SELECT 1") 224 cur.close() 225 return True 226 except Exception: 227 return False 228 229 def _create_tables(self) -> None: 230 cur = self._conn.cursor() 231 cur.execute(""" 232 CREATE TABLE IF NOT EXISTS zelos_events ( 233 id SERIAL PRIMARY KEY, 234 stream VARCHAR(255) NOT NULL, 235 position INTEGER NOT NULL, 236 event_data JSONB NOT NULL, 237 created_at TIMESTAMP DEFAULT NOW() 238 ); 239 CREATE INDEX IF NOT EXISTS idx_zelos_events_stream_pos 240 ON zelos_events(stream, position); 241 CREATE TABLE IF NOT EXISTS zelos_state ( 242 key VARCHAR(255) PRIMARY KEY, 243 value JSONB NOT NULL, 244 updated_at TIMESTAMP DEFAULT NOW() 245 ); 246 """) 247 cur.close() 248 249 def append(self, stream: str, events: list[dict]) -> int: 250 if not self._conn: 251 return -1 252 cur = self._conn.cursor() 253 # Get current max position 254 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 255 pos = cur.fetchone()[0] 256 for e in events: 257 pos += 1 258 cur.execute( 259 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 260 (stream, pos, json.dumps(e)), 261 ) 262 cur.close() 263 return pos + 1 264 265 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 266 if not self._conn: 267 return [] 268 cur = self._conn.cursor() 269 cur.execute( 270 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 271 (stream, from_position, count), 272 ) 273 rows = cur.fetchall() 274 cur.close() 275 return [r[0] for r in rows] 276 277 def set_state(self, key: str, value: dict) -> None: 278 if not self._conn: 279 return 280 cur = self._conn.cursor() 281 cur.execute( 282 "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) " 283 "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", 284 (key, json.dumps(value)), 285 ) 286 cur.close() 287 288 def get_state(self, key: str) -> dict | None: 289 if not self._conn: 290 return None 291 cur = self._conn.cursor() 292 cur.execute("SELECT value FROM zelos_state WHERE key = %s", (key,)) 293 row = cur.fetchone() 294 cur.close() 295 return row[0] if row else None 296 297 def delete_state(self, key: str) -> None: 298 if not self._conn: 299 return 300 cur = self._conn.cursor() 301 cur.execute("DELETE FROM zelos_state WHERE key = %s", (key,)) 302 cur.close()
PostgreSQL-backed storage. Events table + state table.
Config:
url: postgresql://user:pass@localhost:5432/zelos
def
append(self, stream: str, events: list[dict]) -> int:
249 def append(self, stream: str, events: list[dict]) -> int: 250 if not self._conn: 251 return -1 252 cur = self._conn.cursor() 253 # Get current max position 254 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 255 pos = cur.fetchone()[0] 256 for e in events: 257 pos += 1 258 cur.execute( 259 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 260 (stream, pos, json.dumps(e)), 261 ) 262 cur.close() 263 return pos + 1
def
read(self, stream: str, from_position: int, count: int) -> list[dict]:
265 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 266 if not self._conn: 267 return [] 268 cur = self._conn.cursor() 269 cur.execute( 270 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 271 (stream, from_position, count), 272 ) 273 rows = cur.fetchall() 274 cur.close() 275 return [r[0] for r in rows]
def
set_state(self, key: str, value: dict) -> None:
277 def set_state(self, key: str, value: dict) -> None: 278 if not self._conn: 279 return 280 cur = self._conn.cursor() 281 cur.execute( 282 "INSERT INTO zelos_state (key, value, updated_at) VALUES (%s, %s, NOW()) " 283 "ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", 284 (key, json.dumps(value)), 285 ) 286 cur.close()
Inherited Members
308class MySQLStorageBackend(StorageBackend): 309 """MySQL-backed storage. Same schema as PostgreSQL. 310 311 Config: 312 url: mysql://user:pass@localhost:3306/zelos 313 """ 314 315 def __init__(self, config: dict | None = None): 316 super().__init__(config) 317 self._url = (config or {}).get("url", "mysql://localhost:3306/zelos") 318 self._conn = None 319 320 def connect(self) -> bool: 321 try: 322 # Parse URL 323 from urllib.parse import urlparse 324 325 import mysql.connector 326 327 parsed = urlparse(self._url) 328 self._conn = mysql.connector.connect( 329 host=parsed.hostname or "localhost", 330 port=parsed.port or 3306, 331 user=parsed.username or "root", 332 password=parsed.password or "", 333 database=parsed.path.lstrip("/") or "zelos", 334 autocommit=True, 335 ) 336 self._create_tables() 337 self._connected = True 338 return True 339 except Exception: 340 self._connected = False 341 return False 342 343 def disconnect(self) -> None: 344 if self._conn: 345 self._conn.close() 346 self._connected = False 347 348 def health(self) -> bool: 349 if not self._conn: 350 return False 351 try: 352 cur = self._conn.cursor() 353 cur.execute("SELECT 1") 354 cur.close() 355 return True 356 except Exception: 357 return False 358 359 def _create_tables(self) -> None: 360 cur = self._conn.cursor() 361 cur.execute(""" 362 CREATE TABLE IF NOT EXISTS zelos_events ( 363 id INT AUTO_INCREMENT PRIMARY KEY, 364 stream VARCHAR(255) NOT NULL, 365 position INT NOT NULL, 366 event_data JSON NOT NULL, 367 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 368 INDEX idx_stream_pos (stream, position) 369 ) 370 """) 371 cur.execute(""" 372 CREATE TABLE IF NOT EXISTS zelos_state ( 373 `key` VARCHAR(255) PRIMARY KEY, 374 value JSON NOT NULL, 375 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 376 ) 377 """) 378 cur.close() 379 380 def append(self, stream: str, events: list[dict]) -> int: 381 if not self._conn: 382 return -1 383 cur = self._conn.cursor() 384 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 385 pos = cur.fetchone()[0] 386 for e in events: 387 pos += 1 388 cur.execute( 389 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 390 (stream, pos, json.dumps(e)), 391 ) 392 cur.close() 393 return pos + 1 394 395 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 396 if not self._conn: 397 return [] 398 cur = self._conn.cursor() 399 cur.execute( 400 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 401 (stream, from_position, count), 402 ) 403 rows = cur.fetchall() 404 cur.close() 405 # JSON type returns str in MySQL connector 406 return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows] 407 408 def set_state(self, key: str, value: dict) -> None: 409 if not self._conn: 410 return 411 cur = self._conn.cursor() 412 cur.execute( 413 "INSERT INTO zelos_state (`key`, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)", 414 (key, json.dumps(value)), 415 ) 416 cur.close() 417 418 def get_state(self, key: str) -> dict | None: 419 if not self._conn: 420 return None 421 cur = self._conn.cursor() 422 cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,)) 423 row = cur.fetchone() 424 cur.close() 425 if row: 426 return json.loads(row[0]) if isinstance(row[0], str) else row[0] 427 return None 428 429 def delete_state(self, key: str) -> None: 430 if not self._conn: 431 return 432 cur = self._conn.cursor() 433 cur.execute("DELETE FROM zelos_state WHERE `key` = %s", (key,)) 434 cur.close()
MySQL-backed storage. Same schema as PostgreSQL.
Config:
url: mysql://user:pass@localhost:3306/zelos
def
connect(self) -> bool:
320 def connect(self) -> bool: 321 try: 322 # Parse URL 323 from urllib.parse import urlparse 324 325 import mysql.connector 326 327 parsed = urlparse(self._url) 328 self._conn = mysql.connector.connect( 329 host=parsed.hostname or "localhost", 330 port=parsed.port or 3306, 331 user=parsed.username or "root", 332 password=parsed.password or "", 333 database=parsed.path.lstrip("/") or "zelos", 334 autocommit=True, 335 ) 336 self._create_tables() 337 self._connected = True 338 return True 339 except Exception: 340 self._connected = False 341 return False
def
append(self, stream: str, events: list[dict]) -> int:
380 def append(self, stream: str, events: list[dict]) -> int: 381 if not self._conn: 382 return -1 383 cur = self._conn.cursor() 384 cur.execute("SELECT COALESCE(MAX(position), -1) FROM zelos_events WHERE stream = %s", (stream,)) 385 pos = cur.fetchone()[0] 386 for e in events: 387 pos += 1 388 cur.execute( 389 "INSERT INTO zelos_events (stream, position, event_data) VALUES (%s, %s, %s)", 390 (stream, pos, json.dumps(e)), 391 ) 392 cur.close() 393 return pos + 1
def
read(self, stream: str, from_position: int, count: int) -> list[dict]:
395 def read(self, stream: str, from_position: int, count: int) -> list[dict]: 396 if not self._conn: 397 return [] 398 cur = self._conn.cursor() 399 cur.execute( 400 "SELECT event_data FROM zelos_events WHERE stream = %s AND position >= %s ORDER BY position LIMIT %s", 401 (stream, from_position, count), 402 ) 403 rows = cur.fetchall() 404 cur.close() 405 # JSON type returns str in MySQL connector 406 return [json.loads(r[0]) if isinstance(r[0], str) else r[0] for r in rows]
def
get_state(self, key: str) -> dict | None:
418 def get_state(self, key: str) -> dict | None: 419 if not self._conn: 420 return None 421 cur = self._conn.cursor() 422 cur.execute("SELECT value FROM zelos_state WHERE `key` = %s", (key,)) 423 row = cur.fetchone() 424 cur.close() 425 if row: 426 return json.loads(row[0]) if isinstance(row[0], str) else row[0] 427 return None
Inherited Members
BACKENDS =
{'memory': <class 'InMemoryStorageBackend'>, 'redis': <class 'RedisStorageBackend'>, 'postgresql': <class 'PostgreSQLStorageBackend'>, 'postgres': <class 'PostgreSQLStorageBackend'>, 'pgsql': <class 'PostgreSQLStorageBackend'>, 'mysql': <class 'MySQLStorageBackend'>}
449def create_storage_backend(config: dict) -> StorageBackend: 450 """Factory: create a storage backend from configuration.""" 451 backend_type = config.get("type", "memory").lower() 452 cls = BACKENDS.get(backend_type) 453 if cls is None: 454 raise ValueError(f"Unsupported storage backend: '{backend_type}'. Supported: {', '.join(BACKENDS.keys())}") 455 return cls(config)
Factory: create a storage backend from configuration.