by-framework 0.2.2.dev5__py3-none-any.whl → 0.2.2.dev7__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.
Files changed (37) hide show
  1. by_framework/__init__.py +8 -0
  2. by_framework/admin/__init__.py +5 -0
  3. by_framework/admin/cli.py +344 -0
  4. by_framework/admin/worker_manager.py +146 -0
  5. by_framework/common/__init__.py +8 -1
  6. by_framework/common/config.py +15 -1
  7. by_framework/common/constants.py +367 -98
  8. by_framework/common/emitter.py +10 -0
  9. by_framework/common/logger.py +23 -10
  10. by_framework/common/redis_client.py +62 -9
  11. by_framework/core/discovery.py +1 -1
  12. by_framework/core/extensions/__init__.py +2 -0
  13. by_framework/core/extensions/agent_config_audit.py +144 -0
  14. by_framework/core/protocol/__init__.py +6 -0
  15. by_framework/core/protocol/action_type.py +11 -1
  16. by_framework/core/protocol/commands.py +80 -1
  17. by_framework/core/protocol/content_type.py +1 -0
  18. by_framework/core/registry.py +162 -7
  19. by_framework/metrics/__init__.py +105 -0
  20. by_framework/metrics/catalog.py +409 -0
  21. by_framework/metrics/collector.py +70 -3
  22. by_framework/metrics/read_client.py +243 -0
  23. by_framework/metrics/snapshot.py +563 -74
  24. by_framework/trace/span_recorder.py +40 -25
  25. by_framework/trace/trace_writer.py +34 -20
  26. by_framework/util/discovery_http_client.py +4 -3
  27. by_framework/worker/_control_handling.py +47 -12
  28. by_framework/worker/app.py +56 -14
  29. by_framework/worker/context.py +6 -0
  30. by_framework/worker/heartbeat.py +106 -5
  31. by_framework/worker/runner.py +203 -10
  32. by_framework/worker/worker.py +19 -2
  33. {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/METADATA +6 -1
  34. {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/RECORD +37 -31
  35. by_framework-0.2.2.dev7.dist-info/entry_points.txt +2 -0
  36. {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/WHEEL +0 -0
  37. {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/licenses/LICENSE +0 -0
by_framework/__init__.py CHANGED
@@ -5,6 +5,7 @@ Allows developers to quickly start a Redis-based agent node by inheriting
5
5
  from `GatewayWorker` and running `run_worker`.
6
6
  """
7
7
 
8
+ from .admin import WorkerManager
8
9
  from .client.byai_client import ByaiGatewayClient
9
10
  from .client.client import (
10
11
  CancelTaskResponse,
@@ -59,9 +60,12 @@ from .core.protocol.commands import (
59
60
  AskAgentCommand,
60
61
  BaseCommand,
61
62
  CancelTaskCommand,
63
+ EvictWorkerCommand,
62
64
  GatewayCommand,
63
65
  ReloadPluginsCommand,
64
66
  ResumeCommand,
67
+ ResumeWorkerCommand,
68
+ SuspendWorkerCommand,
65
69
  command_from_dict,
66
70
  get_registered_command,
67
71
  register_command,
@@ -152,6 +156,7 @@ __all__ = [
152
156
  "close_redis",
153
157
  "Redis",
154
158
  "WorkerRegistry",
159
+ "WorkerManager",
155
160
  "check_agent_type_online",
156
161
  "check_worker_online",
157
162
  "RedisKeys",
@@ -168,6 +173,9 @@ __all__ = [
168
173
  "ResumeCommand",
169
174
  "CancelTaskCommand",
170
175
  "ReloadPluginsCommand",
176
+ "SuspendWorkerCommand",
177
+ "ResumeWorkerCommand",
178
+ "EvictWorkerCommand",
171
179
  "GatewayCommand",
172
180
  "command_from_dict",
173
181
  "register_command",
@@ -0,0 +1,5 @@
1
+ """Admin-side APIs for worker lifecycle and admission control."""
2
+
3
+ from .worker_manager import WorkerManager
4
+
5
+ __all__ = ["WorkerManager"]
@@ -0,0 +1,344 @@
1
+ """by-admin — by-framework cluster admin CLI."""
2
+
3
+ # pylint: disable=global-statement
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from by_framework.admin.worker_manager import WorkerManager
16
+ from by_framework.common.redis_client import init_redis_from_url
17
+ from by_framework.core.registry import WorkerRegistry
18
+ from by_framework.metrics.snapshot import (
19
+ build_observability_snapshot,
20
+ load_history_from_redis,
21
+ )
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # App hierarchy
25
+ # --------------------------------------------------------------------------- #
26
+
27
+ app = typer.Typer(
28
+ name="by-admin",
29
+ no_args_is_help=True,
30
+ help="by-framework cluster admin CLI",
31
+ )
32
+ worker_app = typer.Typer(no_args_is_help=True)
33
+ type_app = typer.Typer(no_args_is_help=True)
34
+ metrics_app = typer.Typer(no_args_is_help=True)
35
+
36
+ app.add_typer(worker_app, name="worker", help="Worker lifecycle management")
37
+ app.add_typer(type_app, name="type", help="Agent-type admission control")
38
+ app.add_typer(metrics_app, name="metrics", help="Metrics and observability")
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Global state + helpers
42
+ # --------------------------------------------------------------------------- #
43
+
44
+ console = Console()
45
+ err_console = Console(stderr=True)
46
+ _redis_url = "redis://localhost:6379/0"
47
+
48
+
49
+ @app.callback()
50
+ def _global(
51
+ redis_url: Optional[str] = typer.Option(
52
+ None,
53
+ "--redis-url",
54
+ envvar=["BYAI_REDIS_URL", "REDIS_URL"],
55
+ help="Redis connection URL [env: BYAI_REDIS_URL]",
56
+ ),
57
+ ):
58
+ """by-framework cluster admin CLI."""
59
+ global _redis_url
60
+ if redis_url:
61
+ _redis_url = redis_url
62
+
63
+
64
+ def _run(coro): # type: ignore[no-untyped-def]
65
+ return asyncio.run(coro)
66
+
67
+
68
+ def _die(msg: str, code: int = 1) -> None:
69
+ err_console.print(f"[red]Error:[/red] {msg}")
70
+ raise typer.Exit(code)
71
+
72
+
73
+ def _get_redis():
74
+ return init_redis_from_url(_redis_url)
75
+
76
+
77
+ # --------------------------------------------------------------------------- #
78
+ # worker subcommands
79
+ # --------------------------------------------------------------------------- #
80
+
81
+ _LIFECYCLE_COLORS = {"active": "green", "suspended": "yellow", "evicted": "red"}
82
+
83
+
84
+ @worker_app.command("list")
85
+ def worker_list(
86
+ agent_type: Optional[str] = typer.Option(
87
+ None, "--type", "-t", help="Filter by agent type"
88
+ ),
89
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
90
+ ):
91
+ """List all online workers."""
92
+
93
+ async def _inner():
94
+ registry = WorkerRegistry(_get_redis())
95
+ workers = await registry.get_all_workers()
96
+
97
+ rows = [{"worker_id": wid, **data} for wid, data in workers.items()]
98
+ if agent_type:
99
+ rows = [r for r in rows if agent_type in r.get("agent_types", [])]
100
+
101
+ if as_json:
102
+ typer.echo(json.dumps(rows, default=str))
103
+ return
104
+
105
+ if not rows:
106
+ console.print("[dim]No workers found.[/dim]")
107
+ return
108
+
109
+ table = Table(show_header=True, header_style="bold")
110
+ table.add_column("Worker ID")
111
+ table.add_column("Lifecycle")
112
+ table.add_column("Agent Types")
113
+ table.add_column("IP")
114
+ table.add_column("Last Seen (ms)")
115
+
116
+ for w in rows:
117
+ lc = w.get("lifecycle", "active")
118
+ color = _LIFECYCLE_COLORS.get(lc, "white")
119
+ table.add_row(
120
+ w["worker_id"],
121
+ f"[{color}]{lc}[/{color}]",
122
+ ", ".join(w.get("agent_types", [])),
123
+ w.get("ip_address", ""),
124
+ str(w.get("last_seen", "")),
125
+ )
126
+
127
+ console.print(table)
128
+ console.print(f"\n[dim]{len(rows)} worker(s)[/dim]")
129
+
130
+ _run(_inner())
131
+
132
+
133
+ @worker_app.command("info")
134
+ def worker_info(
135
+ worker_id: str = typer.Argument(..., help="Worker ID"),
136
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
137
+ ):
138
+ """Show detailed info for a single worker."""
139
+
140
+ async def _inner():
141
+ registry = WorkerRegistry(_get_redis())
142
+ workers = await registry.get_all_workers()
143
+ worker = workers.get(worker_id)
144
+
145
+ if worker is None:
146
+ _die(f"worker '{worker_id}' not found")
147
+ return
148
+
149
+ result = {"worker_id": worker_id, **worker}
150
+ if as_json:
151
+ typer.echo(json.dumps(result, default=str))
152
+ return
153
+
154
+ lc = result.get("lifecycle", "active")
155
+ color = _LIFECYCLE_COLORS.get(lc, "white")
156
+ lc_reason = result.get("lifecycle_reason", "")
157
+ agent_types_str = ", ".join(result.get("agent_types", []))
158
+ ip_address = result.get("ip_address", "")
159
+ last_seen = result.get("last_seen", "")
160
+ console.print(f"[bold]Worker:[/bold] {worker_id}")
161
+ console.print(f" lifecycle: [{color}]{lc}[/{color}]")
162
+ console.print(f" lifecycle_reason: {lc_reason}")
163
+ console.print(f" agent_types: {agent_types_str}")
164
+ console.print(f" ip_address: {ip_address}")
165
+ console.print(f" last_seen (ms): {last_seen}")
166
+
167
+ _run(_inner())
168
+
169
+
170
+ @worker_app.command("suspend")
171
+ def worker_suspend(
172
+ worker_id: str = typer.Argument(..., help="Worker ID"),
173
+ reason: str = typer.Option("", "--reason", "-r", help="Reason for suspension"),
174
+ ):
175
+ """Suspend a worker (stops consuming, stays online)."""
176
+
177
+ async def _inner():
178
+ mgr = WorkerManager(_get_redis())
179
+ await mgr.suspend_worker(worker_id, reason=reason)
180
+ console.print(f"[green]✓[/green] Suspended {worker_id}")
181
+
182
+ _run(_inner())
183
+
184
+
185
+ @worker_app.command("resume")
186
+ def worker_resume(
187
+ worker_id: str = typer.Argument(..., help="Worker ID"),
188
+ ):
189
+ """Resume a suspended worker."""
190
+
191
+ async def _inner():
192
+ mgr = WorkerManager(_get_redis())
193
+ await mgr.resume_worker(worker_id)
194
+ console.print(f"[green]✓[/green] Resumed {worker_id}")
195
+
196
+ _run(_inner())
197
+
198
+
199
+ @worker_app.command("evict")
200
+ def worker_evict(
201
+ worker_id: str = typer.Argument(..., help="Worker ID"),
202
+ force: bool = typer.Option(
203
+ False, "--force", help="Cancel in-flight tasks immediately"
204
+ ),
205
+ ):
206
+ """Evict a worker (clears lease, removes from routing)."""
207
+
208
+ async def _inner():
209
+ mgr = WorkerManager(_get_redis())
210
+ await mgr.evict_worker(worker_id, force=force)
211
+ console.print(f"[green]✓[/green] Evicted {worker_id}")
212
+
213
+ _run(_inner())
214
+
215
+
216
+ # --------------------------------------------------------------------------- #
217
+ # type subcommands
218
+ # --------------------------------------------------------------------------- #
219
+
220
+
221
+ @type_app.command("denylist")
222
+ def type_denylist(
223
+ agent_type: str = typer.Argument(..., help="Agent type name"),
224
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
225
+ ):
226
+ """Show all workers denied for an agent type."""
227
+
228
+ async def _inner():
229
+ mgr = WorkerManager(_get_redis())
230
+ denied = await mgr.get_type_denylist(agent_type)
231
+ if as_json:
232
+ typer.echo(json.dumps(denied))
233
+ return
234
+ if not denied:
235
+ console.print(f"[dim]No denied workers for type '{agent_type}'[/dim]")
236
+ return
237
+ for wid in denied:
238
+ console.print(f" {wid}")
239
+
240
+ _run(_inner())
241
+
242
+
243
+ @type_app.command("deny")
244
+ def type_deny(
245
+ agent_type: str = typer.Argument(..., help="Agent type name"),
246
+ worker_id: str = typer.Argument(..., help="Worker ID to deny"),
247
+ ):
248
+ """Deny a worker from consuming an agent type."""
249
+
250
+ async def _inner():
251
+ mgr = WorkerManager(_get_redis())
252
+ await mgr.deny_worker_for_type(agent_type, worker_id)
253
+ console.print(
254
+ f"[green]✓[/green] Denied {worker_id} from consuming '{agent_type}'"
255
+ )
256
+
257
+ _run(_inner())
258
+
259
+
260
+ @type_app.command("allow")
261
+ def type_allow(
262
+ agent_type: str = typer.Argument(..., help="Agent type name"),
263
+ worker_id: str = typer.Argument(..., help="Worker ID to allow"),
264
+ ):
265
+ """Remove denial for a worker on an agent type."""
266
+
267
+ async def _inner():
268
+ mgr = WorkerManager(_get_redis())
269
+ await mgr.allow_worker_for_type(agent_type, worker_id)
270
+ console.print(f"[green]✓[/green] Allowed {worker_id} on '{agent_type}'")
271
+
272
+ _run(_inner())
273
+
274
+
275
+ # --------------------------------------------------------------------------- #
276
+ # metrics subcommands
277
+ # --------------------------------------------------------------------------- #
278
+
279
+
280
+ @metrics_app.command("snapshot")
281
+ def metrics_snapshot(
282
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
283
+ ):
284
+ """Print current cluster observability snapshot."""
285
+
286
+ async def _inner():
287
+ snapshot = await build_observability_snapshot(_get_redis())
288
+ if as_json:
289
+ typer.echo(json.dumps(snapshot, default=str))
290
+ return
291
+ totals = snapshot.get("totals", {})
292
+ workers_online = totals.get("workers_online", 0)
293
+ agent_types_n = totals.get("agent_types", 0)
294
+ active_execs = totals.get("active_executions", 0)
295
+ queue_depth = snapshot.get("queue_depth_total", 0)
296
+ console.print("[bold]Cluster Snapshot[/bold]")
297
+ console.print(f" Workers online: {workers_online}")
298
+ console.print(f" Agent types: {agent_types_n}")
299
+ console.print(f" Active executions: {active_execs}")
300
+ console.print(f" Queue depth total: {queue_depth}")
301
+
302
+ _run(_inner())
303
+
304
+
305
+ @metrics_app.command("history")
306
+ def metrics_history(
307
+ limit: int = typer.Option(
308
+ 20, "--limit", "-n", help="Number of history points to show"
309
+ ),
310
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
311
+ ):
312
+ """Print recent metrics history points."""
313
+
314
+ async def _inner():
315
+ points = await load_history_from_redis(_get_redis(), limit=limit)
316
+ if as_json:
317
+ typer.echo(json.dumps(points, default=str))
318
+ return
319
+ if not points:
320
+ console.print("[dim]No history points found.[/dim]")
321
+ return
322
+ table = Table(show_header=True, header_style="bold")
323
+ table.add_column("Timestamp (ms)")
324
+ table.add_column("Workers")
325
+ table.add_column("Active")
326
+ table.add_column("Queue Depth")
327
+ for p in points:
328
+ table.add_row(
329
+ str(p.get("generated_at", "")),
330
+ str(p.get("workers_online", "")),
331
+ str(p.get("active_executions", "")),
332
+ str(p.get("queue_depth_total", "")),
333
+ )
334
+ console.print(table)
335
+
336
+ _run(_inner())
337
+
338
+
339
+ # --------------------------------------------------------------------------- #
340
+ # Entry point
341
+ # --------------------------------------------------------------------------- #
342
+
343
+ if __name__ == "__main__":
344
+ app()
@@ -0,0 +1,146 @@
1
+ """
2
+ WorkerManager — admin-side API for controlling worker lifecycle and agent-type access.
3
+
4
+ Responsibilities:
5
+ - Suspend / resume / evict individual workers (lifecycle control).
6
+ - Deny / allow workers from consuming a specific agent_type (admission control).
7
+
8
+ Lifecycle commands are delivered via two channels:
9
+ 1. Push: XADD to byai_gateway:ctrl:worker:{worker_id} (immediate delivery).
10
+ 2. Pull: HSET to byai_gateway:registry:worker:admin:{worker_id} (durable fallback,
11
+ read by the worker's heartbeat loop every heartbeat interval).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from typing import Any, Optional
18
+
19
+ from by_framework.common.constants import RedisKeys
20
+ from by_framework.common.redis_client import Redis, get_redis
21
+ from by_framework.core.protocol.commands import (
22
+ EvictWorkerCommand,
23
+ ResumeWorkerCommand,
24
+ SuspendWorkerCommand,
25
+ )
26
+ from by_framework.core.protocol.message_header import MessageHeader
27
+ from by_framework.core.registry import WorkerRegistry
28
+
29
+
30
+ def _admin_header() -> MessageHeader:
31
+ return MessageHeader(
32
+ session_id="admin",
33
+ trace_id=uuid.uuid4().hex,
34
+ message_id=uuid.uuid4().hex,
35
+ )
36
+
37
+
38
+ class WorkerManager:
39
+ """Admin API for worker lifecycle and agent-type admission control."""
40
+
41
+ def __init__(
42
+ self,
43
+ redis_client: Optional[Redis] = None,
44
+ registry: Optional[WorkerRegistry] = None,
45
+ ):
46
+ self.redis = redis_client or get_redis()
47
+ self.registry = registry or WorkerRegistry(self.redis)
48
+
49
+ # ------------------------------------------------------------------
50
+ # Lifecycle control
51
+ # ------------------------------------------------------------------
52
+
53
+ async def suspend_worker(self, worker_id: str, reason: str = "") -> None:
54
+ """Pause a running worker from consuming new tasks.
55
+
56
+ The worker finishes in-flight tasks but stops accepting new ones.
57
+ Immediately removes the worker from all agent_type:members sets so that
58
+ routing skips it at once; the worker re-adds itself on resume.
59
+ """
60
+ await self.registry.set_worker_admin_state(worker_id, "suspended", reason)
61
+ await self.registry.remove_worker_from_type_members(worker_id)
62
+ command = SuspendWorkerCommand(header=_admin_header(), reason=reason)
63
+ await self.redis.xadd(
64
+ RedisKeys.worker_ctrl_stream(worker_id),
65
+ command.to_redis_payload(),
66
+ )
67
+
68
+ async def resume_worker(self, worker_id: str) -> None:
69
+ """Resume a previously suspended worker.
70
+
71
+ Re-adds the worker to all agent_type:members sets immediately (respecting
72
+ the denylist), so routing can reach it again without waiting for the next
73
+ heartbeat cycle.
74
+ """
75
+ await self.registry.set_worker_admin_state(worker_id, "active", "")
76
+ await self.registry.restore_worker_to_type_members(worker_id)
77
+ command = ResumeWorkerCommand(header=_admin_header())
78
+ await self.redis.xadd(
79
+ RedisKeys.worker_ctrl_stream(worker_id),
80
+ command.to_redis_payload(),
81
+ )
82
+
83
+ async def evict_worker(
84
+ self,
85
+ worker_id: str,
86
+ *,
87
+ force: bool = False,
88
+ reason: str = "",
89
+ ) -> None:
90
+ """Shut down a worker.
91
+
92
+ Args:
93
+ worker_id: Target worker ID.
94
+ force: When True, cancels in-flight tasks immediately instead of
95
+ waiting for them to finish.
96
+ reason: Human-readable eviction reason.
97
+
98
+ Immediately removes the worker from all agent_type:members sets so
99
+ routing stops sending new messages before the heartbeat TTL expires.
100
+ """
101
+ await self.registry.set_worker_admin_state(worker_id, "evicted", reason)
102
+ await self.registry.remove_worker_from_type_members(worker_id)
103
+ command = EvictWorkerCommand(header=_admin_header(), reason=reason, force=force)
104
+ await self.redis.xadd(
105
+ RedisKeys.worker_ctrl_stream(worker_id),
106
+ command.to_redis_payload(),
107
+ )
108
+
109
+ # ------------------------------------------------------------------
110
+ # Agent-type admission control
111
+ # ------------------------------------------------------------------
112
+
113
+ async def deny_worker_for_type(self, agent_type: str, worker_id: str) -> None:
114
+ """Prevent worker_id from consuming the agent_type stream.
115
+
116
+ Takes effect on the worker's next heartbeat cycle (or immediately if
117
+ the worker checks the denylist before each XREADGROUP call).
118
+ """
119
+ await self.registry.deny_worker_for_type(agent_type, worker_id)
120
+
121
+ async def allow_worker_for_type(self, agent_type: str, worker_id: str) -> None:
122
+ """Remove worker_id from the denylist for agent_type."""
123
+ await self.registry.allow_worker_for_type(agent_type, worker_id)
124
+
125
+ async def get_type_denylist(self, agent_type: str) -> list[str]:
126
+ """Return all worker_ids currently denied for agent_type."""
127
+ return await self.registry.get_agent_type_denylist(agent_type)
128
+
129
+ # ------------------------------------------------------------------
130
+ # Status queries
131
+ # ------------------------------------------------------------------
132
+
133
+ async def get_worker_admin_state(self, worker_id: str) -> dict[str, Any]:
134
+ """Return the admin-controlled state for a worker.
135
+
136
+ Returns an empty dict when no admin state has been set (default active).
137
+ """
138
+ return await self.registry.get_worker_admin_state(worker_id)
139
+
140
+ async def clear_worker_admin_state(self, worker_id: str) -> None:
141
+ """Remove all admin state for a worker, restoring default-active behaviour."""
142
+ await self.registry.clear_worker_admin_state(worker_id)
143
+
144
+ async def allow_worker_rejoin(self, worker_id: str) -> None:
145
+ """Allow a previously evicted offline worker ID to join on next startup."""
146
+ await self.clear_worker_admin_state(worker_id)
@@ -28,7 +28,13 @@ from .config import (
28
28
  from .constants import RedisKeys
29
29
  from .emitter import (DataLayoutBuilder, DefaultSseLayoutBuilder, GatewayDataEmitter)
30
30
  from .logger import get_logger, logger, setup_logging
31
- from .redis_client import Redis, close_redis, get_redis, init_redis
31
+ from .redis_client import (
32
+ Redis,
33
+ close_redis,
34
+ get_redis,
35
+ init_redis,
36
+ init_redis_from_url,
37
+ )
32
38
 
33
39
  __all__ = [
34
40
  "RedisKeys",
@@ -37,6 +43,7 @@ __all__ = [
37
43
  "setup_logging",
38
44
  "get_redis",
39
45
  "init_redis",
46
+ "init_redis_from_url",
40
47
  "close_redis",
41
48
  "Redis",
42
49
  "DataLayoutBuilder",
@@ -6,7 +6,7 @@ Provides typed configuration loaded from environment variables.
6
6
 
7
7
  import os
8
8
  from dataclasses import dataclass, field
9
- from typing import Optional
9
+ from typing import Literal, Optional
10
10
 
11
11
  from by_framework.common.constants import RedisKeys
12
12
 
@@ -22,6 +22,10 @@ class RedisConfig:
22
22
  username: Optional[str] = None
23
23
  decode_responses: bool = True
24
24
  max_connections: Optional[int] = None
25
+ mode: Literal["standalone", "cluster"] = "standalone"
26
+ cluster_nodes: Optional[list[tuple[str, int]]] = None
27
+ socket_connect_timeout: int = 5
28
+ socket_timeout: int = 10
25
29
 
26
30
  @classmethod
27
31
  def from_env(cls) -> "RedisConfig":
@@ -29,6 +33,14 @@ class RedisConfig:
29
33
  password = os.environ.get("REDIS_PASSWORD", "")
30
34
  username = os.environ.get("REDIS_USERNAME") or None
31
35
  max_connections = os.environ.get("REDIS_MAX_CONNECTIONS")
36
+ mode = os.environ.get("REDIS_MODE", "standalone")
37
+ cluster_nodes_str = os.environ.get("REDIS_CLUSTER_NODES")
38
+ cluster_nodes = None
39
+ if cluster_nodes_str:
40
+ cluster_nodes = []
41
+ for node in cluster_nodes_str.split(","):
42
+ node_host, node_port = node.rsplit(":", 1)
43
+ cluster_nodes.append((node_host, int(node_port)))
32
44
  return cls(
33
45
  host=os.environ.get("REDIS_HOST", "localhost"),
34
46
  port=int(os.environ.get("REDIS_PORT", "6379")),
@@ -36,6 +48,8 @@ class RedisConfig:
36
48
  password=password,
37
49
  username=username,
38
50
  max_connections=int(max_connections) if max_connections else None,
51
+ mode=mode,
52
+ cluster_nodes=cluster_nodes,
39
53
  )
40
54
 
41
55