diff --git a/src/main/java/org/codarama/redlock4j/RedlockReadWriteLock.java b/src/main/java/org/codarama/redlock4j/RedlockReadWriteLock.java index 9e2a04e..51aa03a 100644 --- a/src/main/java/org/codarama/redlock4j/RedlockReadWriteLock.java +++ b/src/main/java/org/codarama/redlock4j/RedlockReadWriteLock.java @@ -194,6 +194,10 @@ public void lock() { @Override public void lockInterruptibly() throws InterruptedException { + // Honor the Lock.lockInterruptibly() contract: respond to a pending interrupt before doing any work + if (Thread.interrupted()) { + throw new InterruptedException(); + } if (!tryLock(config.getLockAcquisitionTimeout())) { throw new RedlockException("Failed to acquire read lock within timeout"); } @@ -420,6 +424,10 @@ public void lock() { @Override public void lockInterruptibly() throws InterruptedException { + // Honor the Lock.lockInterruptibly() contract: respond to a pending interrupt before doing any work + if (Thread.interrupted()) { + throw new InterruptedException(); + } // Wait for readers to finish before acquiring write lock waitForReadersToFinish(); underlyingLock.lockInterruptibly(); diff --git a/src/main/java/org/codarama/redlock4j/driver/LettuceRedisDriver.java b/src/main/java/org/codarama/redlock4j/driver/LettuceRedisDriver.java index 9864bf9..1313d6e 100644 --- a/src/main/java/org/codarama/redlock4j/driver/LettuceRedisDriver.java +++ b/src/main/java/org/codarama/redlock4j/driver/LettuceRedisDriver.java @@ -108,7 +108,7 @@ public LettuceRedisDriver(RedisNodeConfiguration config) { // Detect CAS/CAD support once at initialization this.cadStrategy = detectCADStrategy(); - logger.info("Using {} strategy for CAS/CAD operations on {}", cadStrategy, identifier); + logger.debug("Using {} strategy for CAS/CAD operations on {}", cadStrategy, identifier); } /** diff --git a/src/test/java/org/codarama/redlock4j/FairLockTest.java b/src/test/java/org/codarama/redlock4j/FairLockTest.java index 3b03653..540f3cf 100644 --- a/src/test/java/org/codarama/redlock4j/FairLockTest.java +++ b/src/test/java/org/codarama/redlock4j/FairLockTest.java @@ -129,6 +129,187 @@ void shouldThrowOnNewCondition() { assertThrows(UnsupportedOperationException.class, lock::newCondition); } + // ========== Timeout / Unavailable Paths ========== + + @Test + void shouldReturnFalseWhenTimeoutExceededAndNeverReachesFront() throws RedisDriverException, InterruptedException { + RedlockConfiguration shortConfig = shortRetryConfig(); + + // Add to queue succeeds + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + // Someone else is always at the front of the queue + when(mockDriver1.zRange(anyString(), eq(0L), eq(0L))).thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver2.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver3.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + + FairLock lock = new FairLock("test-timeout", drivers, shortConfig, null); + + boolean acquired = lock.tryLock(Duration.ofMillis(150)); + + assertFalse(acquired); + assertFalse(lock.isHeldByCurrentThread()); + // On the timeout branch the waiter must be removed from the queue for cleanup + verify(mockDriver1, atLeastOnce()).zRem(anyString(), anyString()); + } + + @Test + void shouldReturnFalseWhenAtFrontButLockAcquisitionFails() throws RedisDriverException, InterruptedException { + RedlockConfiguration shortConfig = shortRetryConfig(); + + // Add to queue and become the front-most waiter + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenAnswer(inv -> { + String token = inv.getArgument(2); + lenient().when(mockDriver1.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList(token)); + lenient().when(mockDriver2.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList(token)); + lenient().when(mockDriver3.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList(token)); + return true; + }); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + // We are at the front, but the underlying lock cannot be acquired (quorum not met) + lenient().when(mockDriver1.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + lenient().when(mockDriver2.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + lenient().when(mockDriver3.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + + FairLock lock = new FairLock("test-acquire-fail", drivers, shortConfig, null); + + boolean acquired = lock.tryLock(Duration.ofMillis(150)); + + assertFalse(acquired); + assertFalse(lock.isHeldByCurrentThread()); + verify(mockDriver1, atLeastOnce()).zRem(anyString(), anyString()); + } + + @Test + void shouldReturnFalseForZeroTimeoutWhenUnavailable() throws RedisDriverException { + // Add to queue succeeds + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + // Someone else is at the front, so immediate acquisition is impossible + when(mockDriver1.zRange(anyString(), eq(0L), eq(0L))).thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver2.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver3.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + + FairLock lock = new FairLock("test-zero-timeout", drivers, testConfig, null); + + boolean acquired = lock.tryLock(); + + assertFalse(acquired); + assertFalse(lock.isHeldByCurrentThread()); + verify(mockDriver1, atLeastOnce()).zRem(anyString(), anyString()); + } + + // ========== lock() / lockInterruptibly() Failure Paths ========== + + @Test + void shouldThrowRedlockExceptionWhenLockCannotBeAcquired() throws RedisDriverException { + RedlockConfiguration shortConfig = shortAcquisitionConfig(); + + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + when(mockDriver1.zRange(anyString(), eq(0L), eq(0L))).thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver2.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver3.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + + FairLock lock = new FairLock("test-lock-fail", drivers, shortConfig, null); + + assertThrows(RedlockException.class, lock::lock); + assertFalse(lock.isHeldByCurrentThread()); + } + + @Test + void shouldThrowRedlockExceptionFromLockInterruptiblyWhenLockCannotBeAcquired() throws RedisDriverException { + RedlockConfiguration shortConfig = shortAcquisitionConfig(); + + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + when(mockDriver1.zRange(anyString(), eq(0L), eq(0L))).thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver2.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + lenient().when(mockDriver3.zRange(anyString(), eq(0L), eq(0L))) + .thenReturn(Collections.singletonList("other-token")); + + FairLock lock = new FairLock("test-lock-interruptibly-fail", drivers, shortConfig, null); + + assertThrows(RedlockException.class, lock::lockInterruptibly); + assertFalse(lock.isHeldByCurrentThread()); + } + + // ========== Interruption Paths ========== + + @Test + void shouldThrowInterruptedExceptionAndCleanupQueueWhenInterrupted() throws RedisDriverException { + // Add to queue succeeds so the acquisition loop is entered + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + FairLock lock = new FairLock("test-interrupt", drivers, testConfig, null); + + // Pre-interrupt the current thread so the loop's interrupt check trips immediately + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> lock.tryLock(Duration.ofSeconds(1))); + assertFalse(lock.isHeldByCurrentThread()); + // The catch/interrupt branch must remove the waiter from the queue + verify(mockDriver1, atLeastOnce()).zRem(anyString(), anyString()); + } finally { + // Clear any lingering interrupt status for subsequent tests + Thread.interrupted(); + } + } + + @Test + void shouldThrowRedlockExceptionAndPreserveInterruptWhenLockCalledInterrupted() throws RedisDriverException { + when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver2.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + lenient().when(mockDriver3.zAdd(anyString(), anyDouble(), anyString())).thenReturn(true); + + FairLock lock = new FairLock("test-lock-interrupt", drivers, testConfig, null); + + Thread.currentThread().interrupt(); + try { + // lock() catches InterruptedException, re-sets the flag, and wraps it in a RedlockException + assertThrows(RedlockException.class, lock::lock); + assertTrue(Thread.currentThread().isInterrupted(), "lock() must re-set the interrupt flag"); + } finally { + Thread.interrupted(); + } + } + + private RedlockConfiguration shortRetryConfig() { + return RedlockConfiguration.builder().addRedisNode("localhost", 6379).addRedisNode("localhost", 6380) + .addRedisNode("localhost", 6381).defaultLockTimeout(Duration.ofSeconds(30)) + .retryDelay(Duration.ofMillis(10)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofSeconds(10)) + .build(); + } + + private RedlockConfiguration shortAcquisitionConfig() { + return RedlockConfiguration.builder().addRedisNode("localhost", 6379).addRedisNode("localhost", 6380) + .addRedisNode("localhost", 6381).defaultLockTimeout(Duration.ofSeconds(30)) + .retryDelay(Duration.ofMillis(10)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofMillis(150)) + .build(); + } + private void setupSuccessfulAcquisition() throws RedisDriverException { when(mockDriver1.zAdd(anyString(), anyDouble(), anyString())).thenAnswer(inv -> { String token = inv.getArgument(2); diff --git a/src/test/java/org/codarama/redlock4j/MultiLockTest.java b/src/test/java/org/codarama/redlock4j/MultiLockTest.java index 191dbd8..31ad230 100644 --- a/src/test/java/org/codarama/redlock4j/MultiLockTest.java +++ b/src/test/java/org/codarama/redlock4j/MultiLockTest.java @@ -144,4 +144,93 @@ void shouldThrowOnNewCondition() { MultiLock lock = new MultiLock(Arrays.asList("key1"), drivers, testConfig, null); assertThrows(UnsupportedOperationException.class, lock::newCondition); } + + // ========== Blocking / Failure Paths ========== + + /** + * Builds a configuration with a short acquisition timeout and retry delay so that failure paths complete quickly + * without mutating the shared setUp config. + */ + private RedlockConfiguration shortTimeoutConfig() { + return RedlockConfiguration.builder().addRedisNode("localhost", 6379).addRedisNode("localhost", 6380) + .addRedisNode("localhost", 6381).defaultLockTimeout(Duration.ofSeconds(30)) + .retryDelay(Duration.ofMillis(10)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofMillis(150)) + .build(); + } + + @Test + void shouldReturnFalseWhenTryLockWithTimeoutNeverAcquires() throws RedisDriverException, InterruptedException { + when(mockDriver1.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver2.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver3.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, shortTimeoutConfig(), null); + + assertFalse(lock.tryLock(Duration.ofMillis(150))); + } + + @Test + void shouldReturnFalseWhenTryLockZeroTimeoutUnavailable() throws RedisDriverException { + when(mockDriver1.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver2.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver3.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, testConfig, null); + + assertFalse(lock.tryLock()); + } + + @Test + void shouldThrowRedlockExceptionWhenLockFails() throws RedisDriverException { + when(mockDriver1.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver2.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver3.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, shortTimeoutConfig(), null); + + assertThrows(RedlockException.class, lock::lock); + } + + @Test + void shouldThrowRedlockExceptionWhenLockInterruptiblyFails() throws RedisDriverException { + when(mockDriver1.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver2.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + when(mockDriver3.setIfNotExists(anyString(), anyString(), anyLong())).thenReturn(false); + + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, shortTimeoutConfig(), null); + + assertThrows(RedlockException.class, lock::lockInterruptibly); + } + + @Test + void shouldThrowInterruptedExceptionWhenTryLockPreInterrupted() { + // No setIfNotExists stubs: the interrupt check at the top of the retry loop fires before + // any acquisition attempt, so the drivers are never invoked. + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, testConfig, null); + + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> lock.tryLock(Duration.ofSeconds(1))); + } finally { + // Clear interrupt status so it does not leak into subsequent tests + Thread.interrupted(); + } + } + + @Test + void shouldThrowRedlockExceptionWhenLockPreInterrupted() { + // No setIfNotExists stubs: lock() delegates to tryLock which throws InterruptedException at + // the top of the retry loop before any acquisition attempt reaches the drivers. + MultiLock lock = new MultiLock(Arrays.asList("a", "b"), drivers, testConfig, null); + + Thread.currentThread().interrupt(); + try { + assertThrows(RedlockException.class, lock::lock); + // lock() catches InterruptedException and re-sets the interrupt flag before wrapping + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + // Clear interrupt status so it does not leak into subsequent tests + Thread.interrupted(); + } + } } diff --git a/src/test/java/org/codarama/redlock4j/RedlockReadWriteLockTest.java b/src/test/java/org/codarama/redlock4j/RedlockReadWriteLockTest.java index b2edbfe..76cd1ea 100644 --- a/src/test/java/org/codarama/redlock4j/RedlockReadWriteLockTest.java +++ b/src/test/java/org/codarama/redlock4j/RedlockReadWriteLockTest.java @@ -158,4 +158,147 @@ void shouldThrowOnNewConditionForWriteLock() { RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, testConfig, null); assertThrows(UnsupportedOperationException.class, () -> rwLock.writeLock().newCondition()); } + + // ========== Read Lock - blocking / failure paths ========== + + /** + * Builds a config with a short acquisition timeout and retry delay so blocking paths reach their timeout branch + * quickly without mutating the shared setUp config. + */ + private RedlockConfiguration shortTimeoutConfig() { + return RedlockConfiguration.builder().addRedisNode("localhost", 6379).addRedisNode("localhost", 6380) + .addRedisNode("localhost", 6381).defaultLockTimeout(Duration.ofSeconds(30)) + .retryDelay(Duration.ofMillis(5)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofMillis(150)) + .build(); + } + + @Test + void shouldFailReadTryLockWithTimeoutWhenWriterHolds() throws RedisDriverException, InterruptedException { + // Writer holds the lock on quorum, so the reader is blocked for the whole timeout. + lenient().when(mockDriver1.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver2.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver3.get(contains(":write"))).thenReturn("writer-token"); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.ReadLock readLock = (RedlockReadWriteLock.ReadLock) rwLock.readLock(); + + boolean acquired = readLock.tryLock(Duration.ofMillis(150)); + + assertFalse(acquired); + } + + @Test + void shouldFailReadZeroTimeoutTryLockWhenWriterHolds() throws RedisDriverException { + // Writer holds the lock on quorum -> tryLock() (zero timeout) returns false. + lenient().when(mockDriver1.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver2.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver3.get(contains(":write"))).thenReturn("writer-token"); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.ReadLock readLock = (RedlockReadWriteLock.ReadLock) rwLock.readLock(); + + assertFalse(readLock.tryLock()); + } + + @Test + void shouldThrowWhenReadLockCannotBeAcquiredWithinTimeout() throws RedisDriverException { + // Writer holds the lock on quorum -> lock() exceeds lockAcquisitionTimeout and throws. + lenient().when(mockDriver1.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver2.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver3.get(contains(":write"))).thenReturn("writer-token"); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.ReadLock readLock = (RedlockReadWriteLock.ReadLock) rwLock.readLock(); + + assertThrows(RedlockException.class, readLock::lock); + } + + @Test + void shouldThrowWhenReadLockInterruptiblyCannotBeAcquiredWithinTimeout() throws RedisDriverException { + lenient().when(mockDriver1.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver2.get(contains(":write"))).thenReturn("writer-token"); + lenient().when(mockDriver3.get(contains(":write"))).thenReturn("writer-token"); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.ReadLock readLock = (RedlockReadWriteLock.ReadLock) rwLock.readLock(); + + assertThrows(RedlockException.class, readLock::lockInterruptibly); + } + + @Test + void shouldThrowInterruptedExceptionWhenReadTryLockInterrupted() { + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.ReadLock readLock = (RedlockReadWriteLock.ReadLock) rwLock.readLock(); + + // Pre-interrupt the current thread; the acquisition loop checks the flag first and throws. + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> readLock.tryLock(Duration.ofSeconds(1))); + } finally { + // Clear the interrupt so it doesn't leak into other tests. + Thread.interrupted(); + } + } + + // ========== Write Lock - blocking / failure paths ========== + + @Test + void shouldFailWriteTryLockWithTimeoutWhenReadersActive() throws RedisDriverException, InterruptedException { + // Readers never drain -> tryLock(Duration) times out waiting for readers. + lenient().when(mockDriver1.get(contains(":readers"))).thenReturn("3"); + lenient().when(mockDriver2.get(contains(":readers"))).thenReturn("3"); + lenient().when(mockDriver3.get(contains(":readers"))).thenReturn("3"); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.WriteLock writeLock = (RedlockReadWriteLock.WriteLock) rwLock.writeLock(); + + boolean acquired = writeLock.tryLock(Duration.ofMillis(150)); + + assertFalse(acquired); + } + + @Test + void shouldThrowWhenWriteLockCannotBeAcquiredWithinTimeout() throws RedisDriverException { + // No readers, but the underlying write lock can never be acquired (setIfNotExists returns + // false by default), so lock() exceeds the acquisition timeout and throws. + lenient().when(mockDriver1.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver2.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver3.get(contains(":readers"))).thenReturn(null); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.WriteLock writeLock = (RedlockReadWriteLock.WriteLock) rwLock.writeLock(); + + assertThrows(RedlockException.class, writeLock::lock); + } + + @Test + void shouldThrowWhenWriteLockInterruptiblyCannotBeAcquiredWithinTimeout() throws RedisDriverException { + lenient().when(mockDriver1.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver2.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver3.get(contains(":readers"))).thenReturn(null); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.WriteLock writeLock = (RedlockReadWriteLock.WriteLock) rwLock.writeLock(); + + assertThrows(RedlockException.class, writeLock::lockInterruptibly); + } + + @Test + void shouldThrowInterruptedExceptionWhenWriteLockInterruptiblyInterrupted() throws RedisDriverException { + // No readers so waitForReadersToFinish returns immediately; the pre-interrupt then surfaces + // when the underlying Redlock checks the interrupt flag before attempting acquisition. + lenient().when(mockDriver1.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver2.get(contains(":readers"))).thenReturn(null); + lenient().when(mockDriver3.get(contains(":readers"))).thenReturn(null); + + RedlockReadWriteLock rwLock = new RedlockReadWriteLock("test-rw", drivers, shortTimeoutConfig(), null); + RedlockReadWriteLock.WriteLock writeLock = (RedlockReadWriteLock.WriteLock) rwLock.writeLock(); + + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> writeLock.lockInterruptibly()); + } finally { + Thread.interrupted(); + } + } } diff --git a/src/test/java/org/codarama/redlock4j/driver/LettuceRedisDriverTest.java b/src/test/java/org/codarama/redlock4j/driver/LettuceRedisDriverTest.java index 24ca16e..79d04c3 100644 --- a/src/test/java/org/codarama/redlock4j/driver/LettuceRedisDriverTest.java +++ b/src/test/java/org/codarama/redlock4j/driver/LettuceRedisDriverTest.java @@ -544,4 +544,153 @@ public void testSetexException() { assertTrue(ex.getMessage().contains("Failed to execute SETEX")); } + // ========== CAS (setIfValueMatches) — NATIVE strategy ========== + // With unstubbed dispatch() the CAD detection sees no error and selects NATIVE. + + @Test + public void testSetIfValueMatchesNativeSuccess() throws RedisDriverException { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + when(mockCommands.set(eq("test-key"), eq("new-value"), any(SetArgs.class))).thenReturn("OK"); + + boolean result = driver.setIfValueMatches("test-key", "new-value", "old-value", 10000); + + assertTrue(result); + verify(mockCommands).set(eq("test-key"), eq("new-value"), any(SetArgs.class)); + } + + @Test + public void testSetIfValueMatchesNativeFailure() throws RedisDriverException { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + when(mockCommands.set(eq("test-key"), eq("new-value"), any(SetArgs.class))).thenReturn(null); + + boolean result = driver.setIfValueMatches("test-key", "new-value", "old-value", 10000); + + assertFalse(result); + } + + @Test + public void testSetIfValueMatchesNativeException() { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + when(mockCommands.set(eq("test-key"), eq("new-value"), any(SetArgs.class))) + .thenThrow(new RuntimeException("SET IFEQ failed")); + + RedisDriverException ex = assertThrows(RedisDriverException.class, + () -> driver.setIfValueMatches("test-key", "new-value", "old-value", 10000)); + + assertTrue(ex.getMessage().contains("Failed to execute SET IFEQ command")); + } + + // ========== CAS/CAD — SCRIPT strategy ========== + // Forcing CAD detection to fail (dispatch throws) selects the Lua SCRIPT strategy. + + @Test + public void testSetIfValueMatchesScriptSuccess() throws RedisDriverException { + when(mockCommands.dispatch(any(), any(), any())).thenThrow(new RuntimeException("DELEX unsupported")); + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doReturn("OK").when(mockCommands).eval(anyString(), any(io.lettuce.core.ScriptOutputType.class), + any(String[].class), any(String[].class)); + + boolean result = driver.setIfValueMatches("test-key", "new-value", "old-value", 10000); + + assertTrue(result); + } + + @Test + public void testSetIfValueMatchesScriptException() { + when(mockCommands.dispatch(any(), any(), any())).thenThrow(new RuntimeException("DELEX unsupported")); + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doThrow(new RuntimeException("EVAL failed")).when(mockCommands).eval(anyString(), + any(io.lettuce.core.ScriptOutputType.class), any(String[].class), any(String[].class)); + + RedisDriverException ex = assertThrows(RedisDriverException.class, + () -> driver.setIfValueMatches("test-key", "new-value", "old-value", 10000)); + + assertTrue(ex.getMessage().contains("Failed to execute SET script")); + } + + @Test + public void testDeleteIfValueMatchesScriptSuccess() throws RedisDriverException { + when(mockCommands.dispatch(any(), any(), any())).thenThrow(new RuntimeException("DELEX unsupported")); + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doReturn(1L).when(mockCommands).eval(anyString(), any(io.lettuce.core.ScriptOutputType.class), + any(String[].class), any(String[].class)); + + boolean result = driver.deleteIfValueMatches("test-key", "expected-value"); + + assertTrue(result); + } + + @Test + public void testDeleteIfValueMatchesScriptException() { + when(mockCommands.dispatch(any(), any(), any())).thenThrow(new RuntimeException("DELEX unsupported")); + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doThrow(new RuntimeException("EVAL failed")).when(mockCommands).eval(anyString(), + any(io.lettuce.core.ScriptOutputType.class), any(String[].class), any(String[].class)); + + RedisDriverException ex = assertThrows(RedisDriverException.class, + () -> driver.deleteIfValueMatches("test-key", "expected-value")); + + assertTrue(ex.getMessage().contains("Failed to execute delete script")); + } + + // ========== decrAndPublishIfZero ========== + + @Test + public void testDecrAndPublishIfZeroSuccess() throws RedisDriverException { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doReturn(0L).when(mockCommands).eval(anyString(), any(io.lettuce.core.ScriptOutputType.class), + any(String[].class), any(String[].class)); + + long result = driver.decrAndPublishIfZero("latch", "latch:channel", "zero"); + + assertEquals(0L, result); + } + + @Test + public void testDecrAndPublishIfZeroException() { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + doThrow(new RuntimeException("EVAL failed")).when(mockCommands).eval(anyString(), + any(io.lettuce.core.ScriptOutputType.class), any(String[].class), any(String[].class)); + + RedisDriverException ex = assertThrows(RedisDriverException.class, + () -> driver.decrAndPublishIfZero("latch", "latch:channel", "zero")); + + assertTrue(ex.getMessage().contains("Failed to execute DECR_AND_PUBLISH script")); + } + + // ========== zRemRangeByScore ========== + + @Test + public void testZRemRangeByScoreSuccess() throws RedisDriverException { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + when(mockCommands.zremrangebyscore(eq("zkey"), any(io.lettuce.core.Range.class))).thenReturn(3L); + + long result = driver.zRemRangeByScore("zkey", 0.0, 100.0); + + assertEquals(3L, result); + } + + @Test + public void testZRemRangeByScoreException() { + driver = new LettuceRedisDriver(testConfig, mockRedisClient, mockConnection, mockCommands); + + when(mockCommands.zremrangebyscore(eq("zkey"), any(io.lettuce.core.Range.class))) + .thenThrow(new RuntimeException("ZREMRANGEBYSCORE failed")); + + RedisDriverException ex = assertThrows(RedisDriverException.class, + () -> driver.zRemRangeByScore("zkey", 0.0, 100.0)); + + assertTrue(ex.getMessage().contains("Failed to execute ZREMRANGEBYSCORE")); + } + } diff --git a/src/test/java/org/codarama/redlock4j/driver/RedisDriverExceptionTest.java b/src/test/java/org/codarama/redlock4j/driver/RedisDriverExceptionTest.java new file mode 100644 index 0000000..d9337b4 --- /dev/null +++ b/src/test/java/org/codarama/redlock4j/driver/RedisDriverExceptionTest.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: MIT + * Copyright (c) 2025 Codarama + */ +package org.codarama.redlock4j.driver; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link RedisDriverException}. + */ +@Tag("unit") +public class RedisDriverExceptionTest { + + @Test + public void testMessageOnlyConstructor() { + RedisDriverException exception = new RedisDriverException("boom"); + + assertEquals("boom", exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + public void testMessageAndCauseConstructor() { + Throwable cause = new IllegalStateException("underlying"); + RedisDriverException exception = new RedisDriverException("wrapped", cause); + + assertEquals("wrapped", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + + @Test + public void testCauseOnlyConstructor() { + Throwable cause = new IllegalStateException("underlying"); + RedisDriverException exception = new RedisDriverException(cause); + + assertSame(cause, exception.getCause()); + // Exception(Throwable) uses cause.toString() as the message + assertTrue(exception.getMessage().contains("underlying")); + } + + @Test + public void testIsCheckedException() { + assertTrue(new RedisDriverException("x") instanceof Exception); + } +} diff --git a/src/test/java/org/codarama/redlock4j/integration/LockExtensionIntegrationTest.java b/src/test/java/org/codarama/redlock4j/integration/LockExtensionIntegrationTest.java new file mode 100644 index 0000000..6d1764a --- /dev/null +++ b/src/test/java/org/codarama/redlock4j/integration/LockExtensionIntegrationTest.java @@ -0,0 +1,98 @@ +/* + * SPDX-License-Identifier: MIT + * Copyright (c) 2025 Codarama + */ +package org.codarama.redlock4j.integration; + +import org.codarama.redlock4j.Redlock; +import org.codarama.redlock4j.RedlockManager; +import org.codarama.redlock4j.configuration.RedlockConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for lock extension against real Redis, exercising the driver-level CAS ({@code setIfValueMatches}) + * paths that unit tests with mocked drivers cannot reach. + */ +@Tag("integration") +@Testcontainers +public class LockExtensionIntegrationTest { + + @Container + static GenericContainer> redis1 = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @Container + static GenericContainer> redis2 = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @Container + static GenericContainer> redis3 = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + private static RedlockConfiguration configuration; + + @BeforeAll + static void setUp() { + redis1.start(); + redis2.start(); + redis3.start(); + + configuration = RedlockConfiguration.builder().addRedisNode("localhost", redis1.getMappedPort(6379)) + .addRedisNode("localhost", redis2.getMappedPort(6379)) + .addRedisNode("localhost", redis3.getMappedPort(6379)).defaultLockTimeout(Duration.ofSeconds(10)) + .retryDelay(Duration.ofMillis(100)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofSeconds(5)) + .build(); + } + + @Test + public void testJedisLockExtension() { + try (RedlockManager manager = RedlockManager.withJedis(configuration)) { + Redlock lock = manager.createLock("extend-jedis"); + + assertTrue(lock.tryLock(), "Should acquire the lock"); + try { + Duration before = lock.getRemainingValidityTime(); + + boolean extended = lock.extend(10000); + + assertTrue(extended, "Extension should succeed on a held lock"); + assertTrue(lock.isHeldByCurrentThread()); + assertTrue(lock.getRemainingValidityTime().compareTo(before) > 0, + "Validity should increase after extension"); + } finally { + lock.unlock(); + } + } + } + + @Test + public void testLettuceLockExtension() { + try (RedlockManager manager = RedlockManager.withLettuce(configuration)) { + Redlock lock = manager.createLock("extend-lettuce"); + + assertTrue(lock.tryLock(), "Should acquire the lock"); + try { + Duration before = lock.getRemainingValidityTime(); + + boolean extended = lock.extend(10000); + + assertTrue(extended, "Extension should succeed on a held lock"); + assertTrue(lock.isHeldByCurrentThread()); + assertTrue(lock.getRemainingValidityTime().compareTo(before) > 0, + "Validity should increase after extension"); + } finally { + lock.unlock(); + } + } + } +} diff --git a/src/test/java/org/codarama/redlock4j/integration/NativeCasCadIntegrationTest.java b/src/test/java/org/codarama/redlock4j/integration/NativeCasCadIntegrationTest.java new file mode 100644 index 0000000..00d907e --- /dev/null +++ b/src/test/java/org/codarama/redlock4j/integration/NativeCasCadIntegrationTest.java @@ -0,0 +1,98 @@ +/* + * SPDX-License-Identifier: MIT + * Copyright (c) 2025 Codarama + */ +package org.codarama.redlock4j.integration; + +import org.codarama.redlock4j.Redlock; +import org.codarama.redlock4j.RedlockManager; +import org.codarama.redlock4j.configuration.RedlockConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests against Redis 8.x, where native CAS/CAD commands ({@code DELEX}/{@code SET IFEQ}) are available. + * + *
+ * On Redis 8.x the drivers select the NATIVE strategy, so acquire + extend + release exercises + * {@code setIfValueMatchesNative} and {@code deleteIfValueMatchesNative} — paths that the Redis 7 suites (which fall + * back to Lua scripts for Jedis) cannot reach. + *
+ */ +@Tag("integration") +@Testcontainers +public class NativeCasCadIntegrationTest { + + @Container + static GenericContainer> redis1 = new GenericContainer<>(DockerImageName.parse("redis:8-alpine")) + .withExposedPorts(6379); + + @Container + static GenericContainer> redis2 = new GenericContainer<>(DockerImageName.parse("redis:8-alpine")) + .withExposedPorts(6379); + + @Container + static GenericContainer> redis3 = new GenericContainer<>(DockerImageName.parse("redis:8-alpine")) + .withExposedPorts(6379); + + private static RedlockConfiguration configuration; + + @BeforeAll + static void setUp() { + redis1.start(); + redis2.start(); + redis3.start(); + + configuration = RedlockConfiguration.builder().addRedisNode("localhost", redis1.getMappedPort(6379)) + .addRedisNode("localhost", redis2.getMappedPort(6379)) + .addRedisNode("localhost", redis3.getMappedPort(6379)).defaultLockTimeout(Duration.ofSeconds(10)) + .retryDelay(Duration.ofMillis(100)).maxRetryAttempts(3).lockAcquisitionTimeout(Duration.ofSeconds(5)) + .build(); + } + + @Test + public void testJedisNativeCasCadFullCycle() { + try (RedlockManager manager = RedlockManager.withJedis(configuration)) { + Redlock lock = manager.createLock("native-jedis"); + + assertTrue(lock.tryLock(), "Should acquire the lock (SET NX)"); + boolean extended = lock.extend(10000); // native SET IFEQ + assertTrue(extended, "Native CAS extend should succeed"); + assertTrue(lock.isHeldByCurrentThread()); + + lock.unlock(); // native DELEX + assertFalse(lock.isHeldByCurrentThread(), "Lock should be released after unlock"); + + // Lock is free again: a fresh acquisition must succeed. + assertTrue(lock.tryLock(), "Should re-acquire after release"); + lock.unlock(); + } + } + + @Test + public void testLettuceNativeCasCadFullCycle() { + try (RedlockManager manager = RedlockManager.withLettuce(configuration)) { + Redlock lock = manager.createLock("native-lettuce"); + + assertTrue(lock.tryLock(), "Should acquire the lock (SET NX)"); + boolean extended = lock.extend(10000); // native SET IFEQ + assertTrue(extended, "Native CAS extend should succeed"); + assertTrue(lock.isHeldByCurrentThread()); + + lock.unlock(); // native DELEX + assertFalse(lock.isHeldByCurrentThread(), "Lock should be released after unlock"); + + assertTrue(lock.tryLock(), "Should re-acquire after release"); + lock.unlock(); + } + } +} diff --git a/src/test/java/org/codarama/redlock4j/strategy/Resp2NotSupportedExceptionTest.java b/src/test/java/org/codarama/redlock4j/strategy/Resp2NotSupportedExceptionTest.java new file mode 100644 index 0000000..8e27d9c --- /dev/null +++ b/src/test/java/org/codarama/redlock4j/strategy/Resp2NotSupportedExceptionTest.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: MIT + * Copyright (c) 2025 Codarama + */ +package org.codarama.redlock4j.strategy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.codarama.redlock4j.RedlockException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link Resp2NotSupportedException}. + */ +@Tag("unit") +public class Resp2NotSupportedExceptionTest { + + @Test + public void testDefaultConstructorMessage() { + Resp2NotSupportedException exception = new Resp2NotSupportedException(); + + assertNotNull(exception.getMessage()); + assertTrue(exception.getMessage().contains("RESP3 protocol is required")); + assertTrue(exception.getMessage().contains("WaitStrategy.POLLING")); + } + + @Test + public void testConstructorWithDriverIdentifierAppendsContext() { + Resp2NotSupportedException exception = new Resp2NotSupportedException("redis://localhost:6379"); + + assertTrue(exception.getMessage().contains("RESP3 protocol is required")); + assertTrue(exception.getMessage().contains("Driver: redis://localhost:6379")); + } + + @Test + public void testIsRedlockException() { + assertTrue(new Resp2NotSupportedException() instanceof RedlockException); + } +}