Sitelet https://github.com/redis/node-redis/pull/3295/files
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
21 changes: 21 additions & 0 deletions docs/v5-to-v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ const client = createClient({ RESP: 2 });
```


## Sentinel: `commandOptions` moved off `nodeClientOptions` / `sentinelClientOptions`

In v5, `createSentinel` accepted `commandOptions` on both the top-level options *and* on `nodeClientOptions` / `sentinelClientOptions`. The wrapper-level value silently overrode the per-node value at dispatch time, so the nested location never actually controlled command behavior. In v6, `commandOptions` is removed from `nodeClientOptions` and `sentinelClientOptions` at the type level — set it on the top-level sentinel options instead.

```javascript
// v5 — silently ignored
const sentinel = createSentinel({
name: 'mymaster',
sentinelRootNodes: [...],
nodeClientOptions: { commandOptions: { timeout: 1000 } }
});

// v6
const sentinel = createSentinel({
name: 'mymaster',
sentinelRootNodes: [...],
commandOptions: { timeout: 1000 }
});
```


## Legacy (callback) mode now uses RESP3

`createClient().legacy()` reads the parent client's RESP version. With the v6 default of RESP3, legacy callback consumers will see RESP3-shaped replies for any command whose transforms differ between protocol versions (for example, doubles arriving as `number` instead of `string`, or hash-like replies arriving as `Map`s). To keep the v5 callback reply shapes, pin `RESP: 2` on the parent client:
Expand Down
74 changes: 74 additions & 0 deletions packages/client/lib/client/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,48 @@ export const SQUARE_SCRIPT = defineScript({
});

describe('Client', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: `_commandOptionsProxy` used to layer `_commandOptions` via
// `Object.create(this._commandOptions ?? null)`, which left earlier keys
// (e.g. `asap`) on the prototype. At dispatch, `{...this._commandOptions, ...}`
// only iterates *own* enumerable properties, so those inherited keys
// silently disappeared in the spread.
const client = RedisClient.create({});
const proxy = client
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('module/function namespaces resolve to the receiver, not the original', () => {
// Regression: `attachNamespace` cached the namespace as an own property
// on the receiver, leaking via the prototype chain into any
// `withCommandOptions(...)` proxy. The proxy then dispatched module/function
// commands through the original's `_self`, silently ignoring the override.
const fakeModule = {
noop: {
parseCommand: () => {},
transformReply: undefined as unknown as () => unknown
}
};
const client = RedisClient.create({ modules: { fakeModule } });
type WithNamespace = { fakeModule: { _self: unknown } };
// Force the original to cache its namespace first — pre-fix this is what
// poisoned every subsequent proxy access.
const originalNamespace = (client as unknown as WithNamespace).fakeModule;
assert.equal(originalNamespace._self, client);
const proxy = client.withCommandOptions({});
const proxyNamespace = (proxy as unknown as WithNamespace).fakeModule;
assert.equal(proxyNamespace._self, proxy);
assert.notEqual(proxyNamespace._self, client);
// Per-receiver cache: subsequent accesses on the same receiver are stable.
assert.equal((client as unknown as WithNamespace).fakeModule, originalNamespace);
assert.equal((proxy as unknown as WithNamespace).fakeModule, proxyNamespace);
});

describe('initialization', () => {
describe('clientSideCache validation', () => {
const clientSideCacheConfig = { ttl: 0, maxEntries: 0 };
Expand Down Expand Up @@ -1314,6 +1356,38 @@ describe('Client', () => {
}, GLOBAL.SERVERS.OPEN);
});

describe('withCommandOptions / withTypeMapping dispatch', () => {
testUtils.testWithClient('withTypeMapping override reaches raw sendCommand', async client => {
// Regression for `client/index.ts:1253` (`this._self._commandOptions` →
// `this._commandOptions`): without this fix, the proxy's `withTypeMapping`
// override was silently ignored at `sendCommand` dispatch.
const typed = client.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('withTypeMapping override reaches typed commands', async client => {
const typed = client.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClient('withCommandOptions full override reaches typed commands', async client => {
// The `withCommandOptions` (full replace) path went through the same
// proxy-dispatch fix; covered separately from `withTypeMapping` because
// the two helpers store overrides differently on the proxy.
const proxy = client.withCommandOptions({
typeMapping: { [RESP_TYPES.SIMPLE_STRING]: Buffer }
});
const resp = await proxy.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);
});

describe("socket errors during handshake", () => {

it("should successfully connect when server accepts connection immediately", async () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/client/lib/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,8 +1043,7 @@ export default class RedisClient<
value: V
) {
const proxy = Object.create(this._self);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClientType<
M,
F,
Expand Down Expand Up @@ -1250,7 +1249,7 @@ export default class RedisClient<

// Merge global options with provided options
const opts = {
...this._self._commandOptions,
...this._commandOptions,
...options,
};

Expand Down
85 changes: 75 additions & 10 deletions packages/client/lib/client/pool.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { RESP_TYPES } from '../RESP/decoder';
import { RedisClientPool } from './pool';

describe('RedisClientPool', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: pool's `_commandOptionsProxy` had two related bugs.
// First, it built `_commandOptions` via `Object.create(...)`, leaving earlier
// keys on the prototype where the dispatch-time spread silently dropped them.
// Second, `withTypeMapping`/`withAbortSignal`/`asap` called the helper via
// `this._self.#commandOptionsProxy(...)`, so even the prototype chain was
// discarded — the helper saw the original pool's `_commandOptions`, not the
// prior proxy's.
const pool = RedisClientPool.create({});
const proxy = pool
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('initializes _commandOptions from clientOptions.commandOptions', () => {
// Regression: when constructor commandOptions weren't propagated to the pool's own
// _commandOptions, the typeMapping equality check in client._executeCommand
// failed and silently bypassed client-side cache for pools.
const commandOptions = { typeMapping: {} };
const pool = RedisClientPool.create({ commandOptions });
const internal = Object.getPrototypeOf(pool) as { _commandOptions?: typeof commandOptions };
assert.equal(internal._commandOptions, commandOptions);
});

it('should not have HOTKEYS commands (requires session affinity)', () => {
// HOTKEYS commands require session affinity and are only available on standalone clients
const pool = RedisClientPool.create({});
assert.equal((pool as any).hotkeysStart, undefined);
assert.equal((pool as any).hotkeysStop, undefined);
assert.equal((pool as any).hotkeysGet, undefined);
assert.equal((pool as any).hotkeysReset, undefined);
assert.equal((pool as any).HOTKEYS_START, undefined);
assert.equal((pool as any).HOTKEYS_STOP, undefined);
assert.equal((pool as any).HOTKEYS_GET, undefined);
assert.equal((pool as any).HOTKEYS_RESET, undefined);
const pool = RedisClientPool.create({}) as unknown as Record<string, unknown>;
assert.equal(pool.hotkeysStart, undefined);
assert.equal(pool.hotkeysStop, undefined);
assert.equal(pool.hotkeysGet, undefined);
assert.equal(pool.hotkeysReset, undefined);
assert.equal(pool.HOTKEYS_START, undefined);
assert.equal(pool.HOTKEYS_STOP, undefined);
assert.equal(pool.HOTKEYS_GET, undefined);
assert.equal(pool.HOTKEYS_RESET, undefined);
});

testUtils.testWithClientPool('sendCommand', async pool => {
Expand All @@ -23,6 +52,42 @@ describe('RedisClientPool', () => {
);
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('withTypeMapping override reaches raw sendCommand', async pool => {
// Regression for `pool.ts:534-535`: pool.sendCommand now merges its own
// `_commandOptions` (which a `withCommandOptions` proxy overrides) before
// dispatching to the leased client.
const typed = pool.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('withTypeMapping override reaches typed commands', async pool => {
const typed = pool.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.SERVERS.OPEN);

testUtils.testWithClientPool('constructor commandOptions reach sendCommand without an explicit proxy', async pool => {
// The stated motivation for storing `_commandOptions` on the pool at
// construction was that the typeMapping needs to reach dispatch — the
// earlier internal-shape test only proved the property is stored.
const resp = await pool.sendCommand(['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
commandOptions: {
typeMapping: {
[RESP_TYPES.SIMPLE_STRING]: Buffer
}
}
}
});

testUtils.testWithClientPool('multi sendCommand', async pool => {
assert.deepEqual(
await pool.multi()
Expand Down Expand Up @@ -96,7 +161,7 @@ describe('RedisClientPool', () => {

testUtils.testWithClientPool('execute rejects when pool is closing', async pool => {
// Start a long-running task to keep the pool busy during close
const task1Promise = pool.execute(async client => {
const task1Promise = pool.execute(async _client => {
await new Promise(resolve => setTimeout(resolve, 100));
return 'task1';
});
Expand Down
19 changes: 12 additions & 7 deletions packages/client/lib/client/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export class RedisClientPool<
}

this.#clientFactory = RedisClient.factory(clientOptions).bind(undefined, clientOptions) as () => RedisClientType<M, F, S, RESP, TYPE_MAPPING>;
this._commandOptions = clientOptions?.commandOptions as CommandOptions<TYPE_MAPPING> | undefined;
}

private _self = this;
Expand All @@ -345,16 +346,19 @@ export class RedisClientPool<
>;
}

#commandOptionsProxy<
// Plain (not `#`) method so it can be invoked on prototype-derived proxies
// returned by `withCommandOptions(...)` — JS private (`#`) methods aren't
// accessible through the prototype chain, which would force the helper to
// be called via `this._self`, discarding any prior proxy overrides.
private _commandOptionsProxy<
K extends keyof CommandOptions,
V extends CommandOptions[K]
>(
key: K,
value: V
) {
const proxy = Object.create(this._self);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClientPoolType<
M,
F,
Expand All @@ -368,22 +372,22 @@ export class RedisClientPool<
* Override the `typeMapping` command option
*/
withTypeMapping<TYPE_MAPPING extends TypeMapping>(typeMapping: TYPE_MAPPING) {
return this._self.#commandOptionsProxy('typeMapping', typeMapping);
return this._commandOptionsProxy('typeMapping', typeMapping);
}

/**
* Override the `abortSignal` command option
*/
withAbortSignal(abortSignal: AbortSignal) {
return this._self.#commandOptionsProxy('abortSignal', abortSignal);
return this._commandOptionsProxy('abortSignal', abortSignal);
}

/**
* Override the `asap` command option to `true`
* TODO: remove?
*/
asap() {
return this._self.#commandOptionsProxy('asap', true);
return this._commandOptionsProxy('asap', true);
}

async connect() {
Expand Down Expand Up @@ -530,7 +534,8 @@ export class RedisClientPool<
args: Array<RedisArgument>,
options?: CommandOptions
) {
return this.execute(client => client.sendCommand(args, options));
const mergedOptions = { ...this._commandOptions, ...options };
return this.execute(client => client.sendCommand(args, mergedOptions));
}


Expand Down
51 changes: 42 additions & 9 deletions packages/client/lib/cluster/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,33 @@ import { SQUARE_SCRIPT } from '../client/index.spec';
import { RootNodesUnavailableError } from '../errors';
import { spy } from 'sinon';
import RedisClient from '../client';
import { RESP_TYPES } from '../RESP/decoder';

describe('Cluster', () => {
it('chained withCommandOptions(...).withTypeMapping(...) preserves earlier overrides at dispatch', () => {
// Regression: cluster's `_commandOptionsProxy` used to layer via `Object.create`,
// leaving earlier keys on the prototype where the dispatch-time spread dropped them.
const cluster = RedisCluster.create({ rootNodes: [] });
const proxy = cluster
.withCommandOptions({ asap: true })
.withTypeMapping({ [RESP_TYPES.SIMPLE_STRING]: Buffer });
type WithOptions = { _commandOptions?: { asap?: boolean; typeMapping?: unknown } };
const ownKeys = { ...(proxy as unknown as WithOptions)._commandOptions };
assert.equal(ownKeys.asap, true);
assert.deepEqual(ownKeys.typeMapping, { [RESP_TYPES.SIMPLE_STRING]: Buffer });
});

it('should not have HOTKEYS commands (requires session affinity)', () => {
// HOTKEYS commands require session affinity and are only available on standalone clients
const cluster = RedisCluster.create({ rootNodes: [] });
assert.equal((cluster as any).hotkeysStart, undefined);
assert.equal((cluster as any).hotkeysStop, undefined);
assert.equal((cluster as any).hotkeysGet, undefined);
assert.equal((cluster as any).hotkeysReset, undefined);
assert.equal((cluster as any).HOTKEYS_START, undefined);
assert.equal((cluster as any).HOTKEYS_STOP, undefined);
assert.equal((cluster as any).HOTKEYS_GET, undefined);
assert.equal((cluster as any).HOTKEYS_RESET, undefined);
const cluster = RedisCluster.create({ rootNodes: [] }) as unknown as Record<string, unknown>;
assert.equal(cluster.hotkeysStart, undefined);
assert.equal(cluster.hotkeysStop, undefined);
assert.equal(cluster.hotkeysGet, undefined);
assert.equal(cluster.hotkeysReset, undefined);
assert.equal(cluster.HOTKEYS_START, undefined);
assert.equal(cluster.HOTKEYS_STOP, undefined);
assert.equal(cluster.HOTKEYS_GET, undefined);
assert.equal(cluster.HOTKEYS_RESET, undefined);
});

testUtils.testWithCluster('sendCommand', async cluster => {
Expand All @@ -27,6 +41,25 @@ describe('Cluster', () => {
);
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('withTypeMapping override reaches raw sendCommand', async cluster => {
// Regression for `cluster/index.ts:538` (`this._self._commandOptions` →
// `this._commandOptions`): without this fix, `withTypeMapping`/`withCommandOptions`
// proxies were silently ignored at cluster dispatch.
const typed = cluster.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.sendCommand(undefined, true, ['PING']);
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('withTypeMapping override reaches typed commands', async cluster => {
const typed = cluster.withTypeMapping({
[RESP_TYPES.SIMPLE_STRING]: Buffer
});
const resp = await typed.ping();
assert.deepEqual(resp, Buffer.from('PONG'));
}, GLOBAL.CLUSTERS.OPEN);

testUtils.testWithCluster('isOpen', async cluster => {
assert.equal(cluster.isOpen, true);
await cluster.destroy();
Expand Down
5 changes: 2 additions & 3 deletions packages/client/lib/cluster/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,7 @@ export default class RedisCluster<
value: V
) {
const proxy = Object.create(this);
proxy._commandOptions = Object.create(this._commandOptions ?? null);
proxy._commandOptions[key] = value;
proxy._commandOptions = { ...this._commandOptions, [key]: value };
return proxy as RedisClusterType<
M,
F,
Expand Down Expand Up @@ -535,7 +534,7 @@ export default class RedisCluster<

// Merge global options with local options
const opts = {
...this._self._commandOptions,
...this._commandOptions,
...options
}
return this._self._execute(
Expand Down
Loading
Loading