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

  1. 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.
  2. Re-run FT.AGGREGATE to regenerate both the result set and a fresh cursor, then chain FT.CURSOR READ on the same client.
  3. 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

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


AI-assisted analysis of redis/redis-py@da03cdc7e8 (2026-08-04). Data as JSON: /data/errors/01d29d4eafbf3b5e.json. Report an issue: GitHub.