Skip to content
Open
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
31 changes: 31 additions & 0 deletions docs/client-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
| password | | ACL password or the old "--requirepass" password |
| name | | Client name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname)) |
| database | | Redis database number (see [`SELECT`](https://redis.io/commands/select) command) |
| keyPrefix | | Prefix prepended to every key sent to Redis (ioredis-compatible). See [Key Prefixing](#key-prefixing). |
| modules | | Included [Redis Modules](../README.md#packages) |
| scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) |
| functions | | Function definitions (see [Functions](../README.md#functions)) |
Expand Down Expand Up @@ -90,6 +91,36 @@ createClient({
}
});
```
## Key Prefixing

The `keyPrefix` option prepends a prefix to **every key** sent to Redis. It is an ioredis-compatible
way to isolate keyspaces — for example to isolate tests in CI, or to separate the keys of different
parts of an application (web app, background workers, …) that share a single Redis instance.

```javascript
const client = createClient({ keyPrefix: 'app:' });
await client.connect();

await client.set('key', 'value'); // actually stores 'app:key'
await client.get('key'); // reads 'app:key' -> 'value'
```

The prefix is applied uniformly across the standard client, [cluster](./clustering.md),
[sentinel](./sentinel.md), [pool](./pool.md), and inside [transactions and pipelines](./transactions.md).
In cluster mode the slot is computed from the prefixed key, so routing remains correct.

### Semantics (matching ioredis)

- Only keys **sent** to Redis are prefixed. Keys **returned** by Redis are **not** un-prefixed — e.g.
`KEYS *`, `SCAN`, and `RANDOMKEY` return keys that still include the prefix.
- `SCAN`/`KEYS`/`HSCAN`/… `MATCH` patterns are **not** auto-prefixed. Include the prefix in the
pattern yourself if required (e.g. `client.scan('0', { MATCH: 'app:user:*' })`).
- Pub/Sub channels are **not** prefixed (this includes sharded `SPUBLISH`/`SSUBSCRIBE`), since
channels are a separate namespace from keys.
- The deprecated `parseArgs`/`transformArguments` helper does not apply `keyPrefix`.

`keyPrefix` may be a `string` or a `Buffer`.

## Connection Pooling

In most cases, a single Redis connection is sufficient, as the node-redis client efficiently handles commands using an underlying socket. Unlike traditional databases, Redis does not require connection pooling for optimal performance.
Expand Down
37 changes: 32 additions & 5 deletions packages/client/lib/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ export interface RedisClientOptions<
* Redis database number (see [`SELECT`](https://redis.io/commands/select) command)
*/
database?: number;
/**
* Prefix prepended to every key sent to Redis (ioredis-compatible `keyPrefix`).
*
* Useful for isolating keyspaces — for example per-test isolation in CI, or
* separating an application's components (web app vs. background workers) within a
* single Redis instance.
*
* Matches ioredis semantics: only keys *sent* to Redis are prefixed. Keys *returned*
* by Redis (e.g. `KEYS`, `SCAN`, `RANDOMKEY`) are NOT un-prefixed, `SCAN`/`KEYS`
* `MATCH` patterns are NOT auto-prefixed, and Pub/Sub channels are NOT prefixed.
*
* @example
* ```
* const client = createClient({ keyPrefix: 'app:' });
* await client.set('key', 'value'); // stored as 'app:key'
* ```
*/
keyPrefix?: RedisArgument;
/**
* Maximum length of the client's internal command queue
*/
Expand Down Expand Up @@ -291,7 +309,7 @@ export default class RedisClient<
const transformReply = getTransformReply(command, resp);

return async function (this: ProxyClient, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self._keyPrefix);
command.parseCommand(parser, ...args);

return this._self._executeCommand(command, parser, this._commandOptions, transformReply);
Expand All @@ -302,7 +320,7 @@ export default class RedisClient<
const transformReply = getTransformReply(command, resp);

return async function (this: NamespaceProxyClient, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self._keyPrefix);
command.parseCommand(parser, ...args);

return this._self._executeCommand(command, parser, this._self._commandOptions, transformReply);
Expand All @@ -314,7 +332,7 @@ export default class RedisClient<
const transformReply = getTransformReply(fn, resp);

return async function (this: NamespaceProxyClient, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self._keyPrefix);
parser.push(...prefix);
fn.parseCommand(parser, ...args);

Expand All @@ -327,7 +345,7 @@ export default class RedisClient<
const transformReply = getTransformReply(script, resp);

return async function (this: ProxyClient, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self._keyPrefix);
parser.push(...prefix);
script.parseCommand(parser, ...args)

Expand Down Expand Up @@ -548,6 +566,14 @@ export default class RedisClient<
return this._self.#options;
}

/**
* The configured key prefix (see {@link RedisClientOptions.keyPrefix}), if any.
* @internal
*/
get _keyPrefix(): RedisArgument | undefined {
return this._self.#options.keyPrefix;
}

/**
* @internal
* Returns the client ID for metrics attribution.
Expand Down Expand Up @@ -1606,7 +1632,8 @@ export default class RedisClient<
return new ((this as unknown as { Multi: Multi }).Multi)(
this._executeMulti.bind(this),
this._executePipeline.bind(this),
this._commandOptions?.typeMapping
this._commandOptions?.typeMapping,
this._self._keyPrefix
);
}

Expand Down
176 changes: 176 additions & 0 deletions packages/client/lib/client/key-prefix.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';

const PREFIX = 'test-prefix:';

/**
* End-to-end coverage for the `keyPrefix` option. Because replies are NOT un-prefixed
* (ioredis parity), `KEYS *` reveals the raw keys stored on the server and is used here
* to assert that the prefix was actually applied on the wire.
*/
describe('keyPrefix', () => {
const OPTIONS = {
...GLOBAL.SERVERS.OPEN,
clientOptions: { keyPrefix: PREFIX }
};

testUtils.testWithClient('prefixes keys sent to the server and round-trips transparently', async client => {
await client.set('key', 'value');

// the value is readable through the (prefixed) client
assert.equal(await client.get('key'), 'value');
// ...and the key is actually stored prefixed on the server
assert.deepEqual(await client.keys('*'), [`${PREFIX}key`]);
}, OPTIONS);

testUtils.testWithClient('does not un-prefix keys returned by the server', async client => {
await client.set('key', 'value');
const keys = await client.keys('*');
// ioredis parity: returned keys keep the prefix
assert.deepEqual(keys, [`${PREFIX}key`]);
assert.notDeepEqual(keys, ['key']);
}, OPTIONS);

testUtils.testWithClient('prefixes every key of a multi-key command', async client => {
await client.mSet([['a', '1'], ['b', '2']]);

assert.deepEqual(await client.mGet(['a', 'b']), ['1', '2']);
assert.deepEqual((await client.keys('*')).sort(), [`${PREFIX}a`, `${PREFIX}b`]);

assert.equal(await client.del(['a', 'b']), 2);
}, OPTIONS);

testUtils.testWithClient('prefixes both keys of a two-key command (COPY)', async client => {
await client.set('source', 'value');
await client.copy('source', 'destination');

assert.equal(await client.get('destination'), 'value');
assert.deepEqual((await client.keys('*')).sort(), [`${PREFIX}destination`, `${PREFIX}source`]);
}, OPTIONS);

testUtils.testWithClient('prefixes keys inside a transaction (MULTI/EXEC)', async client => {
const replies = await client.multi()
.set('key', 'value')
.get('key')
.exec();

assert.deepEqual(replies, ['OK', 'value']);
assert.deepEqual(await client.keys('*'), [`${PREFIX}key`]);
}, OPTIONS);

testUtils.testWithClient('prefixes the key of APPEND (regression for push vs pushKey)', async client => {
await client.append('key', 'foo');
await client.append('key', 'bar');

assert.equal(await client.get('key'), 'foobar');
assert.deepEqual(await client.keys('*'), [`${PREFIX}key`]);
}, OPTIONS);

testUtils.testWithClient('prefixes the STORE destination of SORT', async client => {
await client.rPush('list', ['3', '1', '2']);
await client.sortStore('list', 'sorted');

assert.deepEqual(await client.lRange('sorted', 0, -1), ['1', '2', '3']);
assert.deepEqual((await client.keys('*')).sort(), [`${PREFIX}list`, `${PREFIX}sorted`]);
}, OPTIONS);

testUtils.testWithClient('prefixes the KEYS of a script while keeping numkeys correct', async client => {
await client.eval(
"redis.call('SET', KEYS[1], ARGV[1]); return 1",
{
keys: ['key'],
arguments: ['value']
}
);

assert.equal(await client.get('key'), 'value');
assert.deepEqual(await client.keys('*'), [`${PREFIX}key`]);
}, OPTIONS);

testUtils.testWithClient('does not prefix Pub/Sub channels', async publisher => {
const subscriber = publisher.duplicate();
await subscriber.connect();

try {
let resolve!: (msg: string) => void;
const received = new Promise<string>(r => { resolve = r; });

await subscriber.subscribe('channel', message => resolve(message));
await publisher.publish('channel', 'hello');

assert.equal(await received, 'hello');
} finally {
subscriber.destroy();
}
}, OPTIONS);

testUtils.testWithClientPool('applies keyPrefix to commands sent through a pool', async pool => {
await pool.set('key', 'value');

assert.equal(await pool.get('key'), 'value');
assert.deepEqual(await pool.keys('*'), [`${PREFIX}key`]);
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: { keyPrefix: PREFIX }
});

testUtils.testWithClientPool('applies keyPrefix inside a pool transaction', async pool => {
const replies = await pool.multi()
.set('key', 'value')
.get('key')
.exec();

assert.deepEqual(replies, ['OK', 'value']);
assert.deepEqual(await pool.keys('*'), [`${PREFIX}key`]);
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: { keyPrefix: PREFIX }
});

testUtils.testWithCluster('applies keyPrefix and routes on the prefixed key', async cluster => {
await cluster.set('key', 'value');
assert.equal(await cluster.get('key'), 'value');

// Node clients are not prefixed, so reading them back reveals the raw stored key,
// proving the cluster applied the prefix and routed to the slot of the prefixed key.
const stored: Array<string> = [];
for (const master of cluster.masters) {
const node = await cluster.nodeClient(master);
stored.push(...(await node.keys('*')) as Array<string>);
}
assert.deepEqual(stored, [`${PREFIX}key`]);

assert.equal(await cluster.del('key'), 1);
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: { keyPrefix: PREFIX }
});

testUtils.testWithCluster('applies keyPrefix inside a cluster transaction', async cluster => {
const replies = await cluster.multi()
.set('key', 'value')
.get('key')
.exec();

assert.deepEqual(replies, ['OK', 'value']);
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: { keyPrefix: PREFIX }
});

testUtils.testWithClientSentinel('applies keyPrefix to commands sent through sentinel', async sentinel => {
await sentinel.set('key', 'value');

assert.equal(await sentinel.get('key'), 'value');
assert.deepEqual(await sentinel.keys('*'), [`${PREFIX}key`]);

// transactions go through a separate multi-command path
const replies = await sentinel.multi()
.get('key')
.exec();
assert.deepEqual(replies, ['value']);
}, {
...GLOBAL.SENTINEL.OPEN,
sentinelOptions: { keyPrefix: PREFIX }
});
});
12 changes: 7 additions & 5 deletions packages/client/lib/client/multi-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export default class RedisClientMultiCommand<REPLIES = []> {
const transformReply = getTransformReply(command, resp);

return function (this: RedisClientMultiCommand, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this.#keyPrefix);
command.parseCommand(parser, ...args);

const redisArgs: CommandArguments = parser.redisArgs;
Expand All @@ -125,7 +125,7 @@ export default class RedisClientMultiCommand<REPLIES = []> {
const transformReply = getTransformReply(command, resp);

return function (this: { _self: RedisClientMultiCommand }, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self.#keyPrefix);
command.parseCommand(parser, ...args);

const redisArgs: CommandArguments = parser.redisArgs;
Expand All @@ -143,7 +143,7 @@ export default class RedisClientMultiCommand<REPLIES = []> {
const transformReply = getTransformReply(fn, resp);

return function (this: { _self: RedisClientMultiCommand }, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this._self.#keyPrefix);
parser.push(...prefix);
fn.parseCommand(parser, ...args);

Expand All @@ -161,7 +161,7 @@ export default class RedisClientMultiCommand<REPLIES = []> {
const transformReply = getTransformReply(script, resp);

return function (this: RedisClientMultiCommand, ...args: Array<unknown>) {
const parser = new BasicCommandParser();
const parser = new BasicCommandParser(this.#keyPrefix);
script.parseCommand(parser, ...args);

const redisArgs: CommandArguments = parser.redisArgs;
Expand Down Expand Up @@ -195,13 +195,15 @@ export default class RedisClientMultiCommand<REPLIES = []> {
readonly #multi: RedisMultiCommand
readonly #executeMulti: ExecuteMulti;
readonly #executePipeline: ExecuteMulti;
readonly #keyPrefix?: RedisArgument;

#selectedDB?: number;

constructor(executeMulti: ExecuteMulti, executePipeline: ExecuteMulti, typeMapping?: TypeMapping) {
constructor(executeMulti: ExecuteMulti, executePipeline: ExecuteMulti, typeMapping?: TypeMapping, keyPrefix?: RedisArgument) {
this.#multi = new RedisMultiCommand(typeMapping);
this.#executeMulti = executeMulti;
this.#executePipeline = executePipeline;
this.#keyPrefix = keyPrefix;
}

SELECT(db: number, transformReply?: TransformReply): this {
Expand Down
Loading