diff --git a/lib-jedis-pool/README.md b/lib-jedis-pool/README.md index 36a5aa04..3f0101cd 100644 --- a/lib-jedis-pool/README.md +++ b/lib-jedis-pool/README.md @@ -8,7 +8,7 @@ Add this dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-jedis-pool:1.2.0' + implementation 'io.seqera:lib-jedis-pool:1.3.0' } ``` @@ -32,8 +32,13 @@ redis: maxIdle: 10 # Default: 10 maxTotal: 50 # Default: 50 testOnBorrow: false # Default: false — PING-validate connections on borrow + maxWait: -1 # Default: -1 (block indefinitely on an exhausted pool), millis client: - timeout: 5000 # Default: 5000ms + timeout: 5000 # Default: 5000ms — connection and socket timeout + blockingTimeout: -1 # Default: -1 (inherit `timeout`), millis. Socket timeout for + # blocking reads (pub/sub subscribe, BLPOP/BRPOP, XREAD BLOCK). + # Use 0 for no timeout, required by long-lived subscribers that + # sit idle on the socket between messages. ``` ## Usage diff --git a/lib-jedis-pool/VERSION b/lib-jedis-pool/VERSION index 26aaba0e..f0bb29e7 100644 --- a/lib-jedis-pool/VERSION +++ b/lib-jedis-pool/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/lib-jedis-pool/changelog.txt b/lib-jedis-pool/changelog.txt index 75d185f3..f3e8b525 100644 --- a/lib-jedis-pool/changelog.txt +++ b/lib-jedis-pool/changelog.txt @@ -1,5 +1,14 @@ # lib-jedis-pool changelog +1.3.0 - 4 Aug 2026 +- Close the pool on bean disposal via @Bean(preDestroy='close'); previously the pool was + never disposed, letting background threads borrow from a pool whose connections were gone +- Add redis.client.blockingTimeout option for the blocking-read socket timeout (pub/sub + subscribe, BLPOP/BRPOP, XREAD BLOCK); default -1 inherits redis.client.timeout, 0 disables + the timeout as long-lived subscribers require +- Treat a blank redis.password override as absent instead of sending AUTH "" and failing + every connection against a password-less Redis + 1.2.0 - 2 Aug 2026 - Add redis.pool.maxWait option bounding a borrow against an exhausted pool (millis); default -1 preserves the pre-existing unbounded blocking behavior diff --git a/lib-jedis-pool/src/main/java/io/seqera/jedis/JedisPoolFactory.java b/lib-jedis-pool/src/main/java/io/seqera/jedis/JedisPoolFactory.java index c58a81c3..552e3983 100644 --- a/lib-jedis-pool/src/main/java/io/seqera/jedis/JedisPoolFactory.java +++ b/lib-jedis-pool/src/main/java/io/seqera/jedis/JedisPoolFactory.java @@ -20,6 +20,7 @@ import java.time.Duration; import io.micrometer.core.instrument.MeterRegistry; +import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Requires; import io.micronaut.context.annotation.Value; @@ -56,7 +57,13 @@ public class JedisPoolFactory { @Inject private MeterRegistry meterRegistry; + // preDestroy = 'close' makes Micronaut close the pool during bean disposal (reverse + // dependency-injection order), so beans that injected this pool are guaranteed to have + // run their @PreDestroy first. Without it the pool is never closed — JedisPool is + // Closeable but not a Micronaut LifeCycle, so nothing disposes it — and background + // consumer threads can call getResource() on a pool whose Redis connections are gone. @Singleton + @Bean(preDestroy = "close") public JedisPool createRedisPool( @Value("${redis.uri}") String connection, @Value("${redis.pool.minIdle:0}") int minIdle, @@ -65,6 +72,7 @@ public JedisPool createRedisPool( @Value("${redis.pool.testOnBorrow:false}") boolean testOnBorrow, @Value("${redis.pool.maxWait:-1}") long maxWait, @Value("${redis.client.timeout:5000}") int timeout, + @Value("${redis.client.blockingTimeout:-1}") int blockingTimeout, @Nullable @Value("${redis.password}") String password ) { final URI uri = URI.create(connection); @@ -73,8 +81,8 @@ public JedisPool createRedisPool( } final int database = JedisURIHelper.getDBIndex(uri); - log.info("Creating Redis pool - uri={}; database={}; minIdle={}; maxIdle={}; maxTotal={}; testOnBorrow={}; maxWait={}; timeout={}", - maskPassword(connection), database, minIdle, maxIdle, maxTotal, testOnBorrow, maxWait, timeout); + log.info("Creating Redis pool - uri={}; database={}; minIdle={}; maxIdle={}; maxTotal={}; testOnBorrow={}; maxWait={}; timeout={}; blockingTimeout={}", + maskPassword(connection), database, minIdle, maxIdle, maxTotal, testOnBorrow, maxWait, timeout, blockingTimeout); // Pool config final JedisPoolConfig config = new JedisPoolConfig(); @@ -93,7 +101,7 @@ public JedisPool createRedisPool( config.setMaxWait(Duration.ofMillis(maxWait)); // Client config with database support - final JedisClientConfig clientConfig = clientConfig(uri, password, timeout); + final JedisClientConfig clientConfig = clientConfig(uri, password, timeout, blockingTimeout); // Create the Jedis pool final JedisPool pool = new JedisPool(config, JedisURIHelper.getHostAndPort(uri), clientConfig); @@ -109,12 +117,15 @@ public JedisPool createRedisPool( /** * Creates the Jedis client configuration from the URI. * - * @param uri the Redis URI - * @param password optional password override (if null, extracted from URI) - * @param timeout connection timeout in milliseconds + * @param uri the Redis URI + * @param password optional password override (blank or null → extracted from URI) + * @param timeout connection and socket timeout in milliseconds + * @param blockingTimeout socket timeout applied to blocking reads (pub/sub subscribe, + * BLPOP/BRPOP, XREAD BLOCK) in milliseconds; 0 means no timeout + * and any negative value inherits {@code timeout} * @return the configured JedisClientConfig */ - protected JedisClientConfig clientConfig(URI uri, String password, int timeout) { + protected JedisClientConfig clientConfig(URI uri, String password, int timeout, int blockingTimeout) { if (!JedisURIHelper.isValid(uri)) { throw new InvalidURIException("Invalid Redis connection URI: " + uri); } @@ -122,9 +133,15 @@ protected JedisClientConfig clientConfig(URI uri, String password, int timeout) return DefaultJedisClientConfig.builder() .connectionTimeoutMillis(timeout) .socketTimeoutMillis(timeout) - .blockingSocketTimeoutMillis(timeout) + // Jedis applies this timeout only while a blocking read is in flight, and treats + // 0 as "no timeout" — which a long-lived pub/sub subscriber needs, since it sits + // idle on the socket between messages and would otherwise be torn down every + // `timeout` ms. Negative inherits `timeout` to keep the previous behavior. + .blockingSocketTimeoutMillis(blockingTimeout < 0 ? timeout : blockingTimeout) .user(JedisURIHelper.getUser(uri)) - .password(password != null ? password : JedisURIHelper.getPassword(uri)) + // an empty override means "no password configured" — passing it through would make + // Jedis send AUTH "" and fail every connection against a password-less Redis + .password(password != null && !password.isBlank() ? password : JedisURIHelper.getPassword(uri)) .database(JedisURIHelper.getDBIndex(uri)) .protocol(JedisURIHelper.getRedisProtocol(uri)) .ssl(JedisURIHelper.isRedisSSLScheme(uri)) diff --git a/lib-jedis-pool/src/test/groovy/io/seqera/jedis/JedisPoolFactoryTest.groovy b/lib-jedis-pool/src/test/groovy/io/seqera/jedis/JedisPoolFactoryTest.groovy index 0523969a..87f88cd4 100644 --- a/lib-jedis-pool/src/test/groovy/io/seqera/jedis/JedisPoolFactoryTest.groovy +++ b/lib-jedis-pool/src/test/groovy/io/seqera/jedis/JedisPoolFactoryTest.groovy @@ -33,7 +33,7 @@ class JedisPoolFactoryTest extends Specification { def factory = new JedisPoolFactory(meterRegistry: Mock(MeterRegistry)) when: - def pool = factory.createRedisPool(URI_STRING, MIN_IDLE, MAX_IDLE, MAX_TOTAL, false, -1, TIMEOUT, 'password') + def pool = factory.createRedisPool(URI_STRING, MIN_IDLE, MAX_IDLE, MAX_TOTAL, false, -1, TIMEOUT, -1, 'password') then: pool != null @@ -56,7 +56,7 @@ class JedisPoolFactoryTest extends Specification { def factory = new JedisPoolFactory() when: - def clientConfig = factory.clientConfig(URI.create(URI_STRING), null, 5000) + def clientConfig = factory.clientConfig(URI.create(URI_STRING), null, 5000, -1) then: clientConfig.database == EXPECTED_DB @@ -75,7 +75,7 @@ class JedisPoolFactoryTest extends Specification { def factory = new JedisPoolFactory(meterRegistry: Mock(MeterRegistry)) when: - factory.createRedisPool(URI_STRING, 0, 10, 50, false, -1, 5000, null) + factory.createRedisPool(URI_STRING, 0, 10, 50, false, -1, 5000, -1, null) then: def e = thrown(InvalidURIException) @@ -92,7 +92,7 @@ class JedisPoolFactoryTest extends Specification { def factory = new JedisPoolFactory() when: - def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, ON_BORROW, -1, 5000, null) + def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, ON_BORROW, -1, 5000, -1, null) then: pool.testOnBorrow == ON_BORROW @@ -109,7 +109,7 @@ class JedisPoolFactoryTest extends Specification { def factory = new JedisPoolFactory() when: - def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, false, MAX_WAIT, 5000, null) + def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, false, MAX_WAIT, 5000, -1, null) then: pool.maxWaitDuration == java.time.Duration.ofMillis(EXPECTED) @@ -123,12 +123,51 @@ class JedisPoolFactoryTest extends Specification { -1 | -1 // default: block indefinitely - the pre-existing commons-pool2 behavior, unchanged } + def 'should apply the blocking socket timeout'() { + given: + def factory = new JedisPoolFactory() + + when: + def clientConfig = factory.clientConfig(URI.create('redis://localhost:6379'), null, 5000, BLOCKING) + + then: + clientConfig.blockingSocketTimeoutMillis == EXPECTED + and: 'the non-blocking timeout is unaffected' + clientConfig.socketTimeoutMillis == 5000 + + where: + BLOCKING | EXPECTED + 0 | 0 // no timeout — an idle pub/sub subscriber is not torn down between messages + 1000 | 1000 + -1 | 5000 // inherit `timeout`, the pre-existing behavior + } + + def 'should ignore a blank password override'() { + given: + def factory = new JedisPoolFactory() + + when: + def clientConfig = factory.clientConfig(URI.create(URI_STRING), PASSWORD, 5000, -1) + + then: + clientConfig.password == EXPECTED + + where: + URI_STRING | PASSWORD | EXPECTED + 'redis://localhost:6379' | '' | null // must not become AUTH "" + 'redis://localhost:6379' | ' ' | null + 'redis://localhost:6379' | null | null + 'redis://localhost:6379' | 'secret' | 'secret' + 'redis://:from-uri@localhost:6379' | '' | 'from-uri' // blank override falls back to the URI + 'redis://:from-uri@localhost:6379' | 'secret' | 'secret' + } + def 'should create pool without meter registry'() { given: def factory = new JedisPoolFactory() when: - def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, false, -1, 5000, null) + def pool = factory.createRedisPool('redis://localhost:6379', 0, 10, 50, false, -1, 5000, -1, null) then: pool != null