ioredis icon indicating copy to clipboard operation
ioredis copied to clipboard

fix: add blockingTimeout option for blocking commands

Open PavelPashov opened this issue 3 weeks ago • 7 comments

Fixes #1888

PavelPashov avatar Dec 04 '25 14:12 PavelPashov

If I have understood the code correctly, the forced timeout will only be applied if we pass the new blockingTimeout option explicitly. The main issues I see with this approach is that by default the instance can hang forever if the user uses blocking commands, as the watchdog is not enabled when we have a specific timeout argument passed in the Redis command. Also, I was not sure how this setting can be used per command. For example in BullMQ we have different timeouts when we call bzpopmin, depending on the maximum time we want the command to wait until it gives up (this is required by the delayed jobs functionality among other things).

manast avatar Dec 04 '25 14:12 manast

If I have understood the code correctly, the forced timeout will only be applied if we pass the new blockingTimeout option explicitly. The main issues I see with this approach is that by default the instance can hang forever if the user uses blocking commands, as the watchdog is not enabled when we have a specific timeout argument passed in the Redis command. Also, I was not sure how this setting can be used per command. For example in BullMQ we have different timeouts when we call bzpopmin, depending on the maximum time we want the command to wait until it gives up (this is required by the delayed jobs functionality among other things).

You understood it correctly. To begin with, I’d recommend making this an opt-in feature for safety. Supporting per-command options is not straightforward because the command signatures are generated automatically in RedisCommander by this script. A builder pattern or a similar mechanism might offer a more practical way to enable per-command timeouts.

PavelPashov avatar Dec 04 '25 15:12 PavelPashov

So the way to see this new option is like a fallback for this type of disconnections and nothing else. In this case I guess it is ok. I can test it in my setup to see if it solves the problem for us.

manast avatar Dec 04 '25 20:12 manast

Would it be possible to have a setter for this option so that it could be changed on an existing instance?

manast avatar Dec 09 '25 10:12 manast

Something is wrong with this implementation, testing with this code:

import Redis from "ioredis";
const port = 6379;

const connection = new Redis({
  host: "localhost",
  port,
  blockingTimeout: 1000,
});

connection.on("connect", () => console.log("Redis connected!"));
connection.on("ready", () => console.log("Redis ready!"));
connection.on("error", (err) => console.error("Redis error:", err));
connection.on("end", () => console.log("Redis connection ended"));
connection.on("reconnecting", () => console.log("Redis reconnecting..."));

async function test() {
  try {
    console.log("Gonna issue a BZPOPMIN command...");
    const start = Date.now();
    const result = await connection.bzpopmin("key", 10);
    console.log("Issued BZPOPMIN command!", result);
    console.log(`BZPOPMIN command took ${Date.now() - start} ms`);
  } catch {

    console.error("Error issuing BZPOPMIN command");
  }
}

test().catch(console.error);

What happens is that it throws an exception after 1 second, but then it keeps trying to reconnect and throwing the same exception, which should not happen. It should reconnect and thats it.

bun ioredis-bug.ts
Gonna issue a BZPOPMIN command...
Redis connected!
Redis ready!
Redis error: 345 |             this.options.blockingTimeout > 0 &&
346 |             Command_1.default.checkFlag("BLOCKING_COMMANDS", command.name)) {
347 |             command.setBlockingTimeout(this.options.blockingTimeout, () => {
348 |                 var _a;
349 |                 // Destroy stream to force reconnection
350 |                 (_a = this.stream) === null || _a === void 0 ? void 0 : _a.destroy(new Error("Blocking command timed out - reconnecting"));
                                                                                             ^
error: Blocking command timed out - reconnecting
      at /Users/manuelastudillo/dev/ioredis/built/Redis.js:350:88
      at /Users/manuelastudillo/dev/ioredis/built/Command.js:381:16

Error issuing BZPOPMIN command
Redis reconnecting...
Redis connected!
Redis ready!
Redis error: 345 |             this.options.blockingTimeout > 0 &&
346 |             Command_1.default.checkFlag("BLOCKING_COMMANDS", command.name)) {
347 |             command.setBlockingTimeout(this.options.blockingTimeout, () => {
348 |                 var _a;
349 |                 // Destroy stream to force reconnection
350 |                 (_a = this.stream) === null || _a === void 0 ? void 0 : _a.destroy(new Error("Blocking command timed out - reconnecting"));
                                                                                             ^
error: Blocking command timed out - reconnecting
      at /Users/manuelastudillo/dev/ioredis/built/Redis.js:350:88
      at /Users/manuelastudillo/dev/ioredis/built/Command.js:381:16

Redis reconnecting...
Redis connected!
Redis ready!
Redis error: 345 |             this.options.blockingTimeout > 0 &&
346 |             Command_1.default.checkFlag("BLOCKING_COMMANDS", command.name)) {
347 |             command.setBlockingTimeout(this.options.blockingTimeout, () => {
348 |                 var _a;
349 |                 // Destroy stream to force reconnection
350 |                 (_a = this.stream) === null || _a === void 0 ? void 0 : _a.destroy(new Error("Blocking command timed out - reconnecting"));
                                                                                             ^
error: Blocking command timed out - reconnecting
      at /Users/manuelastudillo/dev/ioredis/built/Redis.js:350:88
      at /Users/manuelastudillo/dev/ioredis/built/Command.js:381:16

Redis reconnecting...
Redis connected!
Redis ready!
Redis error: 345 |             this.options.blockingTimeout > 0 &&
346 |             Command_1.default.checkFlag("BLOCKING_COMMANDS", command.name)) {
347 |             command.setBlockingTimeout(this.options.blockingTimeout, () => {
348 |                 var _a;
349 |                 // Destroy stream to force reconnection
350 |                 (_a = this.stream) === null || _a === void 0 ? void 0 : _a.destroy(new Error("Blocking command timed out - reconnecting"));
                                                                                             ^
error: Blocking command timed out - reconnecting
      at /Users/manuelastudillo/dev/ioredis/built/Redis.js:350:88
      at /Users/manuelastudillo/dev/ioredis/built/Command.js:381:16

Redis reconnecting...
Redis connected!
Redis ready!
Redis error: 345 |             this.options.blockingTimeout > 0 &&
346 |             Command_1.default.checkFlag("BLOCKING_COMMANDS", command.name)) {
347 |             command.setBlockingTimeout(this.options.blockingTimeout, () => {
348 |                 var _a;
349 |                 // Destroy stream to force reconnection
350 |                 (_a = this.stream) === null || _a === void 0 ? void 0 : _a.destroy(new Error("Blocking command timed out - reconnecting"));
                                                                                             ^
error: Blocking command timed out - reconnecting
      at /Users/manuelastudillo/dev/ioredis/built/Redis.js:350:88
      at /Users/manuelastudillo/dev/ioredis/built/Command.js:381:16

Redis reconnecting...
Redis connected!
Redis ready!
^C

manast avatar Dec 09 '25 10:12 manast

it is as if it is retrying the command also, but if the command timed-out we should not retry it as it just thrown an exception.

manast avatar Dec 09 '25 10:12 manast

Hey @manast, thank you for taking the time to check it out.

I’ll take a look when I get a chance. If you want to explore a fix in the meantime, feel free to give it a try.

PavelPashov avatar Dec 11 '25 11:12 PavelPashov