You've already forked node-redis
mirror of
https://github.com/redis/node-redis.git
synced 2025-08-06 02:15:48 +03:00
feat(client): add command timeout option (#3008)
Co-authored-by: Florian Schunk <149071178+florian-schunk@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
79749f2461
commit
65a12d50e7
@@ -37,6 +37,19 @@ try {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Timeout
|
||||||
|
|
||||||
|
This option is similar to the Abort Signal one, but provides an easier way to set timeout for commands. Again, this applies to commands that haven't been written to the socket yet.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const client = createClient({
|
||||||
|
commandOptions: {
|
||||||
|
timeout: 1000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## ASAP
|
## ASAP
|
||||||
|
|
||||||
Commands that are executed in the "asap" mode are added to the beginning of the "to sent" queue.
|
Commands that are executed in the "asap" mode are added to the beginning of the "to sent" queue.
|
||||||
|
@@ -3,7 +3,7 @@ import encodeCommand from '../RESP/encoder';
|
|||||||
import { Decoder, PUSH_TYPE_MAPPING, RESP_TYPES } from '../RESP/decoder';
|
import { Decoder, PUSH_TYPE_MAPPING, RESP_TYPES } from '../RESP/decoder';
|
||||||
import { TypeMapping, ReplyUnion, RespVersions, RedisArgument } from '../RESP/types';
|
import { TypeMapping, ReplyUnion, RespVersions, RedisArgument } from '../RESP/types';
|
||||||
import { ChannelListeners, PubSub, PubSubCommand, PubSubListener, PubSubType, PubSubTypeListeners } from './pub-sub';
|
import { ChannelListeners, PubSub, PubSubCommand, PubSubListener, PubSubType, PubSubTypeListeners } from './pub-sub';
|
||||||
import { AbortError, ErrorReply } from '../errors';
|
import { AbortError, ErrorReply, TimeoutError } from '../errors';
|
||||||
import { MonitorCallback } from '.';
|
import { MonitorCallback } from '.';
|
||||||
|
|
||||||
export interface CommandOptions<T = TypeMapping> {
|
export interface CommandOptions<T = TypeMapping> {
|
||||||
@@ -14,6 +14,10 @@ export interface CommandOptions<T = TypeMapping> {
|
|||||||
* Maps between RESP and JavaScript types
|
* Maps between RESP and JavaScript types
|
||||||
*/
|
*/
|
||||||
typeMapping?: T;
|
typeMapping?: T;
|
||||||
|
/**
|
||||||
|
* Timeout for the command in milliseconds
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandToWrite extends CommandWaitingForReply {
|
export interface CommandToWrite extends CommandWaitingForReply {
|
||||||
@@ -23,6 +27,10 @@ export interface CommandToWrite extends CommandWaitingForReply {
|
|||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
listener: () => unknown;
|
listener: () => unknown;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
timeout: {
|
||||||
|
signal: AbortSignal;
|
||||||
|
listener: () => unknown;
|
||||||
|
} | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandWaitingForReply {
|
interface CommandWaitingForReply {
|
||||||
@@ -153,12 +161,26 @@ export default class RedisCommandsQueue {
|
|||||||
args,
|
args,
|
||||||
chainId: options?.chainId,
|
chainId: options?.chainId,
|
||||||
abort: undefined,
|
abort: undefined,
|
||||||
|
timeout: undefined,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
channelsCounter: undefined,
|
channelsCounter: undefined,
|
||||||
typeMapping: options?.typeMapping
|
typeMapping: options?.typeMapping
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const timeout = options?.timeout;
|
||||||
|
if (timeout) {
|
||||||
|
const signal = AbortSignal.timeout(timeout);
|
||||||
|
value.timeout = {
|
||||||
|
signal,
|
||||||
|
listener: () => {
|
||||||
|
this.#toWrite.remove(node);
|
||||||
|
value.reject(new TimeoutError());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', value.timeout.listener, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
const signal = options?.abortSignal;
|
const signal = options?.abortSignal;
|
||||||
if (signal) {
|
if (signal) {
|
||||||
value.abort = {
|
value.abort = {
|
||||||
@@ -181,6 +203,7 @@ export default class RedisCommandsQueue {
|
|||||||
args: command.args,
|
args: command.args,
|
||||||
chainId,
|
chainId,
|
||||||
abort: undefined,
|
abort: undefined,
|
||||||
|
timeout: undefined,
|
||||||
resolve() {
|
resolve() {
|
||||||
command.resolve();
|
command.resolve();
|
||||||
resolve();
|
resolve();
|
||||||
@@ -299,6 +322,7 @@ export default class RedisCommandsQueue {
|
|||||||
args: ['MONITOR'],
|
args: ['MONITOR'],
|
||||||
chainId: options?.chainId,
|
chainId: options?.chainId,
|
||||||
abort: undefined,
|
abort: undefined,
|
||||||
|
timeout: undefined,
|
||||||
// using `resolve` instead of using `.then`/`await` to make sure it'll be called before processing the next reply
|
// using `resolve` instead of using `.then`/`await` to make sure it'll be called before processing the next reply
|
||||||
resolve: () => {
|
resolve: () => {
|
||||||
// after running `MONITOR` only `MONITOR` and `RESET` replies are expected
|
// after running `MONITOR` only `MONITOR` and `RESET` replies are expected
|
||||||
@@ -352,6 +376,7 @@ export default class RedisCommandsQueue {
|
|||||||
args: ['RESET'],
|
args: ['RESET'],
|
||||||
chainId,
|
chainId,
|
||||||
abort: undefined,
|
abort: undefined,
|
||||||
|
timeout: undefined,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
channelsCounter: undefined,
|
channelsCounter: undefined,
|
||||||
@@ -382,6 +407,10 @@ export default class RedisCommandsQueue {
|
|||||||
RedisCommandsQueue.#removeAbortListener(toSend);
|
RedisCommandsQueue.#removeAbortListener(toSend);
|
||||||
toSend.abort = undefined;
|
toSend.abort = undefined;
|
||||||
}
|
}
|
||||||
|
if (toSend.timeout) {
|
||||||
|
RedisCommandsQueue.#removeTimeoutListener(toSend);
|
||||||
|
toSend.timeout = undefined;
|
||||||
|
}
|
||||||
this.#chainInExecution = toSend.chainId;
|
this.#chainInExecution = toSend.chainId;
|
||||||
toSend.chainId = undefined;
|
toSend.chainId = undefined;
|
||||||
this.#waitingForReply.push(toSend);
|
this.#waitingForReply.push(toSend);
|
||||||
@@ -402,10 +431,17 @@ export default class RedisCommandsQueue {
|
|||||||
command.abort!.signal.removeEventListener('abort', command.abort!.listener);
|
command.abort!.signal.removeEventListener('abort', command.abort!.listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #removeTimeoutListener(command: CommandToWrite) {
|
||||||
|
command.timeout!.signal.removeEventListener('abort', command.timeout!.listener);
|
||||||
|
}
|
||||||
|
|
||||||
static #flushToWrite(toBeSent: CommandToWrite, err: Error) {
|
static #flushToWrite(toBeSent: CommandToWrite, err: Error) {
|
||||||
if (toBeSent.abort) {
|
if (toBeSent.abort) {
|
||||||
RedisCommandsQueue.#removeAbortListener(toBeSent);
|
RedisCommandsQueue.#removeAbortListener(toBeSent);
|
||||||
}
|
}
|
||||||
|
if (toBeSent.timeout) {
|
||||||
|
RedisCommandsQueue.#removeTimeoutListener(toBeSent);
|
||||||
|
}
|
||||||
|
|
||||||
toBeSent.reject(err);
|
toBeSent.reject(err);
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
|
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
|
||||||
import RedisClient, { RedisClientOptions, RedisClientType } from '.';
|
import RedisClient, { RedisClientOptions, RedisClientType } from '.';
|
||||||
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors';
|
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, TimeoutError, WatchError } from '../errors';
|
||||||
import { defineScript } from '../lua-script';
|
import { defineScript } from '../lua-script';
|
||||||
import { spy } from 'sinon';
|
import { spy, stub } from 'sinon';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { MATH_FUNCTION, loadMathFunction } from '../commands/FUNCTION_LOAD.spec';
|
import { MATH_FUNCTION, loadMathFunction } from '../commands/FUNCTION_LOAD.spec';
|
||||||
import { RESP_TYPES } from '../RESP/decoder';
|
import { RESP_TYPES } from '../RESP/decoder';
|
||||||
@@ -239,30 +239,84 @@ describe('Client', () => {
|
|||||||
assert.equal(await client.sendCommand(['PING']), 'PONG');
|
assert.equal(await client.sendCommand(['PING']), 'PONG');
|
||||||
}, GLOBAL.SERVERS.OPEN);
|
}, GLOBAL.SERVERS.OPEN);
|
||||||
|
|
||||||
describe('AbortController', () => {
|
testUtils.testWithClient('Unactivated AbortController should not abort', async client => {
|
||||||
before(function () {
|
|
||||||
if (!global.AbortController) {
|
|
||||||
this.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
testUtils.testWithClient('success', async client => {
|
|
||||||
await client.sendCommand(['PING'], {
|
await client.sendCommand(['PING'], {
|
||||||
abortSignal: new AbortController().signal
|
abortSignal: new AbortController().signal
|
||||||
});
|
});
|
||||||
}, GLOBAL.SERVERS.OPEN);
|
}, GLOBAL.SERVERS.OPEN);
|
||||||
|
|
||||||
testUtils.testWithClient('AbortError', client => {
|
testUtils.testWithClient('AbortError', async client => {
|
||||||
const controller = new AbortController();
|
await blockSetImmediate(async () => {
|
||||||
controller.abort();
|
await assert.rejects(client.sendCommand(['PING'], {
|
||||||
|
abortSignal: AbortSignal.timeout(5)
|
||||||
return assert.rejects(
|
}), AbortError);
|
||||||
client.sendCommand(['PING'], {
|
})
|
||||||
abortSignal: controller.signal
|
|
||||||
}),
|
|
||||||
AbortError
|
|
||||||
);
|
|
||||||
}, GLOBAL.SERVERS.OPEN);
|
}, GLOBAL.SERVERS.OPEN);
|
||||||
|
|
||||||
|
testUtils.testWithClient('Timeout with custom timeout config', async client => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(client.sendCommand(['PING'], {
|
||||||
|
timeout: 5
|
||||||
|
}), TimeoutError);
|
||||||
|
})
|
||||||
|
}, GLOBAL.SERVERS.OPEN);
|
||||||
|
|
||||||
|
testUtils.testWithCluster('Timeout with custom timeout config (cluster)', async cluster => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(cluster.sendCommand(undefined, true, ['PING'], {
|
||||||
|
timeout: 5
|
||||||
|
}), TimeoutError);
|
||||||
|
})
|
||||||
|
}, GLOBAL.CLUSTERS.OPEN);
|
||||||
|
|
||||||
|
testUtils.testWithClientSentinel('Timeout with custom timeout config (sentinel)', async sentinel => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(sentinel.sendCommand(true, ['PING'], {
|
||||||
|
timeout: 5
|
||||||
|
}), TimeoutError);
|
||||||
|
})
|
||||||
|
}, GLOBAL.CLUSTERS.OPEN);
|
||||||
|
|
||||||
|
testUtils.testWithClient('Timeout with global timeout config', async client => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(client.ping(), TimeoutError);
|
||||||
|
await assert.rejects(client.sendCommand(['PING']), TimeoutError);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
...GLOBAL.SERVERS.OPEN,
|
||||||
|
clientOptions: {
|
||||||
|
commandOptions: {
|
||||||
|
timeout: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testUtils.testWithCluster('Timeout with global timeout config (cluster)', async cluster => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(cluster.HSET('key', 'foo', 'value'), TimeoutError);
|
||||||
|
await assert.rejects(cluster.sendCommand(undefined, true, ['PING']), TimeoutError);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
...GLOBAL.CLUSTERS.OPEN,
|
||||||
|
clusterConfiguration: {
|
||||||
|
commandOptions: {
|
||||||
|
timeout: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testUtils.testWithClientSentinel('Timeout with global timeout config (sentinel)', async sentinel => {
|
||||||
|
await blockSetImmediate(async () => {
|
||||||
|
await assert.rejects(sentinel.HSET('key', 'foo', 'value'), TimeoutError);
|
||||||
|
await assert.rejects(sentinel.sendCommand(true, ['PING']), TimeoutError);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
...GLOBAL.SENTINEL.OPEN,
|
||||||
|
clientOptions: {
|
||||||
|
commandOptions: {
|
||||||
|
timeout: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
testUtils.testWithClient('undefined and null should not break the client', async client => {
|
testUtils.testWithClient('undefined and null should not break the client', async client => {
|
||||||
@@ -900,3 +954,23 @@ describe('Client', () => {
|
|||||||
}, GLOBAL.SERVERS.OPEN);
|
}, GLOBAL.SERVERS.OPEN);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the provided function in a context where setImmediate is stubbed to not do anything.
|
||||||
|
* This blocks setImmediate callbacks from executing
|
||||||
|
*/
|
||||||
|
async function blockSetImmediate(fn: () => Promise<unknown>) {
|
||||||
|
let setImmediateStub: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setImmediateStub = stub(global, 'setImmediate');
|
||||||
|
setImmediateStub.callsFake(() => {
|
||||||
|
//Dont call the callback, effectively blocking execution
|
||||||
|
});
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
if (setImmediateStub) {
|
||||||
|
setImmediateStub.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -889,7 +889,13 @@ export default class RedisClient<
|
|||||||
return Promise.reject(new ClientOfflineError());
|
return Promise.reject(new ClientOfflineError());
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = this._self.#queue.addCommand<T>(args, options);
|
// Merge global options with provided options
|
||||||
|
const opts = {
|
||||||
|
...this._self._commandOptions,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this._self.#queue.addCommand<T>(args, opts);
|
||||||
this._self.#scheduleWrite();
|
this._self.#scheduleWrite();
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
@@ -508,10 +508,16 @@ export default class RedisCluster<
|
|||||||
options?: ClusterCommandOptions,
|
options?: ClusterCommandOptions,
|
||||||
// defaultPolicies?: CommandPolicies
|
// defaultPolicies?: CommandPolicies
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
|
||||||
|
// Merge global options with local options
|
||||||
|
const opts = {
|
||||||
|
...this._self._commandOptions,
|
||||||
|
...options
|
||||||
|
}
|
||||||
return this._self._execute(
|
return this._self._execute(
|
||||||
firstKey,
|
firstKey,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
options,
|
opts,
|
||||||
(client, opts) => client.sendCommand(args, opts)
|
(client, opts) => client.sendCommand(args, opts)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -342,6 +342,7 @@ export default class TestUtils {
|
|||||||
name: 'mymaster',
|
name: 'mymaster',
|
||||||
sentinelRootNodes: rootNodes,
|
sentinelRootNodes: rootNodes,
|
||||||
nodeClientOptions: {
|
nodeClientOptions: {
|
||||||
|
commandOptions: options.clientOptions?.commandOptions,
|
||||||
password: password || undefined,
|
password: password || undefined,
|
||||||
},
|
},
|
||||||
sentinelClientOptions: {
|
sentinelClientOptions: {
|
||||||
|
Reference in New Issue
Block a user