Skip to content

Commit 2c23375

Browse files
committed
feat: add redis cluster session and memory services
1 parent 5ca3bf2 commit 2c23375

11 files changed

Lines changed: 454 additions & 2 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Construction tests for RedisClusterMemoryService."""
7+
8+
from unittest.mock import MagicMock
9+
from unittest.mock import patch
10+
11+
from trpc_agent_sdk.memory import RedisClusterMemoryService
12+
13+
14+
class TestRedisClusterMemoryService:
15+
@patch("trpc_agent_sdk.memory._redis_cluster_memory_service.RedisClusterStorage")
16+
def test_uses_cluster_storage(self, storage_cls):
17+
storage = MagicMock()
18+
storage_cls.return_value = storage
19+
20+
service = RedisClusterMemoryService(
21+
db_url="redis://seed:6379/0", is_async=True, max_connections=20)
22+
23+
assert service._redis_storage is storage
24+
storage_cls.assert_called_once_with(
25+
is_async=True, redis_url="redis://seed:6379/0", max_connections=20)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Construction tests for RedisClusterSessionService."""
7+
8+
from unittest.mock import MagicMock
9+
from unittest.mock import patch
10+
11+
from trpc_agent_sdk.sessions import RedisClusterSessionService
12+
13+
14+
class TestRedisClusterSessionService:
15+
@patch("trpc_agent_sdk.sessions._redis_cluster_session_service.RedisClusterStorage")
16+
def test_uses_cluster_storage(self, storage_cls):
17+
storage = MagicMock()
18+
storage_cls.return_value = storage
19+
20+
service = RedisClusterSessionService(
21+
db_url="redis://seed:6379/0", is_async=True, max_connections=20)
22+
23+
assert service._redis_storage is storage
24+
storage_cls.assert_called_once_with(
25+
is_async=True, redis_url="redis://seed:6379/0", max_connections=20)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Tests for the Redis Cluster storage adapter without a live cluster."""
7+
8+
from unittest.mock import MagicMock
9+
from unittest.mock import patch
10+
11+
from trpc_agent_sdk.storage import RedisClusterStorage
12+
from trpc_agent_sdk.storage import RedisCommand
13+
from trpc_agent_sdk.storage import RedisCondition
14+
15+
16+
class _FakeClusterClient:
17+
"""Minimal async cluster client used to exercise the storage adapter."""
18+
19+
def __init__(self) -> None:
20+
self.scan_calls: list[tuple[str, int]] = []
21+
self.closed = False
22+
23+
def scan_iter(self, match: str, count: int):
24+
self.scan_calls.append((match, count))
25+
26+
async def _iterate():
27+
# Duplicate keys are possible while a real cluster is resharding.
28+
yield b"memory:app/user:one"
29+
yield b"memory:app/user:two"
30+
yield b"memory:app/user:one"
31+
32+
return _iterate()
33+
34+
async def type(self, key: str):
35+
return "list"
36+
37+
async def lrange(self, key: str, start: int, end: int):
38+
return [f'{key}-event']
39+
40+
async def hgetall(self, key: str):
41+
return {b"field": b"value"}
42+
43+
async def close(self):
44+
self.closed = True
45+
46+
47+
class TestRedisClusterStorage:
48+
async def test_query_scans_all_cluster_keys_and_deduplicates(self):
49+
storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True)
50+
client = _FakeClusterClient()
51+
storage._redis_client = client
52+
53+
async with storage.create_db_session() as conn:
54+
results = await storage.query(conn, "memory:app/user:*", RedisCondition(limit=-1))
55+
56+
assert [key for key, _ in results] == ["memory:app/user:one", "memory:app/user:two"]
57+
assert client.scan_calls == [("memory:app/user:*", storage._SCAN_COUNT)]
58+
59+
async def test_keys_command_uses_scan_not_node_local_keys(self):
60+
storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True)
61+
client = _FakeClusterClient()
62+
63+
keys = await storage.execute_command(client, RedisCommand(method="keys", args=("session:app:*", )))
64+
65+
assert keys == ["memory:app/user:one", "memory:app/user:two"]
66+
assert client.scan_calls == [("session:app:*", storage._SCAN_COUNT)]
67+
68+
async def test_hgetall_normalizes_raw_redis_bytes(self):
69+
storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True, decode_responses=False)
70+
client = _FakeClusterClient()
71+
72+
result = await storage.execute_command(client, RedisCommand(method="hgetall", args=("state", )))
73+
74+
assert result == {"field": "value"}
75+
76+
async def test_async_client_is_created_from_seed_url(self):
77+
storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True, max_connections=20)
78+
client = MagicMock()
79+
with patch("trpc_agent_sdk.storage._redis_cluster.AsyncRedisCluster") as client_cls:
80+
client_cls.from_url.return_value = client
81+
await storage.create_redis_engine()
82+
83+
client_cls.from_url.assert_called_once_with(
84+
"redis://seed:6379/0", decode_responses=True, max_connections=20)
85+
assert storage._redis_client is client
86+
87+
async def test_close_releases_cluster_client(self):
88+
storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True)
89+
client = _FakeClusterClient()
90+
storage._redis_client = client
91+
92+
await storage.close()
93+
94+
assert client.closed is True
95+
assert storage._redis_client is None

trpc_agent_sdk/memory/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ._in_memory_memory_service import EventTtl
1717
from ._in_memory_memory_service import InMemoryMemoryService
1818
from ._redis_memory_service import RedisMemoryService
19+
from ._redis_cluster_memory_service import RedisClusterMemoryService
1920
from ._sql_memory_service import MemStorageData
2021
from ._sql_memory_service import MemStorageEvent
2122
from ._sql_memory_service import SqlMemoryService
@@ -28,6 +29,7 @@
2829
"EventTtl",
2930
"InMemoryMemoryService",
3031
"RedisMemoryService",
32+
"RedisClusterMemoryService",
3133
"MemStorageData",
3234
"MemStorageEvent",
3335
"SqlMemoryService",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Redis Cluster-backed long-term memory service."""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
from trpc_agent_sdk.storage import RedisClusterStorage
13+
14+
from ._redis_memory_service import RedisMemoryService
15+
16+
17+
class RedisClusterMemoryService(RedisMemoryService):
18+
"""Store and search cross-session memories in a Redis Cluster.
19+
20+
Events retain the List-based format of :class:`RedisMemoryService`, but
21+
cluster-wide memory lookups use ``SCAN`` across all primary nodes. This
22+
avoids the incomplete results produced by a node-local ``KEYS`` command in
23+
a sharded deployment.
24+
25+
``db_url`` identifies one cluster seed. Redis Cluster only supports
26+
database 0; pass redis-py cluster options such as ``startup_nodes`` or
27+
``address_remap`` through keyword args when required by the deployment.
28+
"""
29+
30+
def _create_storage(self, db_url: str, is_async: bool, **kwargs: Any) -> RedisClusterStorage:
31+
return RedisClusterStorage(is_async=is_async, redis_url=db_url, **kwargs)

trpc_agent_sdk/memory/_redis_memory_service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ def __init__(
4343
):
4444
super().__init__(memory_service_config=memory_service_config, enabled=enabled)
4545
# Redis needs default TTL configuration
46-
self._redis_storage = RedisStorage(is_async=is_async, redis_url=db_url, **kwargs)
46+
self._redis_storage = self._create_storage(db_url=db_url, is_async=is_async, **kwargs)
47+
48+
def _create_storage(self, db_url: str, is_async: bool, **kwargs: Any) -> RedisStorage:
49+
"""Create the backing storage.
50+
51+
Subclasses override this factory to preserve memory behavior while
52+
selecting a deployment-specific Redis client.
53+
"""
54+
return RedisStorage(is_async=is_async, redis_url=db_url, **kwargs)
4755

4856
@override
4957
async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None:

trpc_agent_sdk/sessions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ._in_memory_session_service import SessionWithTTL
2121
from ._in_memory_session_service import StateWithTTL
2222
from ._redis_session_service import RedisSessionService
23+
from ._redis_cluster_session_service import RedisClusterSessionService
2324
from ._session import Session
2425
from ._session_summarizer import SessionSummarizer
2526
from ._session_summarizer import SessionSummary
@@ -57,6 +58,7 @@
5758
"SessionWithTTL",
5859
"StateWithTTL",
5960
"RedisSessionService",
61+
"RedisClusterSessionService",
6062
"Session",
6163
"SessionSummarizer",
6264
"SessionSummary",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Redis Cluster-backed session service."""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
from trpc_agent_sdk.storage import RedisClusterStorage
13+
14+
from ._redis_session_service import RedisSessionService
15+
16+
17+
class RedisClusterSessionService(RedisSessionService):
18+
"""Persist sessions in a Redis Cluster.
19+
20+
The service preserves :class:`RedisSessionService` semantics while routing
21+
single-key operations through redis-py's cluster client. Session listing
22+
scans every primary node, so sessions are not missed when their keys occupy
23+
different hash slots.
24+
25+
``db_url`` supplies one cluster seed, for example
26+
``redis://user:password@cluster-node-1:6379/0``. Redis Cluster only
27+
supports database 0; additional redis-py cluster options, including
28+
``startup_nodes`` and ``address_remap``, may be provided as keyword args.
29+
"""
30+
31+
def _create_storage(self, db_url: str, is_async: bool, **kwargs: Any) -> RedisClusterStorage:
32+
return RedisClusterStorage(is_async=is_async, redis_url=db_url, **kwargs)

trpc_agent_sdk/sessions/_redis_session_service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,15 @@ def __init__(self,
8686
# Default to store historical events for persistent backends.
8787
self._session_config.store_historical_events = True
8888
# Redis needs default TTL configuration
89-
self._redis_storage = RedisStorage(is_async=is_async, redis_url=db_url, **kwargs)
89+
self._redis_storage = self._create_storage(db_url=db_url, is_async=is_async, **kwargs)
90+
91+
def _create_storage(self, db_url: str, is_async: bool, **kwargs: Any) -> RedisStorage:
92+
"""Create the backing storage.
93+
94+
Subclasses override this factory to retain the session semantics while
95+
selecting a different Redis deployment client, such as Redis Cluster.
96+
"""
97+
return RedisStorage(is_async=is_async, redis_url=db_url, **kwargs)
9098

9199
@override
92100
async def create_session(

trpc_agent_sdk/storage/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
from ._redis import RedisExpire
1616
from ._redis import RedisSession
1717
from ._redis import RedisStorage
18+
from ._redis_cluster import RedisClusterAsyncContextManager
19+
from ._redis_cluster import RedisClusterClient
20+
from ._redis_cluster import RedisClusterStorage
1821
from ._sql import SqlAsyncContextManager
1922
from ._sql import SqlCondition
2023
from ._sql import SqlKey
@@ -45,6 +48,9 @@
4548
"RedisExpire",
4649
"RedisSession",
4750
"RedisStorage",
51+
"RedisClusterAsyncContextManager",
52+
"RedisClusterClient",
53+
"RedisClusterStorage",
4854
"SqlAsyncContextManager",
4955
"SqlCondition",
5056
"SqlKey",

0 commit comments

Comments
 (0)