{"id":"01d29d4eafbf3b5e","repo":"redis/redis-py","slug":"cannot-execute-ft-cursor-commands-without-ft-aggre","errorCode":null,"errorMessage":"Cannot execute FT.CURSOR commands without FT.AGGREGATE","messagePattern":"Cannot execute FT\\.CURSOR commands without FT\\.AGGREGATE","errorType":"exception","errorClass":"RedisClusterException","httpStatus":null,"severity":"error","filePath":"redis/asyncio/cluster.py","lineNumber":889,"sourceCode":"    async def get_nodes_from_slot(self, command: str, *args):\n        \"\"\"\n        Returns a list of nodes that hold the specified keys' slots.\n        \"\"\"\n        # get the node that holds the key's slot\n        return [\n            self.nodes_manager.get_node_from_slot(\n                await self._determine_slot(command, *args),\n                self.read_from_replicas and command in READ_COMMANDS,\n                self.load_balancing_strategy if command in READ_COMMANDS else None,\n            )\n        ]\n\n    def get_special_nodes(self) -> Optional[list[\"ClusterNode\"]]:\n        \"\"\"\n        Returns a list of nodes for commands with a special policy.\n        \"\"\"\n        if not self._aggregate_nodes:\n            raise RedisClusterException(\n                \"Cannot execute FT.CURSOR commands without FT.AGGREGATE\"\n            )\n\n        return self._aggregate_nodes\n\n    def keyslot(self, key: EncodableT) -> int:\n        \"\"\"\n        Find the keyslot for a given key.\n\n        See: https://redis.io/docs/manual/scaling/#redis-cluster-data-sharding\n        \"\"\"\n        return key_slot(self.encoder.encode(key))\n\n    # HIMPORT orchestration (async mirror of redis.cluster.RedisCluster). The one\n    # shared HImportRegistry is mutated once by PREPARE/DISCARD/DISCARDALL and applied\n    # lazily per node; SET routes by key slot to the owning primary's ClusterNode.\n    # See ``.agents/himport_client_support_spec.md``.\n","sourceCodeStart":871,"sourceCodeEnd":907,"githubUrl":"https://github.com/redis/redis-py/blob/da03cdc7e8731092b13e395605c3c1fb2de25de1/redis/asyncio/cluster.py#L871-L907","documentation":"Thrown by get_special_nodes() when an FT.CURSOR command is issued but no prior FT.AGGREGATE has run on this client. The cluster client caches the nodes used by the last FT.AGGREGATE (self._aggregate_nodes, set at cluster.py:978) so FT.CURSOR can be routed back to the same shard that owns the cursor. Without that cache the cursor's owning node is unknown, so the library refuses to guess.","triggerScenarios":"Calling rc.execute_command('FT.CURSOR', 'READ', idx, cursor_id, ...) or rc.ft().cursor_read(...) as the very first RediSearch command on a RedisCluster client, or after the aggregate cursor has expired/been consumed and _aggregate_nodes was never (re)set. Only FT.AGGREGATE populates _aggregate_nodes; FT.SEARCH, FT.INFO, etc. do not.","commonSituations":"Resuming a cursor across a new client instance that never ran FT.AGGREGATE; refactoring code so the FT.AGGREGATE call path is skipped; cursor id handed to a worker process that did not issue the aggregate; mixing FT.CURSOR READ/DEL with a fresh client connection.","solutions":["Run the FT.AGGREGATE command (with the WITHCURSOR option) on the same RedisCluster instance first, then issue FT.CURSOR READ against the returned cursor id.","Re-run FT.AGGREGATE to regenerate both the result set and a fresh cursor, then chain FT.CURSOR READ on the same client.","If you must target the cursor's node directly, bypass auto-routing by passing target_nodes=<ClusterNode of the shard that owns the cursor>."],"exampleFix":"// before\nasync with RedisCluster.from_url(url) as rc:\n    res = await rc.execute_command('FT.CURSOR', 'READ', 'myidx', 12345)\n\n// after\nasync with RedisCluster.from_url(url) as rc:\n    agg = await rc.execute_command('FT.AGGREGATE', 'myidx', '*', 'WITHCURSOR', 'COUNT', '10')\n    cursor_id = agg[1]\n    res = await rc.execute_command('FT.CURSOR', 'READ', 'myidx', cursor_id)","handlingStrategy":"validation","validationCode":"# Ensure an FT.AGGREGATE ran on this client before issuing FT.CURSOR\nfrom redis.asyncio.cluster import RedisCluster\n\ndef assert_cursor_ready(rc: RedisCluster) -> None:\n    if rc._aggregate_nodes is None:\n        raise RuntimeError(\n            \"No FT.AGGREGATE has run on this client; FT.CURSOR cannot be routed. \"\n            \"Run FT.AGGREGATE ... WITHCURSOR first.\"\n        )\n\n# usage\nassert_cursor_ready(rc)\nawait rc.execute_command('FT.CURSOR', 'READ', idx, cursor_id)","typeGuard":"from typing import Protocol\nfrom redis.asyncio.cluster import RedisCluster\n\nclass CursorReadyClient(Protocol):\n    _aggregate_nodes: object  # not None when an aggregate ran\n\ndef has_aggregate_context(rc: RedisCluster) -> bool:\n    return getattr(rc, '_aggregate_nodes', None) is not None","tryCatchPattern":"from redis.cluster import RedisClusterException\n\ntry:\n    await rc.execute_command('FT.CURSOR', 'READ', idx, cursor_id)\nexcept RedisClusterException as e:\n    if 'FT.AGGREGATE' in str(e):\n        await rc.execute_command('FT.AGGREGATE', idx, '*', 'WITHCURSOR', 'COUNT', 10)\n        # retry FT.CURSOR READ with the fresh cursor\n    else:\n        raise","preventionTips":["Keep FT.AGGREGATE and its subsequent FT.CURSOR READ/DEL calls on the same RedisCluster instance and within the same connection block.","Treat cursor ids as scoped to the client that produced them — never hand a cursor to a fresh client.","If you fan out cursors across workers, pass the owning ClusterNode along with the cursor id."],"tags":["redis-cluster","redisearch","ft-cursor","routing"],"analyzedSha":"da03cdc7e8731092b13e395605c3c1fb2de25de1","analyzedAt":"2026-08-04T20:26:47.563Z","schemaVersion":2}