From bcbf7d4c09264e21640453b617d7aebbb92ffbdb Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Wed, 28 Jan 2026 15:24:56 +0200 Subject: [PATCH 1/7] Adding hotkeys commands support. --- redis/_parsers/helpers.py | 1 + redis/cluster.py | 4 + redis/commands/core.py | 84 +++++++++ tests/test_asyncio/test_cluster.py | 37 ++++ tests/test_asyncio/test_commands.py | 257 +++++++++++++++++++++++++++- tests/test_cluster.py | 37 ++++ tests/test_commands.py | 257 +++++++++++++++++++++++++++- 7 files changed, 675 insertions(+), 2 deletions(-) diff --git a/redis/_parsers/helpers.py b/redis/_parsers/helpers.py index b6c3feb877..d84ebd8f2c 100644 --- a/redis/_parsers/helpers.py +++ b/redis/_parsers/helpers.py @@ -805,6 +805,7 @@ def string_keys_to_dict(key_string, callback): "FUNCTION RESTORE": bool_ok, "GEODIST": float_or_none, "HSCAN": parse_hscan, + "HOTKEYS GET": lambda r: pairs_to_dict(r, decode_keys=True), "INFO": parse_info, "LASTSAVE": timestamp_to_datetime, "MEMORY PURGE": bool_ok, diff --git a/redis/cluster.py b/redis/cluster.py index bd371626ad..fa1d4baac1 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -1189,6 +1189,10 @@ def determine_slot(self, *args) -> Optional[int]: if len(eval_keys) == 0: return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) keys = eval_keys + elif command.upper().startswith("HOTKEYS"): + # HOTKEYS commands don't have keys + # so we can just return a random slot + return None else: keys = self._get_command_keys(*args) if keys is None or len(keys) == 0: diff --git a/redis/commands/core.py b/redis/commands/core.py index 52d1c0af8e..e50a094b74 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -405,6 +405,11 @@ def acl_whoami(self, **kwargs) -> ResponseT: AsyncACLCommands = ACLCommands +class HotkeysMetricsTypes(Enum): + CPU = "CPU" + NET = "NET" + + class ManagementCommands(CommandsProtocol): """ Redis management commands @@ -1406,6 +1411,85 @@ def failover(self): "FAILOVER is intentionally not implemented in the client." ) + def hotkeys_start( + self, + count: Optional[int] = None, + metrics: Optional[List[HotkeysMetricsTypes]] = None, + duration: Optional[int] = None, + sample_ratio: Optional[int] = None, + slots: Optional[List[int]] = None, + **kwargs, + ) -> ResponseT: + """ + Start collecting hotkeys data. + Returns an error if there is an ongoing collection session. + + Args: + count: The number of keys to collect in each criteria (CPU and network consumption) + metrics: List of metrics to track. Supported values: [HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET] + duration: Automatically stop the collection after `duration` seconds + sample_ratio: Commands are sampled with probability 1/ratio (1 means no sampling) + slots: Only track keys on the specified hash slots + + For more information, see https://redis.io/commands/hotkeys-start + """ + args: List[Union[str, int]] = ["HOTKEYS", "START"] + + # Add METRICS + if metrics: + args.append("METRICS") + args.append(len(metrics)) + args.extend([str(m.value) for m in metrics]) + + # Add COUNT + if count is not None: + args.extend(["COUNT", count]) + + # Add optional DURATION + if duration is not None: + args.extend(["DURATION", duration]) + + # Add optional SAMPLE ratio + if sample_ratio is not None: + args.extend(["SAMPLE", sample_ratio]) + + # Add optional SLOTS + if slots is not None: + args.append("SLOTS") + args.append(len(slots)) + args.extend(slots) + + return self.execute_command(*args, **kwargs) + + def hotkeys_stop(self, **kwargs) -> ResponseT: + """ + Stop the ongoing hotkeys collection session (if any). + The results of the last collection session are kept for consumption with HOTKEYS GET. + + For more information, see https://redis.io/commands/hotkeys-stop + """ + return self.execute_command("HOTKEYS STOP", **kwargs) + + def hotkeys_reset(self, **kwargs) -> ResponseT: + """ + Discard the last hotkeys collection session results (in order to save memory). + Error if there is an ongoing collection session. + + For more information, see https://redis.io/commands/hotkeys-reset + """ + return self.execute_command("HOTKEYS RESET", **kwargs) + + def hotkeys_get(self, **kwargs) -> ResponseT: + """ + Retrieve the result of the ongoing collection session (if any), + or the last collection session (if any). + + Returns a dictionary with the returned fields detailed in the Redis documentation. + + For more information, see https://redis.io/commands/hotkeys-get + """ + return self.execute_command("HOTKEYS GET", **kwargs) + class AsyncManagementCommands(ManagementCommands): async def command_info(self, **kwargs) -> None: diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index a0c5fb5fc1..fb10a040ee 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -26,6 +26,7 @@ LoadBalancingStrategy, get_node_name, ) +from redis.commands.core import HotkeysMetricsTypes from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot from redis.exceptions import ( AskError, @@ -2465,6 +2466,42 @@ async def test_acl_log( await user_client.aclose() + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_cluster(self, r: RedisCluster) -> None: + """Test all HOTKEYS commands in cluster mode targeting a specific node""" + # Get a primary node to target + node = r.get_primaries()[0] + + # Clean up any existing session + try: + await r.hotkeys_stop(target_nodes=node) + except Exception: + pass + + # Test HOTKEYS START + result = await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], target_nodes=node + ) + assert result == b"OK" + + # Test HOTKEYS GET during ongoing session + result = await r.hotkeys_get(target_nodes=node) + assert isinstance(result, dict) + assert result["tracking-active"] == 1 + + # Test HOTKEYS STOP + result = await r.hotkeys_stop(target_nodes=node) + assert result == b"OK" + + # Test HOTKEYS GET after stopping + result = await r.hotkeys_get(target_nodes=node) + assert isinstance(result, dict) + assert result["tracking-active"] == 0 + + # Test HOTKEYS RESET + result = await r.hotkeys_reset(target_nodes=node) + assert result == b"OK" + class TestNodesManager: """ diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index ceea649463..cf8e3e8dae 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -21,7 +21,7 @@ parse_info, ) from redis.client import EMPTY_RESPONSE, NEVER_DECODE -from redis.commands.core import DataPersistOptions +from redis.commands.core import DataPersistOptions, HotkeysMetricsTypes from redis.commands.json.path import Path from redis.commands.search.field import TextField from redis.commands.search.query import Query @@ -810,6 +810,261 @@ async def test_time(self, r: redis.Redis): assert isinstance(t[0], int) assert isinstance(t[1], int) + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_basic(self, r: redis.Redis): + """Test basic HOTKEYS START command with CPU metric""" + # Reset any previous session + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start collection with CPU metric + result = await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_with_all_metrics(self, r: redis.Redis): + """Test HOTKEYS START with both CPU and NET metrics""" + try: + await r.hotkeys_stop() + except Exception: + pass + + result = await r.hotkeys_start( + count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET] + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_with_duration(self, r: redis.Redis): + """Test HOTKEYS START with duration parameter""" + try: + await r.hotkeys_stop() + except Exception: + pass + + result = await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], duration=60 + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_with_sample_ratio(self, r: redis.Redis): + """Test HOTKEYS START with sample ratio""" + try: + await r.hotkeys_stop() + except Exception: + pass + + result = await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], sample_ratio=10 + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_with_slots(self, r: redis.Redis): + """Test HOTKEYS START with specific hash slots""" + try: + await r.hotkeys_stop() + except Exception: + pass + + result = await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_start_with_all_parameters(self, r: redis.Redis): + """Test HOTKEYS START with all optional parameters""" + try: + await r.hotkeys_stop() + except Exception: + pass + + result = await r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + duration=30, + sample_ratio=5, + slots=[0, 100, 200, 300], + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_stop(self, r: redis.Redis): + """Test HOTKEYS STOP command""" + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Stop the session + result = await r.hotkeys_stop() + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_reset(self, r: redis.Redis): + """Test HOTKEYS RESET command""" + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start a session and generate some data + await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Perform some operations to generate hotkeys data + for i in range(5): + await r.set(f"resetkey{i}", f"value{i}") + await r.get(f"resetkey{i}") + + # Stop the session + await r.hotkeys_stop() + + # Get results before reset - should have data + result_before = await r.hotkeys_get() + assert isinstance(result_before, dict) + assert "tracking-active" in result_before + + # Reset the results + result = await r.hotkeys_reset() + assert result == b"OK" + + # Try to get results after reset - should fail or return empty + try: + result_after = await r.hotkeys_get() + # If it doesn't fail, verify the data is cleared + # The response might indicate no session exists + assert result_after != result_before + except Exception: + # Expected - no session exists after reset + pass + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_get_ongoing_session(self, r: redis.Redis): + """Test HOTKEYS GET during an ongoing collection session""" + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET] + ) + + # Perform some operations to generate hotkeys data + for i in range(10): + await r.set(f"key{i}", f"value{i}") + await r.get(f"key{i}") + + # Get the results + result = await r.hotkeys_get() + + # Verify the response structure + assert isinstance(result, dict) + + # Check tracking-active is 1 (ongoing session) + assert result["tracking-active"] == 1 + + # Stop the session + await r.hotkeys_stop() + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_get_terminated_session(self, r: redis.Redis): + """Test HOTKEYS GET after stopping a collection session""" + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Perform some operations + for i in range(5): + await r.set(f"testkey{i}", f"testvalue{i}") + + # Stop the session + await r.hotkeys_stop() + + # Get the results + result = await r.hotkeys_get() + + # Verify the response structure + assert isinstance(result, dict) + + # Check tracking-active is 0 (terminated session) + assert result["tracking-active"] == 0 + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_get_all_fields(self, r: redis.Redis): + """Test HOTKEYS GET returns all documented fields""" + try: + await r.hotkeys_stop() + except Exception: + pass + + # Start a collection session with all parameters + await r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + sample_ratio=1, + slots=[1, 1584, 9842], + ) + + # Perform operations to generate data + for i in range(20): + await r.set("anyprefix:{3}:key", f"value{i}") + await r.get(f"anyprefix:{3}:key") + await r.set("anyprefix:{1}:key", f"value{i}") + await r.get(f"anyprefix:{1}:key") + + # Stop the session + await r.hotkeys_stop() + + # Get the results + result = await r.hotkeys_get() + + # Verify all documented fields are present + expected_fields = [ + "tracking-active", + "sample-ratio", + "selected-slots", + "all-commands-selected-slots-ms", + "all-commands-all-slots-ms", + "net-bytes-all-commands-selected-slots", + "net-bytes-all-commands-all-slots", + "collection-start-time-unix-ms", + "collection-duration-ms", + "total-cpu-time-user-ms", + "total-cpu-time-sys-ms", + "total-net-bytes", + "by-cpu-time", + "by-net-bytes", + ] + + for field in expected_fields: + assert field in result, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) + async def test_never_decode_option(self, r: redis.Redis): opts = {NEVER_DECODE: []} await r.delete("a") diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 36b8ef65a6..114270888a 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -29,6 +29,7 @@ RedisCluster, get_node_name, ) +from redis.commands.core import HotkeysMetricsTypes from redis.connection import BlockingConnectionPool, Connection, ConnectionPool from redis.crc import key_slot from redis.exceptions import ( @@ -2608,6 +2609,42 @@ def try_delete_libs(self, r, *lib_names): except Exception: pass + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_cluster(self, r): + """Test all HOTKEYS commands in cluster mode targeting a specific node""" + # Get a primary node to target + node = r.get_primaries()[0] + + # Clean up any existing session + try: + r.hotkeys_stop(target_nodes=node) + except Exception: + pass + + # Test HOTKEYS START + result = r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], target_nodes=node + ) + assert result == b"OK" + + # Test HOTKEYS GET during ongoing session + result = r.hotkeys_get(target_nodes=node) + assert isinstance(result, dict) + assert result["tracking-active"] == 1 + + # Test HOTKEYS STOP + result = r.hotkeys_stop(target_nodes=node) + assert result == b"OK" + + # Test HOTKEYS GET after stopping + result = r.hotkeys_get(target_nodes=node) + assert isinstance(result, dict) + assert result["tracking-active"] == 0 + + # Test HOTKEYS RESET + result = r.hotkeys_reset(target_nodes=node) + assert result == b"OK" + @pytest.mark.onlycluster class TestNodesManager: diff --git a/tests/test_commands.py b/tests/test_commands.py index 38bd05de71..2936a6388c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -19,7 +19,7 @@ parse_info, ) from redis.client import EMPTY_RESPONSE, NEVER_DECODE -from redis.commands.core import DataPersistOptions +from redis.commands.core import DataPersistOptions, HotkeysMetricsTypes from redis.commands.json.path import Path from redis.commands.search.field import TextField from redis.commands.search.query import Query @@ -1229,6 +1229,261 @@ def test_time(self, r): assert isinstance(t[0], int) assert isinstance(t[1], int) + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_basic(self, r): + """Test basic HOTKEYS START command with CPU metric""" + # Reset any previous session + try: + r.hotkeys_stop() + except Exception: + pass + + # Start collection with CPU metric + result = r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_with_all_metrics(self, r): + """Test HOTKEYS START with both CPU and NET metrics""" + try: + r.hotkeys_stop() + except Exception: + pass + + result = r.hotkeys_start( + count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET] + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_with_duration(self, r): + """Test HOTKEYS START with duration parameter""" + try: + r.hotkeys_stop() + except Exception: + pass + + result = r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], duration=60 + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_with_sample_ratio(self, r): + """Test HOTKEYS START with sample ratio""" + try: + r.hotkeys_stop() + except Exception: + pass + + result = r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], sample_ratio=10 + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_with_slots(self, r): + """Test HOTKEYS START with specific hash slots""" + try: + r.hotkeys_stop() + except Exception: + pass + + result = r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_start_with_all_parameters(self, r): + """Test HOTKEYS START with all optional parameters""" + try: + r.hotkeys_stop() + except Exception: + pass + + result = r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + duration=30, + sample_ratio=5, + slots=[0, 100, 200, 300], + ) + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_stop(self, r): + """Test HOTKEYS STOP command""" + try: + r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Stop the session + result = r.hotkeys_stop() + assert result == b"OK" + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_reset(self, r): + """Test HOTKEYS RESET command""" + try: + r.hotkeys_stop() + except Exception: + pass + + # Start a session and generate some data + r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Perform some operations to generate hotkeys data + for i in range(5): + r.set(f"resetkey{i}", f"value{i}") + r.get(f"resetkey{i}") + + # Stop the session + r.hotkeys_stop() + + # Get results before reset - should have data + result_before = r.hotkeys_get() + assert isinstance(result_before, dict) + assert "tracking-active" in result_before + + # Reset the results + result = r.hotkeys_reset() + assert result == b"OK" + + # Try to get results after reset - should fail or return empty + try: + result_after = r.hotkeys_get() + # If it doesn't fail, verify the data is cleared + # The response might indicate no session exists + assert result_after != result_before + except Exception: + # Expected - no session exists after reset + pass + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_get_ongoing_session(self, r): + """Test HOTKEYS GET during an ongoing collection session""" + try: + r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET] + ) + + # Perform some operations to generate hotkeys data + for i in range(10): + r.set(f"key{i}", f"value{i}") + r.get(f"key{i}") + + # Get the results + result = r.hotkeys_get() + + # Verify the response structure + assert isinstance(result, dict) + + # Check tracking-active is 1 (ongoing session) + assert result["tracking-active"] == 1 + + # Stop the session + r.hotkeys_stop() + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_get_terminated_session(self, r): + """Test HOTKEYS GET after stopping a collection session""" + try: + r.hotkeys_stop() + except Exception: + pass + + # Start a collection session + r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + + # Perform some operations + for i in range(5): + r.set(f"testkey{i}", f"testvalue{i}") + + # Stop the session + r.hotkeys_stop() + + # Get the results + result = r.hotkeys_get() + + # Verify the response structure + assert isinstance(result, dict) + + # Check tracking-active is 0 (terminated session) + assert result["tracking-active"] == 0 + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_get_all_fields(self, r): + """Test HOTKEYS GET returns all documented fields""" + try: + r.hotkeys_stop() + except Exception: + pass + + # Start a collection session with all parameters + r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + sample_ratio=1, + slots=[1, 1584, 9842], + ) + + # Perform operations to generate data + for i in range(20): + r.set("anyprefix:{3}:key", f"value{i}") + r.get(f"anyprefix:{3}:key") + r.set("anyprefix:{1}:key", f"value{i}") + r.get(f"anyprefix:{1}:key") + + # Stop the session + r.hotkeys_stop() + + # Get the results + result = r.hotkeys_get() + + # Verify all documented fields are present + expected_fields = [ + "tracking-active", + "sample-ratio", + "selected-slots", + "all-commands-selected-slots-ms", + "all-commands-all-slots-ms", + "net-bytes-all-commands-selected-slots", + "net-bytes-all-commands-all-slots", + "collection-start-time-unix-ms", + "collection-duration-ms", + "total-cpu-time-user-ms", + "total-cpu-time-sys-ms", + "total-net-bytes", + "by-cpu-time", + "by-net-bytes", + ] + + for field in expected_fields: + assert field in result, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) + @skip_if_redis_enterprise() def test_bgsave(self, r): assert r.bgsave() From 34d0c79d96ecdd84c329f159057d15cb0cdc9bed Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 5 Feb 2026 15:44:12 +0200 Subject: [PATCH 2/7] Applying latest changes to the command spec --- .github/workflows/integration.yaml | 2 +- redis/_parsers/helpers.py | 2 +- redis/commands/cluster.py | 105 ++++++++++++++++++++++++++++ redis/commands/core.py | 12 ++-- tests/test_asyncio/test_cluster.py | 41 +++-------- tests/test_asyncio/test_commands.py | 103 ++++++++++++++++++++------- tests/test_cluster.py | 41 +++-------- tests/test_commands.py | 101 +++++++++++++++++++------- 8 files changed, 285 insertions(+), 122 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 78ba412164..d1405bc823 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -76,7 +76,7 @@ jobs: max-parallel: 15 fail-fast: false matrix: - redis-version: ['8.6-rc1-21356658603-debian-amd64', '${{ needs.redis_version.outputs.CURRENT }}', '8.2', '8.0.2' ,'7.4.4', '7.2.9'] + redis-version: ['custom-21651605017-debian-amd64', '${{ needs.redis_version.outputs.CURRENT }}', '8.2', '8.0.2' ,'7.4.4', '7.2.9'] python-version: ['3.10', '3.14'] parser-backend: ['plain'] event-loop: ['asyncio'] diff --git a/redis/_parsers/helpers.py b/redis/_parsers/helpers.py index d84ebd8f2c..26bcd0e0d5 100644 --- a/redis/_parsers/helpers.py +++ b/redis/_parsers/helpers.py @@ -805,7 +805,6 @@ def string_keys_to_dict(key_string, callback): "FUNCTION RESTORE": bool_ok, "GEODIST": float_or_none, "HSCAN": parse_hscan, - "HOTKEYS GET": lambda r: pairs_to_dict(r, decode_keys=True), "INFO": parse_info, "LASTSAVE": timestamp_to_datetime, "MEMORY PURGE": bool_ok, @@ -881,6 +880,7 @@ def string_keys_to_dict(key_string, callback): map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r) ), "HGETALL": lambda r: r and pairs_to_dict(r) or {}, + "HOTKEYS GET": lambda r: [pairs_to_dict(m) for m in r], "MEMORY STATS": parse_memory_stats, "MODULE LIST": lambda r: [pairs_to_dict(m) for m in r], "RESET": str_if_bytes, diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index 568f4b4914..017999e75f 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -38,6 +38,7 @@ AsyncScriptCommands, DataAccessCommands, FunctionCommands, + HotkeysMetricsTypes, ManagementCommands, ModuleCommands, PubSubCommands, @@ -827,6 +828,58 @@ def client_tracking_off( target_nodes=target_nodes, ) + def hotkeys_start( + self, + metrics: List[HotkeysMetricsTypes], + count: Optional[int] = None, + duration: Optional[int] = None, + sample_ratio: Optional[int] = None, + slots: Optional[List[int]] = None, + **kwargs, + ) -> ResponseT: + """ + Start collecting hotkeys data on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-start + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + def hotkeys_stop(self, **kwargs) -> ResponseT: + """ + Stop the ongoing hotkeys collection session (if any) on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-stop + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + def hotkeys_reset(self, **kwargs) -> ResponseT: + """ + Discard the last hotkeys collection session results on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-reset + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + def hotkeys_get(self, **kwargs) -> ResponseT: + """ + Retrieve the result of the collection session from the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-get + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + class AsyncClusterManagementCommands( ClusterManagementCommands, AsyncManagementCommands @@ -924,6 +977,58 @@ async def client_tracking_off( target_nodes=target_nodes, ) + async def hotkeys_start( + self, + metrics: List[HotkeysMetricsTypes], + count: Optional[int] = None, + duration: Optional[int] = None, + sample_ratio: Optional[int] = None, + slots: Optional[List[int]] = None, + **kwargs, + ) -> ResponseT: + """ + Start collecting hotkeys data on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-start + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + async def hotkeys_stop(self, **kwargs) -> ResponseT: + """ + Stop the ongoing hotkeys collection session (if any) on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-stop + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + async def hotkeys_reset(self, **kwargs) -> ResponseT: + """ + Discard the last hotkeys collection session results on the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-reset + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + + async def hotkeys_get(self, **kwargs) -> ResponseT: + """ + Retrieve the result of the collection session from the specified node(s). + The command will be sent to the specified target_nodes. + + For more information see https://redis.io/commands/hotkeys-get + """ + raise NotImplementedError( + "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." + ) + class ClusterDataAccessCommands(DataAccessCommands): """ diff --git a/redis/commands/core.py b/redis/commands/core.py index a1e9a9a6ff..1335f4b9df 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1413,8 +1413,8 @@ def failover(self): def hotkeys_start( self, + metrics: List[HotkeysMetricsTypes], count: Optional[int] = None, - metrics: Optional[List[HotkeysMetricsTypes]] = None, duration: Optional[int] = None, sample_ratio: Optional[int] = None, slots: Optional[List[int]] = None, @@ -1436,10 +1436,8 @@ def hotkeys_start( args: List[Union[str, int]] = ["HOTKEYS", "START"] # Add METRICS - if metrics: - args.append("METRICS") - args.append(len(metrics)) - args.extend([str(m.value) for m in metrics]) + args.extend(["METRICS", len(metrics)]) + args.extend([str(m.value) for m in metrics]) # Add COUNT if count is not None: @@ -1484,7 +1482,9 @@ def hotkeys_get(self, **kwargs) -> ResponseT: Retrieve the result of the ongoing collection session (if any), or the last collection session (if any). - Returns a dictionary with the returned fields detailed in the Redis documentation. + HOTKEYS GET response is wrapped in an array for aggregation support. + Each node returns a single-element array, allowing multiple node + responses to be concatenated by DMC or other aggregators. For more information, see https://redis.io/commands/hotkeys-get """ diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index fb10a040ee..cca82f2b93 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -2468,39 +2468,16 @@ async def test_acl_log( @skip_if_server_version_lt("8.5.240") async def test_hotkeys_cluster(self, r: RedisCluster) -> None: - """Test all HOTKEYS commands in cluster mode targeting a specific node""" - # Get a primary node to target - node = r.get_primaries()[0] - - # Clean up any existing session - try: - await r.hotkeys_stop(target_nodes=node) - except Exception: - pass - - # Test HOTKEYS START - result = await r.hotkeys_start( - count=10, metrics=[HotkeysMetricsTypes.CPU], target_nodes=node - ) - assert result == b"OK" - - # Test HOTKEYS GET during ongoing session - result = await r.hotkeys_get(target_nodes=node) - assert isinstance(result, dict) - assert result["tracking-active"] == 1 + """Test all HOTKEYS commands in cluster are raising an error""" - # Test HOTKEYS STOP - result = await r.hotkeys_stop(target_nodes=node) - assert result == b"OK" - - # Test HOTKEYS GET after stopping - result = await r.hotkeys_get(target_nodes=node) - assert isinstance(result, dict) - assert result["tracking-active"] == 0 - - # Test HOTKEYS RESET - result = await r.hotkeys_reset(target_nodes=node) - assert result == b"OK" + with pytest.raises(NotImplementedError): + await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + with pytest.raises(NotImplementedError): + await r.hotkeys_get() + with pytest.raises(NotImplementedError): + await r.hotkeys_reset() + with pytest.raises(NotImplementedError): + await r.hotkeys_stop() class TestNodesManager: diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index ae1b66d8c6..32e6939b66 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -868,17 +868,18 @@ async def test_hotkeys_start_with_sample_ratio(self, r: redis.Redis): @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") - async def test_hotkeys_start_with_slots(self, r: redis.Redis): + async def test_hotkeys_start_with_slots_fail_on_non_cluster_setup( + self, r: redis.Redis + ): """Test HOTKEYS START with specific hash slots""" try: await r.hotkeys_stop() except Exception: pass - - result = await r.hotkeys_start( - count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] - ) - assert result == b"OK" + with pytest.raises(Exception): + await r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] + ) @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") @@ -894,7 +895,6 @@ async def test_hotkeys_start_with_all_parameters(self, r: redis.Redis): metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], duration=30, sample_ratio=5, - slots=[0, 100, 200, 300], ) assert result == b"OK" @@ -936,8 +936,10 @@ async def test_hotkeys_reset(self, r: redis.Redis): # Get results before reset - should have data result_before = await r.hotkeys_get() - assert isinstance(result_before, dict) - assert "tracking-active" in result_before + assert isinstance(result_before, list) + for res_elem in result_before: + assert isinstance(res_elem, dict) + assert b"tracking-active" in res_elem # Reset the results result = await r.hotkeys_reset() @@ -976,10 +978,11 @@ async def test_hotkeys_get_ongoing_session(self, r: redis.Redis): result = await r.hotkeys_get() # Verify the response structure - assert isinstance(result, dict) - - # Check tracking-active is 1 (ongoing session) - assert result["tracking-active"] == 1 + assert isinstance(result, list) + for res_elem in result: + assert isinstance(res_elem, dict) + # Check tracking-active is 1 (ongoing session) + assert res_elem[b"tracking-active"] == 1 # Stop the session await r.hotkeys_stop() @@ -1007,10 +1010,11 @@ async def test_hotkeys_get_terminated_session(self, r: redis.Redis): result = await r.hotkeys_get() # Verify the response structure - assert isinstance(result, dict) - - # Check tracking-active is 0 (terminated session) - assert result["tracking-active"] == 0 + assert isinstance(result, list) + for res_elem in result: + assert isinstance(res_elem, dict) + # Check tracking-active is 0 (terminated session) + assert res_elem[b"tracking-active"] == 0 @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") @@ -1026,7 +1030,6 @@ async def test_hotkeys_get_all_fields(self, r: redis.Redis): count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], sample_ratio=1, - slots=[1, 1584, 9842], ) # Perform operations to generate data @@ -1042,28 +1045,76 @@ async def test_hotkeys_get_all_fields(self, r: redis.Redis): # Get the results result = await r.hotkeys_get() + # Verify all documented fields are present + expected_fields = [ + b"tracking-active", + b"sample-ratio", + b"selected-slots", + b"net-bytes-all-commands-all-slots", + b"collection-start-time-unix-ms", + b"collection-duration-ms", + b"total-cpu-time-user-ms", + b"total-cpu-time-sys-ms", + b"total-net-bytes", + b"by-cpu-time-us", + b"by-net-bytes", + ] + + for elem in result: + for field in expected_fields: + assert field in elem, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + async def test_hotkeys_get_all_fields_decoded(self, decoded_r: redis.Redis): + """Test HOTKEYS GET returns all documented fields""" + try: + await decoded_r.hotkeys_stop() + except Exception: + pass + + # Start a collection session with all parameters + await decoded_r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + sample_ratio=1, + ) + + # Perform operations to generate data + for i in range(20): + await decoded_r.set("anyprefix:{3}:key", f"value{i}") + await decoded_r.get(f"anyprefix:{3}:key") + await decoded_r.set("anyprefix:{1}:key", f"value{i}") + await decoded_r.get(f"anyprefix:{1}:key") + + # Stop the session + await decoded_r.hotkeys_stop() + + # Get the results + result = await decoded_r.hotkeys_get() + # Verify all documented fields are present expected_fields = [ "tracking-active", "sample-ratio", "selected-slots", - "all-commands-selected-slots-ms", - "all-commands-all-slots-ms", - "net-bytes-all-commands-selected-slots", "net-bytes-all-commands-all-slots", "collection-start-time-unix-ms", "collection-duration-ms", "total-cpu-time-user-ms", "total-cpu-time-sys-ms", "total-net-bytes", - "by-cpu-time", + "by-cpu-time-us", "by-net-bytes", ] - for field in expected_fields: - assert field in result, ( - f"Field '{field}' is missing from HOTKEYS GET response" - ) + for elem in result: + for field in expected_fields: + assert field in elem, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) async def test_never_decode_option(self, r: redis.Redis): opts = {NEVER_DECODE: []} diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 114270888a..d23cf7aaa6 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -2611,39 +2611,16 @@ def try_delete_libs(self, r, *lib_names): @skip_if_server_version_lt("8.5.240") def test_hotkeys_cluster(self, r): - """Test all HOTKEYS commands in cluster mode targeting a specific node""" - # Get a primary node to target - node = r.get_primaries()[0] - - # Clean up any existing session - try: - r.hotkeys_stop(target_nodes=node) - except Exception: - pass - - # Test HOTKEYS START - result = r.hotkeys_start( - count=10, metrics=[HotkeysMetricsTypes.CPU], target_nodes=node - ) - assert result == b"OK" - - # Test HOTKEYS GET during ongoing session - result = r.hotkeys_get(target_nodes=node) - assert isinstance(result, dict) - assert result["tracking-active"] == 1 + """Test all HOTKEYS commands in cluster mode are raising an error""" - # Test HOTKEYS STOP - result = r.hotkeys_stop(target_nodes=node) - assert result == b"OK" - - # Test HOTKEYS GET after stopping - result = r.hotkeys_get(target_nodes=node) - assert isinstance(result, dict) - assert result["tracking-active"] == 0 - - # Test HOTKEYS RESET - result = r.hotkeys_reset(target_nodes=node) - assert result == b"OK" + with pytest.raises(NotImplementedError): + r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU]) + with pytest.raises(NotImplementedError): + r.hotkeys_get() + with pytest.raises(NotImplementedError): + r.hotkeys_reset() + with pytest.raises(NotImplementedError): + r.hotkeys_stop() @pytest.mark.onlycluster diff --git a/tests/test_commands.py b/tests/test_commands.py index f06fd106e0..8967b6dd71 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1287,17 +1287,18 @@ def test_hotkeys_start_with_sample_ratio(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") - def test_hotkeys_start_with_slots(self, r): + def test_hotkeys_start_fail__on_noncluster_setup_with_slots(self, r): """Test HOTKEYS START with specific hash slots""" try: r.hotkeys_stop() except Exception: pass - result = r.hotkeys_start( - count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] - ) - assert result == b"OK" + # slots is not supported argument for non-cluster setups + with pytest.raises(Exception): + r.hotkeys_start( + count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200] + ) @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") @@ -1313,7 +1314,6 @@ def test_hotkeys_start_with_all_parameters(self, r): metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], duration=30, sample_ratio=5, - slots=[0, 100, 200, 300], ) assert result == b"OK" @@ -1355,8 +1355,10 @@ def test_hotkeys_reset(self, r): # Get results before reset - should have data result_before = r.hotkeys_get() - assert isinstance(result_before, dict) - assert "tracking-active" in result_before + assert isinstance(result_before, list) + for res_elem in result_before: + assert isinstance(res_elem, dict) + assert b"tracking-active" in res_elem # Reset the results result = r.hotkeys_reset() @@ -1395,10 +1397,11 @@ def test_hotkeys_get_ongoing_session(self, r): result = r.hotkeys_get() # Verify the response structure - assert isinstance(result, dict) - - # Check tracking-active is 1 (ongoing session) - assert result["tracking-active"] == 1 + assert isinstance(result, list) + for res_elem in result: + assert isinstance(res_elem, dict) + # Check tracking-active is 1 (ongoing session) + assert res_elem[b"tracking-active"] == 1 # Stop the session r.hotkeys_stop() @@ -1426,10 +1429,12 @@ def test_hotkeys_get_terminated_session(self, r): result = r.hotkeys_get() # Verify the response structure - assert isinstance(result, dict) + assert isinstance(result, list) + for res_elem in result: + assert isinstance(res_elem, dict) - # Check tracking-active is 0 (terminated session) - assert result["tracking-active"] == 0 + # Check tracking-active is 0 (terminated session) + assert res_elem[b"tracking-active"] == 0 @pytest.mark.onlynoncluster @skip_if_server_version_lt("8.5.240") @@ -1445,7 +1450,6 @@ def test_hotkeys_get_all_fields(self, r): count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], sample_ratio=1, - slots=[1, 1584, 9842], ) # Perform operations to generate data @@ -1460,29 +1464,78 @@ def test_hotkeys_get_all_fields(self, r): # Get the results result = r.hotkeys_get() + assert isinstance(result, list) + + # Verify all documented fields are present + expected_fields = [ + b"tracking-active", + b"sample-ratio", + b"selected-slots", + b"net-bytes-all-commands-all-slots", + b"collection-start-time-unix-ms", + b"collection-duration-ms", + b"total-cpu-time-user-ms", + b"total-cpu-time-sys-ms", + b"total-net-bytes", + b"by-cpu-time-us", + b"by-net-bytes", + ] + + for res_elem in result: + for field in expected_fields: + assert field in res_elem, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("8.5.240") + def test_hotkeys_get_all_fields_decoded(self, decoded_r: redis.Redis): + """Test HOTKEYS GET returns all documented fields""" + try: + decoded_r.hotkeys_stop() + except Exception: + pass + + # Start a collection session with all parameters + decoded_r.hotkeys_start( + count=5, + metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET], + sample_ratio=1, + ) + + # Perform operations to generate data + for i in range(20): + decoded_r.set("anyprefix:{3}:key", f"value{i}") + decoded_r.get(f"anyprefix:{3}:key") + decoded_r.set("anyprefix:{1}:key", f"value{i}") + decoded_r.get(f"anyprefix:{1}:key") + + # Stop the session + decoded_r.hotkeys_stop() + + # Get the results + result = decoded_r.hotkeys_get() # Verify all documented fields are present expected_fields = [ "tracking-active", "sample-ratio", "selected-slots", - "all-commands-selected-slots-ms", - "all-commands-all-slots-ms", - "net-bytes-all-commands-selected-slots", "net-bytes-all-commands-all-slots", "collection-start-time-unix-ms", "collection-duration-ms", "total-cpu-time-user-ms", "total-cpu-time-sys-ms", "total-net-bytes", - "by-cpu-time", + "by-cpu-time-us", "by-net-bytes", ] - for field in expected_fields: - assert field in result, ( - f"Field '{field}' is missing from HOTKEYS GET response" - ) + for elem in result: + for field in expected_fields: + assert field in elem, ( + f"Field '{field}' is missing from HOTKEYS GET response" + ) @skip_if_redis_enterprise() def test_bgsave(self, r): From 12ac5148bc8c3fc6ca3f1fc9fccad48a391ca127 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 5 Feb 2026 15:47:39 +0200 Subject: [PATCH 3/7] Fixing test keys pattern --- tests/test_asyncio/test_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 32e6939b66..f75948e561 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1035,9 +1035,9 @@ async def test_hotkeys_get_all_fields(self, r: redis.Redis): # Perform operations to generate data for i in range(20): await r.set("anyprefix:{3}:key", f"value{i}") - await r.get(f"anyprefix:{3}:key") + await r.get("anyprefix:{3}:key") await r.set("anyprefix:{1}:key", f"value{i}") - await r.get(f"anyprefix:{1}:key") + await r.get("anyprefix:{1}:key") # Stop the session await r.hotkeys_stop() From 3343294f2ae134757a2b25e365f7ae3b02fa133c Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 5 Feb 2026 16:04:02 +0200 Subject: [PATCH 4/7] Removing special case handling for determine slots for hotkeys commands --- redis/cluster.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index fa1d4baac1..bd371626ad 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -1189,10 +1189,6 @@ def determine_slot(self, *args) -> Optional[int]: if len(eval_keys) == 0: return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) keys = eval_keys - elif command.upper().startswith("HOTKEYS"): - # HOTKEYS commands don't have keys - # so we can just return a random slot - return None else: keys = self._get_command_keys(*args) if keys is None or len(keys) == 0: From a152432ad7f76fbc4d35c1d13f5d87cd7b88720c Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 5 Feb 2026 17:20:56 +0200 Subject: [PATCH 5/7] Adding detailed logging for Setup Test environment step in the github test actions --- .github/actions/run-tests/action.yml | 19 ++++++++++++++++++- .github/workflows/integration.yaml | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 9a07e71972..af51df3451 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -78,7 +78,23 @@ runs: echo "::endgroup::" echo "::group::Starting Redis servers" - redis_major_version=$(echo "$REDIS_VERSION" | grep -oP '^\d+') + set -x + # Check if REDIS_VERSION is in the custom map + mapped_version="" + if [[ -n "${REDIS_VERSION_CUSTOM_MAP:-}" ]]; then + for mapping in $REDIS_VERSION_CUSTOM_MAP; do + tag="${mapping%%:*}" + version="${mapping##*:}" + if [[ "$REDIS_VERSION" == "$tag" ]]; then + mapped_version="$version" + echo "Found custom mapping: $REDIS_VERSION -> $mapped_version" + break + fi + done + fi + # Use mapped version if found, otherwise use REDIS_VERSION + version_to_parse="${mapped_version:-$REDIS_VERSION}" + redis_major_version=$(echo "$version_to_parse" | grep -oP '^\d+') echo "REDIS_MAJOR_VERSION=${redis_major_version}" >> $GITHUB_ENV if (( redis_major_version < 8 )); then @@ -117,6 +133,7 @@ runs: fi sleep 10 # time to settle + set +x echo "::endgroup::" shell: bash diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index d1405bc823..43a7929ebc 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -31,6 +31,7 @@ env: # for example after 8.2.1 is published, 8.2 image contains 8.2.1 content CURRENT_CLIENT_LIBS_TEST_STACK_IMAGE_TAG: '8.4.0' CURRENT_REDIS_VERSION: '8.4.0' + REDIS_VERSION_CUSTOM_MAP: 'custom-21651605017-debian-amd64:8.6' jobs: dependency-audit: From aacec58d8d1518d62894905acfd3315ae257001e Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 5 Feb 2026 19:00:31 +0200 Subject: [PATCH 6/7] Applying review comments --- .github/actions/run-tests/action.yml | 2 -- redis/commands/cluster.py | 26 +++++++++----------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index af51df3451..4e058dc64f 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -78,7 +78,6 @@ runs: echo "::endgroup::" echo "::group::Starting Redis servers" - set -x # Check if REDIS_VERSION is in the custom map mapped_version="" if [[ -n "${REDIS_VERSION_CUSTOM_MAP:-}" ]]; then @@ -133,7 +132,6 @@ runs: fi sleep 10 # time to settle - set +x echo "::endgroup::" shell: bash diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index 017999e75f..ab38552d0a 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -838,8 +838,7 @@ def hotkeys_start( **kwargs, ) -> ResponseT: """ - Start collecting hotkeys data on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-start """ @@ -849,8 +848,7 @@ def hotkeys_start( def hotkeys_stop(self, **kwargs) -> ResponseT: """ - Stop the ongoing hotkeys collection session (if any) on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-stop """ @@ -860,8 +858,7 @@ def hotkeys_stop(self, **kwargs) -> ResponseT: def hotkeys_reset(self, **kwargs) -> ResponseT: """ - Discard the last hotkeys collection session results on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-reset """ @@ -871,8 +868,7 @@ def hotkeys_reset(self, **kwargs) -> ResponseT: def hotkeys_get(self, **kwargs) -> ResponseT: """ - Retrieve the result of the collection session from the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-get """ @@ -987,8 +983,7 @@ async def hotkeys_start( **kwargs, ) -> ResponseT: """ - Start collecting hotkeys data on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-start """ @@ -998,8 +993,7 @@ async def hotkeys_start( async def hotkeys_stop(self, **kwargs) -> ResponseT: """ - Stop the ongoing hotkeys collection session (if any) on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-stop """ @@ -1009,8 +1003,7 @@ async def hotkeys_stop(self, **kwargs) -> ResponseT: async def hotkeys_reset(self, **kwargs) -> ResponseT: """ - Discard the last hotkeys collection session results on the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. For more information see https://redis.io/commands/hotkeys-reset """ @@ -1020,10 +1013,9 @@ async def hotkeys_reset(self, **kwargs) -> ResponseT: async def hotkeys_get(self, **kwargs) -> ResponseT: """ - Retrieve the result of the collection session from the specified node(s). - The command will be sent to the specified target_nodes. + Cluster client does not support hotkeys command. Please use the non-cluster client. - For more information see https://redis.io/commands/hotkeys-get + For more information see https://redis.io/commands/hotkeys-get """ raise NotImplementedError( "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." From a730eeaefad43408b52ffb8005bdb64033fed939 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Fri, 6 Feb 2026 13:13:59 +0200 Subject: [PATCH 7/7] Applying review comments --- redis/commands/cluster.py | 21 ++++++++++++--------- redis/commands/core.py | 17 +++++++++++++---- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index ab38552d0a..dea73763da 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -3,6 +3,7 @@ TYPE_CHECKING, Any, AsyncIterator, + Awaitable, Dict, Iterable, Iterator, @@ -836,7 +837,7 @@ def hotkeys_start( sample_ratio: Optional[int] = None, slots: Optional[List[int]] = None, **kwargs, - ) -> ResponseT: + ) -> Union[str, bytes]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -846,7 +847,7 @@ def hotkeys_start( "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - def hotkeys_stop(self, **kwargs) -> ResponseT: + def hotkeys_stop(self, **kwargs) -> Union[str, bytes]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -856,7 +857,7 @@ def hotkeys_stop(self, **kwargs) -> ResponseT: "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - def hotkeys_reset(self, **kwargs) -> ResponseT: + def hotkeys_reset(self, **kwargs) -> Union[str, bytes]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -866,7 +867,7 @@ def hotkeys_reset(self, **kwargs) -> ResponseT: "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - def hotkeys_get(self, **kwargs) -> ResponseT: + def hotkeys_get(self, **kwargs) -> list[dict[Union[str, bytes], Any]]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -981,7 +982,7 @@ async def hotkeys_start( sample_ratio: Optional[int] = None, slots: Optional[List[int]] = None, **kwargs, - ) -> ResponseT: + ) -> Awaitable[Union[str, bytes]]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -991,7 +992,7 @@ async def hotkeys_start( "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - async def hotkeys_stop(self, **kwargs) -> ResponseT: + async def hotkeys_stop(self, **kwargs) -> Awaitable[Union[str, bytes]]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -1001,7 +1002,7 @@ async def hotkeys_stop(self, **kwargs) -> ResponseT: "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - async def hotkeys_reset(self, **kwargs) -> ResponseT: + async def hotkeys_reset(self, **kwargs) -> Awaitable[Union[str, bytes]]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. @@ -1011,11 +1012,13 @@ async def hotkeys_reset(self, **kwargs) -> ResponseT: "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." ) - async def hotkeys_get(self, **kwargs) -> ResponseT: + async def hotkeys_get( + self, **kwargs + ) -> Awaitable[list[dict[Union[str, bytes], Any]]]: """ Cluster client does not support hotkeys command. Please use the non-cluster client. - For more information see https://redis.io/commands/hotkeys-get + For more information see https://redis.io/commands/hotkeys-get """ raise NotImplementedError( "HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client." diff --git a/redis/commands/core.py b/redis/commands/core.py index 1335f4b9df..1b5f8ea027 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1419,7 +1419,7 @@ def hotkeys_start( sample_ratio: Optional[int] = None, slots: Optional[List[int]] = None, **kwargs, - ) -> ResponseT: + ) -> Union[Awaitable[Union[str, bytes]], Union[str, bytes]]: """ Start collecting hotkeys data. Returns an error if there is an ongoing collection session. @@ -1459,7 +1459,9 @@ def hotkeys_start( return self.execute_command(*args, **kwargs) - def hotkeys_stop(self, **kwargs) -> ResponseT: + def hotkeys_stop( + self, **kwargs + ) -> Union[Awaitable[Union[str, bytes]], Union[str, bytes]]: """ Stop the ongoing hotkeys collection session (if any). The results of the last collection session are kept for consumption with HOTKEYS GET. @@ -1468,7 +1470,9 @@ def hotkeys_stop(self, **kwargs) -> ResponseT: """ return self.execute_command("HOTKEYS STOP", **kwargs) - def hotkeys_reset(self, **kwargs) -> ResponseT: + def hotkeys_reset( + self, **kwargs + ) -> Union[Awaitable[Union[str, bytes]], Union[str, bytes]]: """ Discard the last hotkeys collection session results (in order to save memory). Error if there is an ongoing collection session. @@ -1477,7 +1481,12 @@ def hotkeys_reset(self, **kwargs) -> ResponseT: """ return self.execute_command("HOTKEYS RESET", **kwargs) - def hotkeys_get(self, **kwargs) -> ResponseT: + def hotkeys_get( + self, **kwargs + ) -> Union[ + Awaitable[list[dict[Union[str, bytes], Any]]], + list[dict[Union[str, bytes], Any]], + ]: """ Retrieve the result of the ongoing collection session (if any), or the last collection session (if any).