{"record":{"id":"d50b85084fe70d28","repo":"redis/node-redis","slug":"ft-cursor-unknown-cursor-token-on-index-arg","errorCode":null,"errorMessage":"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.","messagePattern":"FT\\.CURSOR: unknown cursor (.+?) on index \"(.+?)\"\\. 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\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/client/lib/cluster/request-response-policies/ft-cursor.ts","lineNumber":111,"sourceCode":"  // 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++) {\n    sub.push(i === 3 ? cursorId : redisArgs[i] as RedisArgument);\n  }\n  return sub;\n}\n\n/**","sourceCodeStart":93,"sourceCodeEnd":129,"githubUrl":"https://github.com/redis/node-redis/blob/90fd0652bc3f2a0a1b2f79fa9096b02a86b0ac58/packages/client/lib/cluster/request-response-policies/ft-cursor.ts#L93-L129","documentation":"Thrown when an FT.CURSOR READ or FT.CURSOR DEL token does not match any binding in this client's cursor map. Cluster cursors are client-minted virtual tokens bound per-client-instance: the token was either never created by this client, already exhausted (cursor reached 0 and the binding was evicted), or expired due to idle timeout (MAXIDLE).","triggerScenarios":"Using a cursor token from a different client instance (e.g. after reconnect, or from a different process); re-reading a cursor whose previous READ returned cursor=0 (exhausted); idle period between READ calls exceeded the MAXIDLE TTL; passing a raw server cursor id instead of the client-minted token.","commonSituations":"Cursor serialized and deserialized across process restarts; long pause between paginated reads; cursor shared between multiple worker processes; cursor reused after the final page was already returned.","solutions":["Re-run FT.AGGREGATE ...WITHCURSOR to obtain a new cursor token","Keep all FT.CURSOR READ calls within the same client instance — do not share tokens across connections or processes","Track cursor exhaustion in application code: when a READ returns cursor=0, stop calling READ on that token","Reduce idle time between READ calls to stay within MAXIDLE, or set MAXIDLE 0 for no idle limit"],"exampleFix":"// before — reusing an exhausted cursor\nconst result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });\nconst page1 = await cluster.ft.cursorRead('myIdx', result.cursor);\n// page1.cursor === 0 means exhausted, binding evicted\nconst page2 = await cluster.ft.cursorRead('myIdx', result.cursor); // throws: unknown cursor\n\n// after — check for exhaustion before re-reading\nconst result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });\nlet cursor = result.cursor;\nwhile (true) {\n  const page = await cluster.ft.cursorRead('myIdx', cursor);\n  // process page.results ...\n  if (page.cursor === 0) break; // exhausted — stop\n  cursor = page.cursor;\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isUnknownCursor(err: unknown): boolean {\n  return err instanceof Error && err.message.includes('unknown cursor');\n}","tryCatchPattern":"try {\n  await cluster.ft.cursorRead('myIdx', cursor);\n} catch (err) {\n  if (err instanceof Error && err.message.includes('unknown cursor')) {\n    // cursor expired or was exhausted — re-run the aggregate\n    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });\n  } else {\n    throw err;\n  }\n}","preventionTips":["Track cursor exhaustion in application code — stop reading when cursor returns 0","Keep all FT.CURSOR READ/DEL calls on the same client instance that created the cursor","Never serialize or share cursor tokens across processes or client instances","Set MAXIDLE to 0 for no idle timeout, or keep reads frequent enough to stay within the TTL"],"tags":["cluster","redisearch","cursor","lifecycle"],"backgroundTag":null,"analyzedSha":"90fd0652bc3f2a0a1b2f79fa9096b02a86b0ac58","analyzedAt":"2026-08-11T15:37:21.243Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}