redis/node-redis · error · Error

FT.CURSOR: the node serving cursor ${token} on index "${argT

Error message

FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" has left the cluster.

What it means

FT.CURSOR's sticky router found the caller's token in the cursor-binding map, but the master that originally served the FT.AGGREGATE (recorded by address at mint time) is no longer in the cluster topology (`getMasterByAddress` returned undefined). The client cannot forward the cursor to a different node because RediSearch cursors live on the node that created them, so it throws rather than guess. This is the cluster-cursor analogue of a stale MOVED.

Source

Thrown at packages/client/lib/cluster/request-response-policies/ft-cursor.ts:105

 * out or guess.
 */
export const routeFtCursor: RequestRouter = async (slots, parser) => {
  const { redisArgs } = parser;

  // Malformed raw command (missing index/cursor): forward to any node so the
  // server returns its own arity error instead of a client-side TypeError.
  if (redisArgs.length < 4) {
    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];
  }

  const token = argToString(redisArgs[3]);

  const binding = slots.lookupCursor(token);
  if (binding) {
    const client = await slots.getMasterByAddress(binding.address);
    if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }];

    throw new Error(
      `FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" ` +
      `has left the cluster.`
    );
  }

  throw new Error(
    `FT.CURSOR: unknown cursor ${token} on index "${argToString(redisArgs[2])}". ` +
    `Cluster cursors are minted per client instance and expire when idle — ` +
    `the cursor was not created by this client, has already been exhausted, ` +
    `or has expired.`
  );
};

/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */
function withCursorArg(parser: CommandParser, cursorId: string): CommandParser {
  const sub = new BasicCommandParser();
  const { redisArgs } = parser;
  for (let i = 0; i < redisArgs.length; i++) {

View on GitHub (pinned to bb5beb5657)

Solutions

  1. Restart the FT.AGGREGATE from scratch — the cursor is unrecoverable once its node is gone.
  2. Use a shorter MAXIDLE so cursors complete before likely topology events, or page through results faster.
  3. Catch this specific error and fall back to re-running the aggregation (consider caching the query params).
  4. During planned resharding, drain outstanding cursors (read until cursor 0) before removing nodes.

Example fix

// before
let cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;
// ... node fails over ...
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);
// throws: the node serving cursor ... has left the cluster

// after — re-run the aggregation on cursor loss
try {
  await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);
} catch (e) {
  if (/has left the cluster/.test(e.message)) {
    const fresh = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });
    cursor = fresh.cursor;
  } else throw e;
}
Defensive patterns

Strategy: fallback

Try / catch

async function readCursorOrRestart(cluster, index, token, count = 100) {
  try {
    return await cluster.sendCommand(['FT.CURSOR', 'READ', index, token, 'COUNT', String(count)]);
  } catch (e) {
    if (/has left the cluster/.test(e.message)) {
      // serving node gone — cursor is unrecoverable, restart the aggregation
      const fresh = await cluster.ft.aggregate(index, '*', { WITHCURSOR: true });
      return cluster.sendCommand(['FT.CURSOR', 'READ', index, fresh.cursor, 'COUNT', String(count)]);
    }
    throw e;
  }
}

Prevention

When it happens

Trigger: Issuing `FT.CURSOR READ` on a token returned by a prior `FT.AGGREGATE ... WITHCURSOR` after the serving shard left the cluster (failover, resharding, or node removal). `routeFtCursor` resolves the token to a binding, then `slots.getMasterByAddress(binding.address)` returns undefined.

Common situations: A failover promotes a replica while an aggregation cursor is mid-iteration; manual cluster resizing that drops the node holding the index/cursor; the node restarted and lost its in-memory cursor state. The cursor's server-side TTL (MAXIDLE) and the client-side eviction make long-idle cursors especially likely to outlive topology changes.

Related errors


AI-assisted analysis of redis/node-redis@bb5beb5657 (2026-08-03). Data as JSON: /data/errors/f87b02e09d4b4d2d.json. Report an issue: GitHub.