{"record":{"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":890,"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":872,"sourceCodeEnd":908,"githubUrl":"https://github.com/redis/redis-py/blob/6a6b581b48225afa0b76912d1028c6035baee932/redis/asyncio/cluster.py#L872-L908","documentation":"Raised by get_special_nodes() in the async cluster client when an FT.CURSOR command is dispatched but self._aggregate_nodes is empty. The cluster client only populates _aggregate_nodes as a side effect of running FT.AGGREGATE (cluster.py:979), so it knows which shard produced the cursor. Without that prior call there is no node to send the cursor read to, so the library refuses rather than guessing.","triggerScenarios":"Calling FT.CURSOR (e.g. client.ft().cursor_read(...) / cursor_delete) on a RedisCluster client before any FT.AGGREGATE has been executed against it in the current slots-cache lifetime, or on a freshly initialized client where the cached aggregate nodes were cleared by a reinitialize() call.","commonSituations":"Using redis-py's search module with a RedisCluster handle and resuming an aggregate cursor across two separate client instances or after a cluster topology refresh wiped _aggregate_nodes. Also from manually executing execute_command('FT.CURSOR', ...) without a preceding FT.AGGREGATE.","solutions":["Run FT.AGGREGATE on the same RedisCluster client first, then immediately issue FT.CURSOR on the returned cursor id.","Keep the aggregate and cursor calls on the same client instance; do not reinitialize the cluster between them.","If the cluster reinitialized (MOVED storm / resharding), re-issue the FT.AGGREGATE to obtain a fresh cursor and node set.","Avoid persisting a cursor id across process/client restarts; cursors are tied to a specific shard and the client's cached aggregate nodes."],"exampleFix":"// before\nrc = RedisCluster(host='localhost', port=7000)\nawait rc.execute_command('FT.CURSOR', 'idx', '0', 'COUNT', 10)\n\n// after\nrc = RedisCluster(host='localhost', port=7000)\nawait rc.execute_command('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'COUNT', 10)\nawait rc.execute_command('FT.CURSOR', 'idx', '<cursor_id>', 'COUNT', 10)","handlingStrategy":"validation","validationCode":"# Before issuing FT.CURSOR, confirm the client has aggregate nodes cached\ndef assert_aggregate_ready(rc):\n    if not getattr(rc, '_aggregate_nodes', None):\n        raise RuntimeError('Run FT.AGGREGATE on this client before FT.CURSOR')\n\nawait assert_aggregate_ready(rc)\nawait rc.execute_command('FT.CURSOR', 'idx', cursor_id, 'COUNT', 10)","typeGuard":"def has_aggregate_nodes(rc) -> bool:\n    return bool(getattr(rc, '_aggregate_nodes', None))","tryCatchPattern":"from redis.exceptions import RedisClusterException\ntry:\n    await rc.execute_command('FT.CURSOR', 'idx', cursor_id)\nexcept RedisClusterException as e:\n    if 'FT.CURSOR commands without FT.AGGREGATE' in str(e):\n        await rc.execute_command('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR')\n        # then retry the cursor read","preventionTips":["Always pair FT.CURSOR with a preceding FT.AGGREGATE on the same client instance.","Do not reuse cursor ids after reinitialize(); re-run the aggregate to get fresh nodes + cursor.","Treat cursors as short-lived session state bound to one cluster client."],"tags":["search","cluster","redis-stack","ft-cursor","routing"],"backgroundTag":null,"analyzedSha":"6a6b581b48225afa0b76912d1028c6035baee932","analyzedAt":"2026-08-10T12:52:44.840Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-21T04:17:39.646Z"}