{"record":{"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/90fd0652bc3f2a0a1b2f79fa9096b02a86b0ac58/packages/client/lib/cluster/request-response-policies/ft-cursor.ts#L87-L123","documentation":"Thrown when an FT.CURSOR READ or FT.CURSOR DEL token is recognized (it was minted by this client instance), but the node address stored in the cursor binding can no longer be resolved via getMasterByAddress — even after topology data. The node that held the search cursor has left the cluster (crash, failover, or removal), and the server-side cursor state is lost with it.","triggerScenarios":"FT.AGGREGATE ...WITHCURSOR created a cursor on node X; node X then failed over or was removed from the cluster; a subsequent FT.CURSOR READ tries to route to X but getMasterByAddress returns undefined.","commonSituations":"RediSearch aggregate queries with cursors during cluster maintenance; node crash mid-pagination; failover event between FT.AGGREGATE and FT.CURSOR READ calls.","solutions":["Re-run the original FT.AGGREGATE ...WITHCURSOR query to obtain a fresh cursor on the new serving node","Use larger COUNT values to reduce the number of continuation reads needed (fewer round-trips = less exposure to node loss)","Avoid long-lived cursors during cluster maintenance windows"],"exampleFix":"// before — cursor becomes invalid after node failover\nconst { cursor } = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });\n// ... node fails over ...\nawait cluster.ft.cursorRead('myIdx', cursor); // throws: node has left\n\n// after — catch and re-run the query\ntry {\n  await cluster.ft.cursorRead('myIdx', cursor);\n} catch (err) {\n  if (err instanceof Error && err.message.includes('has left the cluster')) {\n    // re-run from scratch\n    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true, COUNT: 1000 });\n    // continue pagination with the new cursor\n  }\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isCursorNodeLost(err: unknown): boolean {\n  return err instanceof Error && err.message.includes('has left the cluster');\n}","tryCatchPattern":"try {\n  await cluster.ft.cursorRead('myIdx', cursor);\n} catch (err) {\n  if (err instanceof Error && err.message.includes('has left the cluster')) {\n    // node failover — re-run the original aggregate\n    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true, COUNT: 1000 });\n    // continue with the new cursor\n  } else {\n    throw err;\n  }\n}","preventionTips":["Use larger COUNT values to reduce the number of FT.CURSOR READ round-trips (less exposure to node loss)","Avoid long-lived RediSearch cursors during cluster maintenance or failover windows","Monitor cluster node health and avoid queries with cursors on unstable nodes"],"tags":["cluster","redisearch","cursor","failover"],"backgroundTag":null,"analyzedSha":"90fd0652bc3f2a0a1b2f79fa9096b02a86b0ac58","analyzedAt":"2026-08-11T15:37:21.243Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}