redis/node-redis · error · Error
FT.CURSOR: the node serving cursor ${token} on index "${argT
Error message
FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" has left the cluster. What it means
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.
Source
Thrown at packages/client/lib/cluster/request-response-policies/ft-cursor.ts:105
* out or guess.
*/
export const routeFtCursor: RequestRouter = async (slots, parser) => {
const { redisArgs } = parser;
// Malformed raw command (missing index/cursor): forward to any node so the
// server returns its own arity error instead of a client-side TypeError.
if (redisArgs.length < 4) {
return [{ client: await slots.nodeClient(slots.getRandomNode()) }];
}
const token = argToString(redisArgs[3]);
const binding = slots.lookupCursor(token);
if (binding) {
const client = await slots.getMasterByAddress(binding.address);
if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }];
throw new Error(
`FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" ` +
`has left the cluster.`
);
}
throw new Error(
`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.`
);
};
/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */
function withCursorArg(parser: CommandParser, cursorId: string): CommandParser {
const sub = new BasicCommandParser();
const { redisArgs } = parser;
for (let i = 0; i < redisArgs.length; i++) {View on GitHub (pinned to bb5beb5657)
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.
Example fix
// before
let cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;
// ... node fails over ...
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);
// throws: the node serving cursor ... has left the cluster
// after — re-run the aggregation on cursor loss
try {
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '10']);
} catch (e) {
if (/has left the cluster/.test(e.message)) {
const fresh = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });
cursor = fresh.cursor;
} else throw e;
} Defensive patterns
Strategy: fallback
Try / catch
async function readCursorOrRestart(cluster, index, token, count = 100) {
try {
return await cluster.sendCommand(['FT.CURSOR', 'READ', index, token, 'COUNT', String(count)]);
} catch (e) {
if (/has left the cluster/.test(e.message)) {
// serving node gone — cursor is unrecoverable, restart the aggregation
const fresh = await cluster.ft.aggregate(index, '*', { WITHCURSOR: true });
return cluster.sendCommand(['FT.CURSOR', 'READ', index, fresh.cursor, 'COUNT', String(count)]);
}
throw e;
}
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- FT.CURSOR: unknown cursor ${token} on index "${argToString(r
- SCAN: node ${address} serving this cursor has left the clust
- Could not find shard
- Request policy ${requestPolicy} produced no target nodes
- Cannot find node ${address}
AI-assisted analysis of redis/node-redis@bb5beb5657 (2026-08-03).
Data as JSON: /data/errors/f87b02e09d4b4d2d.json.
Report an issue: GitHub.