Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,49 @@ try {
clientLease.release();
}
```

## Scan Iterator

The sentinel client supports `scanIterator` for iterating over keys on the master node:

```javascript
for await (const keys of sentinel.scanIterator()) {
// ...
}
```

### Behaviour on master failover

SCAN cursors are node-local — a cursor returned by one Redis instance is meaningless on any other instance. Because of this, the sentinel iterator cannot transparently survive a master failover: the in-flight cursor cannot be resumed on the promoted replica, and silently restarting from cursor `0` on the new master would hide both duplicate keys (already yielded from the old master) and data loss (writes that had not yet replicated before the failover).

If a `MASTER_CHANGE` topology event is observed while an iteration is in progress **and** the iterator still needs to issue another `SCAN` (i.e. the cursor has not yet returned to `0`), it throws `ScanIteratorInterruptedError` rather than send a stale, node-local cursor to a different master. The caller decides whether to retry the iteration from scratch, accept the partial result, or fail the surrounding operation.

If the responding master returns `cursor=0` on the same call during which `MASTER_CHANGE` fires, no error is thrown — that node honored SCAN's contract ("every key present at iteration start was returned") and no further calls are needed. SCAN never claims to reflect "the current dataset" at the moment iteration ends, with or without a failover, so this case is not treated as an interruption.

Connection-level errors raised by the underlying client (e.g. `SocketClosedUnexpectedlyError`, `SocketTimeoutError`, `ReconnectStrategyError`) are **not** wrapped. A dropped socket is not by itself evidence of a failover — it may also be a transient network blip on the same master, in which case the cursor is still valid and a higher-level retry policy is appropriate. The original error is propagated as-is, and the caller can distinguish failover from a blip by checking for `ScanIteratorInterruptedError` versus other error types.

In a real failover the dropped socket often precedes the Sentinel `MASTER_CHANGE` event (gated by `down-after-milliseconds`), so callers that want to treat both signals uniformly should catch both `ScanIteratorInterruptedError` **and** connection-class errors:

```javascript
import { ScanIteratorInterruptedError } from '@redis/client';

try {
for await (const keys of sentinel.scanIterator()) {
// ...
}
} catch (err) {
if (err instanceof ScanIteratorInterruptedError) {
// master failed over mid-iteration; restart from the beginning if desired
} else {
throw err;
}
}
```

The iterator listens for the `topology-change` event with `type: "MASTER_CHANGE"`. The listener is attached when the generator body first runs (on the first `.next()` call) and is detached in a `finally` block, so an early `break` out of the `for await` loop will not leak listeners.

The standalone `RedisClient.scanIterator()` still inherits SCAN's documented guarantees, including the possibility of returning the same key multiple times within a single iteration; see the [SCAN guarantees](https://redis.io/docs/latest/commands/scan/#scan-guarantees) page.

### Pool behaviour

The iterator acquires a master client lease only for the duration of each `SCAN` call and releases it before yielding to the consumer. This means commands issued from inside the `for await` loop body (e.g. `sentinel.mGet(keys)`) will not deadlock against the iterator, even with the default `masterPoolSize` of `1`.
2 changes: 1 addition & 1 deletion packages/client/lib/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ type ProxyClient = RedisClient<RedisModules, RedisFunctions, RedisScripts, RespV

type NamespaceProxyClient = { _self: ProxyClient };

interface ScanIteratorOptions {
export interface ScanIteratorOptions {
cursor?: RedisArgument;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/client/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export class RootNodesUnavailableError extends Error {
}
}

export class ScanIteratorInterruptedError extends Error {
constructor(cause?: unknown) {
super('Scan iteration was interrupted by a Sentinel master change', cause === undefined ? undefined : { cause });
}
}

export class ReconnectStrategyError extends Error {
originalError: Error;
socketError: unknown;
Expand Down
Loading