{"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/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/cluster/request-response-policies/ft-cursor.ts#L93-L129","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Iterate FT.CURSOR READ on the same cluster client instance that issued the FT.AGGREGATE.","Don't persist or share cursor tokens across processes/clients — they are per-instance handles.","Drive the cursor loop promptly (within MAXIDLE) and stop when the returned cursor is 0/exhausted.","If you genuinely need resumable scans across restarts, re-issue FT.AGGREGATE rather than reusing a stale token."],"exampleFix":"// before — token reused after exhaustion, or from another client\nconst { cursor } = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });\nawait readAll(cluster, cursor); // loop ends, binding evicted\nawait cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor]); // throws: unknown cursor\n\n// after — stop at exhaustion, never reuse a consumed token\nlet cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;\ndo {\n  const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '100']);\n  cursor = extractCursor(batch);\n} while (cursor !== 0 && cursor !== '0');","handlingStrategy":"validation","validationCode":"// Track cursor lifecycle in the caller so you never reuse a stale token.\nasync function iterateAggregate(cluster, index, query) {\n  const first = await cluster.ft.aggregate(index, query, { WITHCURSOR: true });\n  let cursor = first.cursor;\n  const all = [...first.results];\n  while (cursor !== 0 && cursor !== '0' && cursor !== undefined) {\n    const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', index, String(cursor), 'COUNT', '100']);\n    // extractCursorValue mirrors the library's own extraction\n    const next = Array.isArray(batch) ? batch[1] : (batch?.cursor ?? 0);\n    all.push(...(Array.isArray(batch) ? batch[0] : batch.results ?? []));\n    cursor = next;\n  }\n  return all;\n}","typeGuard":null,"tryCatchPattern":"try {\n  await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', token]);\n} catch (e) {\n  if (/unknown cursor/.test(e.message)) {\n    // token was never valid here, exhausted, or expired — restart the aggregation\n  } else throw e;\n}","preventionTips":["Always iterate FT.CURSOR on the same cluster client instance that issued the FT.AGGREGATE.","Never persist, serialize, or share cursor tokens across processes/clients.","Stop iterating once the returned cursor is 0 — the binding is then evicted.","Complete the cursor loop within MAXIDLE (default 300s) to avoid idle-expiry."],"tags":["cluster","ft-cursor","ft-aggregate","redisearch","cursor-lifecycle"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}