redis/node-redis · error · Error

FT.CURSOR: unknown cursor ${token} on index "${argToString(r

Error message

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.

What it means

FT.CURSOR's sticky router could not find the caller's token in the cursor-binding map at all (`lookupCursor` returned undefined). Cluster cursor tokens are client-minted virtual ids bound per RedisCluster instance and evicted on exhaustion, explicit DEL, or idle-expiry past MAXIDLE (default TTL). A MISS means the token is unusable by this client — the router throws before any network call rather than fan out or route to a random node.

Source

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

  // 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++) {
    sub.push(i === 3 ? cursorId : redisArgs[i] as RedisArgument);
  }
  return sub;
}

/**

View on GitHub (pinned to bb5beb5657)

Solutions

  1. Iterate FT.CURSOR READ on the same cluster client instance that issued the FT.AGGREGATE.
  2. Don't persist or share cursor tokens across processes/clients — they are per-instance handles.
  3. Drive the cursor loop promptly (within MAXIDLE) and stop when the returned cursor is 0/exhausted.
  4. If you genuinely need resumable scans across restarts, re-issue FT.AGGREGATE rather than reusing a stale token.

Example fix

// before — token reused after exhaustion, or from another client
const { cursor } = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });
await readAll(cluster, cursor); // loop ends, binding evicted
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor]); // throws: unknown cursor

// after — stop at exhaustion, never reuse a consumed token
let cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;
do {
  const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '100']);
  cursor = extractCursor(batch);
} while (cursor !== 0 && cursor !== '0');
Defensive patterns

Strategy: validation

Validate before calling

// Track cursor lifecycle in the caller so you never reuse a stale token.
async function iterateAggregate(cluster, index, query) {
  const first = await cluster.ft.aggregate(index, query, { WITHCURSOR: true });
  let cursor = first.cursor;
  const all = [...first.results];
  while (cursor !== 0 && cursor !== '0' && cursor !== undefined) {
    const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', index, String(cursor), 'COUNT', '100']);
    // extractCursorValue mirrors the library's own extraction
    const next = Array.isArray(batch) ? batch[1] : (batch?.cursor ?? 0);
    all.push(...(Array.isArray(batch) ? batch[0] : batch.results ?? []));
    cursor = next;
  }
  return all;
}

Try / catch

try {
  await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', token]);
} catch (e) {
  if (/unknown cursor/.test(e.message)) {
    // token was never valid here, exhausted, or expired — restart the aggregation
  } else throw e;
}

Prevention

When it happens

Trigger: Calling `FT.CURSOR READ`/`DEL` with a token that: (a) was minted by a different cluster client instance; (b) was already read to exhaustion (server returned cursor 0, which evicts the binding); (c) was explicitly DEL'd; (d) sat idle longer than MAXIDLE/the default TTL and was swept by `#sweepStaleCursors`; or (e) is simply a wrong/garbage value.

Common situations: Serializing cursors across processes or using a cursor from a pooled/different connection; pausing iteration longer than MAXIDLE (default 300s); reusing a cursor after the loop already saw cursor 0; copy-paste typos in the token.

Related errors


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