redis/redis-py · error · RedisClusterException
Cannot execute FT.CURSOR commands without FT.AGGREGATE
Error message
Cannot execute FT.CURSOR commands without FT.AGGREGATE
What it means
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.
Source
Thrown at redis/asyncio/cluster.py:889
async def get_nodes_from_slot(self, command: str, *args):
"""
Returns a list of nodes that hold the specified keys' slots.
"""
# get the node that holds the key's slot
return [
self.nodes_manager.get_node_from_slot(
await self._determine_slot(command, *args),
self.read_from_replicas and command in READ_COMMANDS,
self.load_balancing_strategy if command in READ_COMMANDS else None,
)
]
def get_special_nodes(self) -> Optional[list["ClusterNode"]]:
"""
Returns a list of nodes for commands with a special policy.
"""
if not self._aggregate_nodes:
raise RedisClusterException(
"Cannot execute FT.CURSOR commands without FT.AGGREGATE"
)
return self._aggregate_nodes
def keyslot(self, key: EncodableT) -> int:
"""
Find the keyslot for a given key.
See: https://redis.io/docs/manual/scaling/#redis-cluster-data-sharding
"""
return key_slot(self.encoder.encode(key))
# HIMPORT orchestration (async mirror of redis.cluster.RedisCluster). The one
# shared HImportRegistry is mutated once by PREPARE/DISCARD/DISCARDALL and applied
# lazily per node; SET routes by key slot to the owning primary's ClusterNode.
# See ``.agents/himport_client_support_spec.md``.
View on GitHub (pinned to da03cdc7e8)
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>.
Example fix
// before
async with RedisCluster.from_url(url) as rc:
res = await rc.execute_command('FT.CURSOR', 'READ', 'myidx', 12345)
// after
async with RedisCluster.from_url(url) as rc:
agg = await rc.execute_command('FT.AGGREGATE', 'myidx', '*', 'WITHCURSOR', 'COUNT', '10')
cursor_id = agg[1]
res = await rc.execute_command('FT.CURSOR', 'READ', 'myidx', cursor_id) Defensive patterns
Strategy: validation
Validate before calling
# Ensure an FT.AGGREGATE ran on this client before issuing FT.CURSOR
from redis.asyncio.cluster import RedisCluster
def assert_cursor_ready(rc: RedisCluster) -> None:
if rc._aggregate_nodes is None:
raise RuntimeError(
"No FT.AGGREGATE has run on this client; FT.CURSOR cannot be routed. "
"Run FT.AGGREGATE ... WITHCURSOR first."
)
# usage
assert_cursor_ready(rc)
await rc.execute_command('FT.CURSOR', 'READ', idx, cursor_id) Type guard
from typing import Protocol
from redis.asyncio.cluster import RedisCluster
class CursorReadyClient(Protocol):
_aggregate_nodes: object # not None when an aggregate ran
def has_aggregate_context(rc: RedisCluster) -> bool:
return getattr(rc, '_aggregate_nodes', None) is not None Try / catch
from redis.cluster import RedisClusterException
try:
await rc.execute_command('FT.CURSOR', 'READ', idx, cursor_id)
except RedisClusterException as e:
if 'FT.AGGREGATE' in str(e):
await rc.execute_command('FT.AGGREGATE', idx, '*', 'WITHCURSOR', 'COUNT', 10)
# retry FT.CURSOR READ with the fresh cursor
else:
raise Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- No way to dispatch this command to Redis Cluster. Missing ke
- No targets were found to execute {args} command on
- No targets were found to execute {cmd.args} command on
- {cmd_name.upper()} command doesn't exist in Redis commands
- The requested node does not exist in the cluster.
AI-assisted analysis of redis/redis-py@da03cdc7e8 (2026-08-04).
Data as JSON: /data/errors/01d29d4eafbf3b5e.json.
Report an issue: GitHub.