{"id":"621e33d293793041","repo":"redis/node-redis","slug":"scan-unknown-cursor-cursorarg-cluster-wide","errorCode":null,"errorMessage":"SCAN: unknown cursor \"${cursorArg}\". Cluster-wide SCAN cursors are minted per client instance and expire when idle — restart the scan from 0.","messagePattern":"SCAN: unknown cursor \"(.+?)\"\\. Cluster-wide SCAN cursors are minted per client instance and expire when idle — restart the scan from 0\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/client/lib/cluster/request-response-policies/scan-cursor.ts","lineNumber":46,"sourceCode":" */\nexport const routeScan: RequestRouter = async (slots, parser) => {\n  // Malformed raw command (missing cursor): forward to any node so the server\n  // returns its own arity error instead of a client-side TypeError.\n  if (parser.redisArgs.length < 2) {\n    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];\n  }\n\n  const cursorArg = argToString(parser.redisArgs[1]);\n\n  if (cursorArg === '0') {\n    const address = slots.nextScanTarget(EMPTY_VISITED);\n    if (!address) throw new Error('SCAN: no master nodes available');\n    return [{ client: await pinnedMaster(slots, address) }];\n  }\n\n  const entry = slots.lookupScanCursor(cursorArg);\n  if (!entry) {\n    throw new Error(\n      `SCAN: unknown cursor \"${cursorArg}\". Cluster-wide SCAN cursors are ` +\n      `minted per client instance and expire when idle — restart the scan from 0.`\n    );\n  }\n  return [{\n    client: await pinnedMaster(slots, entry.address),\n    parser: withCursor(parser, entry.cursor)\n  }];\n};\n\nconst EMPTY_VISITED: ReadonlySet<string> = new Set();\n\nasync function pinnedMaster(slots: ClusterSlots, address: string) {\n  const client = await slots.getMasterByAddress(address);\n  if (!client) {\n    throw new Error(\n      `SCAN: node ${address} serving this cursor has left the cluster — ` +\n      `restart the scan from 0.`","sourceCodeStart":28,"sourceCodeEnd":64,"githubUrl":"https://github.com/redis/node-redis/blob/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/cluster/request-response-policies/scan-cursor.ts#L28-L64","documentation":"Thrown when the cursor token passed to cluster SCAN does not match any cursor this client instance minted. Cluster-wide SCAN swaps each server cursor for an opaque per-instance token that maps back to (node, real cursor, visited set); tokens expire when idle and never survive across client instances or a restart. The token is opaque on purpose — only the same instance that minted it can resolve it.","triggerScenarios":"Reusing a cursor string from a previous, completed, or expired scan iteration; copying a cursor between two cluster client instances; persisting a cursor to disk and reloading it later; the client's internal cursor map evicted the entry after long idleness between scan pages.","commonSituations":"Long pauses between scan pages (e.g. heavy per-key processing) that exceed the idle eviction window; serializing a scan cursor into a job queue and resuming on a different process/client; splitting a scan loop across reconnects where the client object was recreated.","solutions":["Restart the cluster-wide scan from cursor 0 — the error message itself directs this.","Keep scan pages flowing without long gaps so the internal cursor token is not evicted.","Do not persist, share, or transfer cluster SCAN cursor tokens across client instances or processes.","Run the full scan loop within a single client lifetime."],"exampleFix":"// before: resuming a stale cursor token from a previous run\nlet cursor = loadFromDb('scanCursor');\ndo { const r = await cluster.scan(cursor); cursor = r.cursor; } while (cursor !== '0');\n\n// after: always start a cluster-wide scan at 0\ndo { const r = await cluster.scan(cursor); cursor = r.cursor; /* process promptly */ } while (cursor !== '0');","handlingStrategy":"validation","validationCode":"// Cluster SCAN cursors are opaque per-instance tokens — never persist or reuse.\n// Only validation: start at '0' and do not pass a foreign/stale token.\nfunction startCursor(stored) { return stored === '0' ? '0' : '0'; } // always restart","typeGuard":"function isFreshClusterScanStart(c) { return c === '0'; }","tryCatchPattern":"try { do { const r = await cluster.scan(cursor); cursor = r.cursor; } while (cursor !== '0'); } catch (e) { if (/unknown cursor/.test(e.message)) { cursor = '0'; /* restart, dedupe keys */ continue; } throw e; }","preventionTips":["Never persist, serialize, or share cluster SCAN cursor tokens.","Keep scan pages flowing without long idle gaps.","Run a complete scan within a single client instance lifetime."],"tags":["cluster","scan","cursor","stateful","user-error"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}