{"id":"f87b02e09d4b4d2d","repo":"redis/node-redis","slug":"ft-cursor-the-node-serving-cursor-token-on-ind","errorCode":null,"errorMessage":"FT.CURSOR: the node serving cursor ${token} on index \"${argToString(redisArgs[2])}\" has left the cluster.","messagePattern":"FT\\.CURSOR: the node serving cursor (.+?) on index \"(.+?)\" has left the cluster\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/client/lib/cluster/request-response-policies/ft-cursor.ts","lineNumber":105,"sourceCode":" * out or guess.\n */\nexport const routeFtCursor: RequestRouter = async (slots, parser) => {\n  const { redisArgs } = parser;\n\n  // Malformed raw command (missing index/cursor): forward to any node so the\n  // server returns its own arity error instead of a client-side TypeError.\n  if (redisArgs.length < 4) {\n    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];\n  }\n\n  const token = argToString(redisArgs[3]);\n\n  const binding = slots.lookupCursor(token);\n  if (binding) {\n    const client = await slots.getMasterByAddress(binding.address);\n    if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }];\n\n    throw new Error(\n      `FT.CURSOR: the node serving cursor ${token} on index \"${argToString(redisArgs[2])}\" ` +\n      `has left the cluster.`\n    );\n  }\n\n  throw new Error(\n    `FT.CURSOR: unknown cursor ${token} on index \"${argToString(redisArgs[2])}\". ` +\n    `Cluster cursors are minted per client instance and expire when idle — ` +\n    `the cursor was not created by this client, has already been exhausted, ` +\n    `or has expired.`\n  );\n};\n\n/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */\nfunction withCursorArg(parser: CommandParser, cursorId: string): CommandParser {\n  const sub = new BasicCommandParser();\n  const { redisArgs } = parser;\n  for (let i = 0; i < redisArgs.length; i++) {","sourceCodeStart":87,"sourceCodeEnd":123,"githubUrl":"https://github.com/redis/node-redis/blob/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/cluster/request-response-policies/ft-cursor.ts#L87-L123","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Restart the FT.AGGREGATE from scratch — the cursor is unrecoverable once its node is gone.","Use a shorter MAXIDLE so cursors complete before likely topology events, or page through results faster.","Catch this specific error and fall back to re-running the aggregation (consider caching the query params).","During planned resharding, drain outstanding cursors (read until cursor 0) before removing nodes."],"exampleFix":"// before\nlet cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;\n// ... node fails over ...\nawait cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);\n// throws: the node serving cursor ... has left the cluster\n\n// after — re-run the aggregation on cursor loss\ntry {\n  await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);\n} catch (e) {\n  if (/has left the cluster/.test(e.message)) {\n    const fresh = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });\n    cursor = fresh.cursor;\n  } else throw e;\n}","handlingStrategy":"fallback","validationCode":null,"typeGuard":null,"tryCatchPattern":"async function readCursorOrRestart(cluster, index, token, count = 100) {\n  try {\n    return await cluster.sendCommand(['FT.CURSOR', 'READ', index, token, 'COUNT', String(count)]);\n  } catch (e) {\n    if (/has left the cluster/.test(e.message)) {\n      // serving node gone — cursor is unrecoverable, restart the aggregation\n      const fresh = await cluster.ft.aggregate(index, '*', { WITHCURSOR: true });\n      return cluster.sendCommand(['FT.CURSOR', 'READ', index, fresh.cursor, 'COUNT', String(count)]);\n    }\n    throw e;\n  }\n}","preventionTips":["Drain FT.AGGREGATE cursors (read until cursor 0) before planned resharding or node removal.","Use a shorter MAXIDLE so cursors complete before likely topology events.","Cache the aggregation query so you can restart on cursor loss.","Don't hold cursors across long idle periods during cluster maintenance windows."],"tags":["cluster","ft-cursor","ft-aggregate","topology","failover","redisearch"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}