From 9540b213d22c6ede396f05e9cada5ef181b41a3b Mon Sep 17 00:00:00 2001 From: Garvit Joshi Date: Fri, 9 Oct 2026 03:36:57 +0530 Subject: [PATCH] feat: Locksmith 4.0.0, ground-up rewrite Distributed locks and semaphores for Spring Boot 4.1 on Redisson 4, rebuilt from scratch. Rate limiting, the AspectJ aspects, the templates with callbacks and the skip handlers are gone. The public API, the properties, the metrics and the Redis key layout change; CHANGELOG.md lists every change and the 3.x migration table. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 185 ++ CLAUDE.md | 102 - README.md | 332 +-- pom.xml | 124 +- .../in/riido/locksmith/AcquisitionMode.java | 37 - .../in/riido/locksmith/DistributedLock.java | 220 +- .../riido/locksmith/DistributedSemaphore.java | 164 +- .../locksmith/LeaseExpirationBehavior.java | 33 - .../java/in/riido/locksmith/LockType.java | 24 +- .../LocksmithConfigurationException.java | 27 + .../riido/locksmith/LocksmithException.java | 27 + .../java/in/riido/locksmith/OnFailure.java | 11 + .../java/in/riido/locksmith/RateLimit.java | 175 -- .../riido/locksmith/aop/LocksmithAdvisor.java | 40 + .../aop/LocksmithAutoProxyRegistrar.java | 34 + .../locksmith/aop/LocksmithInterceptor.java | 177 ++ .../in/riido/locksmith/aop/MethodSpec.java | 57 + .../locksmith/aop/MethodSpecFactory.java | 390 +++ .../in/riido/locksmith/aop/package-info.java | 2 + .../aspect/DistributedLockAspect.java | 319 --- .../aspect/DistributedSemaphoreAspect.java | 322 --- .../locksmith/aspect/RateLimitAspect.java | 254 -- .../riido/locksmith/aspect/package-info.java | 23 - .../LocksmithAutoConfiguration.java | 356 ++- .../LocksmithDisabledAutoConfiguration.java | 31 + .../LocksmithEnabledCondition.java | 54 + .../LocksmithInactiveAutoConfiguration.java | 36 + .../LocksmithMetricsAutoConfiguration.java | 106 - .../autoconfigure/LocksmithProperties.java | 402 +-- .../locksmith/autoconfigure/package-info.java | 34 - .../exception/LeaseExpiredException.java | 91 - .../exception/LockNotAcquiredException.java | 66 - .../RateLimitConfigurationException.java | 60 - .../exception/RateLimitExceededException.java | 66 - .../SemaphoreConfigurationException.java | 60 - .../SemaphoreLeaseExpiredException.java | 95 - .../SemaphoreNotAcquiredException.java | 67 - .../locksmith/exception/package-info.java | 32 - .../handler/DefaultValueResolver.java | 73 - .../locksmith/handler/LockSkipHandler.java | 91 - .../handler/RateLimitSkipHandler.java | 92 - .../handler/SemaphoreSkipHandler.java | 92 - .../lock/LockReturnDefaultHandler.java | 37 - .../lock/LockThrowExceptionHandler.java | 24 - .../locksmith/handler/lock/package-info.java | 20 - .../riido/locksmith/handler/package-info.java | 46 - .../RateLimitReturnDefaultHandler.java | 37 - .../RateLimitThrowExceptionHandler.java | 24 - .../handler/ratelimit/package-info.java | 11 - .../SemaphoreReturnDefaultHandler.java | 38 - .../SemaphoreThrowExceptionHandler.java | 25 - .../handler/semaphore/package-info.java | 21 - .../locksmith/lock/LockFailureContext.java | 20 + .../locksmith/lock/LockFailureHandler.java | 21 + .../in/riido/locksmith/lock/LockHandle.java | 159 ++ .../lock/LockNotAcquiredException.java | 47 + .../riido/locksmith/lock/LockOperations.java | 249 ++ .../riido/locksmith/metrics/LockMetrics.java | 133 - .../locksmith/metrics/LocksmithMetrics.java | 74 + .../metrics/MicrometerLocksmithMetrics.java | 68 + .../metrics/NoOpLocksmithMetrics.java | 20 + .../locksmith/metrics/RateLimitMetrics.java | 105 - .../locksmith/metrics/SemaphoreMetrics.java | 116 - .../riido/locksmith/metrics/package-info.java | 52 +- .../riido/locksmith/models/LockContext.java | 51 - .../locksmith/models/RateLimitContext.java | 51 - .../locksmith/models/SemaphoreContext.java | 57 - .../riido/locksmith/models/package-info.java | 20 - .../java/in/riido/locksmith/package-info.java | 42 - .../locksmith/semaphore/PermitHandle.java | 169 ++ .../semaphore/SemaphoreFailureContext.java | 23 + .../semaphore/SemaphoreFailureHandler.java | 21 + .../SemaphoreNotAcquiredException.java | 70 + .../semaphore/SemaphoreOperations.java | 294 +++ .../support/AnnotationValidator.java | 220 ++ .../locksmith/support/AspectSupport.java | 176 -- .../locksmith/support/DurationResolver.java | 51 - .../riido/locksmith/support/KeyTemplate.java | 204 ++ .../locksmith/support/RateLimitConfig.java | 56 - .../support/RateLimitInitializer.java | 154 -- .../locksmith/support/RedissonFutures.java | 226 ++ .../locksmith/support/ReturnDefaults.java | 57 + .../support/SemaphoreInitializer.java | 142 -- .../locksmith/support/SpELKeyResolver.java | 158 -- .../riido/locksmith/support/package-info.java | 19 +- .../template/LocksmithLockTemplate.java | 426 ---- .../template/LocksmithRateLimitTemplate.java | 340 --- .../template/LocksmithSemaphoreTemplate.java | 372 --- .../template/callback/LockCallback.java | 38 - .../template/callback/RateLimitCallback.java | 38 - .../template/callback/SemaphoreCallback.java | 39 - .../template/callback/package-info.java | 22 - .../locksmith/template/handle/LockHandle.java | 108 - .../template/handle/PermitHandle.java | 131 - .../template/handle/package-info.java | 20 - .../locksmith/template/package-info.java | 45 - .../spring-configuration-metadata.json | 152 -- ...ot.autoconfigure.AutoConfiguration.imports | 3 +- .../locksmith/DockerAvailableCondition.java | 17 + .../locksmith/NotAcquiredExceptionsTest.java | 40 + .../locksmith/aop/LocksmithAdvisorTest.java | 72 + .../aop/LocksmithAutoProxyRegistrarTest.java | 156 ++ .../aop/LocksmithInterceptorTest.java | 561 +++++ .../locksmith/aop/MethodSpecFactoryTest.java | 863 +++++++ .../aspect/DistributedLockAspectTest.java | 2161 ----------------- .../DistributedSemaphoreAspectTest.java | 451 ---- .../locksmith/aspect/RateLimitAspectTest.java | 190 -- .../aspect/SpELExpressionPerformanceTest.java | 568 ----- .../aspect/SpELKeyResolutionTest.java | 562 ----- .../aspect/WikiSpELExamplesTest.java | 793 ------ .../autoconfigure/AdviceOrderTest.java | 164 ++ .../AnnotationPathIntegrationTest.java | 306 +++ .../AnnotationValidationTest.java | 730 ++++++ .../AsyncAnnotationPathIntegrationTest.java | 199 ++ .../LocksmithAutoConfigurationTest.java | 556 ++--- .../LocksmithPropertiesTest.java | 132 + .../RedissonStarterIntegrationTest.java | 81 + ...emaphoreAnnotationPathIntegrationTest.java | 190 ++ .../otherpackage/InheritedMethods.java | 25 + .../otherpackage/SamePackageChild.java | 7 + .../LockNotAcquiredExceptionTest.java | 194 -- .../RateLimitExceededExceptionTest.java | 153 -- .../locksmith/handler/LockContextTest.java | 190 -- .../handler/LockReturnDefaultHandlerTest.java | 331 --- .../LockThrowExceptionHandlerTest.java | 208 -- .../RateLimitReturnDefaultHandlerTest.java | 308 --- .../RateLimitThrowExceptionHandlerTest.java | 103 - .../integration/ConcurrentAccessTest.java | 373 --- .../DistributedLockIntegrationTest.java | 314 --- .../DistributedSemaphoreIntegrationTest.java | 448 ---- .../integration/DockerAvailableCondition.java | 30 - .../LockMetricsIntegrationTest.java | 262 -- .../LocksmithLockTemplateIntegrationTest.java | 399 --- ...smithSemaphoreTemplateIntegrationTest.java | 379 --- .../integration/RateLimitIntegrationTest.java | 275 --- .../SemaphoreConcurrentAccessTest.java | 422 ---- .../SemaphoreMetricsIntegrationTest.java | 218 -- .../integration/StressPerformanceTest.java | 410 ---- .../integration/VirtualThreadTest.java | 494 ---- .../service/ConcurrencyTestService.java | 28 - .../service/ConcurrencyTestServiceImpl.java | 130 - .../service/IntegrationTestService.java | 38 - .../service/IntegrationTestServiceImpl.java | 155 -- .../RateLimitIntegrationTestService.java | 39 - .../RateLimitIntegrationTestServiceImpl.java | 104 - .../SemaphoreConcurrencyTestService.java | 48 - .../SemaphoreConcurrencyTestServiceImpl.java | 161 -- .../SemaphoreIntegrationTestService.java | 58 - .../SemaphoreIntegrationTestServiceImpl.java | 222 -- .../service/StressTestService.java | 20 - .../service/StressTestServiceImpl.java | 57 - .../service/VirtualThreadTestService.java | 30 - .../service/VirtualThreadTestServiceImpl.java | 151 -- .../integration/service/package-info.java | 8 - .../riido/locksmith/lock/LockHandleTest.java | 397 +++ .../lock/LockOperationsIntegrationTest.java | 780 ++++++ .../locksmith/lock/LockOperationsTest.java | 477 ++++ .../locksmith/metrics/LockMetricsTest.java | 191 -- .../MicrometerLocksmithMetricsTest.java | 103 + .../metrics/RateLimitMetricsTest.java | 176 -- .../metrics/SemaphoreMetricsTest.java | 149 -- .../locksmith/semaphore/PermitHandleTest.java | 401 +++ .../SemaphoreOperationsIntegrationTest.java | 509 ++++ .../semaphore/SemaphoreOperationsTest.java | 712 ++++++ .../locksmith/support/KeyTemplateTest.java | 260 ++ .../support/RedissonFuturesTest.java | 168 ++ .../locksmith/support/ReturnDefaultsTest.java | 98 + .../template/LocksmithLockTemplateTest.java | 453 ---- .../LocksmithRateLimitTemplateTest.java | 287 --- .../LocksmithSemaphoreTemplateTest.java | 429 ---- 171 files changed, 11126 insertions(+), 20375 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 src/main/java/in/riido/locksmith/AcquisitionMode.java delete mode 100644 src/main/java/in/riido/locksmith/LeaseExpirationBehavior.java create mode 100644 src/main/java/in/riido/locksmith/LocksmithConfigurationException.java create mode 100644 src/main/java/in/riido/locksmith/LocksmithException.java create mode 100644 src/main/java/in/riido/locksmith/OnFailure.java delete mode 100644 src/main/java/in/riido/locksmith/RateLimit.java create mode 100644 src/main/java/in/riido/locksmith/aop/LocksmithAdvisor.java create mode 100644 src/main/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrar.java create mode 100644 src/main/java/in/riido/locksmith/aop/LocksmithInterceptor.java create mode 100644 src/main/java/in/riido/locksmith/aop/MethodSpec.java create mode 100644 src/main/java/in/riido/locksmith/aop/MethodSpecFactory.java create mode 100644 src/main/java/in/riido/locksmith/aop/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/aspect/DistributedLockAspect.java delete mode 100644 src/main/java/in/riido/locksmith/aspect/DistributedSemaphoreAspect.java delete mode 100644 src/main/java/in/riido/locksmith/aspect/RateLimitAspect.java delete mode 100644 src/main/java/in/riido/locksmith/aspect/package-info.java create mode 100644 src/main/java/in/riido/locksmith/autoconfigure/LocksmithDisabledAutoConfiguration.java create mode 100644 src/main/java/in/riido/locksmith/autoconfigure/LocksmithEnabledCondition.java create mode 100644 src/main/java/in/riido/locksmith/autoconfigure/LocksmithInactiveAutoConfiguration.java delete mode 100644 src/main/java/in/riido/locksmith/autoconfigure/LocksmithMetricsAutoConfiguration.java delete mode 100644 src/main/java/in/riido/locksmith/autoconfigure/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/exception/LeaseExpiredException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/LockNotAcquiredException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/RateLimitConfigurationException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/RateLimitExceededException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/SemaphoreConfigurationException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/SemaphoreLeaseExpiredException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/SemaphoreNotAcquiredException.java delete mode 100644 src/main/java/in/riido/locksmith/exception/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/handler/DefaultValueResolver.java delete mode 100644 src/main/java/in/riido/locksmith/handler/LockSkipHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/RateLimitSkipHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/SemaphoreSkipHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/lock/LockReturnDefaultHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/lock/LockThrowExceptionHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/lock/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/handler/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/ratelimit/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreReturnDefaultHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreThrowExceptionHandler.java delete mode 100644 src/main/java/in/riido/locksmith/handler/semaphore/package-info.java create mode 100644 src/main/java/in/riido/locksmith/lock/LockFailureContext.java create mode 100644 src/main/java/in/riido/locksmith/lock/LockFailureHandler.java create mode 100644 src/main/java/in/riido/locksmith/lock/LockHandle.java create mode 100644 src/main/java/in/riido/locksmith/lock/LockNotAcquiredException.java create mode 100644 src/main/java/in/riido/locksmith/lock/LockOperations.java delete mode 100644 src/main/java/in/riido/locksmith/metrics/LockMetrics.java create mode 100644 src/main/java/in/riido/locksmith/metrics/LocksmithMetrics.java create mode 100644 src/main/java/in/riido/locksmith/metrics/MicrometerLocksmithMetrics.java create mode 100644 src/main/java/in/riido/locksmith/metrics/NoOpLocksmithMetrics.java delete mode 100644 src/main/java/in/riido/locksmith/metrics/RateLimitMetrics.java delete mode 100644 src/main/java/in/riido/locksmith/metrics/SemaphoreMetrics.java delete mode 100644 src/main/java/in/riido/locksmith/models/LockContext.java delete mode 100644 src/main/java/in/riido/locksmith/models/RateLimitContext.java delete mode 100644 src/main/java/in/riido/locksmith/models/SemaphoreContext.java delete mode 100644 src/main/java/in/riido/locksmith/models/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/package-info.java create mode 100644 src/main/java/in/riido/locksmith/semaphore/PermitHandle.java create mode 100644 src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureContext.java create mode 100644 src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureHandler.java create mode 100644 src/main/java/in/riido/locksmith/semaphore/SemaphoreNotAcquiredException.java create mode 100644 src/main/java/in/riido/locksmith/semaphore/SemaphoreOperations.java create mode 100644 src/main/java/in/riido/locksmith/support/AnnotationValidator.java delete mode 100644 src/main/java/in/riido/locksmith/support/AspectSupport.java delete mode 100644 src/main/java/in/riido/locksmith/support/DurationResolver.java create mode 100644 src/main/java/in/riido/locksmith/support/KeyTemplate.java delete mode 100644 src/main/java/in/riido/locksmith/support/RateLimitConfig.java delete mode 100644 src/main/java/in/riido/locksmith/support/RateLimitInitializer.java create mode 100644 src/main/java/in/riido/locksmith/support/RedissonFutures.java create mode 100644 src/main/java/in/riido/locksmith/support/ReturnDefaults.java delete mode 100644 src/main/java/in/riido/locksmith/support/SemaphoreInitializer.java delete mode 100644 src/main/java/in/riido/locksmith/support/SpELKeyResolver.java delete mode 100644 src/main/java/in/riido/locksmith/template/LocksmithLockTemplate.java delete mode 100644 src/main/java/in/riido/locksmith/template/LocksmithRateLimitTemplate.java delete mode 100644 src/main/java/in/riido/locksmith/template/LocksmithSemaphoreTemplate.java delete mode 100644 src/main/java/in/riido/locksmith/template/callback/LockCallback.java delete mode 100644 src/main/java/in/riido/locksmith/template/callback/RateLimitCallback.java delete mode 100644 src/main/java/in/riido/locksmith/template/callback/SemaphoreCallback.java delete mode 100644 src/main/java/in/riido/locksmith/template/callback/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/template/handle/LockHandle.java delete mode 100644 src/main/java/in/riido/locksmith/template/handle/PermitHandle.java delete mode 100644 src/main/java/in/riido/locksmith/template/handle/package-info.java delete mode 100644 src/main/java/in/riido/locksmith/template/package-info.java delete mode 100644 src/main/resources/META-INF/spring-configuration-metadata.json create mode 100644 src/test/java/in/riido/locksmith/DockerAvailableCondition.java create mode 100644 src/test/java/in/riido/locksmith/NotAcquiredExceptionsTest.java create mode 100644 src/test/java/in/riido/locksmith/aop/LocksmithAdvisorTest.java create mode 100644 src/test/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrarTest.java create mode 100644 src/test/java/in/riido/locksmith/aop/LocksmithInterceptorTest.java create mode 100644 src/test/java/in/riido/locksmith/aop/MethodSpecFactoryTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/DistributedLockAspectTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/DistributedSemaphoreAspectTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/RateLimitAspectTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/SpELExpressionPerformanceTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/SpELKeyResolutionTest.java delete mode 100644 src/test/java/in/riido/locksmith/aspect/WikiSpELExamplesTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/AdviceOrderTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/AnnotationPathIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/AnnotationValidationTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/AsyncAnnotationPathIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/LocksmithPropertiesTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/RedissonStarterIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/SemaphoreAnnotationPathIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/otherpackage/InheritedMethods.java create mode 100644 src/test/java/in/riido/locksmith/autoconfigure/otherpackage/SamePackageChild.java delete mode 100644 src/test/java/in/riido/locksmith/exception/LockNotAcquiredExceptionTest.java delete mode 100644 src/test/java/in/riido/locksmith/exception/RateLimitExceededExceptionTest.java delete mode 100644 src/test/java/in/riido/locksmith/handler/LockContextTest.java delete mode 100644 src/test/java/in/riido/locksmith/handler/LockReturnDefaultHandlerTest.java delete mode 100644 src/test/java/in/riido/locksmith/handler/LockThrowExceptionHandlerTest.java delete mode 100644 src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandlerTest.java delete mode 100644 src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandlerTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/ConcurrentAccessTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/DistributedLockIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/DistributedSemaphoreIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/DockerAvailableCondition.java delete mode 100644 src/test/java/in/riido/locksmith/integration/LockMetricsIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/LocksmithLockTemplateIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/LocksmithSemaphoreTemplateIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/RateLimitIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/SemaphoreConcurrentAccessTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/SemaphoreMetricsIntegrationTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/StressPerformanceTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/VirtualThreadTest.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/IntegrationTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/IntegrationTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/StressTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/StressTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestService.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestServiceImpl.java delete mode 100644 src/test/java/in/riido/locksmith/integration/service/package-info.java create mode 100644 src/test/java/in/riido/locksmith/lock/LockHandleTest.java create mode 100644 src/test/java/in/riido/locksmith/lock/LockOperationsIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/lock/LockOperationsTest.java delete mode 100644 src/test/java/in/riido/locksmith/metrics/LockMetricsTest.java create mode 100644 src/test/java/in/riido/locksmith/metrics/MicrometerLocksmithMetricsTest.java delete mode 100644 src/test/java/in/riido/locksmith/metrics/RateLimitMetricsTest.java delete mode 100644 src/test/java/in/riido/locksmith/metrics/SemaphoreMetricsTest.java create mode 100644 src/test/java/in/riido/locksmith/semaphore/PermitHandleTest.java create mode 100644 src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsIntegrationTest.java create mode 100644 src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsTest.java create mode 100644 src/test/java/in/riido/locksmith/support/KeyTemplateTest.java create mode 100644 src/test/java/in/riido/locksmith/support/RedissonFuturesTest.java create mode 100644 src/test/java/in/riido/locksmith/support/ReturnDefaultsTest.java delete mode 100644 src/test/java/in/riido/locksmith/template/LocksmithLockTemplateTest.java delete mode 100644 src/test/java/in/riido/locksmith/template/LocksmithRateLimitTemplateTest.java delete mode 100644 src/test/java/in/riido/locksmith/template/LocksmithSemaphoreTemplateTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 371c41e..4313fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: cache: maven - name: Run tests - run: mvn -B test --file pom.xml -Dexclude.performance.tests="**/*Performance*" -Dexclude.virtualthread.tests="**/VirtualThread*" + run: mvn -B verify -Pslow --file pom.xml - name: Upload coverage report uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 325c268..5a4f744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,191 @@ All notable changes to this project will be documented in this file. +## [4.0.0] - 2026-10-11 + +A ground-up rewrite. The API, the properties, the metrics and the Redis keys change, and nothing +from 3.x is kept for compatibility. + +### Added +- Startup validation: every `@DistributedLock` and `@DistributedSemaphore` is checked when its bean + is created, and a misconfiguration fails the context refresh with a + `LocksmithConfigurationException` that names the class, the method and the value. 3.x checked + keys, durations and permit counts on each call. +- Startup fails for an annotated method that Spring's proxy never intercepts: a private, static or + final method, or a package-private one declared in another package or class loader than the bean + class. +- Startup fails for an annotated method whose work can outlive the call: a reactive return type, a + Kotlin `suspend` function, or a `Future` or `CompletionStage` without `@Async`. 3.x released the + lock or permit when such a method returned, while its work could still be running. An annotated + `@Async` method must return `void`, Kotlin `Unit`, `Future` or `CompletableFuture`. +- Startup fails when two `@DistributedSemaphore` annotations use one key text with different permit + counts, or two `@DistributedLock` annotations use one key text, one with `REENTRANT` and one with + `READ` or `WRITE`. +- `LocksmithException`, the unchecked base of every Locksmith exception, and + `LocksmithConfigurationException` for a misconfiguration. +- `SemaphoreOperations.availablePermits(key)`, never below zero, and `key()` on `LockHandle` and + `PermitHandle`, the full Redis key. +- One WARN at startup when Locksmith is enabled but there is no `RedissonClient` bean, and one when + `locksmith.enabled` is `false`. 3.x logged nothing in either case. + +### Changed +- Requires Spring Boot 4.1.x; 3.x required 4.0 or later. +- A Spring AOP advisor applies the annotations in place of the AspectJ aspects, so AspectJ is not + needed. It runs at `Ordered.LOWEST_PRECEDENCE - 1`: after Spring Security's method checks, and + around a `@Transactional` at its default order, so the transaction commits before the lock is + released. 3.x ran at `Ordered.HIGHEST_PRECEDENCE`, before the security checks. With both + annotations on one method, the permit is taken before the lock and released after it. +- A lock without `leaseTime` is renewed while it is held (Redisson's watchdog). 3.x gave it a fixed + 10-minute lease from `locksmith.lock.lease-time`. +- `waitTime` alone decides whether an acquire waits; blank or zero means try once. 3.x annotations + waited only with `mode = WAIT_AND_SKIP`, 60 seconds by default, and otherwise ignored `waitTime`. +- `onFailure` replaces `skipHandler`: `THROW`, the default, throws the not-acquired exception, + `SKIP` returns the default value of the return type, and `HANDLER` returns what the handler bean + returns. `handler` names a `LockFailureHandler` or `SemaphoreFailureHandler` type that must have + exactly one bean; Locksmith never creates it by reflection. +- The `LockOperations` and `SemaphoreOperations` beans replace the templates, and the annotations + run through the same code. Only the handle style is kept: there is no callback API and no release + by key. +- Keys are templates: literal text with `#{...}` islands, such as `"user:#{#userId}"`. 3.x + evaluated a key only when the whole key was one `#{...}`, so `"user:#{#userId}"` stayed literal. + A key, or any island of it, that resolves to null or blank throws + `LocksmithConfigurationException`. +- Your code owns the semaphore count. The first acquire of a key in a JVM, and the first with + another count, sets it in Redis, so the last writer wins, with one INFO line on a change. 3.x kept + the first count written and logged a WARN on a mismatch. +- `READ` and `WRITE` locks have their own Redis key, apart from `REENTRANT` locks of the same key, + and the two kinds do not exclude each other. In 3.x they shared one key, where a held `REENTRANT` + lock did not block `READ` or `WRITE`. +- `locksmith.enabled`, `locksmith.key-prefix` (default `locksmith:`) and + `locksmith.semaphore.lease-time` (default 5m) replace the per-primitive properties. Redis keys + move to `locksmith:lock:`, `locksmith:rwlock:` and `locksmith:semaphore:`. +- Metrics are two Micrometer timers, `locksmith.acquire` and `locksmith.held`, recorded whenever a + `MeterRegistry` bean exists. They replace 3.x's per-primitive counters, timers and gauge, and its + `metrics-enabled` switches. +- On Redisson's own threads an acquire throws `IllegalStateException` before anything is sent: on + an I/O or timer thread always, and with a wait time on any other Redisson thread, such as a topic + listener. `isLocked` and `availablePermits` refuse the I/O and timer threads. 3.x refused only the + I/O threads, through Redisson. + +### Removed +- Rate limiting: `@RateLimit`, `LocksmithRateLimitTemplate` and everything around them. +- The AspectJ aspects `DistributedLockAspect`, `DistributedSemaphoreAspect` and `RateLimitAspect`. +- The templates `LocksmithLockTemplate` and `LocksmithSemaphoreTemplate`, with their callback API, + `unlock(key)` and `releasePermit(key, permitId)`. +- The skip handlers and their built-in implementations. +- The `mode`, `autoRenew` and `onLeaseExpired` attributes, with `AcquisitionMode`, + `LeaseExpirationBehavior` and the builder's `autoRenew()`. +- `LeaseExpiredException`, `SemaphoreLeaseExpiredException` and `SemaphoreConfigurationException`. +- The per-primitive properties, `debug` and `metrics-enabled` among them, and + `LocksmithMetricsAutoConfiguration`. +- The public helpers `DefaultValueResolver`, `DurationResolver` and `SpELKeyResolver`, and the + metrics classes `LockMetrics` and `SemaphoreMetrics`. + +### Fixed +- Locksmith registers when the `RedissonClient` bean comes from `redisson-spring-boot-starter`. + 3.x's auto-configuration was evaluated before the starter's, found no client and registered + nothing, so annotated methods ran without a lock or permit. +- Metrics are recorded with the `MeterRegistry` that Spring Boot auto-configures. 3.x's metrics + auto-configuration was evaluated before Spring Boot's, found no registry and recorded nothing, + even with `metrics-enabled`, unless the application declared its own `MeterRegistry` bean. +- An interrupt no longer leaves a lock or permit taken with no handle to release it. In 3.x an + acquire on an interrupted thread, or one interrupted before Redis answered its acquire command, + threw Redisson's `RedisException` instead of reaching the skip handler, while the command could + still take the lock or permit. It then stayed held until its lease ran out, and a lock with + `autoRenew` kept being renewed. +- A `LockHandle` closed on another thread than the one that acquired the lock releases it. In 3.x + the release ran as the closing thread and left the lock held, usually with a WARN that it + `was already released (possibly expired)`. +- Closing a handle a second time does nothing. In 3.x a second `close()` of a `LockHandle` unlocked + again, which could end a hold the same thread had taken since. +- `close()` no longer waits forever when the `RedissonClient` shuts down while Redis is slow to + answer a release; it stops waiting within about a second, and the lock or permit expires on its + own. In 3.x some of those `close()` calls never returned. +- A semaphore that Redis lost, for example after a restart without persistence, is set up again + with its count by the next acquire that finds no free permit, which logs one INFO line. In 3.x, + after such a restart, instances that had used the key got no permit for it until a first use + elsewhere, such as on a restarted instance, set it up again. +- On a Redis Cluster client, a key with a brace that forms no hash tag, such as `order:{42`, throws + `LocksmithConfigurationException` before anything is sent. In 3.x such a `REENTRANT` or `WRITE` + lock was taken but its release failed, so it stayed until it expired, and every call of such a + `READ` lock or semaphore failed. +- `locksmith.enabled` must be `true` or `false`, in any case; any other value fails the startup + with a `LocksmithConfigurationException` that names it. In 3.x, `yes` for + `locksmith.lock.enabled` or `locksmith.semaphore.enabled`, set in a properties file or the + environment, switched that annotation off without a word. +- `OnFailure.SKIP` returns an empty `OptionalInt`, `OptionalLong` or `OptionalDouble`; 3.x's + `ReturnDefaultHandler`s returned `null`. + +### Migration from 3.x + +One row per dropped or changed item. Class names without a package are in `in.riido.locksmith` or +the package named in the 4.0 column. + +| 3.x | 4.0 | what to do | +|---|---|---| +| Spring Boot 4.0 or later | Spring Boot 4.1.x | Upgrade to Spring Boot 4.1.x. | +| `org.aspectj:aspectjweaver` required on the classpath | not used | Remove the dependency if nothing else needs it. | +| AspectJ aspects `aspect.DistributedLockAspect`, `DistributedSemaphoreAspect`, `RateLimitAspect` | Spring AOP advisor, internal | Remove any bean that replaced or referenced an aspect. | +| Both aspects at `Ordered.HIGHEST_PRECEDENCE`, relative order not fixed | one advisor at `Ordered.LOWEST_PRECEDENCE - 1`: after Spring Security's method checks, before `@Transactional` at its default order; permit first, then lock | Check advice you ordered relative to Locksmith. | +| `LocksmithMetricsAutoConfiguration` | merged into `autoconfigure.LocksmithAutoConfiguration` | Remove it from `spring.autoconfigure.exclude` and from `exclude = ...` on `@SpringBootApplication`; the class no longer exists. | +| `@RateLimit` (with Redisson's `RateType` for `type`) | removed | Use a rate-limiting library such as Bucket4j or Resilience4j. | +| `template.LocksmithRateLimitTemplate`, `template.callback.RateLimitCallback`, `handler.RateLimitSkipHandler`, `handler.ratelimit.RateLimitThrowExceptionHandler`, `handler.ratelimit.RateLimitReturnDefaultHandler`, `models.RateLimitContext`, `exception.RateLimitExceededException`, `exception.RateLimitConfigurationException`, `metrics.RateLimitMetrics`, `support.RateLimitConfig` | removed | Replace with the rate-limiting library's own types. | +| `locksmith.rate-limit.*` properties (`enabled`, `wait-time`, `key-prefix`, `debug`, `metrics-enabled`) | removed | Delete them. | +| `@DistributedLock(mode = ...)`, `@DistributedSemaphore(mode = ...)`, `AcquisitionMode` (`SKIP_IMMEDIATELY`, `WAIT_AND_SKIP`) | removed; `waitTime` alone decides | Drop `mode`. Where it was `WAIT_AND_SKIP`, set `waitTime`, which 3.x defaulted to 60s. Where it was not, remove any `waitTime`: 3.x ignored it there, and 4.0 waits that long. | +| `locksmith.lock.wait-time`, `locksmith.semaphore.wait-time` (default 60s, used by `WAIT_AND_SKIP`) | removed; no property-level wait | Set `waitTime` on each annotation or builder that should wait. | +| Lock `leaseTime` blank, or not set on the builder: fixed lease from `locksmith.lock.lease-time` (10m), not renewed | blank or not set: renewed while held (Redisson watchdog); a value: fixed lease, not renewed | Nothing for most methods. To keep a fixed lease, set `leaseTime`. | +| `locksmith.lock.lease-time` | removed | Delete it; set `leaseTime` per annotation where a fixed lease is wanted. | +| `@DistributedLock(autoRenew = true)`, builder `autoRenew()` | removed; renewal is the default | Drop it and leave `leaseTime` blank or unset. | +| `@DistributedLock(onLeaseExpired = ...)`, `@DistributedSemaphore(onLeaseExpired = ...)`, `LeaseExpirationBehavior` | removed; an outrun lease logs one WARN at release and the result is returned | Drop the attribute. Alert on the WARN if you need to know. | +| `exception.LeaseExpiredException`, `exception.SemaphoreLeaseExpiredException` | removed | Remove the catch blocks. | +| A zero `leaseTime` accepted and passed to Redisson, which then renews a lock instead of expiring it | zero or below one millisecond rejected: at startup for annotations, `IllegalArgumentException` in the builders | Use at least `1ms`, or leave a lock lease blank for renewal. | +| `skipHandler = ...` attribute | `onFailure` plus `handler` | Replace with `onFailure = THROW`, `SKIP` or `HANDLER` (with `handler = ...`). | +| `handler.lock.LockThrowExceptionHandler`, `handler.semaphore.SemaphoreThrowExceptionHandler` (built-in, default) | `OnFailure.THROW`, the default | Remove the `skipHandler` attribute. | +| `handler.lock.LockReturnDefaultHandler`, `handler.semaphore.SemaphoreReturnDefaultHandler` | `OnFailure.SKIP` | Use `onFailure = OnFailure.SKIP`. It returns the same defaults, except an empty `OptionalInt`, `OptionalLong` or `OptionalDouble` where 3.x returned `null`. | +| `handler.LockSkipHandler.handle(LockContext)`, `handler.SemaphoreSkipHandler.handle(SemaphoreContext)` | `lock.LockFailureHandler.onFailure(LockFailureContext)`, `semaphore.SemaphoreFailureHandler.onFailure(SemaphoreFailureContext)` | Implement the new interface and set `onFailure = OnFailure.HANDLER, handler = YourHandler.class`. | +| Handler looked up as a bean, else created by reflection with a no-argument constructor | exactly one bean of the handler type, checked at startup; never created by reflection | Register the handler as a Spring bean. | +| `models.LockContext(lockKey, methodName, method, args, returnType)` | `lock.LockFailureContext(key, method, args, waitTime)` | Use `key()`; use `method().getName()` and `method().getReturnType()` for the dropped fields. | +| `models.SemaphoreContext(semaphoreKey, methodName, method, args, returnType, permitId)` | `semaphore.SemaphoreFailureContext(key, permits, method, args, waitTime)` | As above; there is no permit id, since none was acquired. | +| `handler.DefaultValueResolver` (public) | internal | Stop using it; `OnFailure.SKIP` applies the defaults. | +| `template.LocksmithLockTemplate` | `lock.LockOperations` bean | Inject `LockOperations`. | +| `template.LocksmithSemaphoreTemplate` | `semaphore.SemaphoreOperations` bean | Inject `SemaphoreOperations`. | +| `withKey(String)` | `key(String)` | Rename the call. | +| `LockOperationBuilder.lockType(LockType)` | `LockOperations.Builder.type(LockType)` | Rename the call. | +| `tryLock()`, `tryAcquire()` on the builders | `acquire()` | Rename the call. | +| `execute(callback)` on the builders, `template.callback.LockCallback`, `template.callback.SemaphoreCallback` | removed; no callback-style API | Use try-with-resources on the handle and check `acquired()`. | +| `template.handle.LockHandle`, `template.handle.PermitHandle` | `lock.LockHandle`, `semaphore.PermitHandle` | Change the imports. | +| `LockHandle.isAcquired()`, `PermitHandle.isAcquired()` | `acquired()` | Rename the call. `key()` is new on both. | +| `LockHandle.acquired(...)`, `LockHandle.notAcquired()`, `PermitHandle.acquired(...)`, `PermitHandle.notAcquired()` (public factories) | removed; only the operations create handles | Mock the handle in tests instead. | +| `LocksmithLockTemplate.unlock(key)`, `unlock(key, type)` | removed; no unlock by name | Close the `LockHandle` that acquired the lock. | +| `LocksmithLockTemplate.isLocked(key)` | `LockOperations.isLocked(key, type)` | Pass the `LockType`. | +| `LocksmithSemaphoreTemplate.releasePermit(key, permitId)` | removed | Close the `PermitHandle`. | +| `@DistributedSemaphore(permits = 5)`: `int`, default 1 | `permits = "5"`: `String`, required, `${...}` placeholders resolved | Quote the number, or use a placeholder such as `"${reports.max-concurrent}"`. | +| Semaphore count: first writer wins, WARN on mismatch, `:meta` bucket in Redis | your code owns the count: the last writer wins, INFO on change, no metadata key | Delete the old `semaphore:*:meta` keys. Keep one count per key. | +| `exception.SemaphoreConfigurationException`, for a count below one or not set, and for one key used with two counts in one JVM | removed: a count below one throws `LocksmithConfigurationException`, at startup for annotations; two annotations with one key text and different counts fail the startup; through `SemaphoreOperations` the last writer wins | Remove the catch blocks. | +| Key: evaluated only when the whole string is `#{...}`; `"user:#{#id}"` was a literal | template mode: literal text with `#{...}` islands, so `"user:#{#id}"` is evaluated | Check literal keys that contain `#{`. | +| A `#{...}` value turned into text with `toString()` | turned into text by Spring's type conversion; an object it cannot convert to a `String`, such as a `LocalDate`, fails each call with a `SpelEvaluationException` | Use a property, or call `toString()` in the key, such as `#{#order.id}` or `#{#day.toString()}`. | +| Keys, durations and handlers checked on the first call | checked at startup; a misconfiguration fails the context refresh; an unknown key variable fails with a `-parameters` hint | Fix the reported annotation. | +| Key resolved to null or blank: `IllegalArgumentException` | `LocksmithConfigurationException`, also when one `#{...}` island of a longer key resolves to null or blank | Update catch blocks. | +| `#root` or a bean reference such as `@myBean` in a `#{...}` key: each call failed | fails the startup | Pass the value as a method parameter. | +| `exception` package | `lock.LockNotAcquiredException`, `semaphore.SemaphoreNotAcquiredException`, `LocksmithConfigurationException`, `LocksmithException` | Change the imports. | +| Exceptions extend `RuntimeException` | all extend `LocksmithException`, which extends `RuntimeException` | Catch `LocksmithException` to handle every Locksmith error. | +| `LockNotAcquiredException(lockKey, methodName)`, `getLockKey()`, `getMethodName()` | `(key, waitTime)`, `key()`, `waitTime()`; message `Lock [] not acquired within ` | Use `key()`; the method name is no longer carried. | +| `SemaphoreNotAcquiredException(semaphoreKey, methodName)`, `getSemaphoreKey()`, `getMethodName()` | `(key, permits, waitTime)`, `key()`, `permits()`, `waitTime()`; message `Semaphore [] permit not acquired within (permits )` | Use `key()`. | +| `locksmith.lock.enabled`, `locksmith.semaphore.enabled` | `locksmith.enabled`, one switch for both; `false` logs one WARN; any value but `true` or `false` fails the startup | Replace with `locksmith.enabled`. | +| `locksmith.lock.key-prefix` (`lock:`), `locksmith.semaphore.key-prefix` (`semaphore:`) | `locksmith.key-prefix` (`locksmith:`) plus a fixed `lock:`, `rwlock:` or `semaphore:` part | Replace with `locksmith.key-prefix` if you need another prefix. | +| Redis keys `lock:` and `semaphore:` | `locksmith:lock:`, `locksmith:rwlock:` for read and write locks, and `locksmith:semaphore:` | 3.x and 4.0 instances do not exclude each other. Do not run both against the same keys at once. | +| `locksmith.semaphore.lease-time`: zero or negative replaced by the default | same property and default (5m); a value below one millisecond fails the startup | Set a value of at least `1ms`. | +| `locksmith.lock.debug`, `locksmith.semaphore.debug`; each acquire, release and skip of an annotated method logged at INFO | removed; one DEBUG line per release or failed acquire | Set `logging.level.in.riido.locksmith=DEBUG` to see them. | +| `locksmith.lock.metrics-enabled`, `locksmith.semaphore.metrics-enabled` | removed; metrics are recorded whenever a `MeterRegistry` bean exists | Delete them. | +| `autoconfigure.LocksmithProperties` with nested `LockProperties`, `SemaphoreProperties`, `RateLimitProperties` and `defaults()` | `LocksmithProperties(enabled, keyPrefix, semaphore)` with nested `Semaphore(leaseTime)` | Update code that reads the properties. | +| Metrics `locksmith.lock.acquired`, `locksmith.semaphore.acquired` (counters) | `locksmith.acquire` timer with `outcome=acquired` | Count the timer, tagged `primitive=lock` or `semaphore`. | +| Metrics `locksmith.lock.skipped`, `locksmith.semaphore.skipped` with tag `reason=immediate` or `timeout` | `locksmith.acquire` with `outcome=skipped`; `outcome=interrupted` is new; no `reason` tag | Update dashboards and alerts. | +| Metrics `locksmith.lock.acquisition.time`, `locksmith.semaphore.acquisition.time` | `locksmith.acquire`, one record per acquire that returns, the time it took | Update dashboards. | +| Metrics `locksmith.lock.held.time`, `locksmith.semaphore.held.time` | `locksmith.held` with tag `primitive` | Update dashboards. | +| Metrics `locksmith.lock.lease.expired`, `locksmith.semaphore.lease.expired` | removed; an outrun lease is a WARN log line | Alert on the log line instead. | +| Gauge `locksmith.lock.autorenew.active` | removed | Remove it from dashboards. | +| Packages `aspect`, `exception`, `handler` (with `lock`, `semaphore`, `ratelimit`), `models`, `template` (with `callback`, `handle`); the public classes of `support` and `metrics` | public packages are `in.riido.locksmith`, `lock`, `semaphore`, `autoconfigure`; `aop`, `support` and `metrics` are internal | Change the imports as the rows above say. Stop using `DurationResolver`, `SpELKeyResolver`, `LockMetrics` and `SemaphoreMetrics`. | + ## [3.0.3] - 2026-03-06 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d135309..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,102 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Locksmith is a Spring Boot starter library providing Redis-based distributed coordination primitives (locks, semaphores, rate limiters) via annotations and programmatic APIs. Built on Redisson, targeting Java 17+ and Spring Boot 4.0+. - -## Build Commands - -```bash -mvn clean compile # Compile (also auto-formats code via fmt-maven-plugin) -mvn test # Run all tests (requires Docker for Testcontainers) -mvn clean package # Build JAR -mvn clean install # Install to local Maven repo -``` - -**CI-style test run** (excludes performance and virtual thread tests): -```bash -mvn -B test -Dexclude.performance.tests="**/*Performance*" -Dexclude.virtualthread.tests="**/VirtualThread*" -``` - -**Run a single test class:** -```bash -mvn test -Dtest=DistributedLockIntegrationTest -``` - -**Run a single test method:** -```bash -mvn test -Dtest=DistributedLockIntegrationTest#testMethodName -``` - -## Code Formatting - -Google Java Style is enforced via `fmt-maven-plugin` (runs automatically during compile phase). No manual formatting step needed. - -## Architecture - -**Pattern:** Spring AOP aspects intercept annotated methods, acquire Redis-based coordination primitives via Redisson, execute the method, then release. - -**Three coordination primitives**, each following the same layered pattern: - -| Layer | Lock | Semaphore | Rate Limit | -|-------|------|-----------|------------| -| Annotation | `@DistributedLock` | `@DistributedSemaphore` | `@RateLimit` | -| Aspect | `DistributedLockAspect` | `DistributedSemaphoreAspect` | `RateLimitAspect` | -| Template | `LocksmithLockTemplate` | `LocksmithSemaphoreTemplate` | `LocksmithRateLimitTemplate` | -| Skip Handler | `LockSkipHandler` | `SemaphoreSkipHandler` | `RateLimitSkipHandler` | -| Metrics | `LockMetrics` | `SemaphoreMetrics` | `RateLimitMetrics` | -| Context | `LockContext` | `SemaphoreContext` | `RateLimitContext` | - -**Key packages under `in.riido.locksmith`:** -- `aspect/` — AOP aspects that intercept annotations -- `autoconfigure/` — Spring Boot auto-configuration and properties -- `exception/` — Custom exceptions for each primitive -- `handler/` — Skip handler interfaces + built-in implementations (throw exception, return default) -- `metrics/` — Optional Micrometer integration -- `models/` — Context records passed to skip handlers -- `support/` — SpEL key resolution (`SpELKeyResolver`) and duration parsing (`DurationResolver`) -- `template/` — Programmatic APIs with builder pattern - -**Handler resolution:** Aspects first check the Spring ApplicationContext for a matching bean, then fall back to reflective instantiation. Instances are cached in a `ConcurrentHashMap`. - -**SpEL keys:** Expressions must be wrapped in `#{...}` (e.g., `#{#userId}`). Literal strings without `#{...}` are used as-is. - -## Testing - -- **Unit tests:** Handler and metrics classes in `src/test/java/.../handler/` and `metrics/` -- **Integration tests:** In `src/test/java/.../integration/` — require Docker (Testcontainers spins up Redis) -- Tests use `@Nested` classes, `@DisplayName`, and `DockerAvailableCondition` to skip when Docker is unavailable -- Performance tests (`*Performance*`) and virtual thread tests (`VirtualThread*`) are excluded in CI -- Coverage reports: `target/site/jacoco/` - -## Key Design Details - -- **Aspect ordering:** All aspects use `@Order(Ordered.HIGHEST_PRECEDENCE)` so locks/permits are acquired before transactions start. -- **Auto-configuration ordering:** `LocksmithMetricsAutoConfiguration` runs before `LocksmithAutoConfiguration` (`@AutoConfigureBefore`) so metrics beans are available for injection into aspects. -- **Null-safety:** The project uses `org.jspecify.annotations` (`@NonNull`, `@Nullable`) throughout. Follow this convention when adding code. -- **Duration format:** Annotation duration strings (leaseTime, waitTime, interval) support both simple (`"10s"`, `"5m"`) and ISO-8601 (`"PT10S"`) formats via `DurationResolver`. - -## Configuration Properties - -All under `locksmith.*` prefix — see `LocksmithProperties` record in `autoconfigure/`. Lock and semaphore have `enabled`, `lease-time`, `wait-time`, `key-prefix`, `debug`, and `metrics-enabled` properties. Rate limit has the same except no `lease-time`. - -## Dependencies - -Core dependencies (Spring AOP, Spring Context, AspectJ, Redisson, Spring Boot Autoconfigure) are `provided` scope — the consuming application supplies them. Micrometer is `optional`. - -## Design Principles (Strictly Enforced by the User) - -The user is meticulous about code quality and will reject sloppy work. Follow these principles without exception: - -- **No code duplication** — never copy-paste logic across classes. If something exists already (e.g., handler resolution in aspects, SpEL key resolution), reuse it. Do not create separate utility methods that duplicate existing patterns. -- **Reusable logic belongs in `support/`** — generic operations like key resolution and duration parsing go in `support/` as `final` classes with private constructors. Never put reusable static methods inside aspect or template classes. -- **Design for extensibility** — the three primitives follow an identical layered pattern (annotation → aspect → template → skip handler → metrics → context). When adding a new primitive or feature, follow this same structure. Separate generic logic from primitive-specific logic. -- **Don't assume — ask** — when requirements are ambiguous or there are multiple valid approaches, ask the user before implementing. The user values being consulted over speed. -- **Push back when something is wrong** — if the user asks for something that violates software engineering principles, introduces code smells, breaks separation of concerns, or is otherwise a bad idea, say so bluntly. Do not silently comply. Explain why it's wrong and suggest the correct approach. The user respects honest technical disagreement and prefers being challenged over receiving bad code. -- **Always use existing helper/wrapper methods** — if a private helper exists for an operation (e.g., `getHandlerInstance()`, `formatMethodSignature()`), use it consistently everywhere in that class. Never bypass it by calling the underlying logic directly. Consistency is non-negotiable. -- **No magic strings** — never hardcode string literals that represent domain values. Always use enum values or constants. If the value exists in an enum (e.g., `AcquisitionMode`, `LockType`, `RateType`), reference the enum. -- **Data normalization at the source** — formatting, sanitization, and key resolution must happen at a single point (the aspect or template), never duplicated across consumers downstream. This prevents duplication and ensures consistency. -- **Records for truly immutable data only** — context objects (`LockContext`, `SemaphoreContext`, `RateLimitContext`) and properties (`LocksmithProperties`) are records because they are genuinely immutable. Do not use records for objects that need post-construction mutation. -- **Utility classes must be `final` with private constructor** — see `SpELKeyResolver` and `DurationResolver` as examples. diff --git a/README.md b/README.md index acbccb5..7865222 100644 --- a/README.md +++ b/README.md @@ -1,312 +1,136 @@ - # Locksmith -[![Maven Central](https://img.shields.io/maven-central/v/in.riido/locksmith-spring-boot-starter)](https://central.sonatype.com/artifact/in.riido/locksmith-spring-boot-starter) +Redis-based distributed locks and semaphores for Spring Boot, built on Redisson. -A Spring Boot starter for Redis-based distributed locking, semaphores, and rate limiting using annotations. +## Overview -> *Made by a human who guards the locks, and an AI that picks them — for good reasons, mostly.* +Locksmith coordinates work across the instances of a Spring Boot application through Redis. It +offers two primitives: -## Overview +| Primitive | Guarantees | Typical use | +|---|---|---| +| Lock | One holder at a time per key (or many readers, one writer) | a scheduled job that must not overlap itself, one order handled by one instance at a time | +| Semaphore | At most N holders at a time per key | a limit on concurrent calls to a slow service | -Locksmith provides three coordination primitives for distributed systems: +A key names what is coordinated, for example one order or one job. -| Primitive | Purpose | Example Use Case | -|-----------|---------|------------------| -| `@DistributedLock` | Exclusive access - only one instance executes at a time | Payment processing, scheduled jobs | -| `@DistributedSemaphore` | Limited concurrency - up to N instances execute simultaneously | Connection pooling, batch processing | -| `@RateLimit` | Throughput control - limit requests per time interval | API rate limiting, throttling | +Each primitive has two entry points that share one implementation: + +- an annotation on a Spring bean method: `@DistributedLock`, `@DistributedSemaphore`; +- a programmatic API: the `LockOperations` and `SemaphoreOperations` beans. ## Requirements -- Java 17+ -- Spring Boot 4.0+ -- Redis -- Redisson 4.0+ +- Java 17 or later. +- Spring Boot 4.1.x. +- Redisson 4.x, supplied by you as a `RedissonClient` bean, for example through + `redisson-spring-boot-starter`. Locksmith 4.0.0 is built and tested against Redisson 4.8.0. +- A Redis server. + +Locksmith declares Spring and Redisson as `provided` dependencies. It does not bring them onto your +classpath; your application does. Micrometer is optional. AspectJ is not needed. ## Installation -Add to your `pom.xml`: +Maven: ```xml in.riido locksmith-spring-boot-starter - 3.0.3 + 4.0.0 + org.redisson - redisson - 4.3.0 - - - - org.aspectj - aspectjweaver + redisson-spring-boot-starter + 4.8.0 ``` -For Gradle: +Gradle: ```groovy -implementation 'in.riido:locksmith-spring-boot-starter:3.0.3' -implementation 'org.redisson:redisson:4.3.0' -implementation 'org.aspectj:aspectjweaver' +implementation 'in.riido:locksmith-spring-boot-starter:4.0.0' +implementation 'org.redisson:redisson-spring-boot-starter:4.8.0' ``` -## Quick Start +To declare the `RedissonClient` bean yourself instead, see +[Configuration](https://github.com/riido-git/locksmith/wiki/Configuration#your-own-redissonclient-bean). -### 1. Configure Redis Connection +Upgrading from 3.x: the migration table is in [CHANGELOG.md](CHANGELOG.md). -Provide a `RedissonClient` bean: +## Quick start -```java -@Configuration -public class RedisConfig { - - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress("redis://localhost:6379"); - return Redisson.create(config); - } -} -``` - -### 2. Use Annotations +A lock: ```java @Service public class OrderService { - // Only one instance processes this order at a time - @DistributedLock(key = "#{'order-' + #orderId}") - public void processOrder(String orderId) { - // Critical section - } - - // Up to 5 concurrent API calls across all instances - @DistributedSemaphore(key = "external-api", permits = 5) - public Response callExternalApi() { - return httpClient.get("/api/data"); - } - - // Maximum 100 requests per minute - @RateLimit(key = "api-endpoint", permits = 100, interval = "1m") - public Response handleRequest() { - return processRequest(); + @DistributedLock(key = "order:#{#orderId}") + public void process(String orderId) { + // runs for one orderId at a time, across all instances } } ``` -## Distributed Locks - -Use `@DistributedLock` when only one instance should execute a method at a time. - -```java -// Basic lock -@DistributedLock(key = "my-task") -public void exclusiveTask() { } - -// Dynamic key using SpEL (must use #{...} wrapper) -@DistributedLock(key = "#{#userId}") -public void processUser(String userId) { } - -// Wait up to 30 seconds for lock -@DistributedLock(key = "resource", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "30s") -public void waitForLock() { } - -// Auto-renew for long-running tasks -@DistributedLock(key = "long-task", autoRenew = true) -public void longRunningTask() { } - -// Read/Write locks for concurrent reads -@DistributedLock(key = "data", type = LockType.READ) -public Data readData() { } - -@DistributedLock(key = "data", type = LockType.WRITE) -public void writeData(Data data) { } -``` - -**Handling Lock Failures:** - -```java -// Default: throws LockNotAcquiredException -@DistributedLock(key = "task") -public void task() { } - -// Silent skip: returns null/default value -@DistributedLock(key = "task", skipHandler = LockReturnDefaultHandler.class) -public void task() { } -``` - -## Distributed Semaphores - -Use `@DistributedSemaphore` to limit concurrent executions to N instances. - -```java -// Allow 10 concurrent executions -@DistributedSemaphore(key = "db-pool", permits = 10) -public void queryDatabase() { } - -// Per-user concurrency limit -@DistributedSemaphore(key = "#{#userId}", permits = 3) -public void userOperation(String userId) { } - -// Wait for permit -@DistributedSemaphore(key = "pool", permits = 5, mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "30s") -public void waitForPermit() { } -``` - -## Rate Limiting +A call with `orderId = "42"` locks the Redis key `locksmith:lock:order:42`. Locksmith tries once. If +another thread or instance holds that lock, the method does not run and the call throws +`LockNotAcquiredException` with the message +`Lock [locksmith:lock:order:42] not acquired within PT0S`. To wait, set `waitTime`. To return +quietly instead of throwing, set `onFailure = OnFailure.SKIP`; see +[Failure handling](https://github.com/riido-git/locksmith/wiki/Failure-handling). -Use `@RateLimit` to control request throughput over time. +The lock is released when the method returns or throws. -```java -// 10 requests per second (default) -@RateLimit(key = "api") -public void apiCall() { } - -// 100 requests per minute -@RateLimit(key = "heavy-api", permits = 100, interval = "1m") -public void heavyOperation() { } - -// Per-user rate limiting -@RateLimit(key = "#{#userId}", permits = 60, interval = "1m") -public void userRequest(String userId) { } - -// Per-instance rate limiting -@RateLimit(key = "local-api", permits = 50, interval = "1s", type = RateType.PER_CLIENT) -public void localOperation() { } -``` - -## Programmatic API - -For scenarios where annotations are not suitable: +A semaphore: ```java @Service -public class MyService { - - private final LocksmithLockTemplate lockTemplate; - private final LocksmithSemaphoreTemplate semaphoreTemplate; - private final LocksmithRateLimitTemplate rateLimitTemplate; - - // Lock with callback - public String withLock() { - return lockTemplate.executeWithLock("my-key", () -> { - return computeResult(); - }); - } - - // Lock with builder - public void customLock() { - lockTemplate.forKey("my-key") - .waitTime(Duration.ofSeconds(30)) - .leaseTime(Duration.ofMinutes(5)) - .lockType(LockType.WRITE) - .execute(() -> doWork()); - } +public class ReportService { - // Semaphore with callback - public String withSemaphore() { - return semaphoreTemplate.executeWithPermit("pool", 5, () -> { - return callApi(); - }); - } - - // Rate limit with callback - public String withRateLimit() { - return rateLimitTemplate.executeWithRateLimit("api", () -> { - return processRequest(); - }); + @DistributedSemaphore(key = "reports", permits = "5") + public void export() { + // at most five of these run at once, across all instances } } ``` -## Configuration - -```yaml -locksmith: - lock: - enabled: true # Enable/disable locks - lease-time: 10m # Auto-release time - wait-time: 60s # Wait time for WAIT_AND_SKIP - key-prefix: "lock:" # Redis key prefix - metrics-enabled: false # Micrometer metrics - semaphore: - enabled: true - lease-time: 5m - wait-time: 60s - key-prefix: "semaphore:" - metrics-enabled: false - rate-limit: - enabled: true - wait-time: 60s - key-prefix: "ratelimit:" - metrics-enabled: false -``` - -## SpEL Key Syntax - -Dynamic keys use Spring Expression Language. **SpEL expressions must be wrapped in `#{...}`:** +Each call holds one of five permits while it runs. The semaphore lives at the Redis key +`locksmith:semaphore:reports`. Locksmith tries once. If all five are held, the method does not run +and the call throws `SemaphoreNotAcquiredException`. -| Expression | Type | Result | -|------------|------|--------| -| `"my-task"` | Literal | `my-task` | -| `"order#123"` | Literal | `order#123` | -| `"#{#userId}"` | SpEL | Value of `userId` parameter | -| `"#{'user-' + #id}"` | SpEL | `user-42` (concatenation) | -| `"#{#order.customerId}"` | SpEL | Property access | - -## Exception Handling - -```java -try { - lockedMethod(); -} catch (LockNotAcquiredException e) { - // Lock was not acquired -} catch (LeaseExpiredException e) { - // Method exceeded lease time -} - -try { - semaphoreMethod(); -} catch (SemaphoreNotAcquiredException e) { - // No permit available -} - -try { - rateLimitedMethod(); -} catch (RateLimitExceededException e) { - // Rate limit exceeded -} -``` +The permit is released when the method returns or throws. It also expires 5 minutes after it was +acquired, by default, even if the method is still running; see +[Semaphores](https://github.com/riido-git/locksmith/wiki/Semaphores). ## Documentation -For detailed documentation, see the **[Wiki](https://github.com/riido-git/locksmith/wiki)**: - -- [Installation](https://github.com/riido-git/locksmith/wiki/Installation) -- [Configuration](https://github.com/riido-git/locksmith/wiki/Configuration) -- [Distributed Locks](https://github.com/riido-git/locksmith/wiki/Distributed-Locks) -- [Distributed Semaphores](https://github.com/riido-git/locksmith/wiki/Distributed-Semaphores) -- [Rate Limiting](https://github.com/riido-git/locksmith/wiki/Rate-Limiting) -- [Dynamic Keys with SpEL](https://github.com/riido-git/locksmith/wiki/Dynamic-Keys-with-SpEL) -- [Lock Types (Read/Write)](https://github.com/riido-git/locksmith/wiki/Lock-Types) -- [Auto-Renew Lease Time](https://github.com/riido-git/locksmith/wiki/Auto-Renew-Lease-Time) -- [Skip Handlers](https://github.com/riido-git/locksmith/wiki/Skip-Handlers) -- [Programmatic Templates](https://github.com/riido-git/locksmith/wiki/Programmatic-Templates) -- [Micrometer Metrics](https://github.com/riido-git/locksmith/wiki/Micrometer-Metrics) -- [High Concurrency Best Practices](https://github.com/riido-git/locksmith/wiki/High-Concurrency-Best-Practices) -- [Troubleshooting](https://github.com/riido-git/locksmith/wiki/Troubleshooting) - -## Issues - -Found a bug or have a feature request? [Create an issue](https://github.com/riido-git/locksmith/issues). +The [wiki](https://github.com/riido-git/locksmith/wiki) covers everything else, one topic per page: + +- [Locks](https://github.com/riido-git/locksmith/wiki/Locks): `@DistributedLock`, its attributes, + the lock types and how a lock's lease is renewed or fixed. +- [Semaphores](https://github.com/riido-git/locksmith/wiki/Semaphores): `@DistributedSemaphore`, its + attributes, permit leases and how the permit count is kept in Redis. +- [Key templates](https://github.com/riido-git/locksmith/wiki/Key-templates): how a `key` such as + `order:#{#orderId}` is resolved to a Redis key. +- [Failure handling](https://github.com/riido-git/locksmith/wiki/Failure-handling): throw, skip or + call a handler when a lock or permit is not acquired. +- [With other Spring annotations](https://github.com/riido-git/locksmith/wiki/With-other-Spring-annotations): + how Locksmith works with Spring Security, `@Transactional`, `@Cacheable`, `@Async` and retries, + including methods that return a future. +- [Programmatic API](https://github.com/riido-git/locksmith/wiki/Programmatic-API): the + `LockOperations` and `SemaphoreOperations` beans, for locking in code instead of with an + annotation. +- [Configuration](https://github.com/riido-git/locksmith/wiki/Configuration): the properties, your + own `RedissonClient` bean, startup checks and what Locksmith logs. +- [Metrics](https://github.com/riido-git/locksmith/wiki/Metrics): the two Micrometer timers Locksmith + records. +- [Limitations](https://github.com/riido-git/locksmith/wiki/Limitations): what Locksmith does not + guarantee, and what to do about it. ## License -Apache License 2.0 +Apache License 2.0; see [LICENSE](LICENSE). diff --git a/pom.xml b/pom.xml index aaa02d8..4984be9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,11 +6,11 @@ in.riido locksmith-spring-boot-starter - 3.0.3 + 4.0.0 jar Locksmith Spring Boot Starter - A Spring Boot starter for Redis-based distributed locking using annotations + A Spring Boot starter for Redis-based distributed locks and semaphores https://github.com/riido-git/locksmith @@ -43,9 +43,8 @@ ${java.version} UTF-8 - - - + + slow 4.1.1 @@ -79,13 +78,6 @@ provided - - - org.aspectj - aspectjweaver - provided - - org.springframework.boot @@ -108,7 +100,14 @@ provided - + + + org.jspecify + jspecify + provided + + + org.slf4j slf4j-api @@ -129,15 +128,25 @@ test - + - org.springframework.boot - spring-boot-starter-data-redis-test + org.redisson + redisson-spring-boot-starter + ${redisson.version} test + + - org.springframework.boot - spring-boot-testcontainers + org.jetbrains.kotlin + kotlin-stdlib + test + + + + + org.testcontainers + testcontainers test @@ -145,11 +154,9 @@ testcontainers-junit-jupiter test - - - io.micrometer - micrometer-test + com.redis + testcontainers-redis test @@ -177,9 +184,11 @@ org.apache.maven.plugins maven-compiler-plugin + 3.16.0 ${java.version} true + full @@ -188,10 +197,7 @@ maven-surefire-plugin 3.6.0 - - ${exclude.performance.tests} - ${exclude.virtualthread.tests} - + ${surefire.excludedGroups} @@ -219,6 +225,7 @@ org.apache.maven.plugins maven-source-plugin + 3.4.0 attach-sources @@ -232,6 +239,7 @@ org.apache.maven.plugins maven-javadoc-plugin + 3.12.0 attach-javadocs @@ -242,31 +250,49 @@ - - org.apache.maven.plugins - maven-gpg-plugin - - - sign-artifacts - verify - - sign - - - - - - - org.sonatype.central - central-publishing-maven-plugin - 0.11.0 - true - - central - - + + + + slow + + + + + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + central + + + + + + diff --git a/src/main/java/in/riido/locksmith/AcquisitionMode.java b/src/main/java/in/riido/locksmith/AcquisitionMode.java deleted file mode 100644 index 291b78c..0000000 --- a/src/main/java/in/riido/locksmith/AcquisitionMode.java +++ /dev/null @@ -1,37 +0,0 @@ -package in.riido.locksmith; - -/** - * Defines the behavior when attempting to acquire a distributed lock or semaphore permit. - * - * @author Garvit Joshi - * @since 1.0.0 - */ -public enum AcquisitionMode { - - /** - * Immediately skip execution if the lock is already held or no permit is available. Does not wait - * for availability. - */ - SKIP_IMMEDIATELY("immediate"), - - /** - * Wait for a configured duration to acquire the lock or permit. If it cannot be acquired within - * the wait time, skip execution. - */ - WAIT_AND_SKIP("timeout"); - - private final String metricsReason; - - AcquisitionMode(String metricsReason) { - this.metricsReason = metricsReason; - } - - /** - * Returns the reason string used for metrics tagging when acquisition fails in this mode. - * - * @return the metrics reason tag value - */ - public String metricsReason() { - return metricsReason; - } -} diff --git a/src/main/java/in/riido/locksmith/DistributedLock.java b/src/main/java/in/riido/locksmith/DistributedLock.java index bda6b8b..de3cdb9 100644 --- a/src/main/java/in/riido/locksmith/DistributedLock.java +++ b/src/main/java/in/riido/locksmith/DistributedLock.java @@ -1,9 +1,6 @@ package in.riido.locksmith; -import in.riido.locksmith.exception.LockNotAcquiredException; -import in.riido.locksmith.handler.LockSkipHandler; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import in.riido.locksmith.handler.lock.LockThrowExceptionHandler; +import in.riido.locksmith.lock.LockFailureHandler; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -11,50 +8,19 @@ import java.lang.annotation.Target; /** - * Annotation to apply distributed locking on a method. Only one instance across all servers can - * execute the annotated method at a time for the given lock key. - * - *

When the lock cannot be acquired, the behavior is controlled by {@link #skipHandler()}: - * - *

    - *
  • {@link LockThrowExceptionHandler} (default): Throws {@link LockNotAcquiredException} - *
  • {@link LockReturnDefaultHandler}: Returns null for objects, default values for primitives - *
- * - *

Usage examples: + * Runs the annotated method while holding a distributed lock on a key. The lock is released when + * the method returns or throws. A method that returns a future must be {@code @Async}, and an + * {@code @Async} method may return only {@code void}, {@code Future} or {@code CompletableFuture}, + * as Spring's {@code @Async} requires; the lock then covers its body on the worker thread. * *

{@code
- * // Static key - throws exception if lock not acquired
- * @DistributedLock(key = "critical-task")
- * public void criticalTask() { }
- *
- * // For scheduled tasks - silently skip if lock not acquired
- * @DistributedLock(key = "scheduled-task", skipHandler = LockReturnDefaultHandler.class)
- * public void scheduledTask() { }
- *
- * // SpEL with method parameter - lock per user
- * @DistributedLock(key = "#{#userId}")
- * public void processUser(String userId) { }
- *
- * // SpEL with object property
- * @DistributedLock(key = "#{#user.id}")
- * public void updateUser(User user) { }
- *
- * // SpEL with concatenation
- * @DistributedLock(key = "#{'user-' + #userId}")
- * public void processUser(Long userId) { }
- *
- * // Read lock - allows concurrent reads
- * @DistributedLock(key = "resource", type = LockType.READ)
- * public Data readData() { }
- *
- * // Write lock - exclusive access for writes
- * @DistributedLock(key = "resource", type = LockType.WRITE)
- * public void writeData(Data data) { }
+ * @DistributedLock(key = "order:#{#orderId}", waitTime = "5s")
+ * public void process(String orderId) { ... }
  * }
* - * @author Garvit Joshi - * @since 1.0.0 + *

Every attribute is checked at startup; a misconfigured annotation fails the application + * context refresh with a {@link LocksmithConfigurationException}. The annotation is honoured only + * on calls through the Spring proxy: a call on {@code this} bypasses it. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @@ -62,171 +28,53 @@ public @interface DistributedLock { /** - * The unique key for the lock. This key is used to identify the lock in Redis. Different tasks - * should use different keys. + * The lock key, as a template: literal text with {@code #{...}} SpEL islands whose variables are + * the method parameters by name, {@code #pN} or {@code #aN}, for example {@code + * "user:#{#userId}"}; {@code #this} is allowed inside a selection or projection. Must not be + * blank. The Redis key is {@code lock:}, or {@code + * rwlock:} for read and write locks. * - *

Supports Spring Expression Language (SpEL). SpEL expressions must be wrapped in {@code - * #{...}} syntax. Use {@code #{#paramName}} to reference method parameters, {@code - * #{#paramName.property}} to access object properties. - * - *

Literal keys (without {@code #{...}}) are used as-is and can contain any characters - * including {@code #}. - * - * @return the lock key (literal or SpEL expression) + * @return the key template */ String key(); /** - * The type of lock to acquire. - * - *

    - *
  • {@link LockType#REENTRANT} (default): Exclusive lock, only one holder at a time - *
  • {@link LockType#READ}: Shared lock, multiple concurrent readers allowed - *
  • {@link LockType#WRITE}: Exclusive lock, no readers or writers allowed simultaneously - *
+ * The lock type. Read and write locks of one key share the Redis key. * - *

When using READ or WRITE locks, all methods accessing the same resource should use the same - * lock key to ensure proper synchronization. - * - * @return the lock type, defaults to REENTRANT + * @return the lock type; defaults to {@link LockType#REENTRANT} */ LockType type() default LockType.REENTRANT; /** - * The lock acquisition mode. Determines behavior when the lock is already held. + * How long to wait for the lock, as {@code "5s"} or {@code "PT5S"}. The default {@code ""} means + * try once and give up. Must not be negative. * - * @return the acquisition mode, defaults to SKIP_IMMEDIATELY - */ - AcquisitionMode mode() default AcquisitionMode.SKIP_IMMEDIATELY; - - /** - * Override the default lease time. The lock will be automatically released after this duration. - * Use an empty string to use the default from configuration. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "10m" (10 minutes), "30s" (30 seconds), "1h" (1 hour) - *
  • ISO-8601 format: "PT10M" (10 minutes), "PT30S" (30 seconds) - *
- * - * @return lease time duration string, empty for default - */ - String leaseTime() default ""; - - /** - * Override the default wait time for WAIT_AND_SKIP mode. Use an empty string to use the default - * from configuration. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "10s" (10 seconds), "5m" (5 minutes), "1h" (1 hour) - *
  • ISO-8601 format: "PT10S" (10 seconds), "PT5M" (5 minutes) - *
- * - * @return wait time duration string, empty for default + * @return the wait time */ String waitTime() default ""; /** - * Custom handler for lock acquisition failures. - * - *

Handlers can be defined as Spring beans (with dependency injection support) or as plain - * classes with a public no-argument constructor. Spring beans are looked up first by type, then - * reflection-based instantiation is used as a fallback. - * - *

Built-in handlers: - * - *

    - *
  • {@link LockThrowExceptionHandler} (default): Throws {@link LockNotAcquiredException} - *
  • {@link LockReturnDefaultHandler}: Returns null/default values - *
- * - *

Example Spring bean handler with dependency injection: - * - *

{@code
-   * @Component
-   * public class AlertingHandler implements LockSkipHandler {
-   *     private final AlertService alertService;
-   *
-   *     public AlertingHandler(AlertService alertService) {
-   *         this.alertService = alertService;
-   *     }
-   *
-   *     @Override
-   *     public Object handle(LockContext context) {
-   *         alertService.sendAlert("Lock failed: " + context.lockKey());
-   *         return null;
-   *     }
-   * }
-   *
-   * @DistributedLock(key = "my-task", skipHandler = AlertingHandler.class)
-   * public void myTask() { }
-   * }
+ * A fixed lease, as {@code "30s"} or {@code "PT30S"}: the lock expires this long after it is + * acquired and is not renewed. The default {@code ""} means the lock is renewed for as long as it + * is held. Must be at least one millisecond when set. * - * @return the skip handler class, defaults to ThrowExceptionHandler - * @see LockSkipHandler + * @return the lease time */ - Class skipHandler() default LockThrowExceptionHandler.class; + String leaseTime() default ""; /** - * Defines the behavior when method execution time exceeds the configured lease duration. - * - *

When a method runs longer than its lock's lease time, the lock may expire while the method - * is still executing. This can lead to concurrent access by other instances. This parameter - * configures how to handle detection of such scenarios after method completion. - * - *

    - *
  • {@link LeaseExpirationBehavior#LOG_WARNING} (default): Log a warning message - *
  • {@link LeaseExpirationBehavior#THROW_EXCEPTION}: Throw {@link - * in.riido.locksmith.exception.LeaseExpiredException} - *
  • {@link LeaseExpirationBehavior#IGNORE}: Silently ignore - *
+ * What the method does when the lock is not acquired. * - *

Note: This setting has no effect when {@link #autoRenew()} is enabled, as the lock - * will be automatically renewed and never expire during method execution. - * - * @return the lease expiration behavior, defaults to LOG_WARNING + * @return the failure policy; defaults to {@link OnFailure#THROW} */ - LeaseExpirationBehavior onLeaseExpired() default LeaseExpirationBehavior.LOG_WARNING; + OnFailure onFailure() default OnFailure.THROW; /** - * Enables automatic lease renewal using Redisson's watchdog mechanism. - * - *

When enabled, Redisson will automatically extend the lock's lease time approximately every - * 10 seconds (lockWatchdogTimeout / 3, where lockWatchdogTimeout defaults to 30 seconds) as long - * as the method is executing and the thread is alive. The lock will be released when the method - * completes or the thread terminates. - * - *

Trade-offs: - * - *

    - *
  • Benefit: Eliminates premature lock expiration for long-running operations - *
  • Risk: If the method hangs indefinitely, the lock will be held until the thread - * dies or the application shuts down - *
- * - *

Interaction with other settings: - * - *

    - *
  • When enabled, {@link #leaseTime()} is ignored (a warning is logged if specified) - *
  • When enabled, {@link #onLeaseExpired()} has no effect (a warning is logged if set to - * THROW_EXCEPTION) - *
- * - *

Usage example: - * - *

{@code
-   * @DistributedLock(key = "long-task", autoRenew = true)
-   * public void longRunningTask() {
-   *     // Lock automatically extends during execution
-   *     // Safe for tasks with unpredictable duration
-   * }
-   * }
+ * The failure handler, resolved as the single Spring bean of this type. Required when {@link + * #onFailure()} is {@link OnFailure#HANDLER} and not allowed otherwise. The default, the {@link + * LockFailureHandler} interface itself, means not set. * - * @return true to enable automatic lease renewal, false (default) to use fixed lease time - * @since 1.3.0 + * @return the handler type */ - boolean autoRenew() default false; + Class handler() default LockFailureHandler.class; } diff --git a/src/main/java/in/riido/locksmith/DistributedSemaphore.java b/src/main/java/in/riido/locksmith/DistributedSemaphore.java index 17c78ed..1a3ee53 100644 --- a/src/main/java/in/riido/locksmith/DistributedSemaphore.java +++ b/src/main/java/in/riido/locksmith/DistributedSemaphore.java @@ -1,9 +1,6 @@ package in.riido.locksmith; -import in.riido.locksmith.exception.SemaphoreNotAcquiredException; -import in.riido.locksmith.handler.SemaphoreSkipHandler; -import in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler; -import in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -11,53 +8,21 @@ import java.lang.annotation.Target; /** - * Annotation to apply distributed semaphore-based concurrency control on a method. Allows up to N - * concurrent executions across all servers for the given semaphore key. - * - *

Unlike {@link DistributedLock} which allows only one execution at a time, a semaphore allows - * multiple concurrent executions up to the configured {@link #permits()} limit. This is useful for: - * - *

    - *
  • Connection pool limiting - *
  • Batch processing with max parallelism - *
  • Resource throttling - *
- * - *

When a permit cannot be acquired, the behavior is controlled by {@link #skipHandler()}: - * - *

    - *
  • {@link SemaphoreThrowExceptionHandler} (default): Throws {@link - * SemaphoreNotAcquiredException} - *
  • {@link SemaphoreReturnDefaultHandler}: Returns null for objects, default values for - * primitives - *
- * - *

Usage examples: + * Runs the annotated method while holding one permit of a distributed semaphore. The permit is + * released when the method returns or throws. A method that returns a future must be + * {@code @Async}, and an {@code @Async} method may return only {@code void}, {@code Future} or + * {@code CompletableFuture}, as Spring's {@code @Async} requires; the permit then covers its body + * on the worker thread. * *

{@code
- * // Allow up to 10 concurrent database queries
- * @DistributedSemaphore(key = "db-pool", permits = 10, leaseTime = "30s")
- * public void queryDatabase() { }
- *
- * // Allow up to 3 concurrent exports per type
- * @DistributedSemaphore(key = "#{#type + '-export'}", permits = 3, leaseTime = "5m")
- * public void exportData(String type) { }
- *
- * // For scheduled tasks - silently skip if no permit available
- * @DistributedSemaphore(
- *     key = "batch-job",
- *     permits = 5,
- *     leaseTime = "10m",
- *     skipHandler = SemaphoreReturnDefaultHandler.class)
- * public void batchProcess() { }
+ * @DistributedSemaphore(key = "reports", permits = "${reports.max-concurrent}", waitTime = "2s")
+ * public Report build(String id) { ... }
  * }
* - *

Important: This annotation uses Redisson's {@code RPermitExpirableSemaphore} which - * provides automatic permit expiration. Each permit has a lease time after which it is - * automatically released, preventing permit leaks if a server crashes. - * - * @author Garvit Joshi - * @since 2.0.0 + *

Every attribute is checked at startup; a misconfigured annotation fails the application + * context refresh with a {@link LocksmithConfigurationException}. The annotation is honoured only + * on calls through the Spring proxy: a call on {@code this} bypasses it. If a method also carries + * {@link DistributedLock}, the permit is acquired before the lock and released after it. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @@ -65,106 +30,53 @@ public @interface DistributedSemaphore { /** - * The unique key for the semaphore. This key is used to identify the semaphore in Redis. - * Different resources should use different keys. + * The semaphore key, as a template: literal text with {@code #{...}} SpEL islands whose variables + * are the method parameters by name, {@code #pN} or {@code #aN}; {@code #this} is allowed inside + * a selection or projection. Must not be blank. The Redis key is {@code + * semaphore:}. * - *

Supports Spring Expression Language (SpEL). SpEL expressions must be wrapped in {@code - * #{...}} syntax. Use {@code #{#paramName}} to reference method parameters, {@code - * #{#paramName.property}} to access object properties. - * - *

Literal keys (without {@code #{...}}) are used as-is and can contain any characters - * including {@code #}. - * - * @return the semaphore key (literal or SpEL expression) + * @return the key template */ String key(); /** - * The maximum number of concurrent permits allowed for this semaphore. - * - *

This value is set when the semaphore is first created in Redis. If the semaphore already - * exists with a different permits value, a warning is logged and the existing value is used. - * - *

Must be a positive integer (greater than 0). - * - * @return the maximum number of concurrent permits, defaults to 1 - */ - int permits() default 1; - - /** - * The time after which an acquired permit is automatically released. This is required to prevent - * permit leaks if a server crashes while holding a permit. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "10m" (10 minutes), "30s" (30 seconds), "1h" (1 hour) - *
  • ISO-8601 format: "PT10M" (10 minutes), "PT30S" (30 seconds) - *
- * - *

Use an empty string to use the default from configuration. + * The number of permits, as a string so a {@code ${...}} placeholder works. Must resolve to an + * integer greater than zero. * - * @return lease time duration string, empty for default + * @return the permit count */ - String leaseTime() default ""; + String permits(); /** - * The permit acquisition mode. Determines behavior when no permit is available. + * How long to wait for a permit, as {@code "5s"} or {@code "PT5S"}. The default {@code ""} means + * try once and give up. Must not be negative. * - * @return the acquisition mode, defaults to SKIP_IMMEDIATELY + * @return the wait time */ - AcquisitionMode mode() default AcquisitionMode.SKIP_IMMEDIATELY; + String waitTime() default ""; /** - * Override the default wait time for WAIT_AND_SKIP mode. Use an empty string to use the default - * from configuration. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "10s" (10 seconds), "5m" (5 minutes), "1h" (1 hour) - *
  • ISO-8601 format: "PT10S" (10 seconds), "PT5M" (5 minutes) - *
+ * The lease of a permit, as {@code "30s"} or {@code "PT30S"}: the permit expires this long after + * it is acquired. The default {@code ""} means {@code locksmith.semaphore.lease-time}. Must be at + * least one millisecond when set. * - * @return wait time duration string, empty for default + * @return the lease time */ - String waitTime() default ""; + String leaseTime() default ""; /** - * Defines the behavior when method execution time exceeds the configured lease duration. - * - *

When a method runs longer than its permit's lease time, the permit may expire while the - * method is still executing. This can lead to more concurrent executions than intended. This - * parameter configures how to handle detection of such scenarios after method completion. - * - *

    - *
  • {@link LeaseExpirationBehavior#LOG_WARNING} (default): Log a warning message - *
  • {@link LeaseExpirationBehavior#THROW_EXCEPTION}: Throw {@link - * in.riido.locksmith.exception.SemaphoreLeaseExpiredException} - *
  • {@link LeaseExpirationBehavior#IGNORE}: Silently ignore - *
+ * What the method does when no permit is acquired. * - * @return the lease expiration behavior, defaults to LOG_WARNING + * @return the failure policy; defaults to {@link OnFailure#THROW} */ - LeaseExpirationBehavior onLeaseExpired() default LeaseExpirationBehavior.LOG_WARNING; + OnFailure onFailure() default OnFailure.THROW; /** - * Custom handler for permit acquisition failures. - * - *

Handlers can be defined as Spring beans (with dependency injection support) or as plain - * classes with a public no-argument constructor. Spring beans are looked up first by type, then - * reflection-based instantiation is used as a fallback. - * - *

Built-in handlers: - * - *

    - *
  • {@link SemaphoreThrowExceptionHandler} (default): Throws {@link - * SemaphoreNotAcquiredException} - *
  • {@link SemaphoreReturnDefaultHandler}: Returns null/default values - *
+ * The failure handler, resolved as the single Spring bean of this type. Required when {@link + * #onFailure()} is {@link OnFailure#HANDLER} and not allowed otherwise. The default, the {@link + * SemaphoreFailureHandler} interface itself, means not set. * - * @return the skip handler class, defaults to SemaphoreThrowExceptionHandler - * @see SemaphoreSkipHandler + * @return the handler type */ - Class skipHandler() default SemaphoreThrowExceptionHandler.class; + Class handler() default SemaphoreFailureHandler.class; } diff --git a/src/main/java/in/riido/locksmith/LeaseExpirationBehavior.java b/src/main/java/in/riido/locksmith/LeaseExpirationBehavior.java deleted file mode 100644 index 5668a7d..0000000 --- a/src/main/java/in/riido/locksmith/LeaseExpirationBehavior.java +++ /dev/null @@ -1,33 +0,0 @@ -package in.riido.locksmith; - -/** - * Defines the behavior when a method's execution time exceeds the configured lease duration. - * - *

When a method runs longer than its lock's lease time, the lock may expire while the method is - * still executing. This can lead to concurrent access by multiple instances. This enum configures - * how to handle detection of such scenarios after method completion. - * - * @author Garvit Joshi - * @since 1.2.0 - */ -public enum LeaseExpirationBehavior { - - /** - * Log a warning when execution time exceeds lease duration. This is the default behavior, - * providing visibility into potential issues without disrupting the application. - */ - LOG_WARNING, - - /** - * Throw a {@link in.riido.locksmith.exception.LeaseExpiredException} after the method completes - * if execution time exceeded lease duration. Use this for strict enforcement where such - * violations should be treated as errors. - */ - THROW_EXCEPTION, - - /** - * Silently ignore when execution time exceeds lease duration. Use this only when you're certain - * the extended execution is acceptable and won't cause issues. - */ - IGNORE -} diff --git a/src/main/java/in/riido/locksmith/LockType.java b/src/main/java/in/riido/locksmith/LockType.java index 9a11c85..f6ecf9d 100644 --- a/src/main/java/in/riido/locksmith/LockType.java +++ b/src/main/java/in/riido/locksmith/LockType.java @@ -1,29 +1,17 @@ package in.riido.locksmith; -/** - * Defines the type of lock to acquire for distributed locking. - * - * @author Garvit Joshi - * @since 1.2.0 - */ +/** The kind of distributed lock taken for a key. */ public enum LockType { - - /** - * A reentrant mutual exclusion lock. Only one thread/instance can hold the lock at a time. This - * is the default lock type. - */ + /** Exclusive lock that the holding thread may acquire again. */ REENTRANT, - /** - * A shared read lock. Multiple threads/instances can hold the read lock simultaneously, as long - * as no thread holds the write lock. Use this for read-heavy operations where concurrent reads - * are safe. + * Shared lock; any number of readers may hold it while no writer does. A waiting writer does not + * hold new readers back. */ READ, - /** - * An exclusive write lock. Only one thread/instance can hold the write lock, and no read locks - * can be held simultaneously. Use this for write operations that require exclusive access. + * Exclusive lock that excludes readers and other writers of the same key; it waits until no + * reader holds the key. */ WRITE } diff --git a/src/main/java/in/riido/locksmith/LocksmithConfigurationException.java b/src/main/java/in/riido/locksmith/LocksmithConfigurationException.java new file mode 100644 index 0000000..9299c3b --- /dev/null +++ b/src/main/java/in/riido/locksmith/LocksmithConfigurationException.java @@ -0,0 +1,27 @@ +package in.riido.locksmith; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** Thrown when an annotation, a key template or a property is misconfigured. */ +public class LocksmithConfigurationException extends LocksmithException { + + /** + * Creates an exception with a message. + * + * @param message what is misconfigured and where + */ + public LocksmithConfigurationException(@NonNull String message) { + super(message); + } + + /** + * Creates an exception with a message and a cause. + * + * @param message what is misconfigured and where + * @param cause the underlying cause, or null + */ + public LocksmithConfigurationException(@NonNull String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/in/riido/locksmith/LocksmithException.java b/src/main/java/in/riido/locksmith/LocksmithException.java new file mode 100644 index 0000000..ef58c2a --- /dev/null +++ b/src/main/java/in/riido/locksmith/LocksmithException.java @@ -0,0 +1,27 @@ +package in.riido.locksmith; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** Base class of every exception Locksmith throws. Unchecked. */ +public abstract class LocksmithException extends RuntimeException { + + /** + * Creates an exception with a message. + * + * @param message the detail message + */ + protected LocksmithException(@NonNull String message) { + super(message); + } + + /** + * Creates an exception with a message and a cause. + * + * @param message the detail message + * @param cause the underlying cause, or null + */ + protected LocksmithException(@NonNull String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/in/riido/locksmith/OnFailure.java b/src/main/java/in/riido/locksmith/OnFailure.java new file mode 100644 index 0000000..13eaba9 --- /dev/null +++ b/src/main/java/in/riido/locksmith/OnFailure.java @@ -0,0 +1,11 @@ +package in.riido.locksmith; + +/** What an annotated method does when its lock or permit is not acquired. */ +public enum OnFailure { + /** Throw the primitive's not-acquired exception. */ + THROW, + /** Skip the method and return the default value of its return type. */ + SKIP, + /** Skip the method and return the value of the configured failure handler bean. */ + HANDLER +} diff --git a/src/main/java/in/riido/locksmith/RateLimit.java b/src/main/java/in/riido/locksmith/RateLimit.java deleted file mode 100644 index 7f360d2..0000000 --- a/src/main/java/in/riido/locksmith/RateLimit.java +++ /dev/null @@ -1,175 +0,0 @@ -package in.riido.locksmith; - -import in.riido.locksmith.exception.RateLimitExceededException; -import in.riido.locksmith.handler.RateLimitSkipHandler; -import in.riido.locksmith.handler.ratelimit.RateLimitReturnDefaultHandler; -import in.riido.locksmith.handler.ratelimit.RateLimitThrowExceptionHandler; -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import org.redisson.api.RateType; - -/** - * Annotation to apply distributed rate limiting on a method. Limits the number of executions within - * a specified time interval across all servers. - * - *

Unlike {@link DistributedSemaphore} which limits concurrent executions, rate limiting controls - * the throughput over time. This is useful for: - * - *

    - *
  • API rate limiting per user/client - *
  • Throttling background job execution - *
  • Preventing resource exhaustion - *
- * - *

When the rate limit is exceeded, the behavior is controlled by {@link #skipHandler()}: - * - *

    - *
  • {@link RateLimitThrowExceptionHandler} (default): Throws {@link RateLimitExceededException} - *
  • {@link RateLimitReturnDefaultHandler}: Returns null for objects, default values for - * primitives - *
- * - *

Usage examples: - * - *

{@code
- * // Basic: 10 requests per second (defaults)
- * @RateLimit(key = "api-call")
- * public void apiCall() { }
- *
- * // Custom rate: 100 requests per minute
- * @RateLimit(key = "heavy-operation", permits = 100, interval = "1m")
- * public void heavyOperation() { }
- *
- * // Per-user rate limiting with SpEL
- * @RateLimit(key = "#{#userId}", permits = 5, interval = "1s")
- * public void userAction(String userId) { }
- *
- * // Wait for permit instead of immediate rejection
- * @RateLimit(key = "throttled", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s")
- * public void throttledOperation() { }
- *
- * // Per-client instance rate limiting
- * @RateLimit(key = "local-api", permits = 20, type = RateType.PER_CLIENT)
- * public void localApiCall() { }
- *
- * // Return default value on rate limit exceeded
- * @RateLimit(key = "api", skipHandler = RateLimitReturnDefaultHandler.class)
- * public String getData() { return "data"; }
- * }
- * - *

Important: This annotation uses Redisson's {@code RRateLimiter} which provides - * distributed rate limiting. The rate limiter automatically replenishes permits based on the - * configured interval. - * - * @author Garvit Joshi - * @since 3.0.0 - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface RateLimit { - - /** - * The unique key for the rate limiter. This key is used to identify the rate limiter in Redis. - * Different resources should use different keys. - * - *

Supports Spring Expression Language (SpEL). SpEL expressions must be wrapped in {@code - * #{...}} syntax. Use {@code #{#paramName}} to reference method parameters, {@code - * #{#paramName.property}} to access object properties. - * - *

Literal keys (without {@code #{...}}) are used as-is and can contain any characters - * including {@code #}. - * - * @return the rate limiter key (literal or SpEL expression) - */ - String key(); - - /** - * The number of permits allowed in the interval. - * - *

This value is set when the rate limiter is first created in Redis. If the rate limiter - * already exists with a different configuration, a warning is logged and the existing value is - * used. - * - *

Must be a positive value (greater than 0). - * - * @return the number of permits per interval, defaults to 10 - */ - long permits() default 10; - - /** - * The time interval for rate limiting. Permits are replenished after each interval. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "1s" (1 second), "10s" (10 seconds), "1m" (1 minute), "1h" (1 hour) - *
  • ISO-8601 format: "PT1S" (1 second), "PT10S" (10 seconds), "PT1M" (1 minute) - *
- * - * @return interval duration string, defaults to "1s" (permits per second) - */ - String interval() default "1s"; - - /** - * Rate limit type. Determines how permits are shared. - * - *
    - *
  • {@link RateType#OVERALL}: Permits are shared across all clients/instances - *
  • {@link RateType#PER_CLIENT}: Each Redisson client instance gets its own rate limit quota - *
- * - * @return the rate type, defaults to OVERALL - */ - RateType type() default RateType.OVERALL; - - /** - * The permit acquisition mode. Determines behavior when no permit is available. - * - *
    - *
  • {@link AcquisitionMode#SKIP_IMMEDIATELY}: Return immediately if no permit available - *
  • {@link AcquisitionMode#WAIT_AND_SKIP}: Wait up to {@link #waitTime()} for a permit - *
- * - * @return the acquisition mode, defaults to SKIP_IMMEDIATELY - */ - AcquisitionMode mode() default AcquisitionMode.SKIP_IMMEDIATELY; - - /** - * Override the default wait time for WAIT_AND_SKIP mode. Use an empty string to use the default - * from configuration. - * - *

Accepts duration strings in the following formats: - * - *

    - *
  • Simple format: "10s" (10 seconds), "5m" (5 minutes), "1h" (1 hour) - *
  • ISO-8601 format: "PT10S" (10 seconds), "PT5M" (5 minutes) - *
- * - * @return wait time duration string, empty for default - */ - String waitTime() default ""; - - /** - * Custom handler for rate limit exceeded scenarios. - * - *

Handlers can be defined as Spring beans (with dependency injection support) or as plain - * classes with a public no-argument constructor. Spring beans are looked up first by type, then - * reflection-based instantiation is used as a fallback. - * - *

Built-in handlers: - * - *

    - *
  • {@link RateLimitThrowExceptionHandler} (default): Throws {@link - * RateLimitExceededException} - *
  • {@link RateLimitReturnDefaultHandler}: Returns null/default values - *
- * - * @return the skip handler class, defaults to RateLimitThrowExceptionHandler - * @see RateLimitSkipHandler - */ - Class skipHandler() default RateLimitThrowExceptionHandler.class; -} diff --git a/src/main/java/in/riido/locksmith/aop/LocksmithAdvisor.java b/src/main/java/in/riido/locksmith/aop/LocksmithAdvisor.java new file mode 100644 index 0000000..9dcc946 --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/LocksmithAdvisor.java @@ -0,0 +1,40 @@ +package in.riido.locksmith.aop; + +import java.lang.reflect.Method; +import org.jspecify.annotations.NonNull; +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.support.StaticMethodMatcherPointcutAdvisor; +import org.springframework.core.Ordered; + +/** + * Applies {@link LocksmithInterceptor} to every method that carries {@code @DistributedLock} or + * {@code @DistributedSemaphore}, on the class or on an interface it implements. Ordered at {@code + * Ordered.LOWEST_PRECEDENCE - 1}: inside Spring Security's method authorization, so a call it + * denies never reaches Redis, and outside advice at the default order, such as transactions and + * caching, so a transaction commits before the lock is released. + */ +public class LocksmithAdvisor extends StaticMethodMatcherPointcutAdvisor { + + /** + * Creates the advisor. + * + * @param interceptor the advice applied to matching methods + */ + public LocksmithAdvisor(@NonNull LocksmithInterceptor interceptor) { + super(interceptor); + setOrder(Ordered.LOWEST_PRECEDENCE - 1); + } + + /** + * Matches a method whose most specific implementation on the target class carries either + * annotation. + * + * @param method the candidate method + * @param targetClass the target class + * @return {@code true} if the method is annotated + */ + @Override + public boolean matches(@NonNull Method method, @NonNull Class targetClass) { + return MethodSpecFactory.isAnnotated(AopUtils.getMostSpecificMethod(method, targetClass)); + } +} diff --git a/src/main/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrar.java b/src/main/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrar.java new file mode 100644 index 0000000..c793110 --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrar.java @@ -0,0 +1,34 @@ +package in.riido.locksmith.aop; + +import org.jspecify.annotations.NonNull; +import org.springframework.aop.config.AopConfigUtils; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * Registers Spring's infrastructure auto-proxy creator unless one exists, so {@link + * LocksmithAdvisor} is applied even when Spring Boot's AOP auto-configuration registers none, for + * example with {@code spring.aop.auto=false} or {@code spring.aop.proxy-target-class=false} and no + * AspectJ. The same approach as the registrar behind {@code @EnableTransactionManagement}. A + * creator that already exists is kept, including its proxying mode. + */ +public class LocksmithAutoProxyRegistrar implements ImportBeanDefinitionRegistrar { + + /** Creates the registrar; Spring instantiates it through {@code @Import}. */ + public LocksmithAutoProxyRegistrar() {} + + /** + * Registers the infrastructure auto-proxy creator if the registry has no auto-proxy creator yet. + * Without a creator the annotations would silently do nothing. + * + * @param importingClassMetadata metadata of the importing configuration; not used + * @param registry the registry the creator is added to + */ + @Override + public void registerBeanDefinitions( + @NonNull AnnotationMetadata importingClassMetadata, + @NonNull BeanDefinitionRegistry registry) { + AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry); + } +} diff --git a/src/main/java/in/riido/locksmith/aop/LocksmithInterceptor.java b/src/main/java/in/riido/locksmith/aop/LocksmithInterceptor.java new file mode 100644 index 0000000..724166c --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/LocksmithInterceptor.java @@ -0,0 +1,177 @@ +package in.riido.locksmith.aop; + +import in.riido.locksmith.aop.MethodSpec.LockSpec; +import in.riido.locksmith.aop.MethodSpec.SemaphoreSpec; +import in.riido.locksmith.lock.LockFailureContext; +import in.riido.locksmith.lock.LockHandle; +import in.riido.locksmith.lock.LockNotAcquiredException; +import in.riido.locksmith.lock.LockOperations; +import in.riido.locksmith.semaphore.PermitHandle; +import in.riido.locksmith.semaphore.SemaphoreFailureContext; +import in.riido.locksmith.semaphore.SemaphoreNotAcquiredException; +import in.riido.locksmith.semaphore.SemaphoreOperations; +import in.riido.locksmith.support.KeyTemplate; +import in.riido.locksmith.support.ReturnDefaults; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.util.function.SingletonSupplier; + +/** + * Runs an annotated method under its semaphore permit and its lock. A thin adapter: the keys are + * resolved from the method arguments, the permit is taken through {@link SemaphoreOperations} and + * the lock through {@link LockOperations}, and the failure policy of the annotation decides the + * result when either is not acquired. The permit is acquired before the lock and released after it, + * so a caller never holds a lock while queued for a permit. + * + *

The operations and the factory are resolved on first use, not in the constructor. The advisor, + * and with it this interceptor, is created while bean post-processors are still being registered; + * resolving the operations then would create them, the properties and the adopter's {@code + * RedissonClient} too early for every post-processor to apply to them. + */ +public class LocksmithInterceptor implements MethodInterceptor { + + private final @NonNull SingletonSupplier lockOperations; + private final @NonNull SingletonSupplier semaphoreOperations; + private final @NonNull SingletonSupplier factory; + private final @NonNull BeanFactory beanFactory; + private final Map specs = new ConcurrentHashMap<>(); + + /** + * Creates the interceptor. + * + * @param lockOperations takes and releases the locks; resolved on first use + * @param semaphoreOperations takes and releases the semaphore permits; resolved on first use + * @param factory builds the spec of a method on its first call; resolved on first use + * @param beanFactory resolves failure handler beans on first use + */ + public LocksmithInterceptor( + @NonNull ObjectProvider lockOperations, + @NonNull ObjectProvider semaphoreOperations, + @NonNull ObjectProvider factory, + @NonNull BeanFactory beanFactory) { + this.lockOperations = SingletonSupplier.of(lockOperations::getObject); + this.semaphoreOperations = SingletonSupplier.of(semaphoreOperations::getObject); + this.factory = SingletonSupplier.of(factory::getObject); + this.beanFactory = beanFactory; + } + + /** + * Acquires the permit, then the lock, of the invoked method, proceeds, and releases the lock and + * then the permit whether the method returns or throws. When either is not acquired, the method + * does not run, a permit already held is released, and the failure policy of the annotation that + * failed decides the result. + * + * @param invocation the intercepted call + * @return the method result, or the failure policy result + * @throws SemaphoreNotAcquiredException if the permit is not acquired and the policy is {@code + * THROW} + * @throws LockNotAcquiredException if the lock is not acquired and the policy is {@code THROW} + * @throws in.riido.locksmith.LocksmithConfigurationException if an annotation is misconfigured or + * a key resolves to null or blank + * @throws Throwable whatever the method, the failure handler or Redisson throws, unchanged + */ + @Override + public @Nullable Object invoke(@NonNull MethodInvocation invocation) throws Throwable { + Object target = invocation.getThis(); + // A bean that is itself a JDK proxy, such as a Spring Data repository, carries the annotations + // on its interfaces: resolve against the proxy class, as the startup check does, not against + // the class of the object behind it. + Class targetClass = + target == null + ? null + : Proxy.isProxyClass(target.getClass()) + ? target.getClass() + : AopUtils.getTargetClass(target); + Method method = + MethodSpecFactory.interfaceMethodOfJdkProxy( + AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass)); + MethodSpec spec = specs.computeIfAbsent(method, m -> factory.obtain().create(m)); + Object[] args = invocation.getArguments(); + SemaphoreSpec semaphore = spec.semaphore(); + LockSpec lock = spec.lock(); + try (PermitHandle permit = semaphore == null ? null : acquire(semaphore, method, args)) { + if (permit != null && !permit.acquired()) { + return semaphoreFailure(semaphore, permit.key(), method, args); + } + try (LockHandle held = lock == null ? null : acquire(lock, method, args)) { + if (held != null && !held.acquired()) { + if (permit != null) { + permit.close(); + } + return lockFailure(lock, held.key(), method, args); + } + return invocation.proceed(); + } + } + } + + private @NonNull PermitHandle acquire( + @NonNull SemaphoreSpec spec, @NonNull Method method, @Nullable Object @NonNull [] args) { + return semaphoreOperations + .obtain() + .key(KeyTemplate.evaluate(spec.key(), method, args)) + .permits(spec.permits()) + .waitTime(spec.waitTime()) + .leaseTime(spec.leaseTime()) + .acquire(); + } + + private @NonNull LockHandle acquire( + @NonNull LockSpec spec, @NonNull Method method, @Nullable Object @NonNull [] args) { + LockOperations.Builder builder = + lockOperations + .obtain() + .key(KeyTemplate.evaluate(spec.key(), method, args)) + .type(spec.type()) + .waitTime(spec.waitTime()); + if (spec.leaseTime() != null) { + builder.leaseTime(spec.leaseTime()); + } + return builder.acquire(); + } + + private @Nullable Object semaphoreFailure( + @NonNull SemaphoreSpec spec, + @NonNull String fullKey, + @NonNull Method method, + @Nullable Object @NonNull [] args) { + return switch (spec.onFailure()) { + case THROW -> + throw new SemaphoreNotAcquiredException(fullKey, spec.permits(), spec.waitTime()); + case SKIP -> ReturnDefaults.forType(method.getReturnType()); + case HANDLER -> + handler(spec.handlerType()) + .onFailure( + new SemaphoreFailureContext( + fullKey, spec.permits(), method, args, spec.waitTime())); + }; + } + + private @Nullable Object lockFailure( + @NonNull LockSpec spec, + @NonNull String fullKey, + @NonNull Method method, + @Nullable Object @NonNull [] args) { + return switch (spec.onFailure()) { + case THROW -> throw new LockNotAcquiredException(fullKey, spec.waitTime()); + case SKIP -> ReturnDefaults.forType(method.getReturnType()); + case HANDLER -> + handler(spec.handlerType()) + .onFailure(new LockFailureContext(fullKey, method, args, spec.waitTime())); + }; + } + + private @NonNull T handler(@Nullable Class handlerType) { + // MethodSpecFactory guarantees a handler type whenever onFailure is HANDLER. + return beanFactory.getBean(handlerType); + } +} diff --git a/src/main/java/in/riido/locksmith/aop/MethodSpec.java b/src/main/java/in/riido/locksmith/aop/MethodSpec.java new file mode 100644 index 0000000..6f834e0 --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/MethodSpec.java @@ -0,0 +1,57 @@ +package in.riido.locksmith.aop; + +import in.riido.locksmith.LockType; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; +import java.time.Duration; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.expression.Expression; + +/** + * The checked, parsed form of the Locksmith annotations on one method, built by {@link + * MethodSpecFactory}. + * + * @param lock the lock settings, or null if the method has no {@code @DistributedLock} + * @param semaphore the semaphore settings, or null if the method has no + * {@code @DistributedSemaphore} + */ +public record MethodSpec(@Nullable LockSpec lock, @Nullable SemaphoreSpec semaphore) { + + /** + * The settings of a {@code @DistributedLock}. + * + * @param key the parsed key template + * @param type the lock type + * @param waitTime how long to wait; zero means try once + * @param leaseTime the fixed lease, or null for renewal while held + * @param onFailure what to do when the lock is not acquired + * @param handlerType the handler bean type, set only when {@code onFailure} is {@code HANDLER} + */ + public record LockSpec( + @NonNull Expression key, + @NonNull LockType type, + @NonNull Duration waitTime, + @Nullable Duration leaseTime, + @NonNull OnFailure onFailure, + @Nullable Class handlerType) {} + + /** + * The settings of a {@code @DistributedSemaphore}. + * + * @param key the parsed key template + * @param permits the permit count, greater than zero + * @param waitTime how long to wait; zero means try once + * @param leaseTime the lease of a permit + * @param onFailure what to do when no permit is acquired + * @param handlerType the handler bean type, set only when {@code onFailure} is {@code HANDLER} + */ + public record SemaphoreSpec( + @NonNull Expression key, + int permits, + @NonNull Duration waitTime, + @NonNull Duration leaseTime, + @NonNull OnFailure onFailure, + @Nullable Class handlerType) {} +} diff --git a/src/main/java/in/riido/locksmith/aop/MethodSpecFactory.java b/src/main/java/in/riido/locksmith/aop/MethodSpecFactory.java new file mode 100644 index 0000000..373eae6 --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/MethodSpecFactory.java @@ -0,0 +1,390 @@ +package in.riido.locksmith.aop; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.aop.MethodSpec.LockSpec; +import in.riido.locksmith.aop.MethodSpec.SemaphoreSpec; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; +import in.riido.locksmith.support.KeyTemplate; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.Future; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.convert.DurationStyle; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.env.Environment; +import org.springframework.expression.Expression; +import org.springframework.expression.ParseException; +import org.springframework.scheduling.annotation.Async; + +/** + * Reads the Locksmith annotations of a method, checks every rule and builds its {@link MethodSpec}. + * The single place where annotations are read, durations and key templates parsed, and + * misconfigurations reported. + */ +public class MethodSpecFactory { + + private static final String WAIT_TIME = "waitTime"; + private static final String LEASE_TIME = "leaseTime"; + private static final String PERMITS = "permits"; + + /** Matched by name, so Reactive Streams need not be on the classpath. */ + private static final String REACTIVE_STREAMS_PUBLISHER = "org.reactivestreams.Publisher"; + + /** The last parameter of a Kotlin suspend function; matched by name, like the publisher. */ + private static final String KOTLIN_CONTINUATION = "kotlin.coroutines.Continuation"; + + /** + * The JVM return type of a Kotlin function declared {@code Unit?}, or with an expression body of + * that type; matched by name, as Spring's {@code @Async} matches it. + */ + private static final String KOTLIN_UNIT = "kotlin.Unit"; + + private final @NonNull Environment environment; + private final @NonNull LocksmithProperties properties; + + /** + * Creates the factory. + * + * @param environment resolves {@code ${...}} placeholders in semaphore permit counts + * @param properties supplies the default semaphore lease time + */ + public MethodSpecFactory( + @NonNull Environment environment, @NonNull LocksmithProperties properties) { + this.environment = environment; + this.properties = properties; + } + + /** + * Reports whether a method carries {@link DistributedLock} or {@link DistributedSemaphore}, + * directly or through an interface or superclass declaration. + * + * @param method the method + * @return {@code true} if either annotation is present + */ + public static boolean isAnnotated(@NonNull Method method) { + return AnnotatedElementUtils.findMergedAnnotation(method, DistributedLock.class) != null + || AnnotatedElementUtils.findMergedAnnotation(method, DistributedSemaphore.class) != null; + } + + /** + * Returns the interface method behind a method of a JDK dynamic proxy class, the kind of bean a + * Feign or HTTP interface client is. The proxy class redeclares every interface method as final + * and without parameter names, so the annotated interface method is the one to check and to take + * key variable names from. Returns any other method unchanged. + * + * @param method the method, possibly declared by a JDK proxy class + * @return the annotated interface method, or {@code method} + */ + public static @NonNull Method interfaceMethodOfJdkProxy(@NonNull Method method) { + Class proxyClass = method.getDeclaringClass(); + if (!Proxy.isProxyClass(proxyClass)) { + return method; + } + // getMethods, not getMethod: an interface can inherit one method from two unrelated + // interfaces, and getMethod returns only the first, which may lack the annotation. + for (Class type : proxyClass.getInterfaces()) { + for (Method candidate : type.getMethods()) { + if (candidate.getName().equals(method.getName()) + && Arrays.equals(candidate.getParameterTypes(), method.getParameterTypes()) + && isAnnotated(candidate)) { + return candidate; + } + } + } + return method; + } + + /** + * Builds the spec of a method. A method without either annotation gets a spec with both parts + * null. + * + * @param method the annotated method + * @return the spec + * @throws LocksmithConfigurationException on the first rule the annotations break, naming the + * class, the method and the offending attribute and value + */ + public @NonNull MethodSpec create(@NonNull Method method) { + DistributedLock lock = + AnnotatedElementUtils.findMergedAnnotation(method, DistributedLock.class); + DistributedSemaphore semaphore = + AnnotatedElementUtils.findMergedAnnotation(method, DistributedSemaphore.class); + return new MethodSpec( + lock == null ? null : lockSpec(lock, method), + semaphore == null ? null : semaphoreSpec(semaphore, method)); + } + + private static @NonNull LockSpec lockSpec( + @NonNull DistributedLock annotation, @NonNull Method method) { + Class type = DistributedLock.class; + checkInterceptable(type, method); + Expression key = parseKey(type, annotation.key(), method); + Duration waitTime = parseWaitTime(type, annotation.waitTime(), method); + Duration leaseTime = parseLeaseTime(type, annotation.leaseTime(), method); + Class handler = + annotation.handler() == LockFailureHandler.class ? null : annotation.handler(); + checkHandler(type, annotation.onFailure(), handler, LockFailureHandler.class, method); + checkReturnType(type, method); + return new LockSpec( + key, annotation.type(), waitTime, leaseTime, annotation.onFailure(), handler); + } + + private @NonNull SemaphoreSpec semaphoreSpec( + @NonNull DistributedSemaphore annotation, @NonNull Method method) { + Class type = DistributedSemaphore.class; + checkInterceptable(type, method); + Expression key = parseKey(type, annotation.key(), method); + int permits = parsePermits(annotation.permits(), method); + Duration waitTime = parseWaitTime(type, annotation.waitTime(), method); + Duration leaseTime = parseLeaseTime(type, annotation.leaseTime(), method); + Class handler = + annotation.handler() == SemaphoreFailureHandler.class ? null : annotation.handler(); + checkHandler(type, annotation.onFailure(), handler, SemaphoreFailureHandler.class, method); + checkReturnType(type, method); + return new SemaphoreSpec( + key, + permits, + waitTime, + leaseTime == null ? properties.semaphore().leaseTime() : leaseTime, + annotation.onFailure(), + handler); + } + + /** + * Rejects a private, static or final method: Spring's proxies never intercept one, so the + * annotation would silently do nothing. + */ + private static void checkInterceptable( + @NonNull Class type, @NonNull Method method) { + int modifiers = method.getModifiers(); + String kind = + Modifier.isPrivate(modifiers) + ? "private" + : Modifier.isStatic(modifiers) + ? "static" + : Modifier.isFinal(modifiers) ? "final" : null; + if (kind != null) { + throw error( + type, + method, + "the method is " + + kind + + ", so Spring's proxy never intercepts it and it would run without coordination;" + + " make it a public method that is not static or final"); + } + } + + private static @NonNull Expression parseKey( + @NonNull Class type, @NonNull String template, @NonNull Method method) { + if (template.isBlank()) { + throw error(type, method, "key must not be blank"); + } + Expression key; + try { + key = KeyTemplate.parse(template); + } catch (ParseException e) { + throw error( + type, method, "key [" + template + "] is not a valid template: " + e.getMessage(), e); + } + KeyTemplate.validateVariables(key, method); + return key; + } + + private int parsePermits(@NonNull String text, @NonNull Method method) { + Class type = DistributedSemaphore.class; + String resolved; + try { + resolved = environment.resolveRequiredPlaceholders(text); + } catch (IllegalArgumentException e) { + throw error( + type, method, PERMITS + " [" + text + "] cannot be resolved: " + e.getMessage(), e); + } + String shown = + resolved.equals(text) ? "[" + text + "]" : "[" + resolved + "] (from [" + text + "])"; + int permits; + try { + permits = Integer.parseInt(resolved); + } catch (NumberFormatException e) { + throw error(type, method, PERMITS + " " + shown + " is not an integer", e); + } + if (permits <= 0) { + throw error(type, method, PERMITS + " " + shown + " must be greater than zero"); + } + return permits; + } + + private static @NonNull Duration parseWaitTime( + @NonNull Class type, @NonNull String value, @NonNull Method method) { + if (value.isBlank()) { + return Duration.ZERO; + } + Duration waitTime = parseDuration(type, WAIT_TIME, value, method); + if (waitTime.isNegative()) { + throw error(type, method, WAIT_TIME + " [" + value + "] must not be negative"); + } + return waitTime; + } + + /** Returns null for a blank value; the caller applies its default. */ + private static @Nullable Duration parseLeaseTime( + @NonNull Class type, @NonNull String value, @NonNull Method method) { + if (value.isBlank()) { + return null; + } + Duration leaseTime = parseDuration(type, LEASE_TIME, value, method); + // The operations reject a lease below one millisecond; Redisson would renew a lock instead. + if (leaseTime.toMillis() <= 0) { + throw error(type, method, LEASE_TIME + " [" + value + "] must be positive, at least 1ms"); + } + return leaseTime; + } + + private static @NonNull Duration parseDuration( + @NonNull Class type, + @NonNull String attribute, + @NonNull String value, + @NonNull Method method) { + try { + return DurationStyle.detectAndParse(value); + } catch (IllegalArgumentException e) { + throw error( + type, method, attribute + " [" + value + "] is not a duration such as 5s or PT5S", e); + } + } + + private static void checkHandler( + @NonNull Class type, + @NonNull OnFailure onFailure, + @Nullable Class handler, + @NonNull Class handlerInterface, + @NonNull Method method) { + if (onFailure == OnFailure.HANDLER && handler == null) { + throw error( + type, + method, + "onFailure HANDLER requires handler to be set to a " + + handlerInterface.getName() + + " bean type"); + } + if (onFailure != OnFailure.HANDLER && handler != null) { + throw error( + type, + method, + "handler [" + + handler.getName() + + "] is set but onFailure is " + + onFailure + + "; set onFailure HANDLER or remove handler"); + } + } + + /** + * Rejects a method whose work can outlive its return, since the lock is released on return: a + * reactive return type, a Kotlin suspend function, and a {@link Future} or {@link + * CompletionStage}, unless Spring's {@code @Async} runs the whole method under the lock on its + * worker thread. Also rejects an {@code @Async} method that returns anything but {@code void}, + * Kotlin's {@code Unit}, {@link Future} or {@link CompletableFuture}, the only types Spring's + * {@code @Async} can return: any other type fails every call. + */ + private static void checkReturnType( + @NonNull Class type, @NonNull Method method) { + Class returnType = method.getReturnType(); + Class[] parameters = method.getParameterTypes(); + if (parameters.length > 0 + && parameters[parameters.length - 1].getName().equals(KOTLIN_CONTINUATION)) { + throw error( + type, + method, + "Kotlin suspend functions are not supported; use a function that is not suspend"); + } + if (isReactive(returnType)) { + throw error( + type, + method, + "reactive return types are not supported, got [" + + returnType.getName() + + "]; return a plain value, or a future from an @Async method"); + } + if (isAsync(method)) { + if (returnType != void.class + && !returnType.getName().equals(KOTLIN_UNIT) + && returnType != Future.class + && returnType != CompletableFuture.class) { + throw error( + type, + method, + "@Async methods can return only void, Kotlin Unit, Future or CompletableFuture, got [" + + returnType.getName() + + "]; Spring's @Async proxy fails every call of any other return type"); + } + } else if (Future.class.isAssignableFrom(returnType) + || CompletionStage.class.isAssignableFrom(returnType)) { + throw error( + type, + method, + "return type " + + returnType.getName() + + " would let the work outlive the lock, which is released when the method returns;" + + " mark the method @Async and declare Future or CompletableFuture, so the lock" + + " covers its body on the worker thread"); + } + } + + private static boolean isReactive(@Nullable Class type) { + if (type == null) { + return false; + } + if (Flow.Publisher.class.isAssignableFrom(type) + || type.getName().equals(REACTIVE_STREAMS_PUBLISHER) + || isReactive(type.getSuperclass())) { + return true; + } + for (Class parent : type.getInterfaces()) { + if (isReactive(parent)) { + return true; + } + } + return false; + } + + private static boolean isAsync(@NonNull Method method) { + return AnnotatedElementUtils.hasAnnotation(method, Async.class) + || AnnotatedElementUtils.hasAnnotation(method.getDeclaringClass(), Async.class); + } + + private static @NonNull LocksmithConfigurationException error( + @NonNull Class annotation, + @NonNull Method method, + @NonNull String detail) { + return error(annotation, method, detail, null); + } + + private static @NonNull LocksmithConfigurationException error( + @NonNull Class annotation, + @NonNull Method method, + @NonNull String detail, + @Nullable Throwable cause) { + return new LocksmithConfigurationException( + "@" + + annotation.getSimpleName() + + " on " + + method.getDeclaringClass().getName() + + "." + + method.getName() + + ": " + + detail, + cause); + } +} diff --git a/src/main/java/in/riido/locksmith/aop/package-info.java b/src/main/java/in/riido/locksmith/aop/package-info.java new file mode 100644 index 0000000..9691418 --- /dev/null +++ b/src/main/java/in/riido/locksmith/aop/package-info.java @@ -0,0 +1,2 @@ +/** Internal API. Not for use by adopters. May change without notice. */ +package in.riido.locksmith.aop; diff --git a/src/main/java/in/riido/locksmith/aspect/DistributedLockAspect.java b/src/main/java/in/riido/locksmith/aspect/DistributedLockAspect.java deleted file mode 100644 index e29b800..0000000 --- a/src/main/java/in/riido/locksmith/aspect/DistributedLockAspect.java +++ /dev/null @@ -1,319 +0,0 @@ -package in.riido.locksmith.aspect; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.LeaseExpirationBehavior; -import in.riido.locksmith.LockType; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.LockProperties; -import in.riido.locksmith.exception.LeaseExpiredException; -import in.riido.locksmith.handler.LockSkipHandler; -import in.riido.locksmith.metrics.LockMetrics; -import in.riido.locksmith.models.LockContext; -import in.riido.locksmith.support.AspectSupport; -import in.riido.locksmith.support.DurationResolver; -import in.riido.locksmith.support.SpELKeyResolver; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; - -/** - * Aspect that handles distributed locking for methods annotated with {@link DistributedLock}. Uses - * Redisson's RLock implementation for distributed lock management across multiple server instances. - * - *

This aspect is ordered with {@link Ordered#HIGHEST_PRECEDENCE} to ensure the lock is acquired - * before any transaction starts. - * - * @author Garvit Joshi - * @since 1.0.0 - */ -@Aspect -@Order(Ordered.HIGHEST_PRECEDENCE) -public class DistributedLockAspect { - - private static final Logger LOG = LoggerFactory.getLogger(DistributedLockAspect.class); - - private final RedissonClient redissonClient; - private final LockProperties lockProperties; - private final ApplicationContext applicationContext; - @Nullable private final LockMetrics lockMetrics; - - private final Map, LockSkipHandler> handlerCache = - new ConcurrentHashMap<>(5); - - /** - * Constructs a new DistributedLockAspect. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - */ - public DistributedLockAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext) { - this(redissonClient, properties, applicationContext, null); - } - - /** - * Constructs a new DistributedLockAspect with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param lockMetrics the optional lock metrics for observability - */ - public DistributedLockAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @Nullable LockMetrics lockMetrics) { - this.redissonClient = redissonClient; - this.lockProperties = properties.lock(); - this.applicationContext = applicationContext; - this.lockMetrics = lockMetrics; - } - - /** - * Around advice that handles the distributed lock lifecycle for annotated methods. - * - * @param joinPoint the join point representing the intercepted method - * @return the result of the method execution, or a default value if skipped - * @throws Throwable if the method execution throws an exception - */ - @Around("@annotation(in.riido.locksmith.DistributedLock)") - @Nullable - public Object handleDistributedLock(@NonNull ProceedingJoinPoint joinPoint) throws Throwable { - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final DistributedLock annotation = signature.getMethod().getAnnotation(DistributedLock.class); - final boolean debugMode = lockProperties.debug(); - final String methodName = AspectSupport.formatMethodSignature(joinPoint); - - if (annotation.key().isBlank()) { - throw new IllegalArgumentException( - "DistributedLock key must not be blank on method: " - + signature.getDeclaringType().getName() - + "." - + signature.getName()); - } - - final String resolvedKey = SpELKeyResolver.resolve(annotation.key(), joinPoint); - final String lockKey = lockProperties.keyPrefix() + resolvedKey; - final RLock lock = getLock(lockKey, annotation.type()); - final boolean autoRenew = annotation.autoRenew(); - - // Log warnings for conflicting settings when autoRenew is enabled - if (autoRenew) { - if (!annotation.leaseTime().isBlank()) { - LOG.warn( - "autoRenew is enabled for [{}] but leaseTime is also specified. " - + "leaseTime will be ignored as Redisson's watchdog will manage lease renewal.", - methodName); - } - if (annotation.onLeaseExpired() == LeaseExpirationBehavior.THROW_EXCEPTION) { - LOG.warn( - "autoRenew is enabled for [{}] but onLeaseExpired is set to THROW_EXCEPTION. " - + "This setting will have no effect as the lock will never expire during execution.", - methodName); - } - } - - final Duration leaseTime = - autoRenew - ? Duration.ofMillis(-1) - : DurationResolver.resolve(annotation.leaseTime(), lockProperties.leaseTime()); - final Duration waitTime = - DurationResolver.resolve(annotation.waitTime(), lockProperties.waitTime()); - - if (debugMode) { - LOG.info( - "Acquiring lock [{}] for [{}] - type={}, mode={}, leaseTime={}, waitTime={}, autoRenew={}", - lockKey, - methodName, - annotation.type(), - annotation.mode(), - leaseTime, - waitTime, - autoRenew); - } - - boolean lockAcquired = false; - long startTime = 0; - final long acquisitionStartTime = System.currentTimeMillis(); - - try { - lockAcquired = tryAcquireLock(lock, annotation.mode(), waitTime, leaseTime); - - if (!lockAcquired) { - if (lockMetrics != null) { - lockMetrics.recordSkipped(annotation.mode()); - } - if (debugMode) { - LOG.info( - "Lock acquisition failed for [{}] in [{}], invoking skip handler: {}", - lockKey, - methodName, - annotation.skipHandler().getSimpleName()); - } else { - LOG.info( - "Skipping execution of [{}] - lock [{}] is held by another instance", - methodName, - lockKey); - } - return handleSkip(annotation, joinPoint, lockKey, methodName); - } - - if (lockMetrics != null) { - lockMetrics.recordAcquisitionTime(System.currentTimeMillis() - acquisitionStartTime); - lockMetrics.recordAcquired(); - if (autoRenew) { - lockMetrics.incrementAutoRenewActive(); - } - } - - LOG.info("Lock [{}] acquired for [{}]", lockKey, methodName); - - startTime = System.currentTimeMillis(); - final Object result = joinPoint.proceed(); - final long executionTime = System.currentTimeMillis() - startTime; - - if (debugMode) { - LOG.info( - "Method [{}] executed in {}ms, returnType={}, hasResult={}", - methodName, - executionTime, - signature.getReturnType().getSimpleName(), - result != null); - } - - // Skip lease expiration check when autoRenew is enabled - if (!autoRenew) { - AspectSupport.checkLeaseExpiration( - annotation.onLeaseExpired(), - leaseTime, - executionTime, - lockKey, - methodName, - "Lock", - lockMetrics != null ? lockMetrics::recordLeaseExpired : null, - () -> - new LeaseExpiredException( - lockKey, methodName, leaseTime.toMillis(), executionTime)); - } - - return result; - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (lockAcquired) { - // InterruptedException came from joinPoint.proceed() (user's method), not from - // lock acquisition. Propagate the original exception instead of swallowing it. - throw e; - } - // Lock acquisition was interrupted - LOG.warn("Thread interrupted while waiting for lock [{}] in [{}]", lockKey, methodName); - if (lockMetrics != null) { - lockMetrics.recordSkipped(annotation.mode()); - } - return handleSkip(annotation, joinPoint, lockKey, methodName); - } finally { - if (lockAcquired) { - if (lockMetrics != null) { - lockMetrics.recordHeldTime(System.currentTimeMillis() - startTime); - } - if (autoRenew && lockMetrics != null) { - lockMetrics.decrementAutoRenewActive(); - } - releaseLock(lock, lockKey, methodName); - } - } - } - - private void releaseLock( - @NonNull RLock lock, @NonNull String lockKey, @NonNull String methodName) { - try { - lock.unlock(); - LOG.info("Lock [{}] released for [{}]", lockKey, methodName); - } catch (IllegalMonitorStateException e) { - // Lock may have expired or been released due to virtual thread carrier thread changes - LOG.warn( - "Lock [{}] was already released (possibly expired) for [{}]: {}", - lockKey, - methodName, - e.getMessage()); - } catch (Exception e) { - // Handle other exceptions (e.g., Redis connection failures) - LOG.warn("Failed to release lock [{}] for [{}]", lockKey, methodName, e); - } - } - - /** - * Gets the appropriate lock based on the lock type. - * - * @param lockKey the key for the lock - * @param lockType the type of lock to acquire - * @return the appropriate RLock instance - */ - @NonNull - private RLock getLock(@NonNull String lockKey, @NonNull LockType lockType) { - return switch (lockType) { - case REENTRANT -> redissonClient.getLock(lockKey); - case READ -> redissonClient.getReadWriteLock(lockKey).readLock(); - case WRITE -> redissonClient.getReadWriteLock(lockKey).writeLock(); - }; - } - - private boolean tryAcquireLock( - @NonNull RLock lock, - @NonNull AcquisitionMode mode, - @NonNull Duration waitTime, - @NonNull Duration leaseTime) - throws InterruptedException { - final long leaseTimeMs = leaseTime.toMillis(); - final long waitTimeMs = waitTime.toMillis(); - return switch (mode) { - case SKIP_IMMEDIATELY -> lock.tryLock(0, leaseTimeMs, TimeUnit.MILLISECONDS); - case WAIT_AND_SKIP -> lock.tryLock(waitTimeMs, leaseTimeMs, TimeUnit.MILLISECONDS); - }; - } - - @NonNull - private LockSkipHandler getHandlerInstance( - @NonNull Class handlerClass) { - return handlerCache.computeIfAbsent( - handlerClass, - clazz -> AspectSupport.resolveHandler(clazz, applicationContext, lockProperties.debug())); - } - - @Nullable - private Object handleSkip( - @NonNull DistributedLock annotation, - @NonNull ProceedingJoinPoint joinPoint, - @NonNull String lockKey, - @NonNull String methodName) { - final LockSkipHandler handler = getHandlerInstance(annotation.skipHandler()); - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final LockContext context = - new LockContext( - lockKey, - methodName, - signature.getMethod(), - joinPoint.getArgs(), - signature.getReturnType()); - return handler.handle(context); - } -} diff --git a/src/main/java/in/riido/locksmith/aspect/DistributedSemaphoreAspect.java b/src/main/java/in/riido/locksmith/aspect/DistributedSemaphoreAspect.java deleted file mode 100644 index 6f4af9a..0000000 --- a/src/main/java/in/riido/locksmith/aspect/DistributedSemaphoreAspect.java +++ /dev/null @@ -1,322 +0,0 @@ -package in.riido.locksmith.aspect; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedSemaphore; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.SemaphoreProperties; -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import in.riido.locksmith.exception.SemaphoreLeaseExpiredException; -import in.riido.locksmith.handler.SemaphoreSkipHandler; -import in.riido.locksmith.metrics.SemaphoreMetrics; -import in.riido.locksmith.models.SemaphoreContext; -import in.riido.locksmith.support.AspectSupport; -import in.riido.locksmith.support.DurationResolver; -import in.riido.locksmith.support.SemaphoreInitializer; -import in.riido.locksmith.support.SpELKeyResolver; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RPermitExpirableSemaphore; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; - -/** - * Aspect that handles distributed semaphore-based concurrency control for methods annotated with - * {@link DistributedSemaphore}. Uses Redisson's RPermitExpirableSemaphore for distributed permit - * management with automatic lease expiration. - * - *

This aspect is ordered with {@link Ordered#HIGHEST_PRECEDENCE} to ensure permits are acquired - * before any transaction starts. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -@Aspect -@Order(Ordered.HIGHEST_PRECEDENCE) -public class DistributedSemaphoreAspect { - - private static final Logger LOG = LoggerFactory.getLogger(DistributedSemaphoreAspect.class); - - private final SemaphoreInitializer semaphoreInitializer; - - /** Cache of handler instances per class type for reuse. */ - private final Map, SemaphoreSkipHandler> handlerCache = - new ConcurrentHashMap<>(5); - - private final RedissonClient redissonClient; - private final SemaphoreProperties semaphoreProperties; - private final ApplicationContext applicationContext; - @Nullable private final SemaphoreMetrics semaphoreMetrics; - - /** - * Constructs a new DistributedSemaphoreAspect. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - */ - public DistributedSemaphoreAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext) { - this(redissonClient, properties, applicationContext, null); - } - - /** - * Constructs a new DistributedSemaphoreAspect with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param semaphoreMetrics the optional semaphore metrics for observability - */ - public DistributedSemaphoreAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @Nullable SemaphoreMetrics semaphoreMetrics) { - this.redissonClient = redissonClient; - this.semaphoreProperties = properties.semaphore(); - this.applicationContext = applicationContext; - this.semaphoreMetrics = semaphoreMetrics; - this.semaphoreInitializer = new SemaphoreInitializer(redissonClient); - } - - /** - * Around advice that handles the distributed semaphore lifecycle for annotated methods. - * - * @param joinPoint the join point representing the intercepted method - * @return the result of the method execution, or a default value if skipped - * @throws Throwable if the method execution throws an exception - */ - @Around("@annotation(in.riido.locksmith.DistributedSemaphore)") - @Nullable - public Object handleDistributedSemaphore(@NonNull ProceedingJoinPoint joinPoint) - throws Throwable { - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final DistributedSemaphore annotation = - signature.getMethod().getAnnotation(DistributedSemaphore.class); - final boolean debugMode = semaphoreProperties.debug(); - final String methodName = AspectSupport.formatMethodSignature(joinPoint); - - // Validate key is not blank - if (annotation.key().isBlank()) { - throw new IllegalArgumentException( - "DistributedSemaphore key must not be blank on method: " - + signature.getDeclaringType().getName() - + "." - + signature.getName()); - } - - // Validate permits is positive - if (annotation.permits() <= 0) { - throw new SemaphoreConfigurationException( - String.format( - "DistributedSemaphore permits must be positive on method [%s], got: %d", - methodName, annotation.permits()), - annotation.key()); - } - - final String resolvedKey = SpELKeyResolver.resolve(annotation.key(), joinPoint); - final String semaphoreKey = semaphoreProperties.keyPrefix() + resolvedKey; - final int permits = annotation.permits(); - - // Validate consistency: same key must have same permits within this codebase - semaphoreInitializer.validatePermitsConsistency(semaphoreKey, permits, methodName); - - // Initialize semaphore in Redis (first time only per key per JVM) - semaphoreInitializer.ensureInitialized(semaphoreKey, permits); - - final RPermitExpirableSemaphore semaphore = - redissonClient.getPermitExpirableSemaphore(semaphoreKey); - - final Duration leaseTime = - DurationResolver.resolve(annotation.leaseTime(), semaphoreProperties.leaseTime()); - final Duration waitTime = - DurationResolver.resolve(annotation.waitTime(), semaphoreProperties.waitTime()); - - if (debugMode) { - LOG.info( - "Acquiring permit from [{}] for [{}] - permits={}, mode={}, leaseTime={}, waitTime={}", - semaphoreKey, - methodName, - permits, - annotation.mode(), - leaseTime, - waitTime); - } - - String permitId = null; - long startTime = 0; - final long acquisitionStartTime = System.currentTimeMillis(); - - try { - permitId = tryAcquirePermit(semaphore, annotation.mode(), waitTime, leaseTime); - - if (permitId == null) { - if (semaphoreMetrics != null) { - semaphoreMetrics.recordSkipped(annotation.mode()); - } - if (debugMode) { - LOG.info( - "Permit acquisition failed for [{}] in [{}], invoking skip handler: {}", - semaphoreKey, - methodName, - annotation.skipHandler().getSimpleName()); - } else { - LOG.info( - "Skipping execution of [{}] - no permit available from semaphore [{}]", - methodName, - semaphoreKey); - } - return handleSkip(annotation, joinPoint, semaphoreKey, methodName, permitId); - } - - if (semaphoreMetrics != null) { - semaphoreMetrics.recordAcquisitionTime(System.currentTimeMillis() - acquisitionStartTime); - semaphoreMetrics.recordAcquired(); - } - - LOG.info("Permit [{}] acquired from [{}] for [{}]", permitId, semaphoreKey, methodName); - - startTime = System.currentTimeMillis(); - final Object result = joinPoint.proceed(); - final long executionTime = System.currentTimeMillis() - startTime; - - if (debugMode) { - LOG.info( - "Method [{}] executed in {}ms, returnType={}, hasResult={}", - methodName, - executionTime, - signature.getReturnType().getSimpleName(), - result != null); - } - - AspectSupport.checkLeaseExpiration( - annotation.onLeaseExpired(), - leaseTime, - executionTime, - semaphoreKey, - methodName, - "Semaphore permit", - semaphoreMetrics != null ? semaphoreMetrics::recordLeaseExpired : null, - () -> - new SemaphoreLeaseExpiredException( - semaphoreKey, methodName, leaseTime.toMillis(), executionTime)); - - return result; - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (permitId != null) { - // InterruptedException came from joinPoint.proceed() (user's method), not from - // permit acquisition. Propagate the original exception instead of swallowing it. - throw e; - } - // Permit acquisition was interrupted - LOG.warn( - "Thread interrupted while waiting for permit from [{}] in [{}]", - semaphoreKey, - methodName); - if (semaphoreMetrics != null) { - semaphoreMetrics.recordSkipped(annotation.mode()); - } - return handleSkip(annotation, joinPoint, semaphoreKey, methodName, permitId); - } finally { - if (permitId != null) { - if (semaphoreMetrics != null) { - semaphoreMetrics.recordHeldTime(System.currentTimeMillis() - startTime); - } - releasePermit(semaphore, permitId, semaphoreKey, methodName); - } - } - } - - /** - * Attempts to acquire a permit from the semaphore. - * - * @return the permit ID if acquired, null otherwise - */ - @Nullable - private String tryAcquirePermit( - @NonNull RPermitExpirableSemaphore semaphore, - @NonNull AcquisitionMode mode, - @NonNull Duration waitTime, - @NonNull Duration leaseTime) - throws InterruptedException { - final long leaseTimeMs = leaseTime.toMillis(); - final long waitTimeMs = waitTime.toMillis(); - - return switch (mode) { - case SKIP_IMMEDIATELY -> semaphore.tryAcquire(0, leaseTimeMs, TimeUnit.MILLISECONDS); - case WAIT_AND_SKIP -> semaphore.tryAcquire(waitTimeMs, leaseTimeMs, TimeUnit.MILLISECONDS); - }; - } - - private void releasePermit( - @NonNull RPermitExpirableSemaphore semaphore, - @NonNull String permitId, - @NonNull String semaphoreKey, - @NonNull String methodName) { - try { - semaphore.release(permitId); - LOG.info("Permit [{}] released from [{}] for [{}]", permitId, semaphoreKey, methodName); - } catch (IllegalArgumentException e) { - // Permit may have expired - LOG.warn( - "Permit [{}] was already released (possibly expired) from [{}] for [{}]: {}", - permitId, - semaphoreKey, - methodName, - e.getMessage()); - } catch (Exception e) { - // Handle other Redis exceptions (e.g., when permit has already expired) - LOG.warn( - "Failed to release permit [{}] from [{}] for [{}]", - permitId, - semaphoreKey, - methodName, - e); - } - } - - @NonNull - private SemaphoreSkipHandler getHandlerInstance( - @NonNull Class handlerClass) { - return handlerCache.computeIfAbsent( - handlerClass, - clazz -> - AspectSupport.resolveHandler(clazz, applicationContext, semaphoreProperties.debug())); - } - - @Nullable - private Object handleSkip( - @NonNull DistributedSemaphore annotation, - @NonNull ProceedingJoinPoint joinPoint, - @NonNull String semaphoreKey, - @NonNull String methodName, - @Nullable String permitId) { - final SemaphoreSkipHandler handler = getHandlerInstance(annotation.skipHandler()); - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final SemaphoreContext context = - new SemaphoreContext( - semaphoreKey, - methodName, - signature.getMethod(), - joinPoint.getArgs(), - signature.getReturnType(), - permitId); - return handler.handle(context); - } -} diff --git a/src/main/java/in/riido/locksmith/aspect/RateLimitAspect.java b/src/main/java/in/riido/locksmith/aspect/RateLimitAspect.java deleted file mode 100644 index 63a8b02..0000000 --- a/src/main/java/in/riido/locksmith/aspect/RateLimitAspect.java +++ /dev/null @@ -1,254 +0,0 @@ -package in.riido.locksmith.aspect; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.RateLimit; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.RateLimitProperties; -import in.riido.locksmith.exception.RateLimitConfigurationException; -import in.riido.locksmith.handler.RateLimitSkipHandler; -import in.riido.locksmith.metrics.RateLimitMetrics; -import in.riido.locksmith.models.RateLimitContext; -import in.riido.locksmith.support.AspectSupport; -import in.riido.locksmith.support.DurationResolver; -import in.riido.locksmith.support.RateLimitInitializer; -import in.riido.locksmith.support.SpELKeyResolver; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RRateLimiter; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationContext; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; - -/** - * Aspect that handles distributed rate limiting for methods annotated with {@link RateLimit}. Uses - * Redisson's RRateLimiter for distributed rate limiting across all server instances. - * - *

This aspect is ordered with {@link Ordered#HIGHEST_PRECEDENCE} to ensure rate limits are - * checked before any transaction starts. - * - * @author Garvit Joshi - * @since 3.0.0 - */ -@Aspect -@Order(Ordered.HIGHEST_PRECEDENCE) -public class RateLimitAspect { - - private static final Logger LOG = LoggerFactory.getLogger(RateLimitAspect.class); - - private final RateLimitInitializer rateLimitInitializer; - - /** Cache of handler instances per class type for reuse. */ - private final Map, RateLimitSkipHandler> handlerCache = - new ConcurrentHashMap<>(5); - - private final RedissonClient redissonClient; - private final RateLimitProperties rateLimitProperties; - private final ApplicationContext applicationContext; - @Nullable private final RateLimitMetrics rateLimitMetrics; - - /** - * Constructs a new RateLimitAspect. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - */ - public RateLimitAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext) { - this(redissonClient, properties, applicationContext, null); - } - - /** - * Constructs a new RateLimitAspect with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param rateLimitMetrics the optional rate limit metrics for observability - */ - public RateLimitAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @Nullable RateLimitMetrics rateLimitMetrics) { - this.redissonClient = redissonClient; - this.rateLimitProperties = properties.rateLimit(); - this.applicationContext = applicationContext; - this.rateLimitMetrics = rateLimitMetrics; - this.rateLimitInitializer = new RateLimitInitializer(redissonClient); - } - - /** - * Around advice that handles the distributed rate limiting for annotated methods. - * - * @param joinPoint the join point representing the intercepted method - * @return the result of the method execution, or a default value if rate limited - * @throws Throwable if the method execution throws an exception - */ - @Around("@annotation(in.riido.locksmith.RateLimit)") - @Nullable - public Object handleRateLimit(@NonNull ProceedingJoinPoint joinPoint) throws Throwable { - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final RateLimit annotation = signature.getMethod().getAnnotation(RateLimit.class); - final boolean debugMode = rateLimitProperties.debug(); - final String methodName = AspectSupport.formatMethodSignature(joinPoint); - - // Validate key is not blank - if (annotation.key().isBlank()) { - throw new IllegalArgumentException( - "RateLimit key must not be blank on method: " - + signature.getDeclaringType().getName() - + "." - + signature.getName()); - } - - // Validate permits is positive - if (annotation.permits() <= 0) { - throw new RateLimitConfigurationException( - String.format( - "RateLimit permits must be positive on method [%s], got: %d", - methodName, annotation.permits()), - annotation.key()); - } - - final String resolvedKey = SpELKeyResolver.resolve(annotation.key(), joinPoint); - final String rateLimitKey = rateLimitProperties.keyPrefix() + resolvedKey; - final long permits = annotation.permits(); - - // Parse interval - final Duration interval = - DurationResolver.resolve(annotation.interval(), Duration.ofSeconds(1)); - - // Validate interval is positive - if (interval.isZero() || interval.isNegative()) { - throw new RateLimitConfigurationException( - String.format( - "RateLimit interval must be positive on method [%s], got: %s", methodName, interval), - annotation.key()); - } - - final Duration waitTime = - DurationResolver.resolve(annotation.waitTime(), rateLimitProperties.waitTime()); - - // Initialize rate limiter in Redis (first time only per key per JVM) - rateLimitInitializer.ensureInitialized(rateLimitKey, permits, interval, annotation.type()); - - final RRateLimiter rateLimiter = redissonClient.getRateLimiter(rateLimitKey); - - if (debugMode) { - LOG.info( - "Checking rate limit [{}] for [{}] - permits={}, interval={}, mode={}, waitTime={}", - rateLimitKey, - methodName, - permits, - interval, - annotation.mode(), - waitTime); - } - - final long acquisitionStartTime = System.currentTimeMillis(); - boolean permitAcquired = tryAcquirePermit(rateLimiter, annotation.mode(), waitTime); - - if (!permitAcquired) { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordExceeded(annotation.mode()); - } - if (debugMode) { - LOG.info( - "Rate limit exceeded for [{}] in [{}], invoking skip handler: {}", - rateLimitKey, - methodName, - annotation.skipHandler().getSimpleName()); - } else { - LOG.info("Skipping execution of [{}] - rate limit [{}] exceeded", methodName, rateLimitKey); - } - return handleSkip(annotation, joinPoint, rateLimitKey, methodName); - } - - if (rateLimitMetrics != null) { - rateLimitMetrics.recordAcquisitionTime(System.currentTimeMillis() - acquisitionStartTime); - rateLimitMetrics.recordAcquired(); - } - - LOG.info("Rate limit permit acquired for [{}] in [{}]", rateLimitKey, methodName); - - final long startTime = System.currentTimeMillis(); - try { - final Object result = joinPoint.proceed(); - - if (debugMode) { - final long executionTime = System.currentTimeMillis() - startTime; - LOG.info( - "Method [{}] executed in {}ms, returnType={}, hasResult={}", - methodName, - executionTime, - signature.getReturnType().getSimpleName(), - result != null); - } - - return result; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw e; - } finally { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordExecutionTime(System.currentTimeMillis() - startTime); - } - LOG.info("Rate limit execution completed for [{}] in [{}]", rateLimitKey, methodName); - } - } - - /** - * Attempts to acquire a permit from the rate limiter. - * - * @return true if a permit was acquired, false otherwise - */ - private boolean tryAcquirePermit( - @NonNull RRateLimiter rateLimiter, - @NonNull AcquisitionMode mode, - @NonNull Duration waitTime) { - return switch (mode) { - case SKIP_IMMEDIATELY -> rateLimiter.tryAcquire(); - case WAIT_AND_SKIP -> rateLimiter.tryAcquire(1, waitTime); - }; - } - - @NonNull - private RateLimitSkipHandler getHandlerInstance( - @NonNull Class handlerClass) { - return handlerCache.computeIfAbsent( - handlerClass, - clazz -> - AspectSupport.resolveHandler(clazz, applicationContext, rateLimitProperties.debug())); - } - - @Nullable - private Object handleSkip( - @NonNull RateLimit annotation, - @NonNull ProceedingJoinPoint joinPoint, - @NonNull String rateLimitKey, - @NonNull String methodName) { - final RateLimitSkipHandler handler = getHandlerInstance(annotation.skipHandler()); - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - final RateLimitContext context = - new RateLimitContext( - rateLimitKey, - methodName, - signature.getMethod(), - joinPoint.getArgs(), - signature.getReturnType()); - return handler.handle(context); - } -} diff --git a/src/main/java/in/riido/locksmith/aspect/package-info.java b/src/main/java/in/riido/locksmith/aspect/package-info.java deleted file mode 100644 index d375be0..0000000 --- a/src/main/java/in/riido/locksmith/aspect/package-info.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * AOP aspect implementations for distributed locking and semaphores. - * - *

This package contains the AspectJ aspects that intercept methods annotated with {@link - * in.riido.locksmith.DistributedLock} and {@link in.riido.locksmith.DistributedSemaphore} and - * handle the lock/permit acquisition/release lifecycle. - * - *

    - *
  • {@link in.riido.locksmith.aspect.DistributedLockAspect} - Handles distributed lock - * lifecycle - *
  • {@link in.riido.locksmith.aspect.DistributedSemaphoreAspect} - Handles distributed - * semaphore permit lifecycle - *
- * - *

The aspects are automatically registered by the autoconfiguration when a {@link - * org.redisson.api.RedissonClient} bean is available. - * - * @author Garvit Joshi - * @since 1.0.0 - * @see in.riido.locksmith.aspect.DistributedLockAspect - * @see in.riido.locksmith.aspect.DistributedSemaphoreAspect - */ -package in.riido.locksmith.aspect; diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfiguration.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfiguration.java index 6a2d70a..a1528dc 100644 --- a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfiguration.java +++ b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfiguration.java @@ -1,269 +1,221 @@ package in.riido.locksmith.autoconfigure; -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.aspect.DistributedSemaphoreAspect; -import in.riido.locksmith.aspect.RateLimitAspect; -import in.riido.locksmith.metrics.LockMetrics; -import in.riido.locksmith.metrics.RateLimitMetrics; -import in.riido.locksmith.metrics.SemaphoreMetrics; -import in.riido.locksmith.template.LocksmithLockTemplate; -import in.riido.locksmith.template.LocksmithRateLimitTemplate; -import in.riido.locksmith.template.LocksmithSemaphoreTemplate; +import in.riido.locksmith.aop.LocksmithAdvisor; +import in.riido.locksmith.aop.LocksmithAutoProxyRegistrar; +import in.riido.locksmith.aop.LocksmithInterceptor; +import in.riido.locksmith.aop.MethodSpecFactory; +import in.riido.locksmith.lock.LockOperations; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.MicrometerLocksmithMetrics; +import in.riido.locksmith.metrics.NoOpLocksmithMetrics; +import in.riido.locksmith.semaphore.SemaphoreOperations; +import in.riido.locksmith.support.AnnotationValidator; +import io.micrometer.core.instrument.MeterRegistry; import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; import org.redisson.api.RedissonClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.SpringBootVersion; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; +import org.springframework.core.env.Environment; /** - * Autoconfiguration for Locksmith distributed locking and semaphore support. - * - *

This configuration is automatically applied when: - * - *

    - *
  • Redisson classes are on the classpath - *
  • A {@link RedissonClient} bean is available - *
- * - *

The user must provide their own {@link RedissonClient} bean. This starter does not - * autoconfigure Redis connections, giving users full control over their Redis setup. - * - *

Usage in your application: - * - *

{@code
- * @Configuration
- * public class RedisConfig {
- *     @Bean
- *     public RedissonClient redissonClient() {
- *         Config config = new Config();
- *         config.useSingleServer().setAddress("redis://localhost:6379");
- *         return Redisson.create(config);
- *     }
- * }
- * }
- * - * @author Garvit Joshi - * @since 1.0.0 + * Registers Locksmith when a {@link RedissonClient} bean exists and {@code locksmith.enabled} is + * {@code true} or not set: the operations, the advisor that applies the annotations, the startup + * validator, and an auto-proxy creator if the context has none. Logs one INFO line with the + * effective settings. */ -@AutoConfiguration +@AutoConfiguration( + afterName = { + "org.redisson.spring.starter.RedissonAutoConfigurationV4", + "org.springframework.boot.micrometer.metrics.autoconfigure" + + ".CompositeMeterRegistryAutoConfiguration" + }) @ConditionalOnClass(RedissonClient.class) @ConditionalOnBean(RedissonClient.class) +@Conditional(LocksmithEnabledCondition.class) @EnableConfigurationProperties(LocksmithProperties.class) -public class LocksmithAutoConfiguration { +@Import(LocksmithAutoProxyRegistrar.class) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class LocksmithAutoConfiguration implements SmartInitializingSingleton { + + /** Property that switches Locksmith off when {@code false}. */ + static final String ENABLED_PROPERTY = "locksmith.enabled"; private static final Logger LOG = LoggerFactory.getLogger(LocksmithAutoConfiguration.class); - /** Default constructor. */ - public LocksmithAutoConfiguration() {} + private static final String UNKNOWN_VERSION = "unknown"; /** - * Creates the distributed lock aspect bean. - * - *

This bean is only created when {@code locksmith.lock.enabled} is {@code true} (the default). - * Set it to {@code false} to disable the lock aspect entirely. + * Registers the Micrometer metrics when a {@link MeterRegistry} bean exists. Declared first, so + * its bean is registered before the no-op fallback is considered. + */ + @Configuration(proxyBeanMethods = false) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @ConditionalOnClass(MeterRegistry.class) + @ConditionalOnBean(MeterRegistry.class) + static class MicrometerConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @NonNull LocksmithMetrics micrometerLocksmithMetrics(@NonNull MeterRegistry registry) { + return new MicrometerLocksmithMetrics(registry); + } + } + + private final @NonNull ObjectProvider properties; + + /** + * Creates the configuration. The properties are resolved only in {@link + * #afterSingletonsInstantiated()}: this class is created while bean post-processors are still + * being registered, and resolving them here would create the properties bean too early. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param lockMetricsProvider optional lock metrics provider for observability - * @return the configured DistributedLockAspect + * @param properties the bound properties, resolved lazily */ - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.lock.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public DistributedLockAspect distributedLockAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @NonNull ObjectProvider lockMetricsProvider) { + public LocksmithAutoConfiguration(@NonNull ObjectProvider properties) { + this.properties = properties; + } + + /** + * Logs the effective settings with the Spring Boot and Redisson versions, once per application + * context, after every singleton has been created. + */ + @Override + public void afterSingletonsInstantiated() { + LocksmithProperties bound = properties.getObject(); String redissonVersion = RedissonClient.class.getPackage().getImplementationVersion(); - String springBootVersion = SpringBootVersion.getVersion(); - @Nullable LockMetrics lockMetrics = lockMetricsProvider.getIfAvailable(); LOG.info( - "Initializing locksmith lock aspect with Spring Boot {} and Redisson {} - Lock Properties: {}, Metrics: {}", - springBootVersion, - redissonVersion, - properties.lock(), - lockMetrics != null ? "enabled" : "disabled"); - return new DistributedLockAspect(redissonClient, properties, applicationContext, lockMetrics); + "Locksmith enabled: key-prefix [{}], semaphore lease-time {}, Spring Boot {}, Redisson {}", + bound.keyPrefix(), + bound.semaphore().leaseTime(), + SpringBootVersion.getVersion(), + redissonVersion == null ? UNKNOWN_VERSION : redissonVersion); } /** - * Creates the distributed semaphore aspect bean. - * - *

This bean is only created when {@code locksmith.semaphore.enabled} is {@code true} (the - * default). Set it to {@code false} to disable the semaphore aspect entirely. + * The lock operations. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param semaphoreMetricsProvider optional semaphore metrics provider for observability - * @return the configured DistributedSemaphoreAspect - * @since 2.0.0 + * @param redisson the adopter's client + * @param properties the bound properties + * @param metrics the metrics + * @return the operations */ @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.semaphore.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public DistributedSemaphoreAspect distributedSemaphoreAspect( - @NonNull RedissonClient redissonClient, + public @NonNull LockOperations lockOperations( + @NonNull RedissonClient redisson, @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @NonNull ObjectProvider semaphoreMetricsProvider) { - String redissonVersion = RedissonClient.class.getPackage().getImplementationVersion(); - String springBootVersion = SpringBootVersion.getVersion(); - @Nullable SemaphoreMetrics semaphoreMetrics = semaphoreMetricsProvider.getIfAvailable(); - LOG.info( - "Initializing locksmith semaphore aspect with Spring Boot {} and Redisson {} - Semaphore Properties: {}, Metrics: {}", - springBootVersion, - redissonVersion, - properties.semaphore(), - semaphoreMetrics != null ? "enabled" : "disabled"); - return new DistributedSemaphoreAspect( - redissonClient, properties, applicationContext, semaphoreMetrics); + @NonNull LocksmithMetrics metrics) { + return new LockOperations(redisson, properties, metrics); } /** - * Creates the lock template bean for programmatic lock access. - * - *

This bean is only created when {@code locksmith.lock.enabled} is {@code true} (the default). - * Set it to {@code false} to disable the lock template entirely. + * The semaphore operations. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param lockMetricsProvider optional lock metrics provider for observability - * @return the configured LocksmithLockTemplate - * @since 2.1.0 + * @param redisson the adopter's client + * @param properties the bound properties + * @param metrics the metrics + * @return the operations */ @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.lock.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public LocksmithLockTemplate locksmithLockTemplate( - @NonNull RedissonClient redissonClient, + public @NonNull SemaphoreOperations semaphoreOperations( + @NonNull RedissonClient redisson, @NonNull LocksmithProperties properties, - @NonNull ObjectProvider lockMetricsProvider) { - @Nullable LockMetrics lockMetrics = lockMetricsProvider.getIfAvailable(); - LOG.info( - "Initializing locksmith lock template, Metrics: {}", - lockMetrics != null ? "enabled" : "disabled"); - return new LocksmithLockTemplate(redissonClient, properties, lockMetrics); + @NonNull LocksmithMetrics metrics) { + return new SemaphoreOperations(redisson, properties, metrics); } /** - * Creates the semaphore template bean for programmatic semaphore access. - * - *

This bean is only created when {@code locksmith.semaphore.enabled} is {@code true} (the - * default). Set it to {@code false} to disable the semaphore template entirely. + * The interceptor that runs annotated methods under their permit and lock. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param semaphoreMetricsProvider optional semaphore metrics provider for observability - * @return the configured LocksmithSemaphoreTemplate - * @since 2.1.0 + * @param lockOperations the lock operations, resolved lazily + * @param semaphoreOperations the semaphore operations, resolved lazily + * @param methodSpecFactory builds method specs, resolved lazily + * @param beanFactory resolves failure handler beans + * @return the interceptor */ @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.semaphore.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public LocksmithSemaphoreTemplate locksmithSemaphoreTemplate( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ObjectProvider semaphoreMetricsProvider) { - @Nullable SemaphoreMetrics semaphoreMetrics = semaphoreMetricsProvider.getIfAvailable(); - LOG.info( - "Initializing locksmith semaphore template, Metrics: {}", - semaphoreMetrics != null ? "enabled" : "disabled"); - return new LocksmithSemaphoreTemplate(redissonClient, properties, semaphoreMetrics); + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NonNull LocksmithInterceptor locksmithInterceptor( + @NonNull ObjectProvider lockOperations, + @NonNull ObjectProvider semaphoreOperations, + @NonNull ObjectProvider methodSpecFactory, + @NonNull BeanFactory beanFactory) { + return new LocksmithInterceptor( + lockOperations, semaphoreOperations, methodSpecFactory, beanFactory); } /** - * Creates the rate limit aspect bean. + * The advisor that applies the interceptor to annotated methods. Infrastructure role, because + * without AspectJ the auto-proxy creator, Spring Boot's or the one {@link + * LocksmithAutoProxyRegistrar} registers, is an {@code InfrastructureAdvisorAutoProxyCreator}, + * which applies only infrastructure advisors. * - *

This bean is only created when {@code locksmith.rate-limit.enabled} is {@code true} (the - * default). Set it to {@code false} to disable the rate limit aspect entirely. + * @param interceptor the interceptor + * @return the advisor + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NonNull LocksmithAdvisor locksmithAdvisor(@NonNull LocksmithInterceptor interceptor) { + return new LocksmithAdvisor(interceptor); + } + + /** + * The factory that reads and checks the annotations. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param applicationContext the Spring application context for handler bean lookup - * @param rateLimitMetricsProvider optional rate limit metrics provider for observability - * @return the configured RateLimitAspect - * @since 3.0.0 + * @param environment resolves placeholders + * @param properties the bound properties + * @return the factory */ @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.rate-limit.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public RateLimitAspect rateLimitAspect( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ApplicationContext applicationContext, - @NonNull ObjectProvider rateLimitMetricsProvider) { - String redissonVersion = RedissonClient.class.getPackage().getImplementationVersion(); - String springBootVersion = SpringBootVersion.getVersion(); - @Nullable RateLimitMetrics rateLimitMetrics = rateLimitMetricsProvider.getIfAvailable(); - LOG.info( - "Initializing locksmith rate limit aspect with Spring Boot {} and Redisson {} - Rate Limit Properties: {}, Metrics: {}", - springBootVersion, - redissonVersion, - properties.rateLimit(), - rateLimitMetrics != null ? "enabled" : "disabled"); - return new RateLimitAspect(redissonClient, properties, applicationContext, rateLimitMetrics); + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NonNull MethodSpecFactory methodSpecFactory( + @NonNull Environment environment, @NonNull LocksmithProperties properties) { + return new MethodSpecFactory(environment, properties); } /** - * Creates the rate limit template bean for programmatic rate limit access. + * The startup validator. Static, so it is created before ordinary beans without creating this + * configuration. * - *

This bean is only created when {@code locksmith.rate-limit.enabled} is {@code true} (the - * default). Set it to {@code false} to disable the rate limit template entirely. + * @param methodSpecFactory the factory, resolved lazily + * @param beanFactory counts handler beans + * @return the validator + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public static @NonNull AnnotationValidator annotationValidator( + @NonNull ObjectProvider methodSpecFactory, + @NonNull ListableBeanFactory beanFactory) { + return new AnnotationValidator(methodSpecFactory, beanFactory); + } + + /** + * The no-op metrics, used when no Micrometer registry exists. * - * @param redissonClient the Redisson client (must be provided by the user) - * @param properties the locksmith configuration properties - * @param rateLimitMetricsProvider optional rate limit metrics provider for observability - * @return the configured LocksmithRateLimitTemplate - * @since 3.0.0 + * @return the no-op metrics */ @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty( - name = "locksmith.rate-limit.enabled", - havingValue = "true", - matchIfMissing = true) - @NonNull - public LocksmithRateLimitTemplate locksmithRateLimitTemplate( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @NonNull ObjectProvider rateLimitMetricsProvider) { - @Nullable RateLimitMetrics rateLimitMetrics = rateLimitMetricsProvider.getIfAvailable(); - LOG.info( - "Initializing locksmith rate limit template, Metrics: {}", - rateLimitMetrics != null ? "enabled" : "disabled"); - return new LocksmithRateLimitTemplate(redissonClient, properties, rateLimitMetrics); + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + @ConditionalOnMissingBean(LocksmithMetrics.class) + public @NonNull LocksmithMetrics noOpLocksmithMetrics() { + return new NoOpLocksmithMetrics(); } } diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithDisabledAutoConfiguration.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithDisabledAutoConfiguration.java new file mode 100644 index 0000000..5b5baee --- /dev/null +++ b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithDisabledAutoConfiguration.java @@ -0,0 +1,31 @@ +package in.riido.locksmith.autoconfigure; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Role; + +/** + * Active when {@code locksmith.enabled=false}: registers nothing and logs one WARN, so an operator + * sees that annotated methods run without coordination. Infrastructure role, so lazy initialization + * still creates it and the WARN is logged. + */ +@AutoConfiguration +@Conditional(LocksmithEnabledCondition.Disabled.class) +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class LocksmithDisabledAutoConfiguration { + + /** The WARN logged at startup. */ + static final String DISABLED_WARNING = + "Locksmith is disabled: annotated methods run without any coordination"; + + private static final Logger LOG = + LoggerFactory.getLogger(LocksmithDisabledAutoConfiguration.class); + + /** Creates the configuration and logs the disabled WARN. */ + public LocksmithDisabledAutoConfiguration() { + LOG.warn(DISABLED_WARNING); + } +} diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithEnabledCondition.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithEnabledCondition.java new file mode 100644 index 0000000..dd2594d --- /dev/null +++ b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithEnabledCondition.java @@ -0,0 +1,54 @@ +package in.riido.locksmith.autoconfigure; + +import in.riido.locksmith.LocksmithConfigurationException; +import org.jspecify.annotations.NonNull; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Matches when {@code locksmith.enabled} is {@code true} or not set; {@link Disabled} matches when + * it is {@code false}. Both ignore case. Any other value, an empty one included, fails the startup + * with a {@link LocksmithConfigurationException} that names it, so a mistyped value cannot switch + * Locksmith off without a word. + */ +class LocksmithEnabledCondition implements Condition { + + @Override + public boolean matches( + @NonNull ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + return enabled(context.getEnvironment()); + } + + /** + * Reads {@code locksmith.enabled}. + * + * @throws LocksmithConfigurationException if it is set to anything but {@code true} or {@code + * false}, ignoring case + */ + static boolean enabled(@NonNull Environment environment) { + String value = environment.getProperty(LocksmithAutoConfiguration.ENABLED_PROPERTY); + if (value == null || value.equalsIgnoreCase("true")) { + return true; + } + if (value.equalsIgnoreCase("false")) { + return false; + } + throw new LocksmithConfigurationException( + LocksmithAutoConfiguration.ENABLED_PROPERTY + + " must be true or false, got [" + + value + + "]"); + } + + /** Matches when {@code locksmith.enabled} is {@code false}, ignoring case. */ + static final class Disabled extends LocksmithEnabledCondition { + + @Override + public boolean matches( + @NonNull ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + return !enabled(context.getEnvironment()); + } + } +} diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithInactiveAutoConfiguration.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithInactiveAutoConfiguration.java new file mode 100644 index 0000000..82588ff --- /dev/null +++ b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithInactiveAutoConfiguration.java @@ -0,0 +1,36 @@ +package in.riido.locksmith.autoconfigure; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Role; + +/** + * Active when Locksmith is enabled but no {@code RedissonClient} bean exists: registers nothing and + * logs one WARN, so an operator sees that annotated methods run without coordination. Ordered after + * the Redisson starter, so its client counts; the type is named as a string, so the WARN also comes + * when Redisson is not on the classpath at all. Infrastructure role, so lazy initialization still + * creates it and the WARN is logged. + */ +@AutoConfiguration(afterName = "org.redisson.spring.starter.RedissonAutoConfigurationV4") +@Conditional(LocksmithEnabledCondition.class) +@ConditionalOnMissingBean(type = "org.redisson.api.RedissonClient") +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class LocksmithInactiveAutoConfiguration { + + /** The WARN logged at startup. */ + static final String INACTIVE_WARNING = + "Locksmith is inactive: there is no RedissonClient bean, so annotated methods run without" + + " any coordination"; + + private static final Logger LOG = + LoggerFactory.getLogger(LocksmithInactiveAutoConfiguration.class); + + /** Creates the configuration and logs the inactive WARN. */ + public LocksmithInactiveAutoConfiguration() { + LOG.warn(INACTIVE_WARNING); + } +} diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithMetricsAutoConfiguration.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithMetricsAutoConfiguration.java deleted file mode 100644 index b48ccae..0000000 --- a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithMetricsAutoConfiguration.java +++ /dev/null @@ -1,106 +0,0 @@ -package in.riido.locksmith.autoconfigure; - -import in.riido.locksmith.metrics.LockMetrics; -import in.riido.locksmith.metrics.RateLimitMetrics; -import in.riido.locksmith.metrics.SemaphoreMetrics; -import io.micrometer.core.instrument.MeterRegistry; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigureBefore; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; - -/** - * Auto-configuration for Locksmith metrics integration with Micrometer. - * - *

This configuration is automatically applied when: - * - *

    - *
  • Micrometer's {@link MeterRegistry} is on the classpath - *
  • A {@link MeterRegistry} bean is available - *
  • The respective {@code metrics-enabled} property is set to {@code true} - *
- * - *

Configuration example: - * - *

{@code
- * locksmith:
- *   lock:
- *     metrics-enabled: true
- *   semaphore:
- *     metrics-enabled: true
- *   rate-limit:
- *     metrics-enabled: true
- * }
- * - * @author Garvit Joshi - * @since 2.1.0 - * @see LockMetrics - * @see SemaphoreMetrics - * @see RateLimitMetrics - */ -@AutoConfiguration -@AutoConfigureBefore(LocksmithAutoConfiguration.class) -@ConditionalOnClass(MeterRegistry.class) -@ConditionalOnBean(MeterRegistry.class) -@EnableConfigurationProperties(LocksmithProperties.class) -public class LocksmithMetricsAutoConfiguration { - - private static final Logger LOG = - LoggerFactory.getLogger(LocksmithMetricsAutoConfiguration.class); - - /** Default constructor. */ - public LocksmithMetricsAutoConfiguration() {} - - /** - * Creates the lock metrics bean. - * - * @param registry the Micrometer registry - * @return the configured LockMetrics - */ - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(name = "locksmith.lock.metrics-enabled", havingValue = "true") - @NonNull - public LockMetrics lockMetrics(@NonNull MeterRegistry registry) { - LOG.info("Initializing locksmith lock metrics"); - return new LockMetrics(registry); - } - - /** - * Creates the semaphore metrics bean. - * - * @param registry the Micrometer registry - * @return the configured SemaphoreMetrics - */ - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(name = "locksmith.semaphore.metrics-enabled", havingValue = "true") - @NonNull - public SemaphoreMetrics semaphoreMetrics(@NonNull MeterRegistry registry) { - LOG.info("Initializing locksmith semaphore metrics"); - return new SemaphoreMetrics(registry); - } - - /** - * Creates the rate limit metrics bean. - * - * @param registry the Micrometer registry - * @return the configured RateLimitMetrics - * @since 3.0.0 - */ - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(name = "locksmith.rate-limit.metrics-enabled", havingValue = "true") - @NonNull - public RateLimitMetrics rateLimitMetrics(@NonNull MeterRegistry registry) { - LOG.info("Initializing locksmith rate limit metrics"); - return new RateLimitMetrics(registry); - } -} diff --git a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithProperties.java b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithProperties.java index 1c55c3d..8b9438b 100644 --- a/src/main/java/in/riido/locksmith/autoconfigure/LocksmithProperties.java +++ b/src/main/java/in/riido/locksmith/autoconfigure/LocksmithProperties.java @@ -1,398 +1,72 @@ package in.riido.locksmith.autoconfigure; +import in.riido.locksmith.LocksmithConfigurationException; import java.time.Duration; -import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.boot.context.properties.bind.DefaultValue; /** - * Configuration properties for the Locksmith distributed locking and semaphore mechanism. + * Configuration properties under the {@code locksmith} prefix. Null values are replaced by the + * defaults, so every accessor returns a value once the record is constructed. The defaults are also + * declared with {@link DefaultValue}, so the generated configuration metadata shows them. * - *

Configure these properties in your {@code application.properties} or {@code application.yml}: - * - *

{@code
- * # Lock configuration
- * locksmith.lock.enabled=true
- * locksmith.lock.lease-time=10m
- * locksmith.lock.wait-time=60s
- * locksmith.lock.key-prefix=lock:
- * locksmith.lock.debug=false
- *
- * # Semaphore configuration
- * locksmith.semaphore.enabled=true
- * locksmith.semaphore.lease-time=5m
- * locksmith.semaphore.wait-time=60s
- * locksmith.semaphore.key-prefix=semaphore:
- * locksmith.semaphore.debug=false
- *
- * # Rate limit configuration
- * locksmith.rate-limit.enabled=true
- * locksmith.rate-limit.wait-time=60s
- * locksmith.rate-limit.key-prefix=ratelimit:
- * locksmith.rate-limit.debug=false
- * }
- * - * @param lock Configuration properties for distributed locks. - * @param semaphore Configuration properties for distributed semaphores. - * @param rateLimit Configuration properties for distributed rate limiters. - * @author Garvit Joshi - * @since 2.0.0 + * @param enabled whether Locksmith registers anything; defaults to true + * @param keyPrefix prefix of every Redis key; null or blank falls back to "locksmith:" + * @param semaphore semaphore settings */ @ConfigurationProperties(prefix = "locksmith") public record LocksmithProperties( - @NestedConfigurationProperty @NonNull LockProperties lock, - @NestedConfigurationProperty @NonNull SemaphoreProperties semaphore, - @NestedConfigurationProperty @NonNull RateLimitProperties rateLimit) { + @DefaultValue("true") @Nullable Boolean enabled, + @DefaultValue(DEFAULT_KEY_PREFIX) @Nullable String keyPrefix, + @Nullable Semaphore semaphore) { + + private static final String DEFAULT_KEY_PREFIX = "locksmith:"; /** - * Compact constructor that applies default values for null inputs. + * Fills defaults for null or blank values. * - * @param lock the lock properties, or null to use defaults - * @param semaphore the semaphore properties, or null to use defaults - * @param rateLimit the rate limit properties, or null to use defaults + * @param enabled whether Locksmith registers anything; null means true + * @param keyPrefix prefix of every Redis key; null or blank means "locksmith:" + * @param semaphore semaphore settings; null means the defaults */ public LocksmithProperties { - if (lock == null) { - lock = LockProperties.defaults(); + if (enabled == null) { + enabled = Boolean.TRUE; } - if (semaphore == null) { - semaphore = SemaphoreProperties.defaults(); + if (keyPrefix == null || keyPrefix.isBlank()) { + keyPrefix = DEFAULT_KEY_PREFIX; } - if (rateLimit == null) { - rateLimit = RateLimitProperties.defaults(); + if (semaphore == null) { + semaphore = new Semaphore(null); } } /** - * Creates a new instance with all default values. - * - * @return a new LocksmithProperties with default configuration - */ - @NonNull - public static LocksmithProperties defaults() { - return new LocksmithProperties( - LockProperties.defaults(), SemaphoreProperties.defaults(), RateLimitProperties.defaults()); - } - - @Override - @NonNull - public String toString() { - return "LocksmithProperties[lock=" - + lock - + ", semaphore=" - + semaphore - + ", rateLimit=" - + rateLimit - + "]"; - } - - /** - * Configuration properties for distributed locks. + * Semaphore settings under {@code locksmith.semaphore}. * - * @param enabled When set to false, disables the distributed lock aspect and template. Methods - * annotated with @DistributedLock will execute without acquiring locks. Default: true. - * @param leaseTime The default time after which the lock is automatically released. This prevents - * deadlocks if a server crashes while holding a lock. Default: 10 minutes. - * @param waitTime The default time to wait for acquiring a lock when using WAIT_AND_SKIP mode. - * Default: 60 seconds. - * @param keyPrefix The prefix to use for all lock keys in Redis. Default: "lock:". - * @param debug When enabled, logs detailed information about lock operations including key - * resolution, lock type, timing, and acquisition status. Default: false. - * @param metricsEnabled When enabled, records Micrometer metrics for lock operations. Requires - * micrometer-core on classpath and a MeterRegistry bean. Default: false. + * @param leaseTime default lease of a permit; defaults to five minutes and must be at least one + * millisecond */ - public record LockProperties( - @NonNull Boolean enabled, - @NonNull Duration leaseTime, - @NonNull Duration waitTime, - @NonNull String keyPrefix, - @NonNull Boolean debug, - @NonNull Boolean metricsEnabled) { - - /** Default enabled state for locks. */ - public static final Boolean DEFAULT_ENABLED = Boolean.TRUE; - - /** Default lease time for locks. */ - public static final Duration DEFAULT_LEASE_TIME = Duration.ofMinutes(10); - - /** Default wait time for locks. */ - public static final Duration DEFAULT_WAIT_TIME = Duration.ofSeconds(60); + public record Semaphore(@DefaultValue("5m") @Nullable Duration leaseTime) { - /** Default key prefix for locks. */ - public static final String DEFAULT_KEY_PREFIX = "lock:"; - - /** Default debug mode. */ - public static final Boolean DEFAULT_DEBUG = Boolean.FALSE; - - /** Default metrics enabled. */ - public static final Boolean DEFAULT_METRICS_ENABLED = Boolean.FALSE; + private static final Duration DEFAULT_LEASE_TIME = Duration.ofMinutes(5); /** - * Compact constructor that applies default values for null or invalid inputs. + * Fills the default lease time and checks that it is at least one millisecond. * - * @param enabled the enabled flag, or null to use default - * @param leaseTime the lease time, or null to use default - * @param waitTime the wait time, or null to use default - * @param keyPrefix the key prefix, or null to use default - * @param debug the debug mode, or null to use default - * @param metricsEnabled the metrics enabled flag, or null to use default + * @param leaseTime default lease of a permit; null means five minutes + * @throws LocksmithConfigurationException if the lease time is shorter than one millisecond, + * including zero and negative values */ - public LockProperties { - if (enabled == null) { - enabled = DEFAULT_ENABLED; - } - if (leaseTime == null || leaseTime.isNegative() || leaseTime.isZero()) { + public Semaphore { + if (leaseTime == null) { leaseTime = DEFAULT_LEASE_TIME; } - if (waitTime == null || waitTime.isNegative()) { - waitTime = DEFAULT_WAIT_TIME; - } - if (keyPrefix == null || keyPrefix.isBlank()) { - keyPrefix = DEFAULT_KEY_PREFIX; - } - if (debug == null) { - debug = DEFAULT_DEBUG; + if (leaseTime.toMillis() <= 0) { + throw new LocksmithConfigurationException( + "locksmith.semaphore.lease-time must be at least one millisecond, got " + leaseTime); } - if (metricsEnabled == null) { - metricsEnabled = DEFAULT_METRICS_ENABLED; - } - } - - /** - * Creates a new instance with all default values. - * - * @return a new LockProperties with default configuration - */ - @NonNull - public static LockProperties defaults() { - return new LockProperties( - DEFAULT_ENABLED, - DEFAULT_LEASE_TIME, - DEFAULT_WAIT_TIME, - DEFAULT_KEY_PREFIX, - DEFAULT_DEBUG, - DEFAULT_METRICS_ENABLED); - } - - @Override - @NonNull - public String toString() { - return "LockProperties[enabled=" - + enabled - + ", leaseTime=" - + leaseTime - + ", waitTime=" - + waitTime - + ", keyPrefix='" - + keyPrefix - + "', debug=" - + debug - + ", metricsEnabled=" - + metricsEnabled - + "]"; - } - } - - /** - * Configuration properties for distributed semaphores. - * - * @param enabled When set to false, disables the distributed semaphore aspect and template. - * Methods annotated with @DistributedSemaphore will execute without acquiring permits. - * Default: true. - * @param leaseTime The default time after which the semaphore permit is automatically released. - * This prevents permit leaks if a server crashes while holding a permit. Default: 5 minutes. - * @param waitTime The default time to wait for acquiring a permit when using WAIT_AND_SKIP mode. - * Default: 60 seconds. - * @param keyPrefix The prefix to use for all semaphore keys in Redis. Default: "semaphore:". - * @param debug When enabled, logs detailed information about semaphore operations including key - * resolution, permit acquisition, timing, and status. Default: false. - * @param metricsEnabled When enabled, records Micrometer metrics for semaphore operations. - * Requires micrometer-core on classpath and a MeterRegistry bean. Default: false. - */ - public record SemaphoreProperties( - @NonNull Boolean enabled, - @NonNull Duration leaseTime, - @NonNull Duration waitTime, - @NonNull String keyPrefix, - @NonNull Boolean debug, - @NonNull Boolean metricsEnabled) { - - /** Default enabled state for semaphores. */ - public static final Boolean DEFAULT_ENABLED = Boolean.TRUE; - - /** Default lease time for semaphores. */ - public static final Duration DEFAULT_LEASE_TIME = Duration.ofMinutes(5); - - /** Default wait time for semaphores. */ - public static final Duration DEFAULT_WAIT_TIME = Duration.ofSeconds(60); - - /** Default key prefix for semaphores. */ - public static final String DEFAULT_KEY_PREFIX = "semaphore:"; - - /** Default debug mode. */ - public static final Boolean DEFAULT_DEBUG = Boolean.FALSE; - - /** Default metrics enabled. */ - public static final Boolean DEFAULT_METRICS_ENABLED = Boolean.FALSE; - - /** - * Compact constructor that applies default values for null or invalid inputs. - * - * @param enabled the enabled flag, or null to use default - * @param leaseTime the lease time, or null to use default - * @param waitTime the wait time, or null to use default - * @param keyPrefix the key prefix, or null to use default - * @param debug the debug mode, or null to use default - * @param metricsEnabled the metrics enabled flag, or null to use default - */ - public SemaphoreProperties { - if (enabled == null) { - enabled = DEFAULT_ENABLED; - } - if (leaseTime == null || leaseTime.isNegative() || leaseTime.isZero()) { - leaseTime = DEFAULT_LEASE_TIME; - } - if (waitTime == null || waitTime.isNegative()) { - waitTime = DEFAULT_WAIT_TIME; - } - if (keyPrefix == null || keyPrefix.isBlank()) { - keyPrefix = DEFAULT_KEY_PREFIX; - } - if (debug == null) { - debug = DEFAULT_DEBUG; - } - if (metricsEnabled == null) { - metricsEnabled = DEFAULT_METRICS_ENABLED; - } - } - - /** - * Creates a new instance with all default values. - * - * @return a new SemaphoreProperties with default configuration - */ - @NonNull - public static SemaphoreProperties defaults() { - return new SemaphoreProperties( - DEFAULT_ENABLED, - DEFAULT_LEASE_TIME, - DEFAULT_WAIT_TIME, - DEFAULT_KEY_PREFIX, - DEFAULT_DEBUG, - DEFAULT_METRICS_ENABLED); - } - - @Override - @NonNull - public String toString() { - return "SemaphoreProperties[enabled=" - + enabled - + ", leaseTime=" - + leaseTime - + ", waitTime=" - + waitTime - + ", keyPrefix='" - + keyPrefix - + "', debug=" - + debug - + ", metricsEnabled=" - + metricsEnabled - + "]"; - } - } - - /** - * Configuration properties for distributed rate limiters. - * - * @param enabled When set to false, disables the rate limit aspect and template. Methods - * annotated with @RateLimit will execute without rate limiting. Default: true. - * @param waitTime The default time to wait for acquiring a permit when using WAIT_AND_SKIP mode. - * Default: 60 seconds. - * @param keyPrefix The prefix to use for all rate limiter keys in Redis. Default: "ratelimit:". - * @param debug When enabled, logs detailed information about rate limit operations including key - * resolution, permit acquisition, timing, and status. Default: false. - * @param metricsEnabled When enabled, records Micrometer metrics for rate limit operations. - * Requires micrometer-core on classpath and a MeterRegistry bean. Default: false. - * @since 3.0.0 - */ - public record RateLimitProperties( - @NonNull Boolean enabled, - @NonNull Duration waitTime, - @NonNull String keyPrefix, - @NonNull Boolean debug, - @NonNull Boolean metricsEnabled) { - - /** Default enabled state for rate limiters. */ - public static final Boolean DEFAULT_ENABLED = Boolean.TRUE; - - /** Default wait time for rate limiters. */ - public static final Duration DEFAULT_WAIT_TIME = Duration.ofSeconds(60); - - /** Default key prefix for rate limiters. */ - public static final String DEFAULT_KEY_PREFIX = "ratelimit:"; - - /** Default debug mode. */ - public static final Boolean DEFAULT_DEBUG = Boolean.FALSE; - - /** Default metrics enabled. */ - public static final Boolean DEFAULT_METRICS_ENABLED = Boolean.FALSE; - - /** - * Compact constructor that applies default values for null or invalid inputs. - * - * @param enabled the enabled flag, or null to use default - * @param waitTime the wait time, or null to use default - * @param keyPrefix the key prefix, or null to use default - * @param debug the debug mode, or null to use default - * @param metricsEnabled the metrics enabled flag, or null to use default - */ - public RateLimitProperties { - if (enabled == null) { - enabled = DEFAULT_ENABLED; - } - if (waitTime == null || waitTime.isNegative()) { - waitTime = DEFAULT_WAIT_TIME; - } - if (keyPrefix == null || keyPrefix.isBlank()) { - keyPrefix = DEFAULT_KEY_PREFIX; - } - if (debug == null) { - debug = DEFAULT_DEBUG; - } - if (metricsEnabled == null) { - metricsEnabled = DEFAULT_METRICS_ENABLED; - } - } - - /** - * Creates a new instance with all default values. - * - * @return a new RateLimitProperties with default configuration - */ - @NonNull - public static RateLimitProperties defaults() { - return new RateLimitProperties( - DEFAULT_ENABLED, - DEFAULT_WAIT_TIME, - DEFAULT_KEY_PREFIX, - DEFAULT_DEBUG, - DEFAULT_METRICS_ENABLED); - } - - @Override - @NonNull - public String toString() { - return "RateLimitProperties[enabled=" - + enabled - + ", waitTime=" - + waitTime - + ", keyPrefix='" - + keyPrefix - + "', debug=" - + debug - + ", metricsEnabled=" - + metricsEnabled - + "]"; } } } diff --git a/src/main/java/in/riido/locksmith/autoconfigure/package-info.java b/src/main/java/in/riido/locksmith/autoconfigure/package-info.java deleted file mode 100644 index 1a88a86..0000000 --- a/src/main/java/in/riido/locksmith/autoconfigure/package-info.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Spring Boot auto-configuration for Locksmith. - * - *

This package contains: - * - *

    - *
  • {@link in.riido.locksmith.autoconfigure.LocksmithAutoConfiguration} - Auto-configures the - * distributed lock and semaphore aspects - *
  • {@link in.riido.locksmith.autoconfigure.LocksmithProperties} - Configuration properties - * with prefix {@code locksmith.*} - *
- * - *

Configuration Properties

- * - *
{@code
- * locksmith:
- *   lock:
- *     lease-time: 10m
- *     wait-time: 60s
- *     key-prefix: "lock:"
- *     debug: false
- *   semaphore:
- *     lease-time: 5m
- *     wait-time: 60s
- *     key-prefix: "semaphore:"
- *     debug: false
- * }
- * - * @author Garvit Joshi - * @since 1.0.0 - * @see in.riido.locksmith.autoconfigure.LocksmithAutoConfiguration - * @see in.riido.locksmith.autoconfigure.LocksmithProperties - */ -package in.riido.locksmith.autoconfigure; diff --git a/src/main/java/in/riido/locksmith/exception/LeaseExpiredException.java b/src/main/java/in/riido/locksmith/exception/LeaseExpiredException.java deleted file mode 100644 index 7d2c5c8..0000000 --- a/src/main/java/in/riido/locksmith/exception/LeaseExpiredException.java +++ /dev/null @@ -1,91 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a method's execution time exceeds the configured lease duration. - * - *

This exception is thrown after the method completes when the configured behavior is {@link - * in.riido.locksmith.LeaseExpirationBehavior#THROW_EXCEPTION}. It indicates that the lock may have - * expired during execution, potentially allowing concurrent access by other instances. - * - * @author Garvit Joshi - * @since 1.2.0 - */ -public class LeaseExpiredException extends RuntimeException { - - @Serial private static final long serialVersionUID = 6423605121456789012L; - - /** The Redis key of the lock that expired. */ - private final String lockKey; - - /** The name of the method that exceeded lease time. */ - private final String methodName; - - /** The configured lease time in milliseconds. */ - private final long leaseTimeMs; - - /** The actual execution time in milliseconds. */ - private final long executionTimeMs; - - /** - * Constructs a new LeaseExpiredException. - * - * @param lockKey the lock key that expired - * @param methodName the method that exceeded lease time - * @param leaseTimeMs the configured lease time in milliseconds - * @param executionTimeMs the actual execution time in milliseconds - */ - public LeaseExpiredException( - @NonNull String lockKey, @NonNull String methodName, long leaseTimeMs, long executionTimeMs) { - super( - String.format( - "Lock [%s] lease expired during execution of [%s]. " - + "Lease time: %dms, Execution time: %dms. " - + "The lock may have been acquired by another instance during execution.", - lockKey, methodName, leaseTimeMs, executionTimeMs)); - this.lockKey = lockKey; - this.methodName = methodName; - this.leaseTimeMs = leaseTimeMs; - this.executionTimeMs = executionTimeMs; - } - - /** - * Returns the lock key that expired. - * - * @return the lock key - */ - @NonNull - public String getLockKey() { - return lockKey; - } - - /** - * Returns the method name that exceeded lease time. - * - * @return the method name - */ - @NonNull - public String getMethodName() { - return methodName; - } - - /** - * Returns the configured lease time in milliseconds. - * - * @return the lease time in milliseconds - */ - public long getLeaseTimeMs() { - return leaseTimeMs; - } - - /** - * Returns the actual execution time in milliseconds. - * - * @return the execution time in milliseconds - */ - public long getExecutionTimeMs() { - return executionTimeMs; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/LockNotAcquiredException.java b/src/main/java/in/riido/locksmith/exception/LockNotAcquiredException.java deleted file mode 100644 index 51552e2..0000000 --- a/src/main/java/in/riido/locksmith/exception/LockNotAcquiredException.java +++ /dev/null @@ -1,66 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a distributed lock cannot be acquired within the configured time. - * - *

This exception indicates that another server instance is currently holding the lock for the - * requested resource. Depending on the use case, the caller may choose to: - * - *

    - *
  • Retry the operation after a delay - *
  • Skip the operation entirely - *
  • Log and continue with alternative logic - *
- * - * @author Garvit Joshi - * @since 1.0.0 - */ -public class LockNotAcquiredException extends RuntimeException { - - @Serial private static final long serialVersionUID = -6494677132062171394L; - - /** The Redis key of the lock that could not be acquired. */ - private final String lockKey; - - /** The name of the method that required the lock. */ - private final String methodName; - - /** - * Constructs a new LockNotAcquiredException. - * - * @param lockKey the Redis key of the lock that could not be acquired - * @param methodName the name of the method that required the lock - */ - public LockNotAcquiredException(@NonNull String lockKey, @NonNull String methodName) { - super( - String.format( - "Failed to acquire distributed lock [%s] for method [%s]. " - + "Another instance is currently executing this task.", - lockKey, methodName)); - this.lockKey = lockKey; - this.methodName = methodName; - } - - /** - * Returns the Redis key of the lock that could not be acquired. - * - * @return the lock key - */ - @NonNull - public String getLockKey() { - return lockKey; - } - - /** - * Returns the name of the method that required the lock. - * - * @return the method name - */ - @NonNull - public String getMethodName() { - return methodName; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/RateLimitConfigurationException.java b/src/main/java/in/riido/locksmith/exception/RateLimitConfigurationException.java deleted file mode 100644 index d8f5ebf..0000000 --- a/src/main/java/in/riido/locksmith/exception/RateLimitConfigurationException.java +++ /dev/null @@ -1,60 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a rate limiter configuration is invalid or inconsistent. - * - *

This exception is thrown in the following scenarios: - * - *

    - *
  • The permits value is not a positive integer - *
  • The interval is zero or negative - *
  • Other configuration validation failures - *
- * - * @author Garvit Joshi - * @since 3.0.0 - */ -public class RateLimitConfigurationException extends RuntimeException { - - @Serial private static final long serialVersionUID = -8372649105423781640L; - - /** The rate limiter key with configuration issues. */ - private final String rateLimitKey; - - /** - * Constructs a new RateLimitConfigurationException. - * - * @param message the detail message - * @param rateLimitKey the rate limiter key with configuration issues - */ - public RateLimitConfigurationException(@NonNull String message, @NonNull String rateLimitKey) { - super(message); - this.rateLimitKey = rateLimitKey; - } - - /** - * Constructs a new RateLimitConfigurationException with a cause. - * - * @param message the detail message - * @param rateLimitKey the rate limiter key with configuration issues - * @param cause the cause of this exception - */ - public RateLimitConfigurationException( - @NonNull String message, @NonNull String rateLimitKey, @NonNull Throwable cause) { - super(message, cause); - this.rateLimitKey = rateLimitKey; - } - - /** - * Returns the rate limiter key with configuration issues. - * - * @return the rate limiter key - */ - @NonNull - public String getRateLimitKey() { - return rateLimitKey; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/RateLimitExceededException.java b/src/main/java/in/riido/locksmith/exception/RateLimitExceededException.java deleted file mode 100644 index 7497748..0000000 --- a/src/main/java/in/riido/locksmith/exception/RateLimitExceededException.java +++ /dev/null @@ -1,66 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a rate limit is exceeded and no permit can be acquired. - * - *

This exception indicates that the rate limit for the specified key has been reached for the - * current interval. Depending on the use case, the caller may choose to: - * - *

    - *
  • Retry the operation after a delay - *
  • Skip the operation entirely - *
  • Log and continue with alternative logic - *
  • Return a cached or default response - *
- * - * @author Garvit Joshi - * @since 3.0.0 - */ -public class RateLimitExceededException extends RuntimeException { - - @Serial private static final long serialVersionUID = -91156789012345680L; - - /** The Redis key of the rate limiter that exceeded its limit. */ - private final String rateLimitKey; - - /** The name of the method that was rate limited. */ - private final String methodName; - - /** - * Constructs a new RateLimitExceededException. - * - * @param rateLimitKey the Redis key of the rate limiter that exceeded its limit - * @param methodName the name of the method that was rate limited - */ - public RateLimitExceededException(@NonNull String rateLimitKey, @NonNull String methodName) { - super( - String.format( - "Rate limit exceeded for [%s] in method [%s]. " + "Try again later.", - rateLimitKey, methodName)); - this.rateLimitKey = rateLimitKey; - this.methodName = methodName; - } - - /** - * Returns the Redis key of the rate limiter that exceeded its limit. - * - * @return the rate limiter key - */ - @NonNull - public String getRateLimitKey() { - return rateLimitKey; - } - - /** - * Returns the name of the method that was rate limited. - * - * @return the method name - */ - @NonNull - public String getMethodName() { - return methodName; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/SemaphoreConfigurationException.java b/src/main/java/in/riido/locksmith/exception/SemaphoreConfigurationException.java deleted file mode 100644 index a9d44aa..0000000 --- a/src/main/java/in/riido/locksmith/exception/SemaphoreConfigurationException.java +++ /dev/null @@ -1,60 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a semaphore configuration is invalid or inconsistent. - * - *

This exception is thrown in the following scenarios: - * - *

    - *
  • The same semaphore key is used with different permits values in the same codebase - *
  • The permits value is not a positive integer - *
  • Other configuration validation failures - *
- * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreConfigurationException extends RuntimeException { - - @Serial private static final long serialVersionUID = -1204260811123453780L; - - /** The semaphore key with configuration issues. */ - private final String semaphoreKey; - - /** - * Constructs a new SemaphoreConfigurationException. - * - * @param message the detail message - * @param semaphoreKey the semaphore key with configuration issues - */ - public SemaphoreConfigurationException(@NonNull String message, @NonNull String semaphoreKey) { - super(message); - this.semaphoreKey = semaphoreKey; - } - - /** - * Constructs a new SemaphoreConfigurationException with a cause. - * - * @param message the detail message - * @param semaphoreKey the semaphore key with configuration issues - * @param cause the cause of this exception - */ - public SemaphoreConfigurationException( - @NonNull String message, @NonNull String semaphoreKey, @NonNull Throwable cause) { - super(message, cause); - this.semaphoreKey = semaphoreKey; - } - - /** - * Returns the semaphore key with configuration issues. - * - * @return the semaphore key - */ - @NonNull - public String getSemaphoreKey() { - return semaphoreKey; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/SemaphoreLeaseExpiredException.java b/src/main/java/in/riido/locksmith/exception/SemaphoreLeaseExpiredException.java deleted file mode 100644 index d2c1199..0000000 --- a/src/main/java/in/riido/locksmith/exception/SemaphoreLeaseExpiredException.java +++ /dev/null @@ -1,95 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a method's execution time exceeds the configured semaphore permit lease - * duration. - * - *

This exception is thrown after the method completes when the configured behavior is {@link - * in.riido.locksmith.LeaseExpirationBehavior#THROW_EXCEPTION}. It indicates that the permit may - * have expired during execution, potentially allowing more concurrent executions than intended. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreLeaseExpiredException extends RuntimeException { - - @Serial private static final long serialVersionUID = 1531109849210987651L; - - /** The Redis key of the semaphore whose permit expired. */ - private final String semaphoreKey; - - /** The name of the method that exceeded lease time. */ - private final String methodName; - - /** The configured lease time in milliseconds. */ - private final long leaseTimeMs; - - /** The actual execution time in milliseconds. */ - private final long executionTimeMs; - - /** - * Constructs a new SemaphoreLeaseExpiredException. - * - * @param semaphoreKey the semaphore key whose permit expired - * @param methodName the method that exceeded lease time - * @param leaseTimeMs the configured lease time in milliseconds - * @param executionTimeMs the actual execution time in milliseconds - */ - public SemaphoreLeaseExpiredException( - @NonNull String semaphoreKey, - @NonNull String methodName, - long leaseTimeMs, - long executionTimeMs) { - super( - String.format( - "Semaphore [%s] permit lease expired during execution of [%s]. " - + "Lease time: %dms, Execution time: %dms. " - + "The permit may have been acquired by another instance during execution.", - semaphoreKey, methodName, leaseTimeMs, executionTimeMs)); - this.semaphoreKey = semaphoreKey; - this.methodName = methodName; - this.leaseTimeMs = leaseTimeMs; - this.executionTimeMs = executionTimeMs; - } - - /** - * Returns the semaphore key whose permit expired. - * - * @return the semaphore key - */ - @NonNull - public String getSemaphoreKey() { - return semaphoreKey; - } - - /** - * Returns the method name that exceeded lease time. - * - * @return the method name - */ - @NonNull - public String getMethodName() { - return methodName; - } - - /** - * Returns the configured lease time in milliseconds. - * - * @return the lease time in milliseconds - */ - public long getLeaseTimeMs() { - return leaseTimeMs; - } - - /** - * Returns the actual execution time in milliseconds. - * - * @return the execution time in milliseconds - */ - public long getExecutionTimeMs() { - return executionTimeMs; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/SemaphoreNotAcquiredException.java b/src/main/java/in/riido/locksmith/exception/SemaphoreNotAcquiredException.java deleted file mode 100644 index 5ed163a..0000000 --- a/src/main/java/in/riido/locksmith/exception/SemaphoreNotAcquiredException.java +++ /dev/null @@ -1,67 +0,0 @@ -package in.riido.locksmith.exception; - -import java.io.Serial; -import org.jspecify.annotations.NonNull; - -/** - * Exception thrown when a distributed semaphore permit cannot be acquired within the configured - * time. - * - *

This exception indicates that all permits for the semaphore are currently held by other server - * instances. Depending on the use case, the caller may choose to: - * - *

    - *
  • Retry the operation after a delay - *
  • Skip the operation entirely - *
  • Log and continue with alternative logic - *
- * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreNotAcquiredException extends RuntimeException { - - @Serial private static final long serialVersionUID = -91156789012345670L; - - /** The Redis key of the semaphore that could not acquire a permit. */ - private final String semaphoreKey; - - /** The name of the method that required the permit. */ - private final String methodName; - - /** - * Constructs a new SemaphoreNotAcquiredException. - * - * @param semaphoreKey the Redis key of the semaphore that could not acquire a permit - * @param methodName the name of the method that required the permit - */ - public SemaphoreNotAcquiredException(@NonNull String semaphoreKey, @NonNull String methodName) { - super( - String.format( - "Failed to acquire permit from semaphore [%s] for method [%s]. " - + "All permits are currently held by other instances.", - semaphoreKey, methodName)); - this.semaphoreKey = semaphoreKey; - this.methodName = methodName; - } - - /** - * Returns the Redis key of the semaphore that could not acquire a permit. - * - * @return the semaphore key - */ - @NonNull - public String getSemaphoreKey() { - return semaphoreKey; - } - - /** - * Returns the name of the method that required the permit. - * - * @return the method name - */ - @NonNull - public String getMethodName() { - return methodName; - } -} diff --git a/src/main/java/in/riido/locksmith/exception/package-info.java b/src/main/java/in/riido/locksmith/exception/package-info.java deleted file mode 100644 index 001bd90..0000000 --- a/src/main/java/in/riido/locksmith/exception/package-info.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Exception classes for Locksmith distributed locking and semaphores. - * - *

This package contains exceptions that may be thrown during lock and semaphore operations: - * - *

Lock Exceptions: - * - *

    - *
  • {@link in.riido.locksmith.exception.LockNotAcquiredException} - Thrown when a lock cannot - * be acquired and {@link in.riido.locksmith.handler.lock.LockThrowExceptionHandler} is used - *
  • {@link in.riido.locksmith.exception.LeaseExpiredException} - Thrown when method execution - * exceeds the configured lease time - *
- * - *

Semaphore Exceptions: - * - *

    - *
  • {@link in.riido.locksmith.exception.SemaphoreNotAcquiredException} - Thrown when a permit - * cannot be acquired and {@link - * in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler} is used - *
  • {@link in.riido.locksmith.exception.SemaphoreLeaseExpiredException} - Thrown when method - * execution exceeds the configured permit lease time - *
  • {@link in.riido.locksmith.exception.SemaphoreConfigurationException} - Thrown when - * semaphore configuration is invalid (e.g., same key with different permits) - *
- * - * @author Garvit Joshi - * @since 1.0.0 - * @see in.riido.locksmith.exception.LockNotAcquiredException - * @see in.riido.locksmith.exception.SemaphoreNotAcquiredException - */ -package in.riido.locksmith.exception; diff --git a/src/main/java/in/riido/locksmith/handler/DefaultValueResolver.java b/src/main/java/in/riido/locksmith/handler/DefaultValueResolver.java deleted file mode 100644 index aa9b428..0000000 --- a/src/main/java/in/riido/locksmith/handler/DefaultValueResolver.java +++ /dev/null @@ -1,73 +0,0 @@ -package in.riido.locksmith.handler; - -import java.util.Optional; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Utility class for resolving default values based on return types. - * - *

This class provides a shared implementation for returning appropriate default values when a - * method execution is skipped due to failed lock or semaphore acquisition. - * - *

Default Values: - * - *

    - *
  • {@code null} for object types and {@code void}/{@code Void} - *
  • {@code false} for {@code boolean}/{@code Boolean} - *
  • {@code 0} for numeric primitives and their wrapper types ({@code int}/{@code Integer}, - * {@code long}/{@code Long}, {@code double}/{@code Double}, etc.) - *
  • {@code '\u0000'} for {@code char}/{@code Character} - *
  • {@code Optional.empty()} for {@code Optional} - *
- * - * @author Garvit Joshi - * @since 2.0.0 - */ -public final class DefaultValueResolver { - - private DefaultValueResolver() { - // Utility class - prevent instantiation - } - - /** - * Resolves the default value for a given return type. - * - * @param returnType the class representing the method's return type - * @return the default value appropriate for the return type - */ - @Nullable - public static Object resolve(@NonNull Class returnType) { - if (returnType == void.class || returnType == Void.class) { - return null; - } - if (returnType == boolean.class || returnType == Boolean.class) { - return false; - } - if (returnType == int.class || returnType == Integer.class) { - return 0; - } - if (returnType == long.class || returnType == Long.class) { - return 0L; - } - if (returnType == double.class || returnType == Double.class) { - return 0.0d; - } - if (returnType == float.class || returnType == Float.class) { - return 0.0f; - } - if (returnType == byte.class || returnType == Byte.class) { - return (byte) 0; - } - if (returnType == short.class || returnType == Short.class) { - return (short) 0; - } - if (returnType == char.class || returnType == Character.class) { - return '\u0000'; - } - if (returnType == Optional.class) { - return Optional.empty(); - } - return null; - } -} diff --git a/src/main/java/in/riido/locksmith/handler/LockSkipHandler.java b/src/main/java/in/riido/locksmith/handler/LockSkipHandler.java deleted file mode 100644 index ea4c7bd..0000000 --- a/src/main/java/in/riido/locksmith/handler/LockSkipHandler.java +++ /dev/null @@ -1,91 +0,0 @@ -package in.riido.locksmith.handler; - -import in.riido.locksmith.models.LockContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Interface for handling lock acquisition failures with custom logic. - * - *

Implementations of this interface are invoked when a distributed lock cannot be acquired and - * the method execution is skipped. This enables custom behavior such as: - * - *

    - *
  • Logging to specific systems - *
  • Sending alerts or notifications - *
  • Returning specialized fallback values - *
  • Executing alternative processing logic - *
- * - *

Handler Resolution: Handlers are resolved in the following order: - * - *

    - *
  1. Look up as a Spring bean from ApplicationContext by type - *
  2. Fall back to reflection-based instantiation (requires public no-arg constructor) - *
- * - *

Thread-Safety Requirement: Implementations must be stateless and thread-safe. Handler - * instances are cached and reused across all lock acquisition failures. The same handler instance - * may be invoked concurrently by multiple threads. Do not use instance variables to store state - * between invocations. - * - *

Example Spring bean implementation with dependency injection: - * - *

{@code
- * @Component
- * public class AlertingSkipHandler implements LockSkipHandler {
- *     private final AlertService alertService;
- *
- *     public AlertingSkipHandler(AlertService alertService) {
- *         this.alertService = alertService;
- *     }
- *
- *     @Override
- *     public Object handle(LockContext context) {
- *         alertService.sendAlert("Lock acquisition failed for: " + context.lockKey());
- *         return null; // or return a fallback value
- *     }
- * }
- * }
- * - *

Example simple implementation (no Spring dependencies): - * - *

{@code
- * public class LoggingSkipHandler implements LockSkipHandler {
- *
- *     @Override
- *     public Object handle(LockContext context) {
- *         System.out.println("Lock acquisition failed for: " + context.lockKey());
- *         return null;
- *     }
- * }
- * }
- * - *

Usage: - * - *

{@code
- * @DistributedLock(key = "my-task", skipHandler = AlertingSkipHandler.class)
- * public void myTask() { }
- * }
- * - * @author Garvit Joshi - * @see LockContext - * @since 1.2.0 - */ -public interface LockSkipHandler { - - /** - * Handles the case when a lock cannot be acquired. - * - *

This method is called when lock acquisition fails and the method execution is skipped. The - * returned value will be used as the method's return value. - * - *

Important: This method must be thread-safe as it may be called concurrently by - * multiple threads on the same handler instance. - * - * @param context the lock context containing information about the failed acquisition - * @return the value to return from the method, must be compatible with the method's return type - * @throws RuntimeException implementations may throw exceptions to indicate failure - */ - @Nullable Object handle(@NonNull LockContext context); -} diff --git a/src/main/java/in/riido/locksmith/handler/RateLimitSkipHandler.java b/src/main/java/in/riido/locksmith/handler/RateLimitSkipHandler.java deleted file mode 100644 index 2ffeca8..0000000 --- a/src/main/java/in/riido/locksmith/handler/RateLimitSkipHandler.java +++ /dev/null @@ -1,92 +0,0 @@ -package in.riido.locksmith.handler; - -import in.riido.locksmith.models.RateLimitContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Interface for handling rate limit exceeded scenarios with custom logic. - * - *

Implementations of this interface are invoked when a rate limit is exceeded and the method - * execution is skipped. This enables custom behavior such as: - * - *

    - *
  • Logging to specific systems - *
  • Sending alerts or notifications - *
  • Returning specialized fallback values - *
  • Executing alternative processing logic - *
- * - *

Handler Resolution: Handlers are resolved in the following order: - * - *

    - *
  1. Look up as a Spring bean from ApplicationContext by type - *
  2. Fall back to reflection-based instantiation (requires public no-arg constructor) - *
- * - *

Thread-Safety Requirement: Implementations must be stateless and thread-safe. Handler - * instances are cached and reused across all rate limit breaches. The same handler instance may be - * invoked concurrently by multiple threads. Do not use instance variables to store state between - * invocations. - * - *

Example Spring bean implementation with dependency injection: - * - *

{@code
- * @Component
- * public class AlertingRateLimitHandler implements RateLimitSkipHandler {
- *     private final AlertService alertService;
- *
- *     public AlertingRateLimitHandler(AlertService alertService) {
- *         this.alertService = alertService;
- *     }
- *
- *     @Override
- *     public Object handle(RateLimitContext context) {
- *         alertService.sendAlert("Rate limit exceeded for: " + context.rateLimitKey());
- *         return null; // or return a fallback value
- *     }
- * }
- * }
- * - *

Example simple implementation (no Spring dependencies): - * - *

{@code
- * public class LoggingRateLimitHandler implements RateLimitSkipHandler {
- *
- *     @Override
- *     public Object handle(RateLimitContext context) {
- *         System.out.println("Rate limit exceeded for: " + context.rateLimitKey());
- *         return null;
- *     }
- * }
- * }
- * - *

Usage: - * - *

{@code
- * @RateLimit(key = "api-call", permits = 100, interval = "1m",
- *     skipHandler = AlertingRateLimitHandler.class)
- * public void myApiCall() { }
- * }
- * - * @author Garvit Joshi - * @see RateLimitContext - * @since 3.0.0 - */ -public interface RateLimitSkipHandler { - - /** - * Handles the case when a rate limit is exceeded. - * - *

This method is called when the rate limit is exceeded and the method execution is skipped. - * The returned value will be used as the method's return value. - * - *

Important: This method must be thread-safe as it may be called concurrently by - * multiple threads on the same handler instance. - * - * @param context the rate limit context containing information about the exceeded limit - * @return the value to return from the method, must be compatible with the method's return type - * @throws RuntimeException implementations may throw exceptions to indicate failure - */ - @Nullable Object handle(@NonNull RateLimitContext context); -} diff --git a/src/main/java/in/riido/locksmith/handler/SemaphoreSkipHandler.java b/src/main/java/in/riido/locksmith/handler/SemaphoreSkipHandler.java deleted file mode 100644 index b386dd0..0000000 --- a/src/main/java/in/riido/locksmith/handler/SemaphoreSkipHandler.java +++ /dev/null @@ -1,92 +0,0 @@ -package in.riido.locksmith.handler; - -import in.riido.locksmith.models.SemaphoreContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Interface for handling semaphore permit acquisition failures with custom logic. - * - *

Implementations of this interface are invoked when a distributed semaphore permit cannot be - * acquired and the method execution is skipped. This enables custom behavior such as: - * - *

    - *
  • Logging to specific systems - *
  • Sending alerts or notifications - *
  • Returning specialized fallback values - *
  • Executing alternative processing logic - *
- * - *

Handler Resolution: Handlers are resolved in the following order: - * - *

    - *
  1. Look up as a Spring bean from ApplicationContext by type - *
  2. Fall back to reflection-based instantiation (requires public no-arg constructor) - *
- * - *

Thread-Safety Requirement: Implementations must be stateless and thread-safe. Handler - * instances are cached and reused across all permit acquisition failures. The same handler instance - * may be invoked concurrently by multiple threads. Do not use instance variables to store state - * between invocations. - * - *

Example Spring bean implementation with dependency injection: - * - *

{@code
- * @Component
- * public class AlertingSemaphoreHandler implements SemaphoreSkipHandler {
- *     private final AlertService alertService;
- *
- *     public AlertingSemaphoreHandler(AlertService alertService) {
- *         this.alertService = alertService;
- *     }
- *
- *     @Override
- *     public Object handle(SemaphoreContext context) {
- *         alertService.sendAlert("Permit acquisition failed for: " + context.semaphoreKey());
- *         return null; // or return a fallback value
- *     }
- * }
- * }
- * - *

Example simple implementation (no Spring dependencies): - * - *

{@code
- * public class LoggingSemaphoreHandler implements SemaphoreSkipHandler {
- *
- *     @Override
- *     public Object handle(SemaphoreContext context) {
- *         System.out.println("Permit acquisition failed for: " + context.semaphoreKey());
- *         return null;
- *     }
- * }
- * }
- * - *

Usage: - * - *

{@code
- * @DistributedSemaphore(key = "my-pool", permits = 10, leaseTime = "5m",
- *     skipHandler = AlertingSemaphoreHandler.class)
- * public void myTask() { }
- * }
- * - * @author Garvit Joshi - * @see SemaphoreContext - * @since 2.0.0 - */ -public interface SemaphoreSkipHandler { - - /** - * Handles the case when a semaphore permit cannot be acquired. - * - *

This method is called when permit acquisition fails and the method execution is skipped. The - * returned value will be used as the method's return value. - * - *

Important: This method must be thread-safe as it may be called concurrently by - * multiple threads on the same handler instance. - * - * @param context the semaphore context containing information about the failed acquisition - * @return the value to return from the method, must be compatible with the method's return type - * @throws RuntimeException implementations may throw exceptions to indicate failure - */ - @Nullable Object handle(@NonNull SemaphoreContext context); -} diff --git a/src/main/java/in/riido/locksmith/handler/lock/LockReturnDefaultHandler.java b/src/main/java/in/riido/locksmith/handler/lock/LockReturnDefaultHandler.java deleted file mode 100644 index 5d92215..0000000 --- a/src/main/java/in/riido/locksmith/handler/lock/LockReturnDefaultHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package in.riido.locksmith.handler.lock; - -import in.riido.locksmith.handler.DefaultValueResolver; -import in.riido.locksmith.handler.LockSkipHandler; -import in.riido.locksmith.models.LockContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * A {@link LockSkipHandler} that returns default values when a lock cannot be acquired. - * - *

Returns appropriate default values based on the method's return type: - * - *

    - *
  • {@code null} for object types and {@code void}/{@code Void} - *
  • {@code false} for {@code boolean}/{@code Boolean} - *
  • {@code 0} for numeric primitives and their wrapper types ({@code int}/{@code Integer}, - * {@code long}/{@code Long}, {@code double}/{@code Double}, etc.) - *
  • {@code '\u0000'} for {@code char}/{@code Character} - *
  • {@code Optional.empty()} for {@code Optional} types - *
- * - * @author Garvit Joshi - * @see DefaultValueResolver - * @since 1.2.0 - */ -public class LockReturnDefaultHandler implements LockSkipHandler { - - /** Default constructor. */ - public LockReturnDefaultHandler() {} - - @Override - @Nullable - public Object handle(@NonNull LockContext context) { - return DefaultValueResolver.resolve(context.returnType()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/lock/LockThrowExceptionHandler.java b/src/main/java/in/riido/locksmith/handler/lock/LockThrowExceptionHandler.java deleted file mode 100644 index 4d39589..0000000 --- a/src/main/java/in/riido/locksmith/handler/lock/LockThrowExceptionHandler.java +++ /dev/null @@ -1,24 +0,0 @@ -package in.riido.locksmith.handler.lock; - -import in.riido.locksmith.exception.LockNotAcquiredException; -import in.riido.locksmith.handler.LockSkipHandler; -import in.riido.locksmith.models.LockContext; -import org.jspecify.annotations.NonNull; - -/** - * A {@link LockSkipHandler} that throws {@link LockNotAcquiredException} when a lock cannot be - * acquired. This is the default handler used by {@link in.riido.locksmith.DistributedLock}. - * - * @author Garvit Joshi - * @since 1.2.0 - */ -public class LockThrowExceptionHandler implements LockSkipHandler { - - /** Default constructor. */ - public LockThrowExceptionHandler() {} - - @Override - public Object handle(@NonNull LockContext context) { - throw new LockNotAcquiredException(context.lockKey(), context.methodName()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/lock/package-info.java b/src/main/java/in/riido/locksmith/handler/lock/package-info.java deleted file mode 100644 index 84ff533..0000000 --- a/src/main/java/in/riido/locksmith/handler/lock/package-info.java +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Built-in lock skip handler implementations. - * - *

This package contains the default implementations of {@link - * in.riido.locksmith.handler.LockSkipHandler} that are used when lock acquisition fails: - * - *

    - *
  • {@link in.riido.locksmith.handler.lock.LockThrowExceptionHandler} - Default handler that - * throws {@link in.riido.locksmith.exception.LockNotAcquiredException} - *
  • {@link in.riido.locksmith.handler.lock.LockReturnDefaultHandler} - Handler that returns - * null for objects, default values for primitives - *
- * - * @author Garvit Joshi - * @since 1.2.0 - * @see in.riido.locksmith.handler.LockSkipHandler - * @see in.riido.locksmith.handler.lock.LockThrowExceptionHandler - * @see in.riido.locksmith.handler.lock.LockReturnDefaultHandler - */ -package in.riido.locksmith.handler.lock; diff --git a/src/main/java/in/riido/locksmith/handler/package-info.java b/src/main/java/in/riido/locksmith/handler/package-info.java deleted file mode 100644 index 5ca06cc..0000000 --- a/src/main/java/in/riido/locksmith/handler/package-info.java +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Handler interfaces and implementations for lock and semaphore acquisition failures. - * - *

This package provides a pluggable mechanism for handling cases when a distributed lock or - * semaphore permit cannot be acquired. The key components are: - * - *

Handler Interfaces: - * - *

    - *
  • {@link in.riido.locksmith.handler.LockSkipHandler} - Interface for custom lock handlers - *
  • {@link in.riido.locksmith.handler.SemaphoreSkipHandler} - Interface for custom semaphore - * handlers - *
- * - *

Lock Handler Implementations: (in {@code handler.lock} subpackage) - * - *

    - *
  • {@link in.riido.locksmith.handler.lock.LockThrowExceptionHandler} - Default handler that - * throws exceptions - *
  • {@link in.riido.locksmith.handler.lock.LockReturnDefaultHandler} - Handler that returns - * default values - *
- * - *

Semaphore Handler Implementations: (in {@code handler.semaphore} subpackage) - * - *

    - *
  • {@link in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler} - Default - * handler that throws exceptions - *
  • {@link in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler} - Handler that - * returns default values - *
- * - *

Utilities: - * - *

    - *
  • {@link in.riido.locksmith.handler.DefaultValueResolver} - Shared utility for resolving - * default values based on return types - *
- * - * @author Garvit Joshi - * @since 1.2.0 - * @see in.riido.locksmith.handler.LockSkipHandler - * @see in.riido.locksmith.handler.SemaphoreSkipHandler - * @see in.riido.locksmith.handler.DefaultValueResolver - */ -package in.riido.locksmith.handler; diff --git a/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandler.java b/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandler.java deleted file mode 100644 index 78981c1..0000000 --- a/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandler.java +++ /dev/null @@ -1,37 +0,0 @@ -package in.riido.locksmith.handler.ratelimit; - -import in.riido.locksmith.handler.DefaultValueResolver; -import in.riido.locksmith.handler.RateLimitSkipHandler; -import in.riido.locksmith.models.RateLimitContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * A {@link RateLimitSkipHandler} that returns default values when a rate limit is exceeded. - * - *

Returns appropriate default values based on the method's return type: - * - *

    - *
  • {@code null} for object types and {@code void}/{@code Void} - *
  • {@code false} for {@code boolean}/{@code Boolean} - *
  • {@code 0} for numeric primitives and their wrapper types ({@code int}/{@code Integer}, - * {@code long}/{@code Long}, {@code double}/{@code Double}, etc.) - *
  • {@code '\u0000'} for {@code char}/{@code Character} - *
  • {@code Optional.empty()} for {@code Optional} types - *
- * - * @author Garvit Joshi - * @see DefaultValueResolver - * @since 3.0.0 - */ -public class RateLimitReturnDefaultHandler implements RateLimitSkipHandler { - - /** Default constructor. */ - public RateLimitReturnDefaultHandler() {} - - @Override - @Nullable - public Object handle(@NonNull RateLimitContext context) { - return DefaultValueResolver.resolve(context.returnType()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandler.java b/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandler.java deleted file mode 100644 index 5bfb795..0000000 --- a/src/main/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandler.java +++ /dev/null @@ -1,24 +0,0 @@ -package in.riido.locksmith.handler.ratelimit; - -import in.riido.locksmith.exception.RateLimitExceededException; -import in.riido.locksmith.handler.RateLimitSkipHandler; -import in.riido.locksmith.models.RateLimitContext; -import org.jspecify.annotations.NonNull; - -/** - * A {@link RateLimitSkipHandler} that throws {@link RateLimitExceededException} when a rate limit - * is exceeded. This is the default handler used by {@link in.riido.locksmith.RateLimit}. - * - * @author Garvit Joshi - * @since 3.0.0 - */ -public class RateLimitThrowExceptionHandler implements RateLimitSkipHandler { - - /** Default constructor. */ - public RateLimitThrowExceptionHandler() {} - - @Override - public Object handle(@NonNull RateLimitContext context) { - throw new RateLimitExceededException(context.rateLimitKey(), context.methodName()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/ratelimit/package-info.java b/src/main/java/in/riido/locksmith/handler/ratelimit/package-info.java deleted file mode 100644 index 893907b..0000000 --- a/src/main/java/in/riido/locksmith/handler/ratelimit/package-info.java +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Built-in implementations of {@link in.riido.locksmith.handler.RateLimitSkipHandler} for handling - * rate limit exceeded scenarios. - * - * @author Garvit Joshi - * @since 3.0.0 - */ -@NullMarked -package in.riido.locksmith.handler.ratelimit; - -import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreReturnDefaultHandler.java b/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreReturnDefaultHandler.java deleted file mode 100644 index 7990505..0000000 --- a/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreReturnDefaultHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -package in.riido.locksmith.handler.semaphore; - -import in.riido.locksmith.handler.DefaultValueResolver; -import in.riido.locksmith.handler.SemaphoreSkipHandler; -import in.riido.locksmith.models.SemaphoreContext; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * A {@link SemaphoreSkipHandler} that returns default values when a semaphore permit cannot be - * acquired. - * - *

Returns appropriate default values based on the method's return type: - * - *

    - *
  • {@code null} for object types and {@code void}/{@code Void} - *
  • {@code false} for {@code boolean}/{@code Boolean} - *
  • {@code 0} for numeric primitives and their wrapper types ({@code int}/{@code Integer}, - * {@code long}/{@code Long}, {@code double}/{@code Double}, etc.) - *
  • {@code '\u0000'} for {@code char}/{@code Character} - *
  • {@code Optional.empty()} for {@code Optional} types - *
- * - * @author Garvit Joshi - * @see DefaultValueResolver - * @since 2.0.0 - */ -public class SemaphoreReturnDefaultHandler implements SemaphoreSkipHandler { - - /** Default constructor. */ - public SemaphoreReturnDefaultHandler() {} - - @Override - @Nullable - public Object handle(@NonNull SemaphoreContext context) { - return DefaultValueResolver.resolve(context.returnType()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreThrowExceptionHandler.java b/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreThrowExceptionHandler.java deleted file mode 100644 index 1b1fffe..0000000 --- a/src/main/java/in/riido/locksmith/handler/semaphore/SemaphoreThrowExceptionHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -package in.riido.locksmith.handler.semaphore; - -import in.riido.locksmith.exception.SemaphoreNotAcquiredException; -import in.riido.locksmith.handler.SemaphoreSkipHandler; -import in.riido.locksmith.models.SemaphoreContext; -import org.jspecify.annotations.NonNull; - -/** - * A {@link SemaphoreSkipHandler} that throws {@link SemaphoreNotAcquiredException} when a semaphore - * permit cannot be acquired. This is the default handler used by {@link - * in.riido.locksmith.DistributedSemaphore}. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreThrowExceptionHandler implements SemaphoreSkipHandler { - - /** Default constructor. */ - public SemaphoreThrowExceptionHandler() {} - - @Override - public Object handle(@NonNull SemaphoreContext context) { - throw new SemaphoreNotAcquiredException(context.semaphoreKey(), context.methodName()); - } -} diff --git a/src/main/java/in/riido/locksmith/handler/semaphore/package-info.java b/src/main/java/in/riido/locksmith/handler/semaphore/package-info.java deleted file mode 100644 index 27dbfc3..0000000 --- a/src/main/java/in/riido/locksmith/handler/semaphore/package-info.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Built-in semaphore skip handler implementations. - * - *

This package contains the default implementations of {@link - * in.riido.locksmith.handler.SemaphoreSkipHandler} that are used when semaphore permit acquisition - * fails: - * - *

    - *
  • {@link in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler} - Default - * handler that throws {@link in.riido.locksmith.exception.SemaphoreNotAcquiredException} - *
  • {@link in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler} - Handler that - * returns null for objects, default values for primitives - *
- * - * @author Garvit Joshi - * @since 2.0.0 - * @see in.riido.locksmith.handler.SemaphoreSkipHandler - * @see in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler - * @see in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler - */ -package in.riido.locksmith.handler.semaphore; diff --git a/src/main/java/in/riido/locksmith/lock/LockFailureContext.java b/src/main/java/in/riido/locksmith/lock/LockFailureContext.java new file mode 100644 index 0000000..2e2490e --- /dev/null +++ b/src/main/java/in/riido/locksmith/lock/LockFailureContext.java @@ -0,0 +1,20 @@ +package in.riido.locksmith.lock; + +import java.lang.reflect.Method; +import java.time.Duration; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Describes a call whose lock was not acquired; passed to {@link LockFailureHandler}. + * + * @param key the full Redis key, including the prefix + * @param method the annotated method + * @param args the arguments of the call; the array is never null, its elements may be + * @param waitTime how long the call waited for the lock + */ +public record LockFailureContext( + @NonNull String key, + @NonNull Method method, + @Nullable Object @NonNull [] args, + @NonNull Duration waitTime) {} diff --git a/src/main/java/in/riido/locksmith/lock/LockFailureHandler.java b/src/main/java/in/riido/locksmith/lock/LockFailureHandler.java new file mode 100644 index 0000000..de0cdee --- /dev/null +++ b/src/main/java/in/riido/locksmith/lock/LockFailureHandler.java @@ -0,0 +1,21 @@ +package in.riido.locksmith.lock; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Decides what a {@code @DistributedLock} method returns when its lock is not acquired and its + * failure policy is {@code HANDLER}. Implementations are Spring beans, resolved by type. + */ +@FunctionalInterface +public interface LockFailureHandler { + + /** + * Handles a lock that was not acquired. Any exception thrown here propagates from the annotated + * method. + * + * @param context the key, method, arguments and wait time of the failed call + * @return the value the annotated method returns, as-is + */ + @Nullable Object onFailure(@NonNull LockFailureContext context); +} diff --git a/src/main/java/in/riido/locksmith/lock/LockHandle.java b/src/main/java/in/riido/locksmith/lock/LockHandle.java new file mode 100644 index 0000000..dc8c206 --- /dev/null +++ b/src/main/java/in/riido/locksmith/lock/LockHandle.java @@ -0,0 +1,159 @@ +package in.riido.locksmith.lock; + +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import in.riido.locksmith.support.RedissonFutures; +import java.time.Duration; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The result of one {@link LockOperations.Builder#acquire()}. Use it in try-with-resources and + * check {@link #acquired()} before entering the critical section. + * + *

The lock is owned by the thread that acquired it, so a nested acquire of the same key on that + * thread is reentrant. The handle releases as that owner, so it can be closed on any thread. Until + * it is closed, though, the acquiring thread takes the same key again at once, even for unrelated + * work: after handing the handle to another thread, do not acquire that key again on the acquiring + * thread. + */ +public final class LockHandle implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(LockHandle.class); + + private final @NonNull RedissonClient redisson; + private final @Nullable RLock lock; + private final long ownerId; + private final @NonNull String fullKey; + private final @Nullable Duration fixedLease; + private final long acquiredAtNanos; + private final @NonNull Duration waited; + private final @NonNull LocksmithMetrics metrics; + private final AtomicBoolean closed = new AtomicBoolean(); + + /** A handle whose lock, when acquired, is owned by {@code ownerId} on {@code redisson}. */ + LockHandle( + @NonNull RedissonClient redisson, + @Nullable RLock lock, + long ownerId, + @NonNull String fullKey, + @Nullable Duration fixedLease, + long acquiredAtNanos, + @NonNull Duration waited, + @NonNull LocksmithMetrics metrics) { + this.redisson = redisson; + this.lock = lock; + this.ownerId = ownerId; + this.fullKey = fullKey; + this.fixedLease = fixedLease; + this.acquiredAtNanos = acquiredAtNanos; + this.waited = waited; + this.metrics = metrics; + } + + /** + * Reports whether the lock was acquired. + * + * @return {@code true} if the lock was acquired + */ + public boolean acquired() { + return lock != null; + } + + /** + * Returns the full Redis key of the lock, including the prefix. + * + * @return the full key + */ + public @NonNull String key() { + return fullKey; + } + + /** + * Releases the lock if it was acquired and records how long it was held. Closing an unacquired + * handle, or closing a second time, does nothing. Works on any thread and waits for the release, + * also when the thread is interrupted, whose flag is kept; on a Redisson I/O or timer thread it + * only starts the release, because waiting there can stall Redisson. Once the Redisson client is + * shutting down, it stops waiting within about a second, because Redisson may never answer a + * release it already sent; the key then expires on its own. Never throws: a failed release, for + * example because a fixed lease ran out or Redis is unreachable, is logged as a WARN and the key + * expires on its own. + */ + @Override + public void close() { + if (lock == null || !closed.compareAndSet(false, true)) { + return; + } + Duration held = Duration.ofNanos(System.nanoTime() - acquiredAtNanos); + CompletionStage release; + try { + release = + lock.unlockAsync(ownerId) + .handle( + (ignored, failure) -> { + released(held, RedissonFutures.unwrap(failure)); + return null; + }); + } catch (RuntimeException e) { + released(held, e); + return; + } + if (!RedissonFutures.onRedissonIoOrTimerThread()) { + RedissonFutures.awaitRelease(redisson, release); + } + } + + /** + * Logs a failed release, or records the hold time of a successful one. A release refused because + * the client is shutting down is expected then, so it gets one line without a stack trace. + */ + private void released(@NonNull Duration held, @Nullable Throwable failure) { + if (failure instanceof IllegalMonitorStateException e) { + LOG.warn( + "Lock [{}] was no longer held at release after {}ms ({}); another instance may have run" + + " concurrently: {}", + fullKey, + held.toMillis(), + describeLease(), + e.getMessage()); + return; + } + if (failure instanceof RedissonShutdownException) { + LOG.warn( + "Lock [{}] release was not confirmed after {}ms ({}) because the Redisson client is" + + " shutting down; the key expires on its own", + fullKey, + held.toMillis(), + describeLease()); + return; + } + if (failure != null) { + LOG.warn( + "Lock [{}] release failed after {}ms ({}): {}", + fullKey, + held.toMillis(), + describeLease(), + failure.getMessage(), + failure); + return; + } + try { + metrics.recordHeld(Primitive.LOCK, held); + } catch (RuntimeException e) { + LOG.warn("Lock [{}] metrics recording failed: {}", fullKey, e.getMessage()); + } + LOG.debug( + "Lock [{}] released after {}ms, waited {}ms", fullKey, held.toMillis(), waited.toMillis()); + } + + private @NonNull String describeLease() { + return fixedLease == null ? "fixed lease none" : "fixed lease " + fixedLease.toMillis() + "ms"; + } +} diff --git a/src/main/java/in/riido/locksmith/lock/LockNotAcquiredException.java b/src/main/java/in/riido/locksmith/lock/LockNotAcquiredException.java new file mode 100644 index 0000000..c5ecd65 --- /dev/null +++ b/src/main/java/in/riido/locksmith/lock/LockNotAcquiredException.java @@ -0,0 +1,47 @@ +package in.riido.locksmith.lock; + +import in.riido.locksmith.LocksmithException; +import java.time.Duration; +import org.jspecify.annotations.NonNull; + +/** + * Thrown when a lock is not acquired within its wait time and the failure policy is {@code THROW}. + */ +public class LockNotAcquiredException extends LocksmithException { + + /** The full Redis key, including the prefix. */ + private final @NonNull String key; + + /** How long the caller waited. */ + private final @NonNull Duration waitTime; + + /** + * Creates the exception for a lock that was not acquired. + * + * @param key the full Redis key, including the prefix + * @param waitTime how long the caller waited + */ + public LockNotAcquiredException(@NonNull String key, @NonNull Duration waitTime) { + super("Lock [" + key + "] not acquired within " + waitTime); + this.key = key; + this.waitTime = waitTime; + } + + /** + * Returns the full Redis key, including the prefix. + * + * @return the key + */ + public @NonNull String key() { + return key; + } + + /** + * Returns how long the caller waited. + * + * @return the wait time + */ + public @NonNull Duration waitTime() { + return waitTime; + } +} diff --git a/src/main/java/in/riido/locksmith/lock/LockOperations.java b/src/main/java/in/riido/locksmith/lock/LockOperations.java new file mode 100644 index 0000000..572b519 --- /dev/null +++ b/src/main/java/in/riido/locksmith/lock/LockOperations.java @@ -0,0 +1,249 @@ +package in.riido.locksmith.lock; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import in.riido.locksmith.LockType; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Outcome; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import in.riido.locksmith.support.RedissonFutures; +import java.time.Duration; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Acquires distributed locks over Redis. A reentrant lock lives at the Redis key {@code + * lock:}; read and write locks live at {@code rwlock:}, and read + * and write locks of one key share it. A reentrant lock and a read or write lock of one key are + * therefore separate locks and do not exclude each other. + * + *

{@code
+ * try (LockHandle lock = locks.key("report:" + id).waitTime(Duration.ofSeconds(5)).acquire()) {
+ *   if (lock.acquired()) {
+ *     // critical section
+ *   }
+ * }
+ * }
+ * + *

Redisson exceptions, for example when Redis is unreachable, propagate unchanged from every + * method; they are never treated as "not acquired". + */ +public class LockOperations { + + private static final Logger LOG = LoggerFactory.getLogger(LockOperations.class); + + private static final String KEY_NAMESPACE = "lock:"; + + /** Read and write locks get their own namespace: Redisson stores them in another layout. */ + private static final String READ_WRITE_KEY_NAMESPACE = "rwlock:"; + + private final @NonNull RedissonClient redisson; + private final @NonNull LocksmithProperties properties; + private final @NonNull LocksmithMetrics metrics; + + /** + * Creates the operations. + * + * @param redisson the client every lock is taken through + * @param properties supplies the key prefix + * @param metrics records acquire attempts and hold times + */ + public LockOperations( + @NonNull RedissonClient redisson, + @NonNull LocksmithProperties properties, + @NonNull LocksmithMetrics metrics) { + this.redisson = redisson; + this.properties = properties; + this.metrics = metrics; + } + + /** + * Starts building an acquire of the lock on a key. Defaults: {@link LockType#REENTRANT}, wait + * time {@link Duration#ZERO} (try once), no lease time (the lock is renewed while held). + * + * @param key the key without prefix + * @return a builder for the acquire + */ + public @NonNull Builder key(@NonNull String key) { + return new Builder(key); + } + + /** + * Reports whether any thread on any instance holds the lock on a key right now. + * + * @param key the key without prefix + * @param type the lock type; {@code REENTRANT} reports on {@code lock:}; {@code + * READ} reports whether any read lock is held and {@code WRITE} whether the write lock is + * held, both on their shared {@code rwlock:} + * @return {@code true} if a lock of that type is held + * @throws IllegalStateException if called on a Redisson I/O thread ({@code redisson-netty-*}) or + * the timer thread ({@code redisson-timer-*}), for example inside a callback of a Redisson + * async call; nothing is sent to Redis then + */ + public boolean isLocked(@NonNull String key, @NonNull LockType type) { + RedissonFutures.requireNotRedissonThread(false); + return getLock(prefix(key, type), type).isLocked(); + } + + private @NonNull String prefix(@NonNull String key, @NonNull LockType type) { + String namespace = type == LockType.REENTRANT ? KEY_NAMESPACE : READ_WRITE_KEY_NAMESPACE; + return properties.keyPrefix() + namespace + key; + } + + private @NonNull RLock getLock(@NonNull String fullKey, @NonNull LockType type) { + return switch (type) { + case REENTRANT -> redisson.getLock(fullKey); + case READ -> redisson.getReadWriteLock(fullKey).readLock(); + case WRITE -> redisson.getReadWriteLock(fullKey).writeLock(); + }; + } + + /** Configures and performs one acquire of a lock. Obtain it from {@link LockOperations#key}. */ + public final class Builder { + + private final @NonNull String key; + private @NonNull LockType type = LockType.REENTRANT; + private @NonNull Duration waitTime = Duration.ZERO; + private @Nullable Duration leaseTime; + + private Builder(@NonNull String key) { + this.key = key; + } + + /** + * Sets the lock type. + * + * @param type the lock type + * @return this builder + */ + public @NonNull Builder type(@NonNull LockType type) { + this.type = type; + return this; + } + + /** + * Sets how long {@link #acquire()} waits for the lock. Zero means try once and give up. + * + * @param waitTime the maximum wait + * @return this builder + * @throws IllegalArgumentException if {@code waitTime} is negative + */ + public @NonNull Builder waitTime(@NonNull Duration waitTime) { + requireNotNegative("waitTime", waitTime); + this.waitTime = waitTime; + return this; + } + + /** + * Sets a fixed lease: the lock expires this long after it is acquired and is not renewed. + * Without a lease time the lock is renewed for as long as it is held. + * + * @param leaseTime the fixed lease + * @return this builder + * @throws IllegalArgumentException if {@code leaseTime} is shorter than one millisecond, + * including zero and negative values + */ + public @NonNull Builder leaseTime(@NonNull Duration leaseTime) { + if (leaseTime.toMillis() <= 0) { + throw new IllegalArgumentException( + "leaseTime must be at least one millisecond, got " + leaseTime); + } + this.leaseTime = leaseTime; + return this; + } + + /** + * Tries to acquire the lock, waiting up to the wait time. Never throws for "not acquired": the + * returned handle reports the outcome through {@link LockHandle#acquired()}. If the thread is + * interrupted before or while waiting, its interrupt flag is kept, the handle is unacquired, + * and no lock is left behind in Redis. Only an acquire that completed before the interrupt + * could stop it returns an acquired handle, with the flag set. An interrupt already set when + * this is called sends nothing to Redis. + * + *

The lock is owned by the calling thread, so a nested acquire of the same key on that + * thread is reentrant. The handle releases as that owner, so it can be closed on any thread. + * Until it is closed, though, the calling thread takes the same key again at once, even for + * unrelated work: after handing the handle to another thread, do not acquire that key again on + * this one. + * + * @return the handle, acquired or not + * @throws IllegalStateException if called on a Redisson I/O thread ({@code redisson-netty-*}) + * or the timer thread ({@code redisson-timer-*}), for example inside a callback of a + * Redisson async call, or with a wait time on any other Redisson thread, such as a topic + * listener; nothing is sent to Redis then + * @throws in.riido.locksmith.LocksmithConfigurationException on a Redis Cluster client, if the + * key contains a brace that forms no hash tag such as {@code {42}}; nothing is sent to + * Redis then + * @throws RuntimeException any Redisson exception, unchanged, for example when Redis is + * unreachable + */ + public @NonNull LockHandle acquire() { + RedissonFutures.requireNotRedissonThread(waitTime.toMillis() > 0); + String fullKey = prefix(key, type); + RedissonFutures.requireClusterSafeKey(redisson, fullKey); + long startNanos = System.nanoTime(); + if (Thread.currentThread().isInterrupted()) { + // Nothing is sent: an attempt would take the lock briefly, then its cancel undoes it. + return notAcquired(fullKey, Outcome.INTERRUPTED, startNanos); + } + RLock lock = getLock(fullKey, type); + long lease = leaseTime == null ? -1 : leaseTime.toMillis(); + // Redisson owns a lock by the id of the thread that takes it, as its blocking tryLock does. + // Waiting in RedissonFutures instead of the blocking tryLock makes an interrupt leave nothing + // behind: the pending attempt is cancelled, and Redisson releases a lock it still wins. + long owner = Thread.currentThread().getId(); + boolean acquired; + try { + acquired = + RedissonFutures.await( + redisson, lock.tryLockAsync(waitTime.toMillis(), lease, MILLISECONDS, owner)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return notAcquired(fullKey, Outcome.INTERRUPTED, startNanos); + } + if (!acquired) { + return notAcquired(fullKey, Outcome.SKIPPED, startNanos); + } + long acquiredAtNanos = System.nanoTime(); + Duration waited = Duration.ofNanos(acquiredAtNanos - startNanos); + return recordAcquired( + new LockHandle( + redisson, lock, owner, fullKey, leaseTime, acquiredAtNanos, waited, metrics), + waited); + } + + private @NonNull LockHandle recordAcquired( + @NonNull LockHandle handle, @NonNull Duration waited) { + try { + metrics.recordAcquire(Primitive.LOCK, Outcome.ACQUIRED, waited); + } catch (RuntimeException e) { + handle.close(); + throw e; + } + return handle; + } + + private @NonNull LockHandle notAcquired( + @NonNull String fullKey, @NonNull Outcome outcome, long startNanos) { + Duration waited = Duration.ofNanos(System.nanoTime() - startNanos); + metrics.recordAcquire(Primitive.LOCK, outcome, waited); + LOG.debug( + "Lock [{}] not acquired after waiting {}ms: {}", + fullKey, + waited.toMillis(), + outcome.tagValue()); + return new LockHandle(redisson, null, 0L, fullKey, leaseTime, 0L, waited, metrics); + } + + private static void requireNotNegative(@NonNull String name, @NonNull Duration value) { + if (value.isNegative()) { + throw new IllegalArgumentException(name + " must not be negative, got " + value); + } + } + } +} diff --git a/src/main/java/in/riido/locksmith/metrics/LockMetrics.java b/src/main/java/in/riido/locksmith/metrics/LockMetrics.java deleted file mode 100644 index ade04f3..0000000 --- a/src/main/java/in/riido/locksmith/metrics/LockMetrics.java +++ /dev/null @@ -1,133 +0,0 @@ -package in.riido.locksmith.metrics; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.jspecify.annotations.NonNull; - -/** - * Micrometer metrics for distributed lock operations. - * - *

Provides the following metrics: - * - *

    - *
  • {@code locksmith.lock.acquired} - Counter for successful lock acquisitions - *
  • {@code locksmith.lock.skipped} - Counter for skipped lock acquisitions (tagged by reason) - *
  • {@code locksmith.lock.lease.expired} - Counter for lease expirations detected - *
  • {@code locksmith.lock.acquisition.time} - Timer for lock acquisition duration - *
  • {@code locksmith.lock.held.time} - Timer for duration the lock was held - *
  • {@code locksmith.lock.autorenew.active} - Gauge for currently active auto-renewed locks - *
- * - * @author Garvit Joshi - * @since 2.1.0 - */ -public class LockMetrics { - - private static final String PREFIX = "locksmith.lock."; - - private final Counter acquired; - private final Counter skippedTimeout; - private final Counter skippedImmediate; - private final Counter leaseExpired; - private final Timer acquisitionTime; - private final Timer heldTime; - private final AtomicInteger autoRenewActive; - - /** - * Creates a new LockMetrics instance and registers all metrics with the given registry. - * - * @param registry the Micrometer registry to register metrics with - */ - public LockMetrics(@NonNull MeterRegistry registry) { - this.acquired = - Counter.builder(PREFIX + "acquired") - .description("Number of successful lock acquisitions") - .register(registry); - - this.skippedTimeout = - Counter.builder(PREFIX + "skipped") - .tag("reason", AcquisitionMode.WAIT_AND_SKIP.metricsReason()) - .description("Number of lock acquisitions skipped due to timeout") - .register(registry); - - this.skippedImmediate = - Counter.builder(PREFIX + "skipped") - .tag("reason", AcquisitionMode.SKIP_IMMEDIATELY.metricsReason()) - .description("Number of lock acquisitions skipped immediately (no wait)") - .register(registry); - - this.leaseExpired = - Counter.builder(PREFIX + "lease.expired") - .description("Number of lease expirations detected") - .register(registry); - - this.acquisitionTime = - Timer.builder(PREFIX + "acquisition.time") - .description("Time taken to acquire the lock") - .register(registry); - - this.heldTime = - Timer.builder(PREFIX + "held.time") - .description("Duration the lock was held") - .register(registry); - - this.autoRenewActive = new AtomicInteger(0); - registry.gauge(PREFIX + "autorenew.active", autoRenewActive); - } - - /** Records a successful lock acquisition. */ - public void recordAcquired() { - acquired.increment(); - } - - /** - * Records a skipped lock acquisition. - * - * @param reason the reason for skipping: "timeout" for WAIT_AND_SKIP mode, "immediate" for - * SKIP_IMMEDIATELY mode - */ - public void recordSkipped(@NonNull AcquisitionMode reason) { - if (AcquisitionMode.WAIT_AND_SKIP.equals(reason)) { - skippedTimeout.increment(); - } else { - skippedImmediate.increment(); - } - } - - /** Records a lease expiration event. */ - public void recordLeaseExpired() { - leaseExpired.increment(); - } - - /** - * Records the time taken to acquire a lock. - * - * @param millis the acquisition time in milliseconds - */ - public void recordAcquisitionTime(long millis) { - acquisitionTime.record(millis, TimeUnit.MILLISECONDS); - } - - /** - * Records the duration a lock was held. - * - * @param millis the held time in milliseconds - */ - public void recordHeldTime(long millis) { - heldTime.record(millis, TimeUnit.MILLISECONDS); - } - - /** Increments the count of active auto-renewed locks. */ - public void incrementAutoRenewActive() { - autoRenewActive.incrementAndGet(); - } - - /** Decrements the count of active auto-renewed locks. */ - public void decrementAutoRenewActive() { - autoRenewActive.decrementAndGet(); - } -} diff --git a/src/main/java/in/riido/locksmith/metrics/LocksmithMetrics.java b/src/main/java/in/riido/locksmith/metrics/LocksmithMetrics.java new file mode 100644 index 0000000..cbcbca8 --- /dev/null +++ b/src/main/java/in/riido/locksmith/metrics/LocksmithMetrics.java @@ -0,0 +1,74 @@ +package in.riido.locksmith.metrics; + +import java.time.Duration; +import org.jspecify.annotations.NonNull; + +/** Records acquire attempts and hold times of locks and semaphore permits. */ +public interface LocksmithMetrics { + + /** + * Records one acquire attempt. + * + * @param primitive the primitive that was acquired + * @param outcome how the attempt ended + * @param waited time spent waiting + */ + void recordAcquire( + @NonNull Primitive primitive, @NonNull Outcome outcome, @NonNull Duration waited); + + /** + * Records the time from acquire to release. + * + * @param primitive the primitive that was held + * @param held time held + */ + void recordHeld(@NonNull Primitive primitive, @NonNull Duration held); + + /** The coordination primitive a measurement belongs to. */ + enum Primitive { + /** A distributed lock of any type. */ + LOCK("lock"), + /** A semaphore permit. */ + SEMAPHORE("semaphore"); + + private final @NonNull String tagValue; + + Primitive(@NonNull String tagValue) { + this.tagValue = tagValue; + } + + /** + * Returns the value of the {@code primitive} tag. + * + * @return the tag value + */ + public @NonNull String tagValue() { + return tagValue; + } + } + + /** How an acquire attempt ended. */ + enum Outcome { + /** The lock or permit was acquired. */ + ACQUIRED("acquired"), + /** The wait time ran out without acquiring. */ + SKIPPED("skipped"), + /** The waiting thread was interrupted. */ + INTERRUPTED("interrupted"); + + private final @NonNull String tagValue; + + Outcome(@NonNull String tagValue) { + this.tagValue = tagValue; + } + + /** + * Returns the value of the {@code outcome} tag. + * + * @return the tag value + */ + public @NonNull String tagValue() { + return tagValue; + } + } +} diff --git a/src/main/java/in/riido/locksmith/metrics/MicrometerLocksmithMetrics.java b/src/main/java/in/riido/locksmith/metrics/MicrometerLocksmithMetrics.java new file mode 100644 index 0000000..929d585 --- /dev/null +++ b/src/main/java/in/riido/locksmith/metrics/MicrometerLocksmithMetrics.java @@ -0,0 +1,68 @@ +package in.riido.locksmith.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.NonNull; + +/** + * Records {@link LocksmithMetrics} as two Micrometer timers, {@value #ACQUIRE_TIMER} and {@value + * #HELD_TIMER}. Each timer is registered on first use of its tag combination and cached. + */ +public final class MicrometerLocksmithMetrics implements LocksmithMetrics { + + /** Timer of time spent waiting, one record per acquire attempt. */ + public static final String ACQUIRE_TIMER = "locksmith.acquire"; + + /** Timer of time from acquire to release. */ + public static final String HELD_TIMER = "locksmith.held"; + + /** Tag carrying {@link Primitive#tagValue()}. */ + public static final String PRIMITIVE_TAG = "primitive"; + + /** Tag carrying {@link Outcome#tagValue()}. */ + public static final String OUTCOME_TAG = "outcome"; + + private final @NonNull MeterRegistry registry; + private final Map acquireTimers = new ConcurrentHashMap<>(); + private final Map heldTimers = new ConcurrentHashMap<>(); + + /** + * Creates the metrics on a registry. + * + * @param registry the registry the timers are registered on + */ + public MicrometerLocksmithMetrics(@NonNull MeterRegistry registry) { + this.registry = registry; + } + + /** {@inheritDoc} */ + @Override + public void recordAcquire( + @NonNull Primitive primitive, @NonNull Outcome outcome, @NonNull Duration waited) { + acquireTimers + .computeIfAbsent(new AcquireTags(primitive, outcome), this::acquireTimer) + .record(waited); + } + + /** {@inheritDoc} */ + @Override + public void recordHeld(@NonNull Primitive primitive, @NonNull Duration held) { + heldTimers.computeIfAbsent(primitive, this::heldTimer).record(held); + } + + private @NonNull Timer acquireTimer(@NonNull AcquireTags tags) { + return Timer.builder(ACQUIRE_TIMER) + .tag(PRIMITIVE_TAG, tags.primitive().tagValue()) + .tag(OUTCOME_TAG, tags.outcome().tagValue()) + .register(registry); + } + + private @NonNull Timer heldTimer(@NonNull Primitive primitive) { + return Timer.builder(HELD_TIMER).tag(PRIMITIVE_TAG, primitive.tagValue()).register(registry); + } + + private record AcquireTags(@NonNull Primitive primitive, @NonNull Outcome outcome) {} +} diff --git a/src/main/java/in/riido/locksmith/metrics/NoOpLocksmithMetrics.java b/src/main/java/in/riido/locksmith/metrics/NoOpLocksmithMetrics.java new file mode 100644 index 0000000..9208e5c --- /dev/null +++ b/src/main/java/in/riido/locksmith/metrics/NoOpLocksmithMetrics.java @@ -0,0 +1,20 @@ +package in.riido.locksmith.metrics; + +import java.time.Duration; +import org.jspecify.annotations.NonNull; + +/** {@link LocksmithMetrics} that records nothing; used when no {@code MeterRegistry} exists. */ +public final class NoOpLocksmithMetrics implements LocksmithMetrics { + + /** Creates the no-op metrics. */ + public NoOpLocksmithMetrics() {} + + /** Does nothing. */ + @Override + public void recordAcquire( + @NonNull Primitive primitive, @NonNull Outcome outcome, @NonNull Duration waited) {} + + /** Does nothing. */ + @Override + public void recordHeld(@NonNull Primitive primitive, @NonNull Duration held) {} +} diff --git a/src/main/java/in/riido/locksmith/metrics/RateLimitMetrics.java b/src/main/java/in/riido/locksmith/metrics/RateLimitMetrics.java deleted file mode 100644 index b00c9c8..0000000 --- a/src/main/java/in/riido/locksmith/metrics/RateLimitMetrics.java +++ /dev/null @@ -1,105 +0,0 @@ -package in.riido.locksmith.metrics; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import java.util.concurrent.TimeUnit; -import org.jspecify.annotations.NonNull; - -/** - * Micrometer metrics for distributed rate limit operations. - * - *

Provides the following metrics: - * - *

    - *
  • {@code locksmith.rate.limit.acquired} - Counter for successful permit acquisitions - *
  • {@code locksmith.rate.limit.exceeded} - Counter for rate limit exceeded (tagged by reason) - *
  • {@code locksmith.rate.limit.acquisition.time} - Timer for permit acquisition duration - *
  • {@code locksmith.rate.limit.execution.time} - Timer for method execution time after permit - * acquired - *
- * - * @author Garvit Joshi - * @since 3.0.0 - */ -public class RateLimitMetrics { - - private static final String PREFIX = "locksmith.rate.limit."; - - private final Counter acquired; - private final Counter exceededTimeout; - private final Counter exceededImmediate; - private final Timer acquisitionTime; - private final Timer executionTime; - - /** - * Creates a new RateLimitMetrics instance and registers all metrics with the given registry. - * - * @param registry the Micrometer registry to register metrics with - */ - public RateLimitMetrics(@NonNull MeterRegistry registry) { - this.acquired = - Counter.builder(PREFIX + "acquired") - .description("Number of successful rate limit permit acquisitions") - .register(registry); - - this.exceededTimeout = - Counter.builder(PREFIX + "exceeded") - .tag("reason", AcquisitionMode.WAIT_AND_SKIP.metricsReason()) - .description("Number of rate limits exceeded after timeout") - .register(registry); - - this.exceededImmediate = - Counter.builder(PREFIX + "exceeded") - .tag("reason", AcquisitionMode.SKIP_IMMEDIATELY.metricsReason()) - .description("Number of rate limits exceeded immediately (no wait)") - .register(registry); - - this.acquisitionTime = - Timer.builder(PREFIX + "acquisition.time") - .description("Time taken to acquire the rate limit permit") - .register(registry); - - this.executionTime = - Timer.builder(PREFIX + "execution.time") - .description("Method execution time after permit acquired") - .register(registry); - } - - /** Records a successful permit acquisition. */ - public void recordAcquired() { - acquired.increment(); - } - - /** - * Records a rate limit exceeded event. - * - * @param reason the acquisition mode indicating the reason for exceeding - */ - public void recordExceeded(@NonNull AcquisitionMode reason) { - if (AcquisitionMode.WAIT_AND_SKIP.equals(reason)) { - exceededTimeout.increment(); - } else { - exceededImmediate.increment(); - } - } - - /** - * Records the time taken to acquire a permit. - * - * @param millis the acquisition time in milliseconds - */ - public void recordAcquisitionTime(long millis) { - acquisitionTime.record(millis, TimeUnit.MILLISECONDS); - } - - /** - * Records the method execution time after permit was acquired. - * - * @param millis the execution time in milliseconds - */ - public void recordExecutionTime(long millis) { - executionTime.record(millis, TimeUnit.MILLISECONDS); - } -} diff --git a/src/main/java/in/riido/locksmith/metrics/SemaphoreMetrics.java b/src/main/java/in/riido/locksmith/metrics/SemaphoreMetrics.java deleted file mode 100644 index 016a0c4..0000000 --- a/src/main/java/in/riido/locksmith/metrics/SemaphoreMetrics.java +++ /dev/null @@ -1,116 +0,0 @@ -package in.riido.locksmith.metrics; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import java.util.concurrent.TimeUnit; -import org.jspecify.annotations.NonNull; - -/** - * Micrometer metrics for distributed semaphore operations. - * - *

Provides the following metrics: - * - *

    - *
  • {@code locksmith.semaphore.acquired} - Counter for successful permit acquisitions - *
  • {@code locksmith.semaphore.skipped} - Counter for skipped acquisitions (tagged by reason) - *
  • {@code locksmith.semaphore.lease.expired} - Counter for lease expirations detected - *
  • {@code locksmith.semaphore.acquisition.time} - Timer for permit acquisition duration - *
  • {@code locksmith.semaphore.held.time} - Timer for duration the permit was held - *
- * - * @author Garvit Joshi - * @since 2.1.0 - */ -public class SemaphoreMetrics { - - private static final String PREFIX = "locksmith.semaphore."; - - private final Counter acquired; - private final Counter skippedTimeout; - private final Counter skippedImmediate; - private final Counter leaseExpired; - private final Timer acquisitionTime; - private final Timer heldTime; - - /** - * Creates a new SemaphoreMetrics instance and registers all metrics with the given registry. - * - * @param registry the Micrometer registry to register metrics with - */ - public SemaphoreMetrics(@NonNull MeterRegistry registry) { - this.acquired = - Counter.builder(PREFIX + "acquired") - .description("Number of successful permit acquisitions") - .register(registry); - - this.skippedTimeout = - Counter.builder(PREFIX + "skipped") - .tag("reason", AcquisitionMode.WAIT_AND_SKIP.metricsReason()) - .description("Number of permit acquisitions skipped due to timeout") - .register(registry); - - this.skippedImmediate = - Counter.builder(PREFIX + "skipped") - .tag("reason", AcquisitionMode.SKIP_IMMEDIATELY.metricsReason()) - .description("Number of permit acquisitions skipped immediately (no wait)") - .register(registry); - - this.leaseExpired = - Counter.builder(PREFIX + "lease.expired") - .description("Number of lease expirations detected") - .register(registry); - - this.acquisitionTime = - Timer.builder(PREFIX + "acquisition.time") - .description("Time taken to acquire the permit") - .register(registry); - - this.heldTime = - Timer.builder(PREFIX + "held.time") - .description("Duration the permit was held") - .register(registry); - } - - /** Records a successful permit acquisition. */ - public void recordAcquired() { - acquired.increment(); - } - - /** - * Records a skipped permit acquisition. - * - * @param reason the acquisition mode indicating the reason for skipping - */ - public void recordSkipped(@NonNull AcquisitionMode reason) { - if (AcquisitionMode.WAIT_AND_SKIP.equals(reason)) { - skippedTimeout.increment(); - } else { - skippedImmediate.increment(); - } - } - - /** Records a lease expiration event. */ - public void recordLeaseExpired() { - leaseExpired.increment(); - } - - /** - * Records the time taken to acquire a permit. - * - * @param millis the acquisition time in milliseconds - */ - public void recordAcquisitionTime(long millis) { - acquisitionTime.record(millis, TimeUnit.MILLISECONDS); - } - - /** - * Records the duration a permit was held. - * - * @param millis the held time in milliseconds - */ - public void recordHeldTime(long millis) { - heldTime.record(millis, TimeUnit.MILLISECONDS); - } -} diff --git a/src/main/java/in/riido/locksmith/metrics/package-info.java b/src/main/java/in/riido/locksmith/metrics/package-info.java index da32cf2..43d8277 100644 --- a/src/main/java/in/riido/locksmith/metrics/package-info.java +++ b/src/main/java/in/riido/locksmith/metrics/package-info.java @@ -1,52 +1,2 @@ -/** - * Micrometer metrics support for Locksmith distributed locking and semaphores. - * - *

This package provides optional Micrometer integration for observability of lock and semaphore - * operations. Metrics are only recorded when: - * - *

    - *
  • {@code micrometer-core} is on the classpath - *
  • A {@link io.micrometer.core.instrument.MeterRegistry} bean exists - *
  • The respective {@code metrics-enabled} property is set to {@code true} - *
- * - *

Configuration

- * - *
{@code
- * locksmith:
- *   lock:
- *     metrics-enabled: true
- *   semaphore:
- *     metrics-enabled: true
- * }
- * - *

Available Metrics

- * - *

Lock Metrics

- * - *
    - *
  • {@code locksmith.lock.acquired} - Counter for successful lock acquisitions - *
  • {@code locksmith.lock.skipped} - Counter for skipped acquisitions (tagged by reason) - *
  • {@code locksmith.lock.lease.expired} - Counter for lease expirations - *
  • {@code locksmith.lock.acquisition.time} - Timer for lock acquisition duration - *
  • {@code locksmith.lock.held.time} - Timer for lock held duration - *
  • {@code locksmith.lock.autorenew.active} - Gauge for active auto-renewed locks - *
- * - *

Semaphore Metrics

- * - *
    - *
  • {@code locksmith.semaphore.acquired} - Counter for successful permit acquisitions - *
  • {@code locksmith.semaphore.skipped} - Counter for skipped acquisitions (tagged by reason) - *
  • {@code locksmith.semaphore.lease.expired} - Counter for lease expirations - *
  • {@code locksmith.semaphore.acquisition.time} - Timer for permit acquisition duration - *
  • {@code locksmith.semaphore.held.time} - Timer for permit held duration - *
- * - * @author Garvit Joshi - * @since 2.1.0 - * @see in.riido.locksmith.metrics.LockMetrics - * @see in.riido.locksmith.metrics.SemaphoreMetrics - */ -@org.jspecify.annotations.NullMarked +/** Internal API. Not for use by adopters. May change without notice. */ package in.riido.locksmith.metrics; diff --git a/src/main/java/in/riido/locksmith/models/LockContext.java b/src/main/java/in/riido/locksmith/models/LockContext.java deleted file mode 100644 index 6996ae7..0000000 --- a/src/main/java/in/riido/locksmith/models/LockContext.java +++ /dev/null @@ -1,51 +0,0 @@ -package in.riido.locksmith.models; - -import in.riido.locksmith.handler.LockSkipHandler; -import java.lang.reflect.Method; -import java.util.Objects; -import org.jspecify.annotations.NonNull; - -/** - * Provides contextual information about a lock acquisition attempt. - * - *

This record is passed to {@link LockSkipHandler} implementations to provide all relevant - * information about the failed lock acquisition, enabling custom handling logic. - * - *

Thread Safety Note: The {@code args} array is passed by reference from the aspect and - * is guaranteed not to be modified by the Locksmith framework. Handlers may safely read the - * arguments but should avoid modifying them to prevent unexpected side effects on the original - * method invocation. - * - * @param lockKey the Redis lock key that could not be acquired - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method (read-only by convention; not modified by the - * aspect) - * @param returnType the return type of the method - * @author Garvit Joshi - * @since 1.2.0 - */ -public record LockContext( - @NonNull String lockKey, - @NonNull String methodName, - @NonNull Method method, - @NonNull Object[] args, - @NonNull Class returnType) { - - /** - * Compact constructor that validates all parameters are non-null. - * - * @param lockKey the Redis lock key that could not be acquired - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method - * @param returnType the return type of the method - */ - public LockContext { - Objects.requireNonNull(lockKey, "lockKey must not be null"); - Objects.requireNonNull(methodName, "methodName must not be null"); - Objects.requireNonNull(method, "method must not be null"); - Objects.requireNonNull(args, "args must not be null"); - Objects.requireNonNull(returnType, "returnType must not be null"); - } -} diff --git a/src/main/java/in/riido/locksmith/models/RateLimitContext.java b/src/main/java/in/riido/locksmith/models/RateLimitContext.java deleted file mode 100644 index ce51941..0000000 --- a/src/main/java/in/riido/locksmith/models/RateLimitContext.java +++ /dev/null @@ -1,51 +0,0 @@ -package in.riido.locksmith.models; - -import in.riido.locksmith.handler.RateLimitSkipHandler; -import java.lang.reflect.Method; -import java.util.Objects; -import org.jspecify.annotations.NonNull; - -/** - * Provides contextual information about a rate limit exceeded scenario. - * - *

This record is passed to {@link RateLimitSkipHandler} implementations to provide all relevant - * information about the rate limit breach, enabling custom handling logic. - * - *

Thread Safety Note: The {@code args} array is passed by reference from the aspect and - * is guaranteed not to be modified by the Locksmith framework. Handlers may safely read the - * arguments but should avoid modifying them to prevent unexpected side effects on the original - * method invocation. - * - * @param rateLimitKey the Redis rate limiter key that exceeded its limit - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method (read-only by convention; not modified by the - * aspect) - * @param returnType the return type of the method - * @author Garvit Joshi - * @since 3.0.0 - */ -public record RateLimitContext( - @NonNull String rateLimitKey, - @NonNull String methodName, - @NonNull Method method, - @NonNull Object[] args, - @NonNull Class returnType) { - - /** - * Compact constructor that validates all parameters are non-null. - * - * @param rateLimitKey the Redis rate limiter key that exceeded its limit - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method - * @param returnType the return type of the method - */ - public RateLimitContext { - Objects.requireNonNull(rateLimitKey, "rateLimitKey must not be null"); - Objects.requireNonNull(methodName, "methodName must not be null"); - Objects.requireNonNull(method, "method must not be null"); - Objects.requireNonNull(args, "args must not be null"); - Objects.requireNonNull(returnType, "returnType must not be null"); - } -} diff --git a/src/main/java/in/riido/locksmith/models/SemaphoreContext.java b/src/main/java/in/riido/locksmith/models/SemaphoreContext.java deleted file mode 100644 index b033b89..0000000 --- a/src/main/java/in/riido/locksmith/models/SemaphoreContext.java +++ /dev/null @@ -1,57 +0,0 @@ -package in.riido.locksmith.models; - -import in.riido.locksmith.handler.SemaphoreSkipHandler; -import java.lang.reflect.Method; -import java.util.Objects; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - -/** - * Provides contextual information about a semaphore permit acquisition attempt. - * - *

This record is passed to {@link SemaphoreSkipHandler} implementations to provide all relevant - * information about the failed permit acquisition, enabling custom handling logic. - * - *

Thread Safety Note: The {@code args} array is passed by reference from the aspect and - * is guaranteed not to be modified by the Locksmith framework. Handlers may safely read the - * arguments but should avoid modifying them to prevent unexpected side effects on the original - * method invocation. - * - * @param semaphoreKey the Redis semaphore key that could not acquire a permit - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method (read-only by convention; not modified by the - * aspect) - * @param returnType the return type of the method - * @param permitId the permit ID if one was acquired, null otherwise. When used in skip handlers, - * this is always null because skip handlers are only invoked when permit acquisition fails. - * @author Garvit Joshi - * @since 2.0.0 - */ -public record SemaphoreContext( - @NonNull String semaphoreKey, - @NonNull String methodName, - @NonNull Method method, - @NonNull Object[] args, - @NonNull Class returnType, - @Nullable String permitId) { - - /** - * Compact constructor that validates all parameters are non-null (except permitId which is - * nullable). - * - * @param semaphoreKey the Redis semaphore key that could not acquire a permit - * @param methodName the formatted method name (e.g., "MyService.processOrder") - * @param method the method that was intercepted - * @param args the arguments passed to the method - * @param returnType the return type of the method - * @param permitId the permit ID if one was acquired, null otherwise - */ - public SemaphoreContext { - Objects.requireNonNull(semaphoreKey, "semaphoreKey must not be null"); - Objects.requireNonNull(methodName, "methodName must not be null"); - Objects.requireNonNull(method, "method must not be null"); - Objects.requireNonNull(args, "args must not be null"); - Objects.requireNonNull(returnType, "returnType must not be null"); - } -} diff --git a/src/main/java/in/riido/locksmith/models/package-info.java b/src/main/java/in/riido/locksmith/models/package-info.java deleted file mode 100644 index 35ab1a3..0000000 --- a/src/main/java/in/riido/locksmith/models/package-info.java +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Context models for lock and semaphore handlers. - * - *

This package contains context objects that are passed to skip handlers when lock or semaphore - * acquisition fails. These contexts provide all relevant information about the failed acquisition - * attempt. - * - *

    - *
  • {@link in.riido.locksmith.models.LockContext} - Context information passed to lock skip - * handlers - *
  • {@link in.riido.locksmith.models.SemaphoreContext} - Context information passed to - * semaphore skip handlers - *
- * - * @author Garvit Joshi - * @since 2.0.0 - * @see in.riido.locksmith.models.LockContext - * @see in.riido.locksmith.models.SemaphoreContext - */ -package in.riido.locksmith.models; diff --git a/src/main/java/in/riido/locksmith/package-info.java b/src/main/java/in/riido/locksmith/package-info.java deleted file mode 100644 index 684fed0..0000000 --- a/src/main/java/in/riido/locksmith/package-info.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Locksmith - A Spring Boot starter for Redis-based distributed locking and semaphores. - * - *

This package contains the public API for distributed locking and semaphores: - * - *

    - *
  • {@link in.riido.locksmith.DistributedLock} - The main annotation for locking methods - *
  • {@link in.riido.locksmith.DistributedSemaphore} - The annotation for permit-based - * concurrency control - *
  • {@link in.riido.locksmith.AcquisitionMode} - Lock and semaphore acquisition strategies - *
  • {@link in.riido.locksmith.LockType} - Types of locks (reentrant, read, write) - *
  • {@link in.riido.locksmith.LeaseExpirationBehavior} - Behavior when execution exceeds lease - * time - *
  • {@link in.riido.locksmith.handler.LockSkipHandler} - Custom lock skip handler interface - *
  • {@link in.riido.locksmith.handler.SemaphoreSkipHandler} - Custom semaphore skip handler - * interface - *
- * - *

Quick Start

- * - *
{@code
- * @Service
- * public class MyService {
- *
- *     @DistributedLock(key = "my-task")
- *     public void criticalTask() {
- *         // Only one instance executes this at a time
- *     }
- *
- *     @DistributedSemaphore(key = "api-pool", permits = 5)
- *     public void rateLimitedTask() {
- *         // Up to 5 instances can execute this concurrently
- *     }
- * }
- * }
- * - * @author Garvit Joshi - * @since 1.0.0 - * @see in.riido.locksmith.DistributedLock - * @see in.riido.locksmith.DistributedSemaphore - */ -package in.riido.locksmith; diff --git a/src/main/java/in/riido/locksmith/semaphore/PermitHandle.java b/src/main/java/in/riido/locksmith/semaphore/PermitHandle.java new file mode 100644 index 0000000..f659073 --- /dev/null +++ b/src/main/java/in/riido/locksmith/semaphore/PermitHandle.java @@ -0,0 +1,169 @@ +package in.riido.locksmith.semaphore; + +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import in.riido.locksmith.support.RedissonFutures; +import java.time.Duration; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The result of one {@link SemaphoreOperations.Builder#acquire()}. Use it in try-with-resources and + * check {@link #acquired()} before starting the bounded work. + */ +public final class PermitHandle implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(PermitHandle.class); + + private final @NonNull RedissonClient redisson; + private final @Nullable RPermitExpirableSemaphore semaphore; + private final @Nullable String permitId; + private final @NonNull String fullKey; + private final @NonNull Duration lease; + private final long acquiredAtNanos; + private final @NonNull Duration waited; + private final @NonNull LocksmithMetrics metrics; + private final AtomicBoolean closed = new AtomicBoolean(); + + PermitHandle( + @NonNull RedissonClient redisson, + @Nullable RPermitExpirableSemaphore semaphore, + @Nullable String permitId, + @NonNull String fullKey, + @NonNull Duration lease, + long acquiredAtNanos, + @NonNull Duration waited, + @NonNull LocksmithMetrics metrics) { + this.redisson = redisson; + this.semaphore = semaphore; + this.permitId = permitId; + this.fullKey = fullKey; + this.lease = lease; + this.acquiredAtNanos = acquiredAtNanos; + this.waited = waited; + this.metrics = metrics; + } + + /** + * Reports whether a permit was acquired. + * + * @return {@code true} if a permit was acquired + */ + public boolean acquired() { + return permitId != null; + } + + /** + * Returns the full Redis key of the semaphore, including the prefix. + * + * @return the full key + */ + public @NonNull String key() { + return fullKey; + } + + /** + * Returns the Redisson id of the acquired permit. + * + * @return the permit id, or null if no permit was acquired + */ + public @Nullable String permitId() { + return permitId; + } + + /** + * Releases the permit if it was acquired and records how long it was held. Closing an unacquired + * handle, or closing a second time, does nothing. Works on any thread and waits for the release, + * also when the thread is interrupted, whose flag is kept; on a Redisson I/O or timer thread it + * only starts the release, because waiting there can stall Redisson. Once the Redisson client is + * shutting down, it stops waiting within about a second, because Redisson may never answer a + * release it already sent; the permit then expires on its own. Never throws: a failed release, + * for example because the lease ran out or Redis is unreachable, is logged as a WARN and the + * permit expires on its own. + */ + @Override + public void close() { + if (semaphore == null || permitId == null || !closed.compareAndSet(false, true)) { + return; + } + Duration held = Duration.ofNanos(System.nanoTime() - acquiredAtNanos); + CompletionStage release; + try { + release = + semaphore + .releaseAsync(permitId) + .handle( + (ignored, failure) -> { + released(held, RedissonFutures.unwrap(failure)); + return null; + }); + } catch (RuntimeException e) { + released(held, e); + return; + } + if (!RedissonFutures.onRedissonIoOrTimerThread()) { + RedissonFutures.awaitRelease(redisson, release); + } + } + + /** + * Logs a failed release, or records the hold time of a successful one. Redisson reports an + * expired or unknown permit as an {@link IllegalArgumentException}, which gets one WARN that + * names the possible causes without picking one: the hold time starts when the acquire returned, + * but the lease started when the acquire was sent, so a hold shorter than the lease does not show + * the lease had time left. A release refused because the client is shutting down is expected + * then, so it gets one line without a stack trace. + */ + private void released(@NonNull Duration held, @Nullable Throwable failure) { + if (failure instanceof IllegalArgumentException notHeld) { + LOG.warn( + "Permit [{}] was reported as not held at release, {}ms after its acquire returned (lease" + + " {}ms). Possible causes: the lease ran out, counted from when the acquire was sent," + + " so a slow Redis reply or a pause also uses it up, and another caller may then have" + + " used this permit; the semaphore key was lost in Redis; a server's clock is ahead," + + " and callers on that server may then have used this permit; or Redisson resent a" + + " release that had already succeeded after a slow Redis reply: {}", + fullKey, + held.toMillis(), + lease.toMillis(), + notHeld.getMessage()); + return; + } + if (failure instanceof RedissonShutdownException) { + LOG.warn( + "Permit [{}] release was not confirmed after {}ms (lease {}ms) because the Redisson client" + + " is shutting down; the permit expires on its own", + fullKey, + held.toMillis(), + lease.toMillis()); + return; + } + if (failure != null) { + LOG.warn( + "Permit [{}] release failed after {}ms (lease {}ms): {}", + fullKey, + held.toMillis(), + lease.toMillis(), + failure.getMessage(), + failure); + return; + } + try { + metrics.recordHeld(Primitive.SEMAPHORE, held); + } catch (RuntimeException e) { + LOG.warn("Permit [{}] metrics recording failed: {}", fullKey, e.getMessage()); + } + LOG.debug( + "Permit [{}] released after {}ms, waited {}ms", + fullKey, + held.toMillis(), + waited.toMillis()); + } +} diff --git a/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureContext.java b/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureContext.java new file mode 100644 index 0000000..298f732 --- /dev/null +++ b/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureContext.java @@ -0,0 +1,23 @@ +package in.riido.locksmith.semaphore; + +import java.lang.reflect.Method; +import java.time.Duration; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Describes a call whose semaphore permit was not acquired; passed to {@link + * SemaphoreFailureHandler}. + * + * @param key the full Redis key, including the prefix + * @param permits the permit count configured for the semaphore + * @param method the annotated method + * @param args the arguments of the call; the array is never null, its elements may be + * @param waitTime how long the call waited for a permit + */ +public record SemaphoreFailureContext( + @NonNull String key, + int permits, + @NonNull Method method, + @Nullable Object @NonNull [] args, + @NonNull Duration waitTime) {} diff --git a/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureHandler.java b/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureHandler.java new file mode 100644 index 0000000..3f42798 --- /dev/null +++ b/src/main/java/in/riido/locksmith/semaphore/SemaphoreFailureHandler.java @@ -0,0 +1,21 @@ +package in.riido.locksmith.semaphore; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Decides what a {@code @DistributedSemaphore} method returns when its permit is not acquired and + * its failure policy is {@code HANDLER}. Implementations are Spring beans, resolved by type. + */ +@FunctionalInterface +public interface SemaphoreFailureHandler { + + /** + * Handles a permit that was not acquired. Any exception thrown here propagates from the annotated + * method. + * + * @param context the key, permit count, method, arguments and wait time of the failed call + * @return the value the annotated method returns, as-is + */ + @Nullable Object onFailure(@NonNull SemaphoreFailureContext context); +} diff --git a/src/main/java/in/riido/locksmith/semaphore/SemaphoreNotAcquiredException.java b/src/main/java/in/riido/locksmith/semaphore/SemaphoreNotAcquiredException.java new file mode 100644 index 0000000..d3332b5 --- /dev/null +++ b/src/main/java/in/riido/locksmith/semaphore/SemaphoreNotAcquiredException.java @@ -0,0 +1,70 @@ +package in.riido.locksmith.semaphore; + +import in.riido.locksmith.LocksmithException; +import java.time.Duration; +import org.jspecify.annotations.NonNull; + +/** + * Thrown when a semaphore permit is not acquired within its wait time and the failure policy is + * {@code THROW}. + */ +public class SemaphoreNotAcquiredException extends LocksmithException { + + /** The full Redis key, including the prefix. */ + private final @NonNull String key; + + /** The permit count configured for the semaphore. */ + private final int permits; + + /** How long the caller waited. */ + private final @NonNull Duration waitTime; + + /** + * Creates the exception for a permit that was not acquired. + * + * @param key the full Redis key, including the prefix + * @param permits the permit count configured for the semaphore + * @param waitTime how long the caller waited + */ + public SemaphoreNotAcquiredException( + @NonNull String key, int permits, @NonNull Duration waitTime) { + super( + "Semaphore [" + + key + + "] permit not acquired within " + + waitTime + + " (permits " + + permits + + ")"); + this.key = key; + this.permits = permits; + this.waitTime = waitTime; + } + + /** + * Returns the full Redis key, including the prefix. + * + * @return the key + */ + public @NonNull String key() { + return key; + } + + /** + * Returns the permit count configured for the semaphore. + * + * @return the permit count + */ + public int permits() { + return permits; + } + + /** + * Returns how long the caller waited. + * + * @return the wait time + */ + public @NonNull Duration waitTime() { + return waitTime; + } +} diff --git a/src/main/java/in/riido/locksmith/semaphore/SemaphoreOperations.java b/src/main/java/in/riido/locksmith/semaphore/SemaphoreOperations.java new file mode 100644 index 0000000..6c90174 --- /dev/null +++ b/src/main/java/in/riido/locksmith/semaphore/SemaphoreOperations.java @@ -0,0 +1,294 @@ +package in.riido.locksmith.semaphore; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Outcome; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import in.riido.locksmith.support.RedissonFutures; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Acquires permits of distributed semaphores over Redis. Every semaphore lives at the Redis key + * {@code semaphore:}. A permit always has a fixed lease: it expires that long after + * it is acquired, whether or not it was released. + * + *
{@code
+ * try (PermitHandle permit =
+ *     semaphores.key("reports").permits(5).waitTime(Duration.ofSeconds(2)).acquire()) {
+ *   if (permit.acquired()) {
+ *     // bounded work
+ *   }
+ * }
+ * }
+ * + *

The permit count is owned by the caller. The first acquire of a key in this JVM, and the first + * acquire with a different count, sets the count in Redis: a new semaphore gets it, an existing one + * with another count is changed to it, and the last writer wins. + * + *

Redisson exceptions, for example when Redis is unreachable, propagate unchanged from every + * method; they are never treated as "not acquired". + */ +public class SemaphoreOperations { + + private static final Logger LOG = LoggerFactory.getLogger(SemaphoreOperations.class); + + private static final String KEY_NAMESPACE = "semaphore:"; + + private final @NonNull RedissonClient redisson; + private final @NonNull LocksmithProperties properties; + private final @NonNull LocksmithMetrics metrics; + private final Map appliedPermits = new ConcurrentHashMap<>(); + + /** + * Creates the operations. + * + * @param redisson the client every permit is taken through + * @param properties supplies the key prefix and the default lease time + * @param metrics records acquire attempts and hold times + */ + public SemaphoreOperations( + @NonNull RedissonClient redisson, + @NonNull LocksmithProperties properties, + @NonNull LocksmithMetrics metrics) { + this.redisson = redisson; + this.properties = properties; + this.metrics = metrics; + } + + /** + * Starts building an acquire of one permit of the semaphore on a key. The permit count must be + * set. Defaults: wait time {@link Duration#ZERO} (try once), lease time {@code + * locksmith.semaphore.lease-time}. + * + * @param key the key without prefix + * @return a builder for the acquire + */ + public @NonNull Builder key(@NonNull String key) { + return new Builder(key); + } + + /** + * Returns the number of permits of a semaphore that are free right now, never below zero. After + * the count is lowered below the number of permits held, it stays zero until enough of them are + * released. + * + * @param key the key without prefix + * @return the free permits + * @throws IllegalStateException if called on a Redisson I/O thread ({@code redisson-netty-*}) or + * the timer thread ({@code redisson-timer-*}), for example inside a callback of a Redisson + * async call; nothing is sent to Redis then + */ + public int availablePermits(@NonNull String key) { + RedissonFutures.requireNotRedissonThread(false); + // Redisson subtracts a lowered count from the free permits, so they go negative while held. + return Math.max(0, redisson.getPermitExpirableSemaphore(prefix(key)).availablePermits()); + } + + private @NonNull String prefix(@NonNull String key) { + return properties.keyPrefix() + KEY_NAMESPACE + key; + } + + /** + * Sets the permit count in Redis once per key per distinct value in this JVM. Redisson's {@code + * setPermits} creates a missing semaphore, changes another count and leaves an equal one alone, + * all in one atomic script, so racing threads and instances need no coordination here: the last + * writer wins. The count is read first only for the INFO line on a change. The cache is read + * without locking, and the Redis calls run outside any map operation, so no acquire ever waits on + * another key's Redis calls. + * + * @throws InterruptedException if the thread is interrupted while a call is pending; the count is + * then set again on the next acquire + */ + private void ensurePermits( + @NonNull RPermitExpirableSemaphore semaphore, @NonNull String fullKey, int permits) + throws InterruptedException { + Integer cached = appliedPermits.get(fullKey); + if (cached != null && cached == permits) { + return; + } + int current = RedissonFutures.await(redisson, semaphore.getPermitsAsync()); + if (current != permits) { + RedissonFutures.await(redisson, semaphore.setPermitsAsync(permits)); + if (current != 0) { + LOG.info("Semaphore [{}] permits changed from {} to {}", fullKey, current, permits); + } + } + appliedPermits.put(fullKey, permits); + } + + /** + * Configures and performs one acquire of a permit. Obtain it from {@link + * SemaphoreOperations#key}. + */ + public final class Builder { + + private final @NonNull String key; + private int permits; + private @NonNull Duration waitTime = Duration.ZERO; + private @NonNull Duration leaseTime = properties.semaphore().leaseTime(); + + private Builder(@NonNull String key) { + this.key = key; + } + + /** + * Sets the permit count of the semaphore. Required; must be at least one, which {@link + * #acquire()} checks. + * + * @param permits the permit count + * @return this builder + */ + public @NonNull Builder permits(int permits) { + this.permits = permits; + return this; + } + + /** + * Sets how long {@link #acquire()} waits for a permit. Zero means try once and give up. + * + * @param waitTime the maximum wait + * @return this builder + * @throws IllegalArgumentException if {@code waitTime} is negative + */ + public @NonNull Builder waitTime(@NonNull Duration waitTime) { + if (waitTime.isNegative()) { + throw new IllegalArgumentException("waitTime must not be negative, got " + waitTime); + } + this.waitTime = waitTime; + return this; + } + + /** + * Sets the lease of the permit: it expires this long after it is acquired. + * + * @param leaseTime the lease + * @return this builder + * @throws IllegalArgumentException if {@code leaseTime} is shorter than one millisecond, + * including zero and negative values + */ + public @NonNull Builder leaseTime(@NonNull Duration leaseTime) { + if (leaseTime.toMillis() <= 0) { + throw new IllegalArgumentException( + "leaseTime must be at least one millisecond, got " + leaseTime); + } + this.leaseTime = leaseTime; + return this; + } + + /** + * Tries to acquire one permit, waiting up to the wait time. Before the first acquire of a key, + * and whenever the permit count differs from the one last applied in this JVM, the count is set + * in Redis. Never throws for "not acquired": the returned handle reports the outcome through + * {@link PermitHandle#acquired()}. If the thread is interrupted before or while the count is + * set or the permit is waited for, its interrupt flag is kept, the handle is unacquired, and no + * permit is left taken in Redis. Only an acquire that completed before the interrupt could stop + * it returns an acquired handle, with the flag set. An interrupt already set when this is + * called sends nothing to Redis. + * + *

Semaphores are not reentrant: a nested acquire of the same key on the same thread takes + * another permit. + * + * @return the handle, acquired or not + * @throws LocksmithConfigurationException if the permit count was not set or is below one, or, + * on a Redis Cluster client, if the key contains a brace that forms no hash tag such as + * {@code {42}}; nothing is sent to Redis then + * @throws IllegalStateException if called on a Redisson I/O thread ({@code redisson-netty-*}) + * or the timer thread ({@code redisson-timer-*}), for example inside a callback of a + * Redisson async call, or with a wait time on any other Redisson thread, such as a topic + * listener; nothing is sent to Redis then + * @throws RuntimeException any Redisson exception, unchanged, for example when Redis is + * unreachable + */ + public @NonNull PermitHandle acquire() { + String fullKey = prefix(key); + if (permits < 1) { + throw new LocksmithConfigurationException( + "Semaphore [" + fullKey + "] permits must be set to at least 1, got " + permits); + } + RedissonFutures.requireNotRedissonThread(waitTime.toMillis() > 0); + RedissonFutures.requireClusterSafeKey(redisson, fullKey); + long startNanos = System.nanoTime(); + if (Thread.currentThread().isInterrupted()) { + // Nothing is sent: an attempt would take the permit briefly, then its cancel undoes it. + return notAcquired(fullKey, Outcome.INTERRUPTED, startNanos); + } + RPermitExpirableSemaphore semaphore = redisson.getPermitExpirableSemaphore(fullKey); + String permitId; + try { + ensurePermits(semaphore, fullKey, permits); + long waitMillis = waitTime.toMillis(); + permitId = tryAcquire(semaphore, waitMillis); + // A semaphore always has at least one permit, so a count of 0 means Redis lost its state: + // the count key, or the set of held permits. setPermits restores the count either way. + if (permitId == null && RedissonFutures.await(redisson, semaphore.getPermitsAsync()) == 0) { + RedissonFutures.await(redisson, semaphore.setPermitsAsync(permits)); + LOG.info( + "Semaphore [{}] had no permits in Redis; count set to {} again", fullKey, permits); + long elapsedMillis = Duration.ofNanos(System.nanoTime() - startNanos).toMillis(); + long leftMillis = Math.max(0L, waitMillis - elapsedMillis); + permitId = tryAcquire(semaphore, leftMillis); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return notAcquired(fullKey, Outcome.INTERRUPTED, startNanos); + } + if (permitId == null) { + return notAcquired(fullKey, Outcome.SKIPPED, startNanos); + } + long acquiredAtNanos = System.nanoTime(); + Duration waited = Duration.ofNanos(acquiredAtNanos - startNanos); + PermitHandle handle = + new PermitHandle( + redisson, semaphore, permitId, fullKey, leaseTime, acquiredAtNanos, waited, metrics); + try { + metrics.recordAcquire(Primitive.SEMAPHORE, Outcome.ACQUIRED, waited); + } catch (RuntimeException e) { + handle.close(); + throw e; + } + return handle; + } + + /** + * Tries once to acquire one permit through Redisson's async API. The list variant is used + * because its future is the one Redisson completes, so cancelling it on an interrupt makes + * Redisson release a permit the attempt still wins; the single-permit variant returns a derived + * future that a cancel does not reach. + * + * @return the permit id, or null if none was acquired within {@code waitMillis} + * @throws InterruptedException if the thread is interrupted, including before the call + */ + private @Nullable String tryAcquire( + @NonNull RPermitExpirableSemaphore semaphore, long waitMillis) throws InterruptedException { + List ids = + RedissonFutures.await( + redisson, + semaphore.tryAcquireAsync(1, waitMillis, leaseTime.toMillis(), MILLISECONDS)); + return ids.isEmpty() ? null : ids.get(0); + } + + private @NonNull PermitHandle notAcquired( + @NonNull String fullKey, @NonNull Outcome outcome, long startNanos) { + Duration waited = Duration.ofNanos(System.nanoTime() - startNanos); + metrics.recordAcquire(Primitive.SEMAPHORE, outcome, waited); + LOG.debug( + "Permit [{}] not acquired after waiting {}ms: {}", + fullKey, + waited.toMillis(), + outcome.tagValue()); + return new PermitHandle(redisson, null, null, fullKey, leaseTime, 0L, waited, metrics); + } + } +} diff --git a/src/main/java/in/riido/locksmith/support/AnnotationValidator.java b/src/main/java/in/riido/locksmith/support/AnnotationValidator.java new file mode 100644 index 0000000..47e2cf9 --- /dev/null +++ b/src/main/java/in/riido/locksmith/support/AnnotationValidator.java @@ -0,0 +1,220 @@ +package in.riido.locksmith.support; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.LockType; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.aop.MethodSpec; +import in.riido.locksmith.aop.MethodSpecFactory; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Checks the Locksmith annotations of every bean at startup, so a misconfiguration fails the + * context refresh instead of the first call. The specs it builds only serve the check; the + * interceptor builds its own on a method's first call. It never calls {@code getBean} on the + * handler types it checks, so it triggers no early instantiation. + * + *

It is {@link PriorityOrdered}, so it runs before every {@link Ordered} post-processor, + * including the auto-proxy creator. It therefore sees the raw bean: {@code + * ClassUtils.getUserClass(bean)} is the implementing class even when beans get JDK proxies, error + * messages name that class, and annotations on implementing methods are checked at startup. A bean + * that is itself a JDK dynamic proxy, such as a Feign client, is checked through the annotated + * methods of its interfaces. + */ +public class AnnotationValidator implements BeanPostProcessor, PriorityOrdered { + + private final @NonNull ObjectProvider factory; + private final @NonNull ListableBeanFactory beanFactory; + + /** The first method seen with each {@code @DistributedLock} key text, and its lock type. */ + private final Map lockKeys = new ConcurrentHashMap<>(); + + /** The first method seen with each {@code @DistributedSemaphore} key text, and its count. */ + private final Map semaphoreKeys = new ConcurrentHashMap<>(); + + /** + * Creates the validator. The provider is resolved on the first annotated bean, not here. + * + * @param factory builds and checks the spec of each annotated method + * @param beanFactory counts the beans of each failure handler type + */ + public AnnotationValidator( + @NonNull ObjectProvider factory, + @NonNull ListableBeanFactory beanFactory) { + this.factory = factory; + this.beanFactory = beanFactory; + } + + /** + * Builds the spec of every annotated method of the bean's class, checks that Spring's proxy of + * the bean can intercept each, that each failure handler type has exactly one bean, that no + * {@code @DistributedLock} key text is used both with {@code REENTRANT} and with {@code READ} or + * {@code WRITE}, and that no {@code @DistributedSemaphore} key text is used with two permit + * counts. + * + * @param bean the initialized bean + * @param beanName the bean name + * @return the bean, unchanged + * @throws LocksmithConfigurationException on the first misconfigured annotation, which aborts the + * context refresh + */ + @Override + public @NonNull Object postProcessAfterInitialization( + @NonNull Object bean, @NonNull String beanName) { + Class target = ClassUtils.getUserClass(bean); + for (Method declared : + ReflectionUtils.getUniqueDeclaredMethods(target, MethodSpecFactory::isAnnotated)) { + Method method = MethodSpecFactory.interfaceMethodOfJdkProxy(declared); + MethodSpec spec = factory.getObject().create(method); + if (spec.lock() != null) { + requireInterceptable(DistributedLock.class, method, target); + requireSingleHandlerBean(DistributedLock.class, spec.lock().handlerType(), method); + requireOneLockKind(KeyTemplate.describe(spec.lock().key()), spec.lock().type(), method); + } + if (spec.semaphore() != null) { + requireInterceptable(DistributedSemaphore.class, method, target); + requireSingleHandlerBean( + DistributedSemaphore.class, spec.semaphore().handlerType(), method); + requireOneCount( + KeyTemplate.describe(spec.semaphore().key()), spec.semaphore().permits(), method); + } + } + return bean; + } + + /** + * Returns {@link Ordered#LOWEST_PRECEDENCE}: last among the {@link PriorityOrdered} + * post-processors, and still before every {@link Ordered} one. + * + * @return the order + */ + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + /** + * Fails on a package-private method declared in another package than the bean class, or loaded by + * another class loader: Spring's class-based proxy cannot override it, so it never intercepts it + * and the annotation would silently do nothing. {@link MethodSpecFactory} already refuses + * private, static and final methods. + */ + private static void requireInterceptable( + @NonNull Class annotation, + @NonNull Method method, + @NonNull Class beanClass) { + int modifiers = method.getModifiers(); + Class declaring = method.getDeclaringClass(); + if (Modifier.isPublic(modifiers) + || Modifier.isProtected(modifiers) + || (declaring.getPackageName().equals(beanClass.getPackageName()) + && declaring.getClassLoader() == beanClass.getClassLoader())) { + return; + } + throw new LocksmithConfigurationException( + "@" + + annotation.getSimpleName() + + " on " + + describe(method) + + ": the method is package-private but bean class " + + beanClass.getName() + + " is in another package or class loader, so Spring's proxy never intercepts it and" + + " it would run without coordination; make it public or protected"); + } + + /** + * Fails when a key text is used with {@code REENTRANT} on one method and with {@code READ} or + * {@code WRITE} on another: the two kinds live at different Redis keys, so they would not exclude + * each other. + */ + private void requireOneLockKind( + @NonNull String keyText, @NonNull LockType type, @NonNull Method method) { + KeyUse first = lockKeys.putIfAbsent(keyText, new KeyUse(method, type)); + if (first == null || (first.type() == LockType.REENTRANT) == (type == LockType.REENTRANT)) { + return; + } + throw new LocksmithConfigurationException( + "@DistributedLock key [" + + keyText + + "] is used with " + + first.type() + + " on " + + describe(first.method()) + + " and with " + + type + + " on " + + describe(method) + + "; a REENTRANT lock and a READ/WRITE lock of one key are separate locks and do not" + + " exclude each other. Pick one kind for that key: REENTRANT, or READ/WRITE."); + } + + /** + * Fails when a key text is used with one permit count on one method and with another count on + * another: Redis keeps one count per semaphore, so each would overwrite the other's. + */ + private void requireOneCount(@NonNull String keyText, int permits, @NonNull Method method) { + PermitsUse first = semaphoreKeys.putIfAbsent(keyText, new PermitsUse(method, permits)); + if (first == null || first.permits() == permits) { + return; + } + throw new LocksmithConfigurationException( + "@DistributedSemaphore key [" + + keyText + + "] is used with permits " + + first.permits() + + " on " + + describe(first.method()) + + " and with permits " + + permits + + " on " + + describe(method) + + "; Redis keeps one count per semaphore, so each would overwrite the other's. Use one" + + " count for that key, or give each count its own key."); + } + + private static @NonNull String describe(@NonNull Method method) { + return method.getDeclaringClass().getName() + "." + method.getName(); + } + + /** A method that uses a lock key text, and the lock type it uses it with. */ + private record KeyUse(@NonNull Method method, @NonNull LockType type) {} + + /** A method that uses a semaphore key text, and the permit count it uses it with. */ + private record PermitsUse(@NonNull Method method, int permits) {} + + private void requireSingleHandlerBean( + @NonNull Class annotation, + @Nullable Class handlerType, + @NonNull Method method) { + if (handlerType == null) { + return; + } + int found = beanFactory.getBeanNamesForType(handlerType).length; + if (found != 1) { + throw new LocksmithConfigurationException( + "@" + + annotation.getSimpleName() + + " on " + + method.getDeclaringClass().getName() + + "." + + method.getName() + + ": onFailure HANDLER requires exactly one bean of handler type [" + + handlerType.getName() + + "], found " + + found); + } + } +} diff --git a/src/main/java/in/riido/locksmith/support/AspectSupport.java b/src/main/java/in/riido/locksmith/support/AspectSupport.java deleted file mode 100644 index 1d8e218..0000000 --- a/src/main/java/in/riido/locksmith/support/AspectSupport.java +++ /dev/null @@ -1,176 +0,0 @@ -package in.riido.locksmith.support; - -import in.riido.locksmith.LeaseExpirationBehavior; -import java.time.Duration; -import java.util.function.Supplier; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeansException; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ConfigurableApplicationContext; - -/** - * Utility class providing common operations shared across all locksmith aspects. - * - *

Contains reusable logic for method signature formatting, application context checks, and - * handler resolution that would otherwise be duplicated across {@code DistributedLockAspect}, - * {@code DistributedSemaphoreAspect}, and {@code RateLimitAspect}. - * - * @author Garvit Joshi - * @since 3.0.3 - */ -public final class AspectSupport { - - private static final Logger LOG = LoggerFactory.getLogger(AspectSupport.class); - - private AspectSupport() { - // Utility class - } - - /** - * Formats a join point's method signature as "ClassName.methodName". - * - * @param joinPoint the join point representing the intercepted method - * @return the formatted method signature - */ - @NonNull - public static String formatMethodSignature(@NonNull ProceedingJoinPoint joinPoint) { - final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - return signature.getDeclaringType().getSimpleName() + "." + signature.getName(); - } - - /** - * Checks if the application context is active and can be used for bean lookups. - * - * @param applicationContext the application context to check - * @return true if the context is active, false otherwise - */ - public static boolean isApplicationContextActive(@NonNull ApplicationContext applicationContext) { - if (applicationContext instanceof ConfigurableApplicationContext configurableContext) { - return configurableContext.isActive(); - } - // For non-configurable contexts, assume active - return true; - } - - /** - * Resolves a handler instance by first attempting Spring bean lookup, then falling back to - * reflective instantiation. - * - *

Resolution order: - * - *

    - *
  1. Look up the handler as a Spring bean from ApplicationContext by type - *
  2. Fall back to reflection-based instantiation (requires public no-arg constructor) - *
- * - *

This method does not cache instances. Callers should wrap calls with their own caching - * (e.g., via {@code ConcurrentHashMap.computeIfAbsent}). - * - * @param the handler type - * @param handlerClass the handler class to resolve - * @param applicationContext the Spring application context for bean lookup - * @param debug whether debug logging is enabled - * @return the resolved handler instance - * @throws IllegalStateException if the handler cannot be instantiated - */ - @NonNull - public static T resolveHandler( - @NonNull Class handlerClass, - @NonNull ApplicationContext applicationContext, - boolean debug) { - // First, try to get the handler as a Spring bean (only if context is active) - if (isApplicationContextActive(applicationContext)) { - try { - T bean = applicationContext.getBean(handlerClass); - if (bean != null) { - return bean; - } - } catch (BeansException ignored) { - // Bean not found, will fall back to reflection - } - } else { - if (debug) { - LOG.info( - "ApplicationContext is not active, skipping Spring bean lookup for handler: {}", - handlerClass.getName()); - } - } - // Not a Spring bean, fall back to reflection - if (debug) { - LOG.info( - "Handler {} not found as Spring bean, falling back to reflection-based instantiation", - handlerClass.getName()); - } - - // Fall back to reflection-based instantiation - try { - return handlerClass.getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Failed to instantiate skip handler: " - + handlerClass.getName() - + ". Ensure it is a Spring bean or has a public no-argument constructor.", - e); - } - } - - /** - * Checks if the method execution time exceeded the lease duration and handles accordingly. - * - *

Shared logic used by both {@code DistributedLockAspect} and {@code - * DistributedSemaphoreAspect} to avoid duplication. - * - * @param behavior the configured behavior for lease expiration - * @param leaseTime the configured lease duration - * @param executionTimeMs the actual execution time in milliseconds - * @param key the Redis key (lock key or semaphore key) - * @param methodName the method name - * @param primitiveName the display name for log messages (e.g., "Lock" or "Semaphore permit") - * @param metricsRecorder callback to record lease expiration in metrics, or null if metrics are - * disabled - * @param exceptionSupplier supplier for the exception to throw when behavior is {@link - * LeaseExpirationBehavior#THROW_EXCEPTION} - */ - public static void checkLeaseExpiration( - @NonNull LeaseExpirationBehavior behavior, - @NonNull Duration leaseTime, - long executionTimeMs, - @NonNull String key, - @NonNull String methodName, - @NonNull String primitiveName, - @Nullable Runnable metricsRecorder, - @NonNull Supplier exceptionSupplier) { - - final long leaseTimeMs = leaseTime.toMillis(); - - if (executionTimeMs <= leaseTimeMs) { - return; - } - - if (metricsRecorder != null) { - metricsRecorder.run(); - } - - switch (behavior) { - case LOG_WARNING -> - LOG.warn( - "{} [{}] lease may have expired during execution of [{}]. " - + "Lease time: {}ms, Execution time: {}ms. " - + "Consider increasing the lease time.", - primitiveName, - key, - methodName, - leaseTimeMs, - executionTimeMs); - case THROW_EXCEPTION -> throw exceptionSupplier.get(); - case IGNORE -> { - // Do nothing - } - } - } -} diff --git a/src/main/java/in/riido/locksmith/support/DurationResolver.java b/src/main/java/in/riido/locksmith/support/DurationResolver.java deleted file mode 100644 index 0472a01..0000000 --- a/src/main/java/in/riido/locksmith/support/DurationResolver.java +++ /dev/null @@ -1,51 +0,0 @@ -package in.riido.locksmith.support; - -import java.time.Duration; -import org.jspecify.annotations.Nullable; -import org.springframework.boot.convert.DurationStyle; - -/** - * Utility class for resolving duration strings with fallback to default values. - * - *

Supports Spring Boot's duration format styles: - * - *

    - *
  • Simple format: {@code "10s"}, {@code "5m"}, {@code "1h"} - *
  • ISO-8601 format: {@code "PT10S"}, {@code "PT5M"}, {@code "PT1H"} - *
- * - *

This class is thread-safe and stateless. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public final class DurationResolver { - - private DurationResolver() { - // Utility class - } - - /** - * Resolves a duration from the given string, falling back to the default if blank. - * - * @param durationString the duration string (e.g., "10m", "30s", "PT10M") - * @param defaultValue the default value to use if the string is blank - * @return the resolved Duration, or the default value if the string is blank - * @throws IllegalArgumentException if the value is not a known style or cannot be parsed - */ - public static Duration resolve(@Nullable String durationString, Duration defaultValue) { - if (durationString == null || durationString.isBlank()) { - return defaultValue; - } - try { - return DurationStyle.detectAndParse(durationString); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException( - "Invalid duration format: '" - + durationString - + "'. Expected formats: simple (e.g., '10s', '5m', '1h') " - + "or ISO-8601 (e.g., 'PT10S', 'PT5M', 'PT1H')", - e); - } - } -} diff --git a/src/main/java/in/riido/locksmith/support/KeyTemplate.java b/src/main/java/in/riido/locksmith/support/KeyTemplate.java new file mode 100644 index 0000000..42f1df7 --- /dev/null +++ b/src/main/java/in/riido/locksmith/support/KeyTemplate.java @@ -0,0 +1,204 @@ +package in.riido.locksmith.support; + +import in.riido.locksmith.LocksmithConfigurationException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ParseException; +import org.springframework.expression.ParserContext; +import org.springframework.expression.common.CompositeStringExpression; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.SpelNode; +import org.springframework.expression.spel.ast.BeanReference; +import org.springframework.expression.spel.ast.VariableReference; +import org.springframework.expression.spel.standard.SpelExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; + +/** + * Parses, checks and evaluates key templates: literal text with {@code #{...}} SpEL islands whose + * variables are the method parameters by name, {@code #pN} or {@code #aN}, and {@code #this} inside + * a selection or projection. + */ +public final class KeyTemplate { + + /** Hint added to the message when a template variable does not match a parameter. */ + private static final String PARAMETERS_HINT = "compile with -parameters or use #p0"; + + private static final SpelExpressionParser PARSER = new SpelExpressionParser(); + + /** {@code #pN} or {@code #aN} as {@link MethodBasedEvaluationContext} defines them. */ + private static final Pattern INDEXED_VARIABLE = Pattern.compile("[pa](0|[1-9][0-9]{0,8})"); + + /** {@code #this}, the current element inside a selection or projection. */ + private static final String THIS_VARIABLE = "this"; + + private KeyTemplate() {} + + /** + * Parses a key template in template mode. + * + * @param template the template, for example {@code "user:#{#userId}"} + * @return the parsed expression + * @throws ParseException if the template is not valid SpEL + */ + public static @NonNull Expression parse(@NonNull String template) { + return PARSER.parseExpression(template, ParserContext.TEMPLATE_EXPRESSION); + } + + /** + * Evaluates a parsed template against one invocation of a method. + * + * @param expression the parsed template + * @param method the invoked method + * @param args the invocation arguments + * @return the resolved key, never blank + * @throws LocksmithConfigurationException if the template, or one of its {@code #{...}} islands, + * resolves to null or a blank string + * @throws org.springframework.expression.EvaluationException if evaluation fails + */ + public static @NonNull String evaluate( + @NonNull Expression expression, @NonNull Method method, @Nullable Object @NonNull [] args) { + MethodBasedEvaluationContext context = + new MethodBasedEvaluationContext( + null, method, args, DefaultParameterNameDiscoverer.getSharedInstance()); + String key = + expression instanceof CompositeStringExpression composite + ? evaluateParts(composite, method, context) + : expression.getValue(context, String.class); + if (key == null || key.isBlank()) { + throw new LocksmithConfigurationException( + "Key template [" + + describe(expression) + + "] on " + + describe(method) + + " resolved to a null or blank key"); + } + return key; + } + + /** + * Concatenates the parts the way {@link CompositeStringExpression#getValue} does, but rejects an + * island that resolves to null or blank: the composite would skip it or keep it, and every such + * call would then share one key. + */ + private static @NonNull String evaluateParts( + @NonNull CompositeStringExpression composite, + @NonNull Method method, + @NonNull MethodBasedEvaluationContext context) { + StringBuilder key = new StringBuilder(); + for (Expression part : composite.getExpressions()) { + String value = part.getValue(context, String.class); + if (!(part instanceof LiteralExpression) && (value == null || value.isBlank())) { + throw new LocksmithConfigurationException( + "Key template [" + + describe(composite) + + "] on " + + describe(method) + + ": part " + + describe(part) + + (value == null ? " resolved to null" : " resolved to a blank value")); + } + if (value != null) { + key.append(value); + } + } + return key.toString(); + } + + /** + * Checks that every variable in a parsed template is a parameter name of the method, {@code #pN} + * / {@code #aN} with {@code N} below the parameter count, or {@code #this}, which is allowed + * inside filters and projections, and that the template references no bean. {@code #root} and + * bean references such as {@code @myBean} are rejected: there is no root object and no bean + * resolver, so either would fail every call. + * + * @param expression the parsed template + * @param method the annotated method + * @throws LocksmithConfigurationException naming the class, the method and the variable or the + * bean reference that is not allowed + */ + public static void validateVariables(@NonNull Expression expression, @NonNull Method method) { + String[] names = DefaultParameterNameDiscoverer.getSharedInstance().getParameterNames(method); + List parameterNames = names == null ? List.of() : Arrays.asList(names); + for (SpelNode node : nodes(expression)) { + if (node instanceof BeanReference) { + throw new LocksmithConfigurationException( + "Key template [" + + describe(expression) + + "] on " + + describe(method) + + " uses bean reference " + + node.toStringAST() + + ", which key templates do not support; pass the value as a method argument"); + } + if (!(node instanceof VariableReference)) { + continue; + } + String variable = node.toStringAST().substring(1); + if (!variable.equals(THIS_VARIABLE) + && !parameterNames.contains(variable) + && !isIndexInRange(variable, method)) { + throw new LocksmithConfigurationException( + "Key template [" + + describe(expression) + + "] on " + + describe(method) + + " uses variable #" + + variable + + " which is not a parameter of the method; " + + PARAMETERS_HINT); + } + } + } + + private static boolean isIndexInRange(@NonNull String variable, @NonNull Method method) { + return INDEXED_VARIABLE.matcher(variable).matches() + && Integer.parseInt(variable.substring(1)) < method.getParameterCount(); + } + + private static @NonNull List nodes(@NonNull Expression expression) { + List nodes = new ArrayList<>(); + collect(expression, nodes); + return nodes; + } + + private static void collect(@NonNull Expression expression, @NonNull List nodes) { + if (expression instanceof CompositeStringExpression composite) { + for (Expression part : composite.getExpressions()) { + collect(part, nodes); + } + } else if (expression instanceof SpelExpression spel) { + collect(spel.getAST(), nodes); + } + // LiteralExpression has no nodes. + } + + private static void collect(@NonNull SpelNode node, @NonNull List nodes) { + nodes.add(node); + for (int i = 0; i < node.getChildCount(); i++) { + collect(node.getChild(i), nodes); + } + } + + private static @NonNull String describe(@NonNull Method method) { + return method.getDeclaringClass().getName() + "." + method.getName(); + } + + /** + * Returns the template as written. An island-only template parses to a bare {@link + * SpelExpression} whose string is the island body, so its braces are put back; composite and + * literal expressions already carry the original text. + */ + static @NonNull String describe(@NonNull Expression expression) { + return expression instanceof SpelExpression + ? "#{" + expression.getExpressionString() + "}" + : expression.getExpressionString(); + } +} diff --git a/src/main/java/in/riido/locksmith/support/RateLimitConfig.java b/src/main/java/in/riido/locksmith/support/RateLimitConfig.java deleted file mode 100644 index fb8beaa..0000000 --- a/src/main/java/in/riido/locksmith/support/RateLimitConfig.java +++ /dev/null @@ -1,56 +0,0 @@ -package in.riido.locksmith.support; - -import java.io.Serial; -import java.io.Serializable; -import java.util.Objects; -import org.jspecify.annotations.NonNull; -import org.redisson.api.RateType; - -/** - * Configuration record for rate limiter settings. Used for tracking and validating rate limiter - * configurations both within a JVM and across Redis metadata storage. - * - * @param permits the number of permits per interval - * @param intervalMs the interval in milliseconds - * @param rateType the rate type (OVERALL or PER_CLIENT) - * @author Garvit Joshi - * @since 3.0.0 - */ -public record RateLimitConfig( - /** The number of permits granted per interval. */ - long permits, - /** The interval window in milliseconds. */ - long intervalMs, - /** The Redisson rate type for permit distribution semantics. */ - @NonNull RateType rateType) - implements Serializable { - @Serial private static final long serialVersionUID = 1L; - - /** - * Creates a rate limit configuration and validates required values. - * - * @param permits the number of permits per interval - * @param intervalMs the interval in milliseconds - * @param rateType the rate type (OVERALL or PER_CLIENT) - */ - public RateLimitConfig { - if (permits <= 0) { - throw new IllegalArgumentException("permits must be positive, got: " + permits); - } - if (intervalMs <= 0) { - throw new IllegalArgumentException("intervalMs must be positive, got: " + intervalMs); - } - Objects.requireNonNull(rateType, "rateType must not be null"); - } - - @Override - public String toString() { - return "RateLimitConfig[permits=" - + permits - + ", intervalMs=" - + intervalMs - + ", rateType=" - + rateType - + "]"; - } -} diff --git a/src/main/java/in/riido/locksmith/support/RateLimitInitializer.java b/src/main/java/in/riido/locksmith/support/RateLimitInitializer.java deleted file mode 100644 index e186557..0000000 --- a/src/main/java/in/riido/locksmith/support/RateLimitInitializer.java +++ /dev/null @@ -1,154 +0,0 @@ -package in.riido.locksmith.support; - -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.jspecify.annotations.NonNull; -import org.redisson.api.RBucket; -import org.redisson.api.RRateLimiter; -import org.redisson.api.RateType; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Manages distributed rate limiter initialization and configuration consistency validation. - * - *

This class encapsulates the logic for: - * - *

    - *
  • Initializing rate limiters in Redis with metadata tracking for cross-deployment mismatch - * detection - *
  • Validating that the same rate limiter key is not used with inconsistent configurations - * within a JVM - *
- * - *

Used by both {@code RateLimitAspect} and {@code LocksmithRateLimitTemplate} to avoid - * duplicating initialization logic. Each consumer creates its own instance, maintaining independent - * JVM-level caches. - * - *

Race Condition Note: There is a small race window between {@code trySetRate} and {@code - * metaBucket.set} where another instance could read null from the metadata bucket. This is - * acceptable because: - * - *

    - *
  • Redisson's {@code trySetRate} is itself atomic - only one instance will successfully create - * the rate limiter - *
  • The metadata is only used for logging warnings about configuration mismatches - *
  • Worst case: duplicate "created rate limiter" log messages on first initialization - *
  • The rate limiter's actual configuration in Redis is always correct - *
- * - * @author Garvit Joshi - * @since 3.0.4 - */ -public class RateLimitInitializer { - - private static final Logger LOG = LoggerFactory.getLogger(RateLimitInitializer.class); - private static final String META_SUFFIX = ":rate-meta"; - - /** Cache to track rate limiter configurations per key within this JVM. */ - private final Map initializedConfigs = new ConcurrentHashMap<>(); - - private final RedissonClient redissonClient; - - /** - * Creates a new {@code RateLimitInitializer} with the given Redisson client. - * - * @param redissonClient the Redisson client used for Redis operations - */ - public RateLimitInitializer(@NonNull RedissonClient redissonClient) { - this.redissonClient = redissonClient; - } - - /** - * Validates that the same rate limiter key is not used with different configurations within this - * JVM. If a mismatch is detected, a warning is logged but the existing configuration is - * preserved. - * - * @param rateLimitKey the full rate limiter key including prefix - * @param config the new configuration to validate against existing - */ - public void validateConfigConsistency( - @NonNull String rateLimitKey, @NonNull RateLimitConfig config) { - RateLimitConfig existingConfig = initializedConfigs.get(rateLimitKey); - if (existingConfig != null && !existingConfig.equals(config)) { - LOG.warn( - "Rate limiter [{}] is configured with different settings in this JVM: " - + "existing={}, new={}. Using existing configuration.", - rateLimitKey, - existingConfig, - config); - } - } - - /** - * Ensures the rate limiter is initialized in Redis with the configured rate. Uses metadata - * storage to detect and warn about configuration mismatches across deployments. - * - * @param rateLimitKey the full rate limiter key including prefix - * @param permits the number of permits per interval - * @param interval the interval duration - * @param rateType the rate type (OVERALL or PER_CLIENT) - */ - public void ensureInitialized( - @NonNull String rateLimitKey, - long permits, - @NonNull Duration interval, - @NonNull RateType rateType) { - - RateLimitConfig newConfig = new RateLimitConfig(permits, interval.toMillis(), rateType); - RateLimitConfig existingConfig = initializedConfigs.get(rateLimitKey); - - if (existingConfig != null) { - return; // Already initialized by this JVM - } - - String metaKey = rateLimitKey + META_SUFFIX; - RBucket metaBucket = redissonClient.getBucket(metaKey); - RRateLimiter rateLimiter = redissonClient.getRateLimiter(rateLimitKey); - - RateLimitConfig existingRedisConfig = metaBucket.get(); - - if (existingRedisConfig == null) { - boolean created = rateLimiter.trySetRate(rateType, permits, interval); - - if (created) { - metaBucket.set(newConfig); - LOG.info( - "Created rate limiter [{}] with permits={}, interval={}, type={}", - rateLimitKey, - permits, - interval, - rateType); - } else { - // Race condition: another instance created it between our check and set - existingRedisConfig = metaBucket.get(); - if (existingRedisConfig != null && !existingRedisConfig.equals(newConfig)) { - LOG.warn( - "Rate limiter [{}] was created by another instance with different settings: " - + "existing={}, this={}. Using existing configuration. " - + "To change: delete Redis keys '{}' and '{}', then redeploy all instances.", - rateLimitKey, - existingRedisConfig, - newConfig, - rateLimitKey, - metaKey); - } - } - } else if (!existingRedisConfig.equals(newConfig)) { - LOG.warn( - "Rate limiter [{}] exists with different settings: existing={}, this={}. " - + "Using existing configuration. To change: delete Redis keys '{}' and '{}', " - + "then redeploy all instances.", - rateLimitKey, - existingRedisConfig, - newConfig, - rateLimitKey, - metaKey); - } - - initializedConfigs.put( - rateLimitKey, existingRedisConfig != null ? existingRedisConfig : newConfig); - } -} diff --git a/src/main/java/in/riido/locksmith/support/RedissonFutures.java b/src/main/java/in/riido/locksmith/support/RedissonFutures.java new file mode 100644 index 0000000..5ccafda --- /dev/null +++ b/src/main/java/in/riido/locksmith/support/RedissonFutures.java @@ -0,0 +1,226 @@ +package in.riido.locksmith.support; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import in.riido.locksmith.LocksmithConfigurationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RFuture; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisException; + +/** + * The Redisson rules Locksmith's acquires and releases follow: which Redisson threads must not + * block, which keys Redisson cannot keep in one Redis Cluster slot, and how to wait for an async + * call. Locksmith acquires through Redisson's async API and waits here instead of calling + * Redisson's blocking methods: those throw a {@code RedisException} when the thread is interrupted + * but let the call finish in Redis, so a lock or permit is taken with no handle to release it. + */ +public final class RedissonFutures { + + /** How often a waiting call checks whether its client is shutting down. */ + private static final long SHUTDOWN_CHECK_MILLIS = 1000; + + /** Redisson's I/O threads: they read every Redis reply. */ + private static final String IO_THREAD = "redisson-netty"; + + /** Redisson's timer thread: it runs every timeout and every lock renewal of the client. */ + private static final String TIMER_THREAD = "redisson-timer"; + + /** + * Any Redisson thread, including the executor threads that run listeners and executor service + * tasks and deliver pub/sub messages, such as the one that wakes a waiting acquire. + */ + private static final String REDISSON_THREAD = "redisson"; + + private RedissonFutures() {} + + /** + * Refuses a blocking call, such as an acquire, on a Redisson thread where it could stall + * Redisson. Call it before anything is sent to Redis. A Redisson I/O thread is refused as + * Redisson's blocking methods refuse it, and so is the timer thread, since blocking it stops + * every timeout and lock renewal of the client. Any other Redisson thread, such as a topic + * listener, is refused only for an acquire that waits, because the message that ends the wait is + * delivered on those threads. + * + * @param waits whether the call may wait for a holder to release + * @throws IllegalStateException on a Redisson I/O thread ({@code redisson-netty-*}) or the timer + * thread ({@code redisson-timer-*}), and on any other Redisson thread when {@code waits} + */ + public static void requireNotRedissonThread(boolean waits) { + String thread = Thread.currentThread().getName(); + if (thread.startsWith(IO_THREAD)) { + // Mirrors the guard of org.redisson.command.CommandAsyncService.get in Redisson 4.8.0. + throw new IllegalStateException( + "Sync methods can't be invoked from async/rx/reactive listeners"); + } + if (thread.startsWith(TIMER_THREAD)) { + throw new IllegalStateException( + "Locksmith cannot run on Redisson's timer thread [" + + thread + + "]: blocking it stops every timeout and lock renewal of the client. Call it from" + + " another thread, for example with thenRunAsync."); + } + if (waits && thread.startsWith(REDISSON_THREAD)) { + throw new IllegalStateException( + "Locksmith cannot wait for a lock or permit on Redisson thread [" + + thread + + "]: Redisson delivers the message that ends the wait on its own threads, so the" + + " wait can stall them. Call it from another thread, or use a wait time of zero."); + } + } + + /** + * Reports whether the current thread must not wait for a Redis reply: a Redisson I/O thread, + * which would deliver the reply, or the timer thread, which would time it out. + * + * @return {@code true} on a thread whose name starts with {@code redisson-netty} or {@code + * redisson-timer} + */ + public static boolean onRedissonIoOrTimerThread() { + String thread = Thread.currentThread().getName(); + return thread.startsWith(IO_THREAD) || thread.startsWith(TIMER_THREAD); + } + + /** + * Refuses, on a Redis Cluster client, a key that Redisson cannot keep in one slot. Redisson + * places a lock's or a semaphore's companion keys in the key's slot by wrapping the key in + * braces, but it takes a key that already contains an opening brace as carrying its own hash tag. + * When that brace forms no tag, or when a key without an opening brace contains a closing one, + * which ends Redisson's wrapping early, the companion keys land in other slots and Redis rejects + * the calls that use them: a lock is taken but cannot be released, and a semaphore cannot be + * acquired. + * + * @param redisson the client; its configuration is read only for a key with such a brace + * @param fullKey the full Redis key, including the prefix + * @throws LocksmithConfigurationException on a Cluster client, if the key contains an opening + * brace and the first closing brace after it is missing or directly follows it, or if it + * contains a closing brace and no opening one + */ + public static void requireClusterSafeKey( + @NonNull RedissonClient redisson, @NonNull String fullKey) { + int open = fullKey.indexOf('{'); + if (open < 0 ? fullKey.indexOf('}') < 0 : fullKey.indexOf('}', open + 1) > open + 1) { + return; + } + if (redisson.getConfig().isClusterConfig()) { + throw new LocksmithConfigurationException( + "Key [" + + fullKey + + "] contains a '{' or '}' that forms no Redis Cluster hash tag such as {42}:" + + " Redisson would put its companion keys in other slots, and Redis Cluster would" + + " reject the calls that use them. Remove the brace or complete the hash tag."); + } + } + + /** + * Waits for a Redisson async call. If the thread is interrupted, the call is cancelled and the + * {@link InterruptedException} is rethrown; Redisson then releases a lock or permit the cancelled + * call still wins, provided the future is the one Redisson completes itself. If the call + * completed before the cancel could reach it, its outcome stands and is returned with the + * interrupt flag set, because dropping it would leave a lock or permit taken with no handle to + * release it. + * + *

Every second of waiting, it checks whether the client is shutting down. Redisson's shutdown + * stops the timer and the pub/sub delivery that complete a waiting lock or permit call, so such a + * call would never complete. It is cancelled instead, as on an interrupt, and the wait fails with + * Redisson's own {@link RedissonShutdownException}. A lock or permit the cancelled call still + * wins then expires on its own, after the watchdog timeout or the lease, because a client that is + * shutting down no longer sends the release. + * + * @param redisson the client the call was made on + * @param future the pending call + * @param the result type + * @return the result + * @throws InterruptedException if the thread is interrupted, including before the call, and the + * call was cancelled + * @throws RedissonShutdownException if the client shuts down while the call is pending + * @throws RedisException what the call failed with, converted as Redisson's blocking methods do + */ + public static T await(@NonNull RedissonClient redisson, @NonNull RFuture future) + throws InterruptedException { + CompletableFuture pending = future.toCompletableFuture(); + while (true) { + try { + return pending.get(SHUTDOWN_CHECK_MILLIS, MILLISECONDS); + } catch (TimeoutException e) { + if (redisson.isShuttingDown() && future.cancel(false)) { + throw new RedissonShutdownException("Redisson is shutdown"); + } + // Still waiting, or the cancel lost to the completion and the next get returns at once. + } catch (InterruptedException e) { + if (future.cancel(false)) { + throw e; + } + // The cancel lost to the completion, so the future is done: the next get returns at once. + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + // The same conversion as Redisson's blocking methods, so both paths throw alike. + throw e.getCause() instanceof RedisException redis + ? redis + : new RedisException("Unexpected exception while processing command", e.getCause()); + } + } + } + + /** + * Returns what a release failed with, without the {@link CompletionException}s around it. + * Redisson can wrap a failure more than once: on a Redis Cluster client, a permit release refused + * at shutdown arrives wrapped twice. + * + * @param failure what the release completed with, or null + * @return the innermost cause, or {@code failure} if it is no {@code CompletionException} + */ + public static @Nullable Throwable unwrap(@Nullable Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException wrapped && wrapped.getCause() != null) { + cause = wrapped.getCause(); + } + return cause; + } + + /** + * Waits for a release to finish. An interrupt does not end the wait; the interrupt flag is set + * again when the wait ends. + * + *

Every second of waiting, it checks whether the client is shutting down, and stops waiting + * once it is: Redisson's shutdown stops the timer that times out and retries a pending call, so a + * release it already sent may never finish. The lock or permit then expires on its own, after the + * watchdog timeout or the lease. + * + * @param redisson the client the release was sent on + * @param release the pending release + */ + public static void awaitRelease( + @NonNull RedissonClient redisson, @NonNull CompletionStage release) { + CompletableFuture pending = release.toCompletableFuture(); + boolean interrupted = false; + try { + while (true) { + try { + pending.get(SHUTDOWN_CHECK_MILLIS, MILLISECONDS); + return; + } catch (TimeoutException e) { + if (redisson.isShuttingDown()) { + return; + } + } catch (InterruptedException e) { + interrupted = true; + } catch (ExecutionException e) { + // The release has finished; the stage that failed reports how. + return; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/src/main/java/in/riido/locksmith/support/ReturnDefaults.java b/src/main/java/in/riido/locksmith/support/ReturnDefaults.java new file mode 100644 index 0000000..6af6d85 --- /dev/null +++ b/src/main/java/in/riido/locksmith/support/ReturnDefaults.java @@ -0,0 +1,57 @@ +package in.riido.locksmith.support; + +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** The value an annotated method returns when its failure policy is {@code SKIP}. */ +public final class ReturnDefaults { + + private static final Map, Object> DEFAULTS = + Map.ofEntries( + Map.entry(Optional.class, Optional.empty()), + Map.entry(OptionalInt.class, OptionalInt.empty()), + Map.entry(OptionalLong.class, OptionalLong.empty()), + Map.entry(OptionalDouble.class, OptionalDouble.empty()), + Map.entry(boolean.class, false), + Map.entry(Boolean.class, false), + Map.entry(byte.class, (byte) 0), + Map.entry(Byte.class, (byte) 0), + Map.entry(short.class, (short) 0), + Map.entry(Short.class, (short) 0), + Map.entry(int.class, 0), + Map.entry(Integer.class, 0), + Map.entry(long.class, 0L), + Map.entry(Long.class, 0L), + Map.entry(float.class, 0F), + Map.entry(Float.class, 0F), + Map.entry(double.class, 0D), + Map.entry(Double.class, 0D), + Map.entry(char.class, '\0'), + Map.entry(Character.class, '\0')); + + private ReturnDefaults() {} + + /** + * Returns the default value for a method return type: a new future already completed with {@code + * null} for {@code CompletionStage} and its subtypes, the empty value for {@code Optional}, + * {@code OptionalInt}, {@code OptionalLong} and {@code OptionalDouble}, {@code false} for + * booleans, zero of the type for the other primitives and their boxes, and {@code null} for + * {@code void} and every other reference type. + * + * @param returnType the method return type + * @return the default value, or null + */ + public static @Nullable Object forType(@NonNull Class returnType) { + if (CompletionStage.class.isAssignableFrom(returnType)) { + return CompletableFuture.completedFuture(null); + } + return DEFAULTS.get(returnType); + } +} diff --git a/src/main/java/in/riido/locksmith/support/SemaphoreInitializer.java b/src/main/java/in/riido/locksmith/support/SemaphoreInitializer.java deleted file mode 100644 index 64e16ce..0000000 --- a/src/main/java/in/riido/locksmith/support/SemaphoreInitializer.java +++ /dev/null @@ -1,142 +0,0 @@ -package in.riido.locksmith.support; - -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RBucket; -import org.redisson.api.RPermitExpirableSemaphore; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Manages distributed semaphore initialization and permits consistency validation. - * - *

This class encapsulates the logic for: - * - *

    - *
  • Initializing semaphores in Redis with metadata tracking for cross-deployment mismatch - * detection - *
  • Validating that the same semaphore key is not used with inconsistent permit counts within a - * JVM - *
- * - *

Used by both {@code DistributedSemaphoreAspect} and {@code LocksmithSemaphoreTemplate} to - * avoid duplicating initialization logic. Each consumer creates its own instance, maintaining - * independent JVM-level caches. - * - *

Race Condition Note: There is a small race window between {@code trySetPermits} and - * {@code metaBucket.set} where another instance could read null from the metadata bucket. This is - * acceptable because: - * - *

    - *
  • Redisson's {@code trySetPermits} is itself atomic - only one instance will successfully - * create the semaphore - *
  • The metadata is only used for logging warnings about permit mismatches - *
  • Worst case: duplicate "created semaphore" log messages on first initialization - *
  • The semaphore's actual permit count in Redis is always correct - *
- * - * @author Garvit Joshi - * @since 3.0.3 - */ -public class SemaphoreInitializer { - - private static final Logger LOG = LoggerFactory.getLogger(SemaphoreInitializer.class); - private static final String META_SUFFIX = ":meta"; - - /** Cache to track which keys have been initialized in Redis by this JVM. */ - private final Map initializedKeys = new ConcurrentHashMap<>(); - - /** Cache to track permits per key within this JVM for consistency validation. */ - private final Map keyToPermits = new ConcurrentHashMap<>(); - - private final RedissonClient redissonClient; - - /** - * Creates a new {@code SemaphoreInitializer} with the given Redisson client. - * - * @param redissonClient the Redisson client used for Redis operations - */ - public SemaphoreInitializer(@NonNull RedissonClient redissonClient) { - this.redissonClient = redissonClient; - } - - /** - * Validates that the same semaphore key is not used with different permits values within this - * JVM. - * - * @param semaphoreKey the full semaphore key including prefix - * @param permits the number of permits configured for this call - * @param methodName the method name for error messages, or null if not available - * @throws SemaphoreConfigurationException if the key is used with inconsistent permit counts - */ - public void validatePermitsConsistency( - @NonNull String semaphoreKey, int permits, @Nullable String methodName) { - Integer existingPermits = keyToPermits.putIfAbsent(semaphoreKey, permits); - if (existingPermits != null && existingPermits != permits) { - String location = methodName != null ? " (in " + methodName + ")" : ""; - throw new SemaphoreConfigurationException( - String.format( - "Semaphore key '%s' is used with inconsistent permits: %d (existing) vs %d%s. " - + "Each key must have the same permits across all usages.", - semaphoreKey, existingPermits, permits, location), - semaphoreKey); - } - } - - /** - * Ensures the semaphore is initialized in Redis with the configured permits. Uses metadata - * storage to detect and warn about permit mismatches across deployments. - * - * @param semaphoreKey the full semaphore key including prefix - * @param permits the number of permits to initialize with - */ - public void ensureInitialized(@NonNull String semaphoreKey, int permits) { - if (initializedKeys.containsKey(semaphoreKey)) { - return; - } - - String metaKey = semaphoreKey + META_SUFFIX; - RBucket metaBucket = redissonClient.getBucket(metaKey); - RPermitExpirableSemaphore semaphore = redissonClient.getPermitExpirableSemaphore(semaphoreKey); - - Integer existingPermits = metaBucket.get(); - - if (existingPermits == null) { - boolean created = semaphore.trySetPermits(permits); - if (created) { - metaBucket.set(permits); - LOG.info("Created semaphore [{}] with {} permits", semaphoreKey, permits); - } else { - // Race condition: another instance created it between our check and set - existingPermits = metaBucket.get(); - if (existingPermits != null && existingPermits != permits) { - LOG.warn( - "Semaphore [{}] was created by another instance with {} permits, " - + "but this instance configured {} permits. Using existing value. " - + "To change: delete Redis keys '{}' and '{}', then redeploy all instances.", - semaphoreKey, - existingPermits, - permits, - semaphoreKey, - metaKey); - } - } - } else if (existingPermits != permits) { - LOG.warn( - "Semaphore [{}] exists with {} permits, but this instance configured {} permits. " - + "Using existing value. To change: delete Redis keys '{}' and '{}', " - + "then redeploy all instances.", - semaphoreKey, - existingPermits, - permits, - semaphoreKey, - metaKey); - } - - initializedKeys.put(semaphoreKey, Boolean.TRUE); - } -} diff --git a/src/main/java/in/riido/locksmith/support/SpELKeyResolver.java b/src/main/java/in/riido/locksmith/support/SpELKeyResolver.java deleted file mode 100644 index 514de80..0000000 --- a/src/main/java/in/riido/locksmith/support/SpELKeyResolver.java +++ /dev/null @@ -1,158 +0,0 @@ -package in.riido.locksmith.support; - -import java.lang.reflect.Method; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.springframework.context.expression.MethodBasedEvaluationContext; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.ParameterNameDiscoverer; -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.Expression; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.standard.SpelExpressionParser; - -/** - * Utility class for resolving lock/semaphore keys with SpEL support. - * - *

SpEL expressions must be wrapped in {@code #{...}} syntax. Keys without this wrapper are - * treated as literal strings. - * - *

Examples: - * - *

    - *
  • {@code "#{#userId}"} - SpEL: evaluates to the value of userId parameter - *
  • {@code "#{#order.id}"} - SpEL: evaluates to order.id property - *
  • {@code "#{'prefix-' + #id}"} - SpEL: concatenation - *
  • {@code "my-key"} - Literal: used as-is - *
  • {@code "order#123"} - Literal: used as-is (# without wrapper is not SpEL) - *
- * - *

This class is thread-safe. Parsed SpEL expressions are cached for performance. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public final class SpELKeyResolver { - - private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); - private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = - new DefaultParameterNameDiscoverer(); - - /** - * Cache of parsed SpEL expressions keyed by the expression string (e.g., {@code "#userId"}, - * {@code "'user-' + #id"}). This cache is bounded by the number of unique SpEL expression strings - * declared in annotations across the codebase, which is determined at compile time. For example, - * {@code @DistributedLock(key = "#{#userId}")} always produces the same cache key ({@code - * "#userId"}) regardless of how many different {@code userId} values are evaluated at runtime. - */ - private static final Map EXPRESSION_CACHE = new ConcurrentHashMap<>(); - - private SpELKeyResolver() { - // Utility class - } - - /** - * Resolves the key, evaluating SpEL expressions if present. - * - *

SpEL expressions must be wrapped in {@code #{...}} syntax: {@code #{#userId}}, {@code - * #{'user-' + #id}} - * - *

Literal keys (without {@code #{...}}) are returned as-is and can contain any characters - * including {@code #}: {@code order#123}, {@code item-#1}, {@code task#} - * - * @param keyExpression the key expression (literal or SpEL) - * @param joinPoint the join point for accessing method and arguments - * @return the resolved key string - * @throws IllegalArgumentException if the SpEL expression evaluates to null or blank - */ - @NonNull - public static String resolve( - @NonNull String keyExpression, @NonNull ProceedingJoinPoint joinPoint) { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - return resolve(keyExpression, signature.getMethod(), joinPoint.getArgs()); - } - - /** - * Resolves the key, evaluating SpEL expressions if present. - * - *

SpEL expressions must be wrapped in {@code #{...}} syntax. - * - * @param keyExpression the key expression (literal or SpEL) - * @param method the method being invoked - * @param args the method arguments - * @return the resolved key string - * @throws IllegalArgumentException if the SpEL expression evaluates to null or blank - */ - @NonNull - public static String resolve( - @NonNull String keyExpression, @NonNull Method method, @Nullable Object[] args) { - if (isSpELExpression(keyExpression)) { - return evaluateSpEL(keyExpression.substring(2, keyExpression.length() - 1), method, args); - } - return keyExpression; - } - - /** - * Evaluates a SpEL expression and returns the resolved key. - * - *

Parsed {@link Expression} objects are cached in {@link #EXPRESSION_CACHE} by the expression - * string itself (not the evaluated result). This means the cache size is bounded by the number of - * unique SpEL expressions in the codebase — typically one per annotated method. The same cached - * {@link Expression} is re-evaluated with a fresh {@link EvaluationContext} on each invocation, - * producing different resolved keys from different method arguments without growing the cache. - * - * @param spELExpression the SpEL expression to evaluate (without #{} wrapper) - * @param method the method being invoked - * @param args the method arguments - * @return the resolved key string - * @throws IllegalArgumentException if the expression evaluates to null or blank - */ - @NonNull - private static String evaluateSpEL( - @NonNull String spELExpression, @NonNull Method method, @Nullable Object[] args) { - EvaluationContext context = - new MethodBasedEvaluationContext(null, method, args, PARAMETER_NAME_DISCOVERER); - - Expression expression = - EXPRESSION_CACHE.computeIfAbsent(spELExpression, EXPRESSION_PARSER::parseExpression); - - Object result = expression.getValue(context); - - if (result == null) { - throw new IllegalArgumentException( - "SpEL expression '" - + spELExpression - + "' evaluated to null for method: " - + method.getDeclaringClass().getSimpleName() - + "." - + method.getName()); - } - - String resolvedKey = result.toString(); - if (resolvedKey.isBlank()) { - throw new IllegalArgumentException( - "SpEL expression '" - + spELExpression - + "' evaluated to blank for method: " - + method.getDeclaringClass().getSimpleName() - + "." - + method.getName()); - } - - return resolvedKey; - } - - /** - * Checks if the given key expression is a SpEL expression. - * - * @param keyExpression the key expression to check - * @return true if the expression is wrapped in #{...}, false otherwise - */ - public static boolean isSpELExpression(@NonNull String keyExpression) { - return keyExpression.startsWith("#{") && keyExpression.endsWith("}"); - } -} diff --git a/src/main/java/in/riido/locksmith/support/package-info.java b/src/main/java/in/riido/locksmith/support/package-info.java index 73504a8..b613907 100644 --- a/src/main/java/in/riido/locksmith/support/package-info.java +++ b/src/main/java/in/riido/locksmith/support/package-info.java @@ -1,19 +1,2 @@ -/** - * Support utilities for the Locksmith library. - * - *

This package contains shared utility classes used by both distributed lock and semaphore - * implementations: - * - *

    - *
  • {@link in.riido.locksmith.support.DurationResolver} - Resolves duration strings to {@link - * java.time.Duration} objects - *
  • {@link in.riido.locksmith.support.SpELKeyResolver} - Resolves SpEL expressions in lock and - * semaphore keys - *
- * - * @author Garvit Joshi - * @since 1.0.0 - * @see in.riido.locksmith.support.DurationResolver - * @see in.riido.locksmith.support.SpELKeyResolver - */ +/** Internal API. Not for use by adopters. May change without notice. */ package in.riido.locksmith.support; diff --git a/src/main/java/in/riido/locksmith/template/LocksmithLockTemplate.java b/src/main/java/in/riido/locksmith/template/LocksmithLockTemplate.java deleted file mode 100644 index f279d3b..0000000 --- a/src/main/java/in/riido/locksmith/template/LocksmithLockTemplate.java +++ /dev/null @@ -1,426 +0,0 @@ -package in.riido.locksmith.template; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.LockType; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.LockProperties; -import in.riido.locksmith.metrics.LockMetrics; -import in.riido.locksmith.template.callback.LockCallback; -import in.riido.locksmith.template.handle.LockHandle; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Template class for programmatic distributed lock operations. - * - *

This class provides a builder-based programmatic API for distributed locks, complementing the - * annotation-based approach provided by {@link in.riido.locksmith.DistributedLock}. Use this when - * you need more control over lock acquisition and release, or when annotations are not suitable. - * - *

All methods apply the configured key prefix automatically. For example, if the key prefix is - * "lock:" and you use {@code withKey("my-key")}, the actual Redis key will be "lock:my-key". - * - *

Try-with-resources (recommended)

- * - *
{@code
- * try (LockHandle handle = lockTemplate.withKey("my-key").tryLock()) {
- *     if (handle.isAcquired()) {
- *         // Critical section - lock auto-released on close
- *     }
- * }
- * }
- * - *

Callback-based

- * - *
{@code
- * String result = lockTemplate.withKey("my-key")
- *     .waitTime(Duration.ofSeconds(5))
- *     .execute(() -> "executed");
- * }
- * - *

Builder with custom configuration

- * - *
{@code
- * try (LockHandle handle = lockTemplate.withKey("my-key")
- *         .waitTime(Duration.ofSeconds(5))
- *         .leaseTime(Duration.ofMinutes(2))
- *         .lockType(LockType.WRITE)
- *         .tryLock()) {
- *     if (handle.isAcquired()) {
- *         // work
- *     }
- * }
- *
- * // With auto-renew
- * String result = lockTemplate.withKey("my-key")
- *     .autoRenew()
- *     .execute(() -> longRunningOperation());
- * }
- * - * @author Garvit Joshi - * @since 2.1.0 - * @see LockCallback - * @see LockHandle - * @see LockOperationBuilder - * @see in.riido.locksmith.DistributedLock - */ -public class LocksmithLockTemplate { - - private static final Logger LOG = LoggerFactory.getLogger(LocksmithLockTemplate.class); - - private final RedissonClient redissonClient; - private final LockProperties lockProperties; - @Nullable private final LockMetrics lockMetrics; - - /** - * Constructs a new LocksmithLockTemplate. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - */ - public LocksmithLockTemplate( - @NonNull RedissonClient redissonClient, @NonNull LocksmithProperties properties) { - this(redissonClient, properties, null); - } - - /** - * Constructs a new LocksmithLockTemplate with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param lockMetrics the optional lock metrics for observability - */ - public LocksmithLockTemplate( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @Nullable LockMetrics lockMetrics) { - this.redissonClient = redissonClient; - this.lockProperties = properties.lock(); - this.lockMetrics = lockMetrics; - } - - // ========== Builder Entry Point ========== - - /** - * Creates a builder for lock operations on the specified key. - * - *
{@code
-   * // Try-with-resources
-   * try (LockHandle handle = lockTemplate.withKey("my-key")
-   *         .waitTime(Duration.ofSeconds(5))
-   *         .lockType(LockType.WRITE)
-   *         .tryLock()) {
-   *     if (handle.isAcquired()) {
-   *         // Critical section
-   *     }
-   * }
-   *
-   * // Callback-based
-   * String result = lockTemplate.withKey("my-key")
-   *     .autoRenew()
-   *     .execute(() -> doWork());
-   * }
- * - * @param key the lock key (prefix will be applied automatically) - * @return a builder for configuring and executing lock operations - */ - @NonNull - public LockOperationBuilder withKey(@NonNull String key) { - return new LockOperationBuilder(key); - } - - // ========== Standalone Operations ========== - - /** - * Releases a reentrant lock. - * - *

Prefer using {@link LockHandle} with try-with-resources for automatic release. This method - * is provided for cases where manual release is needed. - * - * @param key the lock key (prefix will be applied automatically) - */ - public void unlock(@NonNull String key) { - doUnlock(key, LockType.REENTRANT); - } - - /** - * Releases a lock of the specified type. - * - *

Prefer using {@link LockHandle} with try-with-resources for automatic release. This method - * is provided for cases where manual release is needed. - * - * @param key the lock key (prefix will be applied automatically) - * @param lockType the type of lock to release - */ - public void unlock(@NonNull String key, @NonNull LockType lockType) { - doUnlock(key, lockType); - } - - /** - * Checks if a reentrant lock is currently held by any thread/instance. - * - * @param key the lock key (prefix will be applied automatically) - * @return true if the lock is held by anyone, false otherwise - */ - public boolean isLocked(@NonNull String key) { - return doIsLocked(key, LockType.REENTRANT); - } - - /** - * Checks if a lock of the specified type is currently held by any thread/instance. - * - * @param key the lock key (prefix will be applied automatically) - * @param lockType the type of lock to check - * @return true if the lock is held by anyone, false otherwise - */ - public boolean isLocked(@NonNull String key, @NonNull LockType lockType) { - return doIsLocked(key, lockType); - } - - // ========== Internal Methods ========== - - @NonNull - private LockHandle doTryLock( - @NonNull String key, - @NonNull Duration waitTime, - @NonNull Duration leaseTime, - @NonNull LockType lockType, - boolean immediateMode) { - String fullKey = lockProperties.keyPrefix() + key; - RLock lock = getLock(fullKey, lockType); - long startTime = System.currentTimeMillis(); - - try { - boolean acquired = - lock.tryLock(waitTime.toMillis(), leaseTime.toMillis(), TimeUnit.MILLISECONDS); - if (acquired) { - if (lockMetrics != null) { - lockMetrics.recordAcquisitionTime(System.currentTimeMillis() - startTime); - lockMetrics.recordAcquired(); - } - LOG.debug("Lock [{}] acquired with type={}", fullKey, lockType); - return LockHandle.acquired(lock, fullKey, lockMetrics); - } else { - if (lockMetrics != null) { - final var reason = - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP; - lockMetrics.recordSkipped(reason); - } - LOG.debug("Failed to acquire lock [{}] with type={}", fullKey, lockType); - return LockHandle.notAcquired(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (lockMetrics != null) { - final var reason = - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP; - lockMetrics.recordSkipped(reason); - } - LOG.warn("Thread interrupted while waiting for lock [{}]", fullKey); - return LockHandle.notAcquired(); - } - } - - @Nullable - private T doExecuteWithLock( - @NonNull String key, - @NonNull Duration waitTime, - @NonNull Duration leaseTime, - @NonNull LockType lockType, - boolean immediateMode, - @NonNull LockCallback callback) - throws Exception { - try (LockHandle handle = doTryLock(key, waitTime, leaseTime, lockType, immediateMode)) { - if (!handle.isAcquired()) { - LOG.debug( - "Failed to acquire lock [{}] for callback execution", lockProperties.keyPrefix() + key); - return null; - } - LOG.debug("Lock [{}] acquired for callback execution", lockProperties.keyPrefix() + key); - return callback.execute(); - } - } - - private void doUnlock(@NonNull String key, @NonNull LockType lockType) { - String fullKey = lockProperties.keyPrefix() + key; - RLock lock = getLock(fullKey, lockType); - - try { - lock.unlock(); - LOG.debug("Lock [{}] released with type={}", fullKey, lockType); - } catch (IllegalMonitorStateException e) { - LOG.warn( - "Failed to unlock [{}] - lock not held by current thread or already released: {}", - fullKey, - e.getMessage()); - } catch (Exception e) { - LOG.warn("Failed to release lock [{}]", fullKey, e); - } - } - - private boolean doIsLocked(@NonNull String key, @NonNull LockType lockType) { - String fullKey = lockProperties.keyPrefix() + key; - RLock lock = getLock(fullKey, lockType); - return lock.isLocked(); - } - - @NonNull - private RLock getLock(@NonNull String fullKey, @NonNull LockType lockType) { - return switch (lockType) { - case REENTRANT -> redissonClient.getLock(fullKey); - case READ -> redissonClient.getReadWriteLock(fullKey).readLock(); - case WRITE -> redissonClient.getReadWriteLock(fullKey).writeLock(); - }; - } - - // ========== Builder Class ========== - - /** - * Builder for configuring and executing lock operations. - * - *

This builder provides a fluent API for lock operations with custom configuration. Use {@link - * LocksmithLockTemplate#withKey(String)} to create an instance. - * - *

Example usage: - * - *

{@code
-   * // Try-with-resources
-   * try (LockHandle handle = lockTemplate.withKey("my-key")
-   *         .waitTime(Duration.ofSeconds(5))
-   *         .leaseTime(Duration.ofMinutes(2))
-   *         .tryLock()) {
-   *     if (handle.isAcquired()) {
-   *         // Critical section
-   *     }
-   * }
-   *
-   * // Callback with auto-renew and write lock
-   * String result = lockTemplate.withKey("my-key")
-   *     .lockType(LockType.WRITE)
-   *     .autoRenew()
-   *     .execute(() -> longRunningOperation());
-   * }
- * - * @since 2.1.0 - */ - public class LockOperationBuilder { - - private final String key; - private Duration waitTime = Duration.ZERO; - private Duration leaseTime = lockProperties.leaseTime(); - private LockType lockType = LockType.REENTRANT; - private boolean autoRenewEnabled = false; - - private LockOperationBuilder(@NonNull String key) { - this.key = key; - } - - /** - * Sets the maximum time to wait for the lock. - * - *

Default is {@link Duration#ZERO} (no waiting). - * - * @param waitTime the maximum wait time - * @return this builder for chaining - */ - @NonNull - public LockOperationBuilder waitTime(@NonNull Duration waitTime) { - this.waitTime = waitTime; - return this; - } - - /** - * Sets the lease time after which the lock is automatically released. - * - *

Default is the configured lease time from properties. Use {@link #autoRenew()} instead of - * setting a negative value. - * - *

Note: Calling this method after {@link #autoRenew()} will disable auto-renew and a - * warning will be logged. - * - * @param leaseTime the lease time - * @return this builder for chaining - */ - @NonNull - public LockOperationBuilder leaseTime(@NonNull Duration leaseTime) { - if (autoRenewEnabled) { - LOG.warn( - "leaseTime() called after autoRenew() for key [{}] - auto-renew will be disabled. " - + "Remove leaseTime() call to use auto-renew, or remove autoRenew() to use fixed lease time.", - key); - autoRenewEnabled = false; - } - this.leaseTime = leaseTime; - return this; - } - - /** - * Sets the type of lock to acquire. - * - *

Default is {@link LockType#REENTRANT}. - * - * @param lockType the lock type (REENTRANT, READ, or WRITE) - * @return this builder for chaining - */ - @NonNull - public LockOperationBuilder lockType(@NonNull LockType lockType) { - this.lockType = lockType; - return this; - } - - /** - * Enables auto-renew mode using Redisson's watchdog. - * - *

When enabled, the lock will be automatically renewed while held, preventing expiration - * during long-running operations. This is equivalent to setting lease time to -1ms. - * - *

Note: Calling {@link #leaseTime(Duration)} after this method will disable - * auto-renew. - * - * @return this builder for chaining - */ - @NonNull - public LockOperationBuilder autoRenew() { - this.autoRenewEnabled = true; - this.leaseTime = Duration.ofMillis(-1); - return this; - } - - /** - * Tries to acquire the lock with the configured settings. - * - *

Returns a {@link LockHandle} that implements {@link AutoCloseable} for use with - * try-with-resources. The lock is automatically released when the handle is closed. - * - * @return a handle representing the acquisition result - */ - @NonNull - public LockHandle tryLock() { - boolean immediateMode = waitTime.isZero(); - return doTryLock(key, waitTime, leaseTime, lockType, immediateMode); - } - - /** - * Executes a callback while holding the lock with the configured settings. - * - *

The lock is automatically released after the callback completes, even if an exception is - * thrown. - * - * @param the type of result returned by the callback - * @param callback the callback to execute while holding the lock - * @return the result of the callback, or null if the lock could not be acquired - * @throws Exception if the callback throws an exception - */ - @Nullable - public T execute(@NonNull LockCallback callback) throws Exception { - boolean immediateMode = waitTime.isZero(); - return doExecuteWithLock(key, waitTime, leaseTime, lockType, immediateMode, callback); - } - } -} diff --git a/src/main/java/in/riido/locksmith/template/LocksmithRateLimitTemplate.java b/src/main/java/in/riido/locksmith/template/LocksmithRateLimitTemplate.java deleted file mode 100644 index 48677cc..0000000 --- a/src/main/java/in/riido/locksmith/template/LocksmithRateLimitTemplate.java +++ /dev/null @@ -1,340 +0,0 @@ -package in.riido.locksmith.template; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.RateLimitProperties; -import in.riido.locksmith.exception.RateLimitConfigurationException; -import in.riido.locksmith.metrics.RateLimitMetrics; -import in.riido.locksmith.support.RateLimitInitializer; -import in.riido.locksmith.template.callback.RateLimitCallback; -import java.time.Duration; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RRateLimiter; -import org.redisson.api.RateType; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Template class for programmatic distributed rate limiting operations. - * - *

This class provides a builder-based programmatic API for distributed rate limiting, - * complementing the annotation-based approach provided by {@link in.riido.locksmith.RateLimit}. Use - * this when you need more control over rate limit checking, or when annotations are not suitable. - * - *

All methods apply the configured key prefix automatically. For example, if the key prefix is - * "ratelimit:" and you use {@code withKey("api-call")}, the actual Redis key will be - * "ratelimit:api-call". - * - *

Simple check

- * - *
{@code
- * if (rateLimitTemplate.withKey("api-call").tryAcquire()) {
- *     // Execute operation
- * }
- * }
- * - *

Callback-based

- * - *
{@code
- * String result = rateLimitTemplate.withKey("api-call")
- *     .execute(() -> apiClient.call());
- * }
- * - *

Builder with custom configuration

- * - *
{@code
- * // Custom rate: 100 requests per minute
- * if (rateLimitTemplate.withKey("heavy-operation")
- *         .permits(100)
- *         .interval(Duration.ofMinutes(1))
- *         .tryAcquire()) {
- *     // Execute operation
- * }
- *
- * // Execute with custom rate and wait time
- * String result = rateLimitTemplate.withKey("throttled-api")
- *     .permits(10)
- *     .interval(Duration.ofSeconds(1))
- *     .waitTime(Duration.ofSeconds(5))
- *     .execute(() -> apiClient.call());
- * }
- * - * @author Garvit Joshi - * @since 3.0.0 - * @see RateLimitCallback - * @see RateLimitOperationBuilder - * @see in.riido.locksmith.RateLimit - */ -public class LocksmithRateLimitTemplate { - - private static final Logger LOG = LoggerFactory.getLogger(LocksmithRateLimitTemplate.class); - - private static final long DEFAULT_PERMITS = 10; - private static final Duration DEFAULT_INTERVAL = Duration.ofSeconds(1); - - private final RedissonClient redissonClient; - private final RateLimitProperties rateLimitProperties; - @Nullable private final RateLimitMetrics rateLimitMetrics; - private final RateLimitInitializer rateLimitInitializer; - - /** - * Constructs a new LocksmithRateLimitTemplate. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - */ - public LocksmithRateLimitTemplate( - @NonNull RedissonClient redissonClient, @NonNull LocksmithProperties properties) { - this(redissonClient, properties, null); - } - - /** - * Constructs a new LocksmithRateLimitTemplate with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param rateLimitMetrics the optional rate limit metrics for observability - */ - public LocksmithRateLimitTemplate( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @Nullable RateLimitMetrics rateLimitMetrics) { - this.redissonClient = redissonClient; - this.rateLimitProperties = properties.rateLimit(); - this.rateLimitMetrics = rateLimitMetrics; - this.rateLimitInitializer = new RateLimitInitializer(redissonClient); - } - - // ========== Builder Entry Point ========== - - /** - * Creates a builder for rate limit operations on the specified key. - * - *
{@code
-   * // Custom rate: 100 requests per minute
-   * boolean acquired = rateLimitTemplate.withKey("heavy-operation")
-   *     .permits(100)
-   *     .interval(Duration.ofMinutes(1))
-   *     .tryAcquire();
-   *
-   * // Execute with custom rate
-   * String result = rateLimitTemplate.withKey("api-call")
-   *     .permits(20)
-   *     .interval(Duration.ofSeconds(1))
-   *     .execute(() -> apiClient.call());
-   * }
- * - * @param key the rate limiter key (prefix will be applied automatically) - * @return a builder for configuring and executing rate limit operations - */ - @NonNull - public RateLimitOperationBuilder withKey(@NonNull String key) { - return new RateLimitOperationBuilder(key); - } - - // ========== Internal Methods ========== - - private boolean doTryAcquire( - @NonNull String key, - long permits, - @NonNull Duration interval, - @NonNull RateType rateType, - @NonNull Duration waitTime, - boolean immediateMode) { - if (permits <= 0) { - throw new RateLimitConfigurationException("Permits must be positive, got: " + permits, key); - } - - String fullKey = rateLimitProperties.keyPrefix() + key; - rateLimitInitializer.ensureInitialized(fullKey, permits, interval, rateType); - RRateLimiter rateLimiter = redissonClient.getRateLimiter(fullKey); - long startTime = System.currentTimeMillis(); - - boolean acquired = - waitTime.isZero() ? rateLimiter.tryAcquire() : rateLimiter.tryAcquire(1, waitTime); - - if (acquired) { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordAcquisitionTime(System.currentTimeMillis() - startTime); - rateLimitMetrics.recordAcquired(); - } - LOG.debug("Rate limit permit acquired for [{}]", fullKey); - } else { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordExceeded( - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP); - } - LOG.debug("Rate limit exceeded for [{}]", fullKey); - } - return acquired; - } - - @Nullable - private T doExecute( - @NonNull String key, - long permits, - @NonNull Duration interval, - @NonNull RateType rateType, - @NonNull Duration waitTime, - boolean immediateMode, - @NonNull RateLimitCallback callback) - throws Exception { - if (permits <= 0) { - throw new RateLimitConfigurationException("Permits must be positive, got: " + permits, key); - } - - String fullKey = rateLimitProperties.keyPrefix() + key; - rateLimitInitializer.ensureInitialized(fullKey, permits, interval, rateType); - RRateLimiter rateLimiter = redissonClient.getRateLimiter(fullKey); - long acquisitionStartTime = System.currentTimeMillis(); - - boolean acquired = - waitTime.isZero() ? rateLimiter.tryAcquire() : rateLimiter.tryAcquire(1, waitTime); - - if (!acquired) { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordExceeded( - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP); - } - LOG.debug("Rate limit exceeded for [{}], callback not executed", fullKey); - return null; - } - - if (rateLimitMetrics != null) { - rateLimitMetrics.recordAcquisitionTime(System.currentTimeMillis() - acquisitionStartTime); - rateLimitMetrics.recordAcquired(); - } - - LOG.debug("Rate limit permit acquired for [{}], executing callback", fullKey); - long executionStartTime = System.currentTimeMillis(); - try { - return callback.execute(); - } finally { - if (rateLimitMetrics != null) { - rateLimitMetrics.recordExecutionTime(System.currentTimeMillis() - executionStartTime); - } - } - } - - // ========== Builder Class ========== - - /** - * Builder for configuring and executing rate limit operations. - * - *

This builder provides a fluent API for rate limit operations with custom configuration. Use - * {@link LocksmithRateLimitTemplate#withKey(String)} to create an instance. - * - *

Example usage: - * - *

{@code
-   * // Custom rate: 100 requests per minute
-   * boolean acquired = rateLimitTemplate.withKey("heavy-operation")
-   *     .permits(100)
-   *     .interval(Duration.ofMinutes(1))
-   *     .tryAcquire();
-   *
-   * // Execute with custom rate and wait time
-   * String result = rateLimitTemplate.withKey("throttled-api")
-   *     .permits(10)
-   *     .interval(Duration.ofSeconds(1))
-   *     .waitTime(Duration.ofSeconds(5))
-   *     .execute(() -> apiClient.call());
-   * }
- * - * @since 3.0.0 - */ - public class RateLimitOperationBuilder { - - private final String key; - private long permits = DEFAULT_PERMITS; - private Duration interval = DEFAULT_INTERVAL; - private RateType rateType = RateType.OVERALL; - private Duration waitTime = Duration.ZERO; - - private RateLimitOperationBuilder(@NonNull String key) { - this.key = key; - } - - /** - * Sets the number of permits allowed per interval. - * - *

Default is 10. - * - * @param permits the number of permits per interval - * @return this builder for chaining - */ - @NonNull - public RateLimitOperationBuilder permits(long permits) { - this.permits = permits; - return this; - } - - /** - * Sets the interval for the rate limit. - * - *

Default is 1 second (permits per second). - * - * @param interval the interval duration - * @return this builder for chaining - */ - @NonNull - public RateLimitOperationBuilder interval(@NonNull Duration interval) { - this.interval = interval; - return this; - } - - /** - * Sets the rate limit type. - * - *

Default is {@link RateType#OVERALL}. - * - * @param rateType the rate type (OVERALL or PER_CLIENT) - * @return this builder for chaining - */ - @NonNull - public RateLimitOperationBuilder rateType(@NonNull RateType rateType) { - this.rateType = rateType; - return this; - } - - /** - * Sets the maximum time to wait for a permit. - * - *

Default is {@link Duration#ZERO} (no waiting, immediate check). - * - * @param waitTime the maximum wait time - * @return this builder for chaining - */ - @NonNull - public RateLimitOperationBuilder waitTime(@NonNull Duration waitTime) { - this.waitTime = waitTime; - return this; - } - - /** - * Tries to acquire a rate limit permit with the configured settings. - * - * @return true if a permit was acquired, false if rate limit exceeded - */ - public boolean tryAcquire() { - boolean immediateMode = waitTime.isZero(); - return doTryAcquire(key, permits, interval, rateType, waitTime, immediateMode); - } - - /** - * Executes a callback after acquiring a rate limit permit with the configured settings. - * - * @param the type of result returned by the callback - * @param callback the callback to execute after permit acquisition - * @return the result of the callback, or null if rate limit exceeded - * @throws Exception if the callback throws an exception - */ - @Nullable - public T execute(@NonNull RateLimitCallback callback) throws Exception { - boolean immediateMode = waitTime.isZero(); - return doExecute(key, permits, interval, rateType, waitTime, immediateMode, callback); - } - } -} diff --git a/src/main/java/in/riido/locksmith/template/LocksmithSemaphoreTemplate.java b/src/main/java/in/riido/locksmith/template/LocksmithSemaphoreTemplate.java deleted file mode 100644 index f0af2ea..0000000 --- a/src/main/java/in/riido/locksmith/template/LocksmithSemaphoreTemplate.java +++ /dev/null @@ -1,372 +0,0 @@ -package in.riido.locksmith.template; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.SemaphoreProperties; -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import in.riido.locksmith.metrics.SemaphoreMetrics; -import in.riido.locksmith.support.SemaphoreInitializer; -import in.riido.locksmith.template.callback.SemaphoreCallback; -import in.riido.locksmith.template.handle.PermitHandle; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RPermitExpirableSemaphore; -import org.redisson.api.RedissonClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Template class for programmatic distributed semaphore operations. - * - *

This class provides a builder-based programmatic API for distributed semaphores, complementing - * the annotation-based approach provided by {@link in.riido.locksmith.DistributedSemaphore}. Use - * this when you need more control over permit acquisition and release, or when annotations are not - * suitable. - * - *

All methods apply the configured key prefix automatically. For example, if the key prefix is - * "semaphore:" and you use {@code withKey("my-key")}, the actual Redis key will be - * "semaphore:my-key". - * - *

Try-with-resources (recommended)

- * - *
{@code
- * try (PermitHandle handle = semaphoreTemplate.withKey("pool")
- *         .permits(5)
- *         .tryAcquire()) {
- *     if (handle.isAcquired()) {
- *         // Use the resource - permit auto-released on close
- *     }
- * }
- * }
- * - *

Callback-based

- * - *
{@code
- * String result = semaphoreTemplate.withKey("pool")
- *     .permits(5)
- *     .execute(() -> "executed");
- * }
- * - *

Builder with custom configuration

- * - *
{@code
- * try (PermitHandle handle = semaphoreTemplate.withKey("pool")
- *         .permits(5)
- *         .waitTime(Duration.ofSeconds(10))
- *         .leaseTime(Duration.ofMinutes(2))
- *         .tryAcquire()) {
- *     if (handle.isAcquired()) {
- *         // work
- *     }
- * }
- * }
- * - * @author Garvit Joshi - * @since 2.1.0 - * @see SemaphoreCallback - * @see PermitHandle - * @see SemaphoreOperationBuilder - * @see in.riido.locksmith.DistributedSemaphore - */ -public class LocksmithSemaphoreTemplate { - - private static final Logger LOG = LoggerFactory.getLogger(LocksmithSemaphoreTemplate.class); - - private final RedissonClient redissonClient; - private final SemaphoreProperties semaphoreProperties; - @Nullable private final SemaphoreMetrics semaphoreMetrics; - private final SemaphoreInitializer semaphoreInitializer; - - /** - * Constructs a new LocksmithSemaphoreTemplate. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - */ - public LocksmithSemaphoreTemplate( - @NonNull RedissonClient redissonClient, @NonNull LocksmithProperties properties) { - this(redissonClient, properties, null); - } - - /** - * Constructs a new LocksmithSemaphoreTemplate with optional metrics support. - * - * @param redissonClient the Redisson client for Redis operations - * @param properties the configuration properties - * @param semaphoreMetrics the optional semaphore metrics for observability - */ - public LocksmithSemaphoreTemplate( - @NonNull RedissonClient redissonClient, - @NonNull LocksmithProperties properties, - @Nullable SemaphoreMetrics semaphoreMetrics) { - this.redissonClient = redissonClient; - this.semaphoreProperties = properties.semaphore(); - this.semaphoreMetrics = semaphoreMetrics; - this.semaphoreInitializer = new SemaphoreInitializer(redissonClient); - } - - // ========== Builder Entry Point ========== - - /** - * Creates a builder for semaphore operations on the specified key. - * - *

Important: You must call {@link SemaphoreOperationBuilder#permits(int)} before - * calling any terminal operation ({@code tryAcquire()} or {@code execute()}). - * - *

{@code
-   * // Try-with-resources
-   * try (PermitHandle handle = semaphoreTemplate.withKey("pool")
-   *         .permits(5)
-   *         .waitTime(Duration.ofSeconds(10))
-   *         .tryAcquire()) {
-   *     if (handle.isAcquired()) {
-   *         // work
-   *     }
-   * }
-   *
-   * // Callback-based
-   * semaphoreTemplate.withKey("pool")
-   *     .permits(5)
-   *     .execute(() -> doWork());
-   * }
- * - * @param key the semaphore key (prefix will be applied automatically) - * @return a builder for configuring and executing semaphore operations - */ - @NonNull - public SemaphoreOperationBuilder withKey(@NonNull String key) { - return new SemaphoreOperationBuilder(key); - } - - // ========== Standalone Operations ========== - - /** - * Releases a permit back to the semaphore. - * - *

Prefer using {@link PermitHandle} with try-with-resources for automatic release. This method - * is provided for cases where manual release is needed. - * - * @param key the semaphore key (prefix will be applied automatically) - * @param permitId the permit ID returned by {@link PermitHandle#permitId()} - */ - public void releasePermit(@NonNull String key, @NonNull String permitId) { - String fullKey = semaphoreProperties.keyPrefix() + key; - RPermitExpirableSemaphore semaphore = redissonClient.getPermitExpirableSemaphore(fullKey); - - try { - semaphore.release(permitId); - LOG.debug("Permit [{}] released from semaphore [{}]", permitId, fullKey); - } catch (IllegalArgumentException e) { - LOG.warn( - "Failed to release permit [{}] from [{}] - permit may have expired: {}", - permitId, - fullKey, - e.getMessage()); - } catch (Exception e) { - LOG.warn("Failed to release permit [{}] from [{}]", permitId, fullKey, e); - } - } - - // ========== Internal Methods ========== - - @NonNull - private PermitHandle doTryAcquirePermit( - @NonNull String key, - int permits, - @NonNull Duration waitTime, - @NonNull Duration leaseTime, - boolean immediateMode) { - String fullKey = semaphoreProperties.keyPrefix() + key; - semaphoreInitializer.validatePermitsConsistency(fullKey, permits, null); - semaphoreInitializer.ensureInitialized(fullKey, permits); - - RPermitExpirableSemaphore semaphore = redissonClient.getPermitExpirableSemaphore(fullKey); - long startTime = System.currentTimeMillis(); - - try { - String permitId = - semaphore.tryAcquire(waitTime.toMillis(), leaseTime.toMillis(), TimeUnit.MILLISECONDS); - if (permitId != null) { - if (semaphoreMetrics != null) { - semaphoreMetrics.recordAcquisitionTime(System.currentTimeMillis() - startTime); - semaphoreMetrics.recordAcquired(); - } - LOG.debug("Permit [{}] acquired from semaphore [{}]", permitId, fullKey); - return PermitHandle.acquired(permitId, semaphore, fullKey, semaphoreMetrics); - } else { - if (semaphoreMetrics != null) { - semaphoreMetrics.recordSkipped( - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP); - } - LOG.debug("Failed to acquire permit from semaphore [{}]", fullKey); - return PermitHandle.notAcquired(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - if (semaphoreMetrics != null) { - semaphoreMetrics.recordSkipped( - immediateMode ? AcquisitionMode.SKIP_IMMEDIATELY : AcquisitionMode.WAIT_AND_SKIP); - } - LOG.warn("Thread interrupted while waiting for permit from [{}]", fullKey); - return PermitHandle.notAcquired(); - } - } - - @Nullable - private T doExecuteWithPermit( - @NonNull String key, - int permits, - @NonNull Duration waitTime, - @NonNull Duration leaseTime, - boolean immediateMode, - @NonNull SemaphoreCallback callback) - throws Exception { - try (PermitHandle handle = - doTryAcquirePermit(key, permits, waitTime, leaseTime, immediateMode)) { - if (!handle.isAcquired()) { - String fullKey = semaphoreProperties.keyPrefix() + key; - LOG.debug("Failed to acquire permit from [{}] for callback execution", fullKey); - return null; - } - String fullKey = semaphoreProperties.keyPrefix() + key; - LOG.debug( - "Permit [{}] acquired from [{}] for callback execution", handle.permitId(), fullKey); - return callback.execute(); - } - } - - // ========== Builder Class ========== - - /** - * Builder for configuring and executing semaphore operations. - * - *

This builder provides a fluent API for semaphore operations with custom configuration. Use - * {@link LocksmithSemaphoreTemplate#withKey(String)} to create an instance. - * - *

Important: {@link #permits(int)} must be called before any terminal operation. - * - *

Example usage: - * - *

{@code
-   * // Try-with-resources
-   * try (PermitHandle handle = semaphoreTemplate.withKey("pool")
-   *         .permits(5)
-   *         .waitTime(Duration.ofSeconds(10))
-   *         .leaseTime(Duration.ofMinutes(2))
-   *         .tryAcquire()) {
-   *     if (handle.isAcquired()) {
-   *         // work
-   *     }
-   * }
-   *
-   * // Callback-based
-   * String result = semaphoreTemplate.withKey("pool")
-   *     .permits(5)
-   *     .execute(() -> doWork());
-   * }
- * - * @since 2.1.0 - */ - public class SemaphoreOperationBuilder { - - private static final int PERMITS_NOT_SET = -1; - - private final String key; - private int permits = PERMITS_NOT_SET; - private Duration waitTime = Duration.ZERO; - private Duration leaseTime = semaphoreProperties.leaseTime(); - - private SemaphoreOperationBuilder(@NonNull String key) { - this.key = key; - } - - /** - * Sets the total number of permits for this semaphore. - * - *

This must be called before {@link #tryAcquire()} or {@link #execute(SemaphoreCallback)}. - * - * @param permits the total number of permits (must be positive) - * @return this builder for chaining - */ - @NonNull - public SemaphoreOperationBuilder permits(int permits) { - this.permits = permits; - return this; - } - - /** - * Sets the maximum time to wait for a permit. - * - *

Default is {@link Duration#ZERO} (no waiting). - * - * @param waitTime the maximum wait time - * @return this builder for chaining - */ - @NonNull - public SemaphoreOperationBuilder waitTime(@NonNull Duration waitTime) { - this.waitTime = waitTime; - return this; - } - - /** - * Sets the lease time after which the permit is automatically released. - * - *

Default is the configured lease time from properties. - * - * @param leaseTime the lease time - * @return this builder for chaining - */ - @NonNull - public SemaphoreOperationBuilder leaseTime(@NonNull Duration leaseTime) { - this.leaseTime = leaseTime; - return this; - } - - /** - * Tries to acquire a permit with the configured settings. - * - *

Returns a {@link PermitHandle} that implements {@link AutoCloseable} for use with - * try-with-resources. The permit is automatically released when the handle is closed. - * - * @return a handle representing the acquisition result - * @throws SemaphoreConfigurationException if permits was not set or is not positive - */ - @NonNull - public PermitHandle tryAcquire() { - validatePermits(); - boolean immediateMode = waitTime.isZero(); - return doTryAcquirePermit(key, permits, waitTime, leaseTime, immediateMode); - } - - /** - * Executes a callback while holding a permit with the configured settings. - * - *

The permit is automatically released after the callback completes, even if an exception is - * thrown. - * - * @param the type of result returned by the callback - * @param callback the callback to execute while holding the permit - * @return the result of the callback, or null if a permit could not be acquired - * @throws Exception if the callback throws an exception - * @throws SemaphoreConfigurationException if permits was not set or is not positive - */ - @Nullable - public T execute(@NonNull SemaphoreCallback callback) throws Exception { - validatePermits(); - boolean immediateMode = waitTime.isZero(); - return doExecuteWithPermit(key, permits, waitTime, leaseTime, immediateMode, callback); - } - - private void validatePermits() { - if (permits == PERMITS_NOT_SET) { - throw new SemaphoreConfigurationException( - "permits() must be called before tryAcquire() or execute()", key); - } - if (permits <= 0) { - throw new SemaphoreConfigurationException("Permits must be positive, got: " + permits, key); - } - } - } -} diff --git a/src/main/java/in/riido/locksmith/template/callback/LockCallback.java b/src/main/java/in/riido/locksmith/template/callback/LockCallback.java deleted file mode 100644 index 48e7fa3..0000000 --- a/src/main/java/in/riido/locksmith/template/callback/LockCallback.java +++ /dev/null @@ -1,38 +0,0 @@ -package in.riido.locksmith.template.callback; - -import in.riido.locksmith.template.LocksmithLockTemplate; -import org.jspecify.annotations.Nullable; - -/** - * Functional interface for code to be executed within a distributed lock. - * - *

This callback is used with {@link LocksmithLockTemplate#withKey} to execute code while holding - * a distributed lock. The callback may throw any exception, which will be propagated to the caller - * after the lock is released. - * - *

Example usage: - * - *

{@code
- * String result = lockTemplate.withKey("my-key")
- *     .execute(() -> {
- *         // Code executed while holding the lock
- *         return "result";
- *     });
- * }
- * - * @param the type of result returned by the callback - * @author Garvit Joshi - * @since 2.1.0 - * @see LocksmithLockTemplate - */ -@FunctionalInterface -public interface LockCallback { - - /** - * Executes the callback logic while holding the distributed lock. - * - * @return the result of the execution, may be null - * @throws Exception if any error occurs during execution - */ - @Nullable T execute() throws Exception; -} diff --git a/src/main/java/in/riido/locksmith/template/callback/RateLimitCallback.java b/src/main/java/in/riido/locksmith/template/callback/RateLimitCallback.java deleted file mode 100644 index 085eb20..0000000 --- a/src/main/java/in/riido/locksmith/template/callback/RateLimitCallback.java +++ /dev/null @@ -1,38 +0,0 @@ -package in.riido.locksmith.template.callback; - -import in.riido.locksmith.template.LocksmithRateLimitTemplate; -import org.jspecify.annotations.Nullable; - -/** - * Functional interface for code to be executed within a rate limit. - * - *

This callback is used with {@link LocksmithRateLimitTemplate#withKey} to execute code after - * acquiring a rate limit permit. The callback may throw any exception, which will be propagated to - * the caller. - * - *

Example usage: - * - *

{@code
- * String result = rateLimitTemplate.withKey("api-call")
- *     .execute(() -> {
- *         // Code executed after permit acquisition
- *         return apiClient.call();
- *     });
- * }
- * - * @param the type of result returned by the callback - * @author Garvit Joshi - * @since 3.0.0 - * @see LocksmithRateLimitTemplate - */ -@FunctionalInterface -public interface RateLimitCallback { - - /** - * Executes the callback logic after rate limit permit acquisition. - * - * @return the result of the execution, may be null - * @throws Exception if any error occurs during execution - */ - @Nullable T execute() throws Exception; -} diff --git a/src/main/java/in/riido/locksmith/template/callback/SemaphoreCallback.java b/src/main/java/in/riido/locksmith/template/callback/SemaphoreCallback.java deleted file mode 100644 index 0fea43a..0000000 --- a/src/main/java/in/riido/locksmith/template/callback/SemaphoreCallback.java +++ /dev/null @@ -1,39 +0,0 @@ -package in.riido.locksmith.template.callback; - -import in.riido.locksmith.template.LocksmithSemaphoreTemplate; -import org.jspecify.annotations.Nullable; - -/** - * Functional interface for code to be executed while holding a distributed semaphore permit. - * - *

This callback is used with {@link LocksmithSemaphoreTemplate#withKey} to execute code while - * holding a semaphore permit. The callback may throw any exception, which will be propagated to the - * caller after the permit is released. - * - *

Example usage: - * - *

{@code
- * String result = semaphoreTemplate.withKey("my-key")
- *     .permits(5)
- *     .execute(() -> {
- *         // Code executed while holding the permit
- *         return "result";
- *     });
- * }
- * - * @param the type of result returned by the callback - * @author Garvit Joshi - * @since 2.1.0 - * @see LocksmithSemaphoreTemplate - */ -@FunctionalInterface -public interface SemaphoreCallback { - - /** - * Executes the callback logic while holding the semaphore permit. - * - * @return the result of the execution, may be null - * @throws Exception if any error occurs during execution - */ - @Nullable T execute() throws Exception; -} diff --git a/src/main/java/in/riido/locksmith/template/callback/package-info.java b/src/main/java/in/riido/locksmith/template/callback/package-info.java deleted file mode 100644 index c08e7bb..0000000 --- a/src/main/java/in/riido/locksmith/template/callback/package-info.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Callback interfaces for template-based distributed operations. - * - *

This package contains functional interfaces used by template APIs to execute user code within - * acquired coordination primitives. - * - *

    - *
  • {@link in.riido.locksmith.template.callback.LockCallback} - Callback for execution within a - * distributed lock - *
  • {@link in.riido.locksmith.template.callback.SemaphoreCallback} - Callback for execution - * while holding a semaphore permit - *
  • {@link in.riido.locksmith.template.callback.RateLimitCallback} - Callback for execution - * after rate limit permit acquisition - *
- * - * @author Garvit Joshi - * @since 2.1.0 - * @see in.riido.locksmith.template.LocksmithLockTemplate - * @see in.riido.locksmith.template.LocksmithSemaphoreTemplate - * @see in.riido.locksmith.template.LocksmithRateLimitTemplate - */ -package in.riido.locksmith.template.callback; diff --git a/src/main/java/in/riido/locksmith/template/handle/LockHandle.java b/src/main/java/in/riido/locksmith/template/handle/LockHandle.java deleted file mode 100644 index 7ca534b..0000000 --- a/src/main/java/in/riido/locksmith/template/handle/LockHandle.java +++ /dev/null @@ -1,108 +0,0 @@ -package in.riido.locksmith.template.handle; - -import in.riido.locksmith.metrics.LockMetrics; -import in.riido.locksmith.template.LocksmithLockTemplate; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RLock; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * An {@link AutoCloseable} handle representing the result of a lock acquisition attempt. - * - *

This class enables the try-with-resources pattern for distributed locks, ensuring automatic - * release when the handle is closed: - * - *

{@code
- * try (LockHandle handle = lockTemplate.withKey("my-key").tryLock()) {
- *     if (handle.isAcquired()) {
- *         // Critical section - lock is held
- *     }
- * }
- * // Lock is automatically released here
- * }
- * - *

If the lock was not acquired, {@link #close()} is a safe no-op. - * - * @author Garvit Joshi - * @since 3.0.3 - * @see LocksmithLockTemplate - */ -public class LockHandle implements AutoCloseable { - - private static final Logger LOG = LoggerFactory.getLogger(LockHandle.class); - - private final boolean acquired; - @Nullable private final RLock lock; - @Nullable private final String fullKey; - @Nullable private final LockMetrics lockMetrics; - private final long heldStartTime; - - private LockHandle( - boolean acquired, - @Nullable RLock lock, - @Nullable String fullKey, - @Nullable LockMetrics lockMetrics) { - this.acquired = acquired; - this.lock = lock; - this.fullKey = fullKey; - this.lockMetrics = lockMetrics; - this.heldStartTime = acquired ? System.currentTimeMillis() : 0; - } - - /** - * Creates a handle representing a successful lock acquisition. - * - * @param lock the acquired lock - * @param fullKey the full Redis key of the lock - * @param lockMetrics the optional lock metrics - * @return a handle with {@code isAcquired() == true} - */ - @NonNull - public static LockHandle acquired( - @NonNull RLock lock, @NonNull String fullKey, @Nullable LockMetrics lockMetrics) { - return new LockHandle(true, lock, fullKey, lockMetrics); - } - - /** - * Creates a handle representing a failed lock acquisition. - * - * @return a handle with {@code isAcquired() == false} - */ - @NonNull - public static LockHandle notAcquired() { - return new LockHandle(false, null, null, null); - } - - /** - * Returns whether the lock was successfully acquired. - * - * @return true if the lock is held by this handle, false otherwise - */ - public boolean isAcquired() { - return acquired; - } - - /** - * Releases the lock if it was acquired. Safe to call multiple times or if the lock was never - * acquired (no-op in both cases). - */ - @Override - public void close() { - if (!acquired || lock == null) { - return; - } - try { - if (lockMetrics != null) { - lockMetrics.recordHeldTime(System.currentTimeMillis() - heldStartTime); - } - lock.unlock(); - LOG.debug("Lock [{}] released via handle", fullKey); - } catch (IllegalMonitorStateException e) { - LOG.warn("Lock [{}] was already released (possibly expired): {}", fullKey, e.getMessage()); - } catch (Exception e) { - LOG.warn("Failed to release lock [{}]", fullKey, e); - } - } -} diff --git a/src/main/java/in/riido/locksmith/template/handle/PermitHandle.java b/src/main/java/in/riido/locksmith/template/handle/PermitHandle.java deleted file mode 100644 index 3c25ea3..0000000 --- a/src/main/java/in/riido/locksmith/template/handle/PermitHandle.java +++ /dev/null @@ -1,131 +0,0 @@ -package in.riido.locksmith.template.handle; - -import in.riido.locksmith.metrics.SemaphoreMetrics; -import in.riido.locksmith.template.LocksmithSemaphoreTemplate; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; -import org.redisson.api.RPermitExpirableSemaphore; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * An {@link AutoCloseable} handle representing the result of a semaphore permit acquisition - * attempt. - * - *

This class enables the try-with-resources pattern for distributed semaphores, ensuring - * automatic permit release when the handle is closed: - * - *

{@code
- * try (PermitHandle handle = semaphoreTemplate.withKey("pool").permits(10).tryAcquire()) {
- *     if (handle.isAcquired()) {
- *         // Use the resource - permit is held
- *         String id = handle.permitId();
- *     }
- * }
- * // Permit is automatically released here
- * }
- * - *

If the permit was not acquired, {@link #close()} is a safe no-op. - * - * @author Garvit Joshi - * @since 3.0.3 - * @see LocksmithSemaphoreTemplate - */ -public class PermitHandle implements AutoCloseable { - - private static final Logger LOG = LoggerFactory.getLogger(PermitHandle.class); - - private final boolean acquired; - @Nullable private final String permitId; - @Nullable private final RPermitExpirableSemaphore semaphore; - @Nullable private final String fullKey; - @Nullable private final SemaphoreMetrics semaphoreMetrics; - private final long heldStartTime; - - private PermitHandle( - boolean acquired, - @Nullable String permitId, - @Nullable RPermitExpirableSemaphore semaphore, - @Nullable String fullKey, - @Nullable SemaphoreMetrics semaphoreMetrics) { - this.acquired = acquired; - this.permitId = permitId; - this.semaphore = semaphore; - this.fullKey = fullKey; - this.semaphoreMetrics = semaphoreMetrics; - this.heldStartTime = acquired ? System.currentTimeMillis() : 0; - } - - /** - * Creates a handle representing a successful permit acquisition. - * - * @param permitId the acquired permit ID - * @param semaphore the semaphore instance - * @param fullKey the full Redis key of the semaphore - * @param semaphoreMetrics the optional semaphore metrics - * @return a handle with {@code isAcquired() == true} - */ - @NonNull - public static PermitHandle acquired( - @NonNull String permitId, - @NonNull RPermitExpirableSemaphore semaphore, - @NonNull String fullKey, - @Nullable SemaphoreMetrics semaphoreMetrics) { - return new PermitHandle(true, permitId, semaphore, fullKey, semaphoreMetrics); - } - - /** - * Creates a handle representing a failed permit acquisition. - * - * @return a handle with {@code isAcquired() == false} - */ - @NonNull - public static PermitHandle notAcquired() { - return new PermitHandle(false, null, null, null, null); - } - - /** - * Returns whether the permit was successfully acquired. - * - * @return true if a permit is held by this handle, false otherwise - */ - public boolean isAcquired() { - return acquired; - } - - /** - * Returns the permit ID if the permit was acquired. - * - * @return the permit ID, or null if the permit was not acquired - */ - @Nullable - public String permitId() { - return permitId; - } - - /** - * Releases the permit if it was acquired. Safe to call multiple times or if the permit was never - * acquired (no-op in both cases). - */ - @Override - public void close() { - if (!acquired || semaphore == null || permitId == null) { - return; - } - try { - if (semaphoreMetrics != null) { - semaphoreMetrics.recordHeldTime(System.currentTimeMillis() - heldStartTime); - } - semaphore.release(permitId); - LOG.debug("Permit [{}] released from [{}] via handle", permitId, fullKey); - } catch (IllegalArgumentException e) { - LOG.warn( - "Permit [{}] was already released (possibly expired) from [{}]: {}", - permitId, - fullKey, - e.getMessage()); - } catch (Exception e) { - LOG.warn("Failed to release permit [{}] from [{}]", permitId, fullKey, e); - } - } -} diff --git a/src/main/java/in/riido/locksmith/template/handle/package-info.java b/src/main/java/in/riido/locksmith/template/handle/package-info.java deleted file mode 100644 index a0d07a2..0000000 --- a/src/main/java/in/riido/locksmith/template/handle/package-info.java +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Auto-closeable handles for template-based distributed operations. - * - *

This package contains handle types returned by template APIs when acquiring locks or semaphore - * permits. These handles are designed for try-with-resources usage to ensure safe release in {@code - * close()}. - * - *

    - *
  • {@link in.riido.locksmith.template.handle.LockHandle} - Handle for distributed lock - * acquisition - *
  • {@link in.riido.locksmith.template.handle.PermitHandle} - Handle for semaphore permit - * acquisition - *
- * - * @author Garvit Joshi - * @since 3.0.3 - * @see in.riido.locksmith.template.LocksmithLockTemplate - * @see in.riido.locksmith.template.LocksmithSemaphoreTemplate - */ -package in.riido.locksmith.template.handle; diff --git a/src/main/java/in/riido/locksmith/template/package-info.java b/src/main/java/in/riido/locksmith/template/package-info.java deleted file mode 100644 index 4efa5ae..0000000 --- a/src/main/java/in/riido/locksmith/template/package-info.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Programmatic API for distributed locks and semaphores. - * - *

This package provides template classes for programmatic access to distributed locks and - * semaphores, complementing the annotation-based approach provided by {@link - * in.riido.locksmith.DistributedLock} and {@link in.riido.locksmith.DistributedSemaphore}. - * - *

Key classes: - * - *

    - *
  • {@link in.riido.locksmith.template.LocksmithLockTemplate} - Template for distributed lock - * operations - *
  • {@link in.riido.locksmith.template.LocksmithSemaphoreTemplate} - Template for distributed - * semaphore operations - *
  • {@link in.riido.locksmith.template.callback.LockCallback} - Callback interface for lock - * execution - *
  • {@link in.riido.locksmith.template.callback.SemaphoreCallback} - Callback interface for - * semaphore execution - *
- * - *

Usage example: - * - *

{@code
- * @Autowired
- * private LocksmithLockTemplate lockTemplate;
- *
- * // Manual lock management
- * if (lockTemplate.tryLock("my-key")) {
- *     try {
- *         // Critical section
- *     } finally {
- *         lockTemplate.unlock("my-key");
- *     }
- * }
- *
- * // Callback-based execution
- * String result = lockTemplate.executeWithLock("my-key", () -> {
- *     return "executed within lock";
- * });
- * }
- * - * @author Garvit Joshi - * @since 2.1.0 - */ -package in.riido.locksmith.template; diff --git a/src/main/resources/META-INF/spring-configuration-metadata.json b/src/main/resources/META-INF/spring-configuration-metadata.json deleted file mode 100644 index d246c57..0000000 --- a/src/main/resources/META-INF/spring-configuration-metadata.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "groups": [ - { - "name": "locksmith", - "type": "in.riido.locksmith.autoconfigure.LocksmithProperties", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties", - "description": "Configuration properties for Locksmith distributed locking, semaphores, and rate limiting." - }, - { - "name": "locksmith.lock", - "type": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties", - "sourceMethod": "lock()", - "description": "Configuration properties for distributed locks." - }, - { - "name": "locksmith.semaphore", - "type": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties", - "sourceMethod": "semaphore()", - "description": "Configuration properties for distributed semaphores." - }, - { - "name": "locksmith.rate-limit", - "type": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties", - "sourceMethod": "rateLimit()", - "description": "Configuration properties for distributed rate limiters." - } - ], - "properties": [ - { - "name": "locksmith.lock.enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "When set to false, disables the distributed lock aspect and template. Methods annotated with @DistributedLock will execute without acquiring locks.", - "defaultValue": true - }, - { - "name": "locksmith.lock.lease-time", - "type": "java.time.Duration", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "The default time after which the lock is automatically released. This prevents deadlocks if a server crashes while holding a lock. Supports duration strings like '10m', '30s', '1h' or ISO-8601 format like 'PT10M'.", - "defaultValue": "10m" - }, - { - "name": "locksmith.lock.wait-time", - "type": "java.time.Duration", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "The default time to wait for acquiring a lock when using WAIT_AND_SKIP mode. If the lock cannot be acquired within this time, the method execution is skipped. Supports duration strings like '60s', '5m' or ISO-8601 format like 'PT1M'.", - "defaultValue": "60s" - }, - { - "name": "locksmith.lock.key-prefix", - "type": "java.lang.String", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "The prefix to use for all lock keys in Redis. This helps namespace your locks and avoid conflicts with other applications sharing the same Redis instance.", - "defaultValue": "lock:" - }, - { - "name": "locksmith.lock.debug", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "When enabled, logs detailed information about lock operations including key resolution, lock type, timing, and acquisition status.", - "defaultValue": false - }, - { - "name": "locksmith.semaphore.enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "When set to false, disables the distributed semaphore aspect and template. Methods annotated with @DistributedSemaphore will execute without acquiring permits.", - "defaultValue": true - }, - { - "name": "locksmith.semaphore.lease-time", - "type": "java.time.Duration", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "The default time after which the semaphore permit is automatically released. This prevents deadlocks if a server crashes while holding a permit. Supports duration strings like '5m', '30s', '1h' or ISO-8601 format like 'PT5M'.", - "defaultValue": "5m" - }, - { - "name": "locksmith.semaphore.wait-time", - "type": "java.time.Duration", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "The default time to wait for acquiring a semaphore permit when using WAIT_AND_SKIP mode. If a permit cannot be acquired within this time, the method execution is skipped. Supports duration strings like '60s', '5m' or ISO-8601 format like 'PT1M'.", - "defaultValue": "60s" - }, - { - "name": "locksmith.semaphore.key-prefix", - "type": "java.lang.String", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "The prefix to use for all semaphore keys in Redis. This helps namespace your semaphores and avoid conflicts with other applications sharing the same Redis instance.", - "defaultValue": "semaphore:" - }, - { - "name": "locksmith.semaphore.debug", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "When enabled, logs detailed information about semaphore operations including key resolution, permit count, timing, and acquisition status.", - "defaultValue": false - }, - { - "name": "locksmith.lock.metrics-enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$LockProperties", - "description": "When enabled, records Micrometer metrics for lock operations including acquisition time, held time, and skip counts. Requires micrometer-core on classpath and a MeterRegistry bean.", - "defaultValue": false - }, - { - "name": "locksmith.semaphore.metrics-enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$SemaphoreProperties", - "description": "When enabled, records Micrometer metrics for semaphore operations including acquisition time, held time, and skip counts. Requires micrometer-core on classpath and a MeterRegistry bean.", - "defaultValue": false - }, - { - "name": "locksmith.rate-limit.enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "description": "When set to false, disables the rate limit aspect and template. Methods annotated with @RateLimit will execute without rate limiting.", - "defaultValue": true - }, - { - "name": "locksmith.rate-limit.wait-time", - "type": "java.time.Duration", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "description": "The default time to wait for acquiring a rate limit permit when using WAIT_AND_SKIP mode. If a permit cannot be acquired within this time, the method execution is skipped. Supports duration strings like '60s', '5m' or ISO-8601 format like 'PT1M'.", - "defaultValue": "60s" - }, - { - "name": "locksmith.rate-limit.key-prefix", - "type": "java.lang.String", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "description": "The prefix to use for all rate limiter keys in Redis. This helps namespace your rate limiters and avoid conflicts with other applications sharing the same Redis instance.", - "defaultValue": "ratelimit:" - }, - { - "name": "locksmith.rate-limit.debug", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "description": "When enabled, logs detailed information about rate limit operations including key resolution, permits, interval, and acquisition status.", - "defaultValue": false - }, - { - "name": "locksmith.rate-limit.metrics-enabled", - "type": "java.lang.Boolean", - "sourceType": "in.riido.locksmith.autoconfigure.LocksmithProperties$RateLimitProperties", - "description": "When enabled, records Micrometer metrics for rate limit operations including acquisition time and rejection counts. Requires micrometer-core on classpath and a MeterRegistry bean.", - "defaultValue": false - } - ] -} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 37aa874..43a0b11 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ -in.riido.locksmith.autoconfigure.LocksmithMetricsAutoConfiguration in.riido.locksmith.autoconfigure.LocksmithAutoConfiguration +in.riido.locksmith.autoconfigure.LocksmithDisabledAutoConfiguration +in.riido.locksmith.autoconfigure.LocksmithInactiveAutoConfiguration diff --git a/src/test/java/in/riido/locksmith/DockerAvailableCondition.java b/src/test/java/in/riido/locksmith/DockerAvailableCondition.java new file mode 100644 index 0000000..f2201e3 --- /dev/null +++ b/src/test/java/in/riido/locksmith/DockerAvailableCondition.java @@ -0,0 +1,17 @@ +package in.riido.locksmith; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.DockerClientFactory; + +/** Disables a test class when no Docker daemon is reachable, so Testcontainers tests skip. */ +public final class DockerAvailableCondition implements ExecutionCondition { + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return DockerClientFactory.instance().isDockerAvailable() + ? ConditionEvaluationResult.enabled("Docker is available") + : ConditionEvaluationResult.disabled("Docker is not available"); + } +} diff --git a/src/test/java/in/riido/locksmith/NotAcquiredExceptionsTest.java b/src/test/java/in/riido/locksmith/NotAcquiredExceptionsTest.java new file mode 100644 index 0000000..d11d95d --- /dev/null +++ b/src/test/java/in/riido/locksmith/NotAcquiredExceptionsTest.java @@ -0,0 +1,40 @@ +package in.riido.locksmith; + +import static org.assertj.core.api.Assertions.assertThat; + +import in.riido.locksmith.lock.LockNotAcquiredException; +import in.riido.locksmith.semaphore.SemaphoreNotAcquiredException; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("not-acquired exceptions") +class NotAcquiredExceptionsTest { + + @Test + @DisplayName("lock exception carries key and wait time and uses the fixed message") + void lockExceptionMessageAndFields() { + LockNotAcquiredException e = + new LockNotAcquiredException("locksmith:lock:scheduler:cleanup", Duration.ZERO); + + assertThat(e).isInstanceOf(LocksmithException.class); + assertThat(e.key()).isEqualTo("locksmith:lock:scheduler:cleanup"); + assertThat(e.waitTime()).isEqualTo(Duration.ZERO); + assertThat(e).hasMessage("Lock [locksmith:lock:scheduler:cleanup] not acquired within PT0S"); + } + + @Test + @DisplayName("semaphore exception carries key, permits and wait time and uses the fixed message") + void semaphoreExceptionMessageAndFields() { + SemaphoreNotAcquiredException e = + new SemaphoreNotAcquiredException("locksmith:semaphore:reports", 5, Duration.ofSeconds(2)); + + assertThat(e).isInstanceOf(LocksmithException.class); + assertThat(e.key()).isEqualTo("locksmith:semaphore:reports"); + assertThat(e.permits()).isEqualTo(5); + assertThat(e.waitTime()).isEqualTo(Duration.ofSeconds(2)); + assertThat(e) + .hasMessage( + "Semaphore [locksmith:semaphore:reports] permit not acquired within PT2S (permits 5)"); + } +} diff --git a/src/test/java/in/riido/locksmith/aop/LocksmithAdvisorTest.java b/src/test/java/in/riido/locksmith/aop/LocksmithAdvisorTest.java new file mode 100644 index 0000000..8b47d44 --- /dev/null +++ b/src/test/java/in/riido/locksmith/aop/LocksmithAdvisorTest.java @@ -0,0 +1,72 @@ +package in.riido.locksmith.aop; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import java.lang.reflect.Method; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; + +@DisplayName("LocksmithAdvisor") +class LocksmithAdvisorTest { + + private final LocksmithAdvisor advisor = new LocksmithAdvisor(mock(LocksmithInterceptor.class)); + + interface Api { + @DistributedLock(key = "iface") + void declaredOnInterface(); + } + + @SuppressWarnings("unused") + static class Service implements Api { + @DistributedLock(key = "k") + public void locked() {} + + @DistributedSemaphore(key = "k", permits = "1") + public void limited() {} + + public void plain() {} + + @Override + public void declaredOnInterface() {} + } + + private static Method method(Class type, String name) throws NoSuchMethodException { + return type.getMethod(name); + } + + @Test + @DisplayName("matches a class method with @DistributedLock") + void matchesLock() throws NoSuchMethodException { + assertThat(advisor.matches(method(Service.class, "locked"), Service.class)).isTrue(); + } + + @Test + @DisplayName("matches the interface method when the annotation is declared on the interface") + void matchesInterfaceDeclaration() throws NoSuchMethodException { + assertThat(advisor.matches(method(Api.class, "declaredOnInterface"), Service.class)).isTrue(); + assertThat(advisor.matches(method(Service.class, "declaredOnInterface"), Service.class)) + .isTrue(); + } + + @Test + @DisplayName("matches a method with @DistributedSemaphore") + void matchesSemaphore() throws NoSuchMethodException { + assertThat(advisor.matches(method(Service.class, "limited"), Service.class)).isTrue(); + } + + @Test + @DisplayName("does not match an unannotated method") + void doesNotMatchPlain() throws NoSuchMethodException { + assertThat(advisor.matches(method(Service.class, "plain"), Service.class)).isFalse(); + } + + @Test + @DisplayName("order is LOWEST_PRECEDENCE - 1") + void order() { + assertThat(advisor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE - 1); + } +} diff --git a/src/test/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrarTest.java b/src/test/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrarTest.java new file mode 100644 index 0000000..3f803b4 --- /dev/null +++ b/src/test/java/in/riido/locksmith/aop/LocksmithAutoProxyRegistrarTest.java @@ -0,0 +1,156 @@ +package in.riido.locksmith.aop; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.autoconfigure.LocksmithAutoConfiguration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.misc.CompletableFutureWrapper; +import org.springframework.aop.support.AopUtils; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +@DisplayName("LocksmithAutoProxyRegistrar") +class LocksmithAutoProxyRegistrarTest { + + private final RedissonClient redisson = mock(RedissonClient.class); + private final RLock lock = mock(RLock.class); + + @BeforeEach + void lockAlwaysFree() { + when(redisson.getLock(anyString())).thenReturn(lock); + when(lock.tryLockAsync(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS), anyLong())) + .thenReturn(new CompletableFutureWrapper<>(true)); + when(lock.unlockAsync(anyLong())).thenReturn(new CompletableFutureWrapper<>((Void) null)); + } + + static class ClassService { + @DistributedLock(key = "k") + public void run() {} + } + + interface Api { + @DistributedLock(key = "k") + void run(); + } + + static class ApiService implements Api { + @Override + public void run() {} + } + + interface BlankKeyApi { + @DistributedLock(key = " ") + void run(); + } + + static class BlankKeyService implements BlankKeyApi { + @Override + public void run() {} + } + + interface PlainApi { + void run(); + } + + static class BlankKeyOnImplementation implements PlainApi { + @Override + @DistributedLock(key = " ") + public void run() {} + } + + private static Throwable configurationFailure(Throwable failure) { + while (failure != null && !(failure instanceof LocksmithConfigurationException)) { + failure = failure.getCause(); + } + return failure; + } + + private ApplicationContextRunner runner(Class... autoConfigurations) { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(autoConfigurations)) + .withBean(RedissonClient.class, () -> redisson); + } + + @Test + @DisplayName("without Boot's AOP auto-configuration the bean is proxied and the lock is taken") + void withoutAopAutoConfiguration() { + runner(LocksmithAutoConfiguration.class) + .withBean(ClassService.class) + .run( + context -> { + ClassService service = context.getBean(ClassService.class); + assertThat(AopUtils.isAopProxy(service)).isTrue(); + + service.run(); + + verify(lock, times(1)) + .tryLockAsync(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS), anyLong()); + verify(lock, times(1)).unlockAsync(anyLong()); + }); + } + + @Test + @DisplayName("with proxy-target-class=false an interface bean gets a JDK proxy and the lock") + void interfaceProxies() { + runner(AopAutoConfiguration.class, LocksmithAutoConfiguration.class) + .withPropertyValues("spring.aop.proxy-target-class=false") + .withBean(ApiService.class) + .run( + context -> { + Api service = context.getBean(Api.class); + assertThat(AopUtils.isJdkDynamicProxy(service)).isTrue(); + + service.run(); + + verify(lock, times(1)) + .tryLockAsync(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS), anyLong()); + verify(lock, times(1)).unlockAsync(anyLong()); + }); + } + + @Test + @DisplayName("with proxy-target-class=false a blank key on an interface still fails the startup") + void interfaceProxiesStillValidated() { + runner(AopAutoConfiguration.class, LocksmithAutoConfiguration.class) + .withPropertyValues("spring.aop.proxy-target-class=false") + .withBean(BlankKeyService.class) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(configurationFailure(context.getStartupFailure())) + .hasMessageContaining(BlankKeyService.class.getName() + ".run") + .hasMessageContaining("key must not be blank") + .hasMessageNotContaining("$Proxy"); + }); + } + + @Test + @DisplayName("with proxy-target-class=false a blank key on the implementing method fails startup") + void implementingMethodValidated() { + runner(AopAutoConfiguration.class, LocksmithAutoConfiguration.class) + .withPropertyValues("spring.aop.proxy-target-class=false") + .withBean(BlankKeyOnImplementation.class) + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(configurationFailure(context.getStartupFailure())) + .hasMessageContaining(BlankKeyOnImplementation.class.getName() + ".run") + .hasMessageContaining("key must not be blank"); + }); + } +} diff --git a/src/test/java/in/riido/locksmith/aop/LocksmithInterceptorTest.java b/src/test/java/in/riido/locksmith/aop/LocksmithInterceptorTest.java new file mode 100644 index 0000000..8e63d0a --- /dev/null +++ b/src/test/java/in/riido/locksmith/aop/LocksmithInterceptorTest.java @@ -0,0 +1,561 @@ +package in.riido.locksmith.aop; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.lock.LockFailureContext; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.lock.LockNotAcquiredException; +import in.riido.locksmith.lock.LockOperations; +import in.riido.locksmith.metrics.NoOpLocksmithMetrics; +import in.riido.locksmith.semaphore.SemaphoreFailureContext; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; +import in.riido.locksmith.semaphore.SemaphoreNotAcquiredException; +import in.riido.locksmith.semaphore.SemaphoreOperations; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.redisson.api.RLock; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.redisson.misc.CompletableFutureWrapper; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.env.MockEnvironment; +import reactor.core.publisher.Mono; + +/** + * Uses a real {@link LockOperations} and {@link SemaphoreOperations} over a mocked {@link + * RedissonClient}: their builders are final inner classes, so the mocks sit one level down at the + * lock and the semaphore. + */ +@DisplayName("LocksmithInterceptor") +class LocksmithInterceptorTest { + + private static final String FULL_KEY = "locksmith:lock:order:42"; + private static final String SEMAPHORE_KEY = "locksmith:semaphore:report:42"; + private static final String PERMIT_ID = "permit-1"; + + private RedissonClient redisson; + private RLock lock; + private RPermitExpirableSemaphore semaphore; + private BeanFactory beanFactory; + private MethodSpecFactory factory; + private LocksmithInterceptor interceptor; + + static class RecordingHandler implements LockFailureHandler { + final List contexts = new ArrayList<>(); + + @Override + public Object onFailure(LockFailureContext context) { + contexts.add(context); + return "handled"; + } + } + + static class RecordingSemaphoreHandler implements SemaphoreFailureHandler { + final List contexts = new ArrayList<>(); + + @Override + public Object onFailure(SemaphoreFailureContext context) { + contexts.add(context); + return "handled"; + } + } + + @SuppressWarnings("unused") + static class Service { + @DistributedSemaphore(key = "report:#{#id}", permits = "3", waitTime = "1s") + @DistributedLock(key = "order:#{#id}") + public String both(String id) { + return "ran"; + } + + @DistributedSemaphore(key = "report:42", permits = "3") + @DistributedLock(key = "order:#{#id}") + public String bothFixedPermitKey(String id) { + return "ran"; + } + + @DistributedSemaphore(key = "report:#{#id}", permits = "3") + @DistributedLock(key = "order:#{#id}", onFailure = OnFailure.SKIP) + public int bothLockSkipping(String id) { + return 1; + } + + @DistributedSemaphore(key = "report:#{#id}", permits = "3", waitTime = "2s", leaseTime = "20s") + public String permitThrowing(String id) { + return "ran"; + } + + @DistributedSemaphore(key = "report:#{#id}", permits = "3", onFailure = OnFailure.SKIP) + public Optional permitSkipping(String id) { + return Optional.of("ran"); + } + + @DistributedSemaphore( + key = "report:#{#id}", + permits = "3", + onFailure = OnFailure.HANDLER, + handler = RecordingSemaphoreHandler.class) + public Object permitHandled(String id) { + return "ran"; + } + + @DistributedLock(key = "order:#{#id}") + public String throwing(String id) { + return "ran"; + } + + @DistributedLock(key = "order:#{#id}", waitTime = "2s", leaseTime = "10s") + public String timed(String id) { + return "ran"; + } + + @DistributedLock(key = "order:#{#id}", onFailure = OnFailure.SKIP) + public Optional skipping(String id) { + return Optional.of("ran"); + } + + @DistributedLock(key = "order:#{#id}", onFailure = OnFailure.SKIP) + public int skippingInt(String id) { + return 1; + } + + @DistributedLock( + key = "order:#{#id}", + onFailure = OnFailure.HANDLER, + handler = RecordingHandler.class) + public Object handled(String id) { + return "ran"; + } + + @DistributedLock(key = "order:#{#id}") + public CompletableFuture future(String id) { + return null; + } + + @DistributedLock(key = "order:#{#id}") + public Mono reactive(String id) { + return null; + } + } + + @BeforeEach + void setUp() { + redisson = mock(RedissonClient.class); + lock = mock(RLock.class); + semaphore = mock(RPermitExpirableSemaphore.class); + when(redisson.getLock(FULL_KEY)).thenReturn(lock); + when(redisson.getPermitExpirableSemaphore(SEMAPHORE_KEY)).thenReturn(semaphore); + when(semaphore.getPermitsAsync()).thenReturn(new CompletableFutureWrapper<>(3)); + when(lock.unlockAsync(anyLong())).thenReturn(new CompletableFutureWrapper<>((Void) null)); + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>((Void) null)); + LocksmithProperties properties = new LocksmithProperties(null, null, null); + LockOperations operations = + new LockOperations(redisson, properties, new NoOpLocksmithMetrics()); + SemaphoreOperations semaphores = + new SemaphoreOperations(redisson, properties, new NoOpLocksmithMetrics()); + beanFactory = mock(BeanFactory.class); + factory = + spy( + new MethodSpecFactory( + new MockEnvironment(), new LocksmithProperties(null, null, null))); + interceptor = + new LocksmithInterceptor( + provider(operations), provider(semaphores), provider(factory), beanFactory); + } + + @SuppressWarnings("unchecked") + private static ObjectProvider provider(T bean) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getObject()).thenReturn(bean); + return provider; + } + + private static Method method(String name) throws NoSuchMethodException { + return Service.class.getMethod(name, String.class); + } + + private MethodInvocation invocation(String name) throws Throwable { + MethodInvocation invocation = mock(MethodInvocation.class); + when(invocation.getMethod()).thenReturn(method(name)); + when(invocation.getThis()).thenReturn(new Service()); + when(invocation.getArguments()).thenReturn(new Object[] {"42"}); + when(invocation.proceed()).thenReturn("ran"); + return invocation; + } + + private void lockAcquired(boolean acquired) { + when(lock.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenReturn(new CompletableFutureWrapper<>(acquired)); + } + + private void permitAcquired(boolean acquired) { + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn( + new CompletableFutureWrapper<>(acquired ? List.of(PERMIT_ID) : List.of())); + } + + @Nested + @DisplayName("lock acquired") + class Acquired { + + @Test + @DisplayName("proceeds, returns the method result and releases the lock") + void proceedsAndReleases() throws Throwable { + lockAcquired(true); + MethodInvocation invocation = invocation("throwing"); + + assertThat(interceptor.invoke(invocation)).isEqualTo("ran"); + + verify(invocation).proceed(); + verify(lock).unlockAsync(Thread.currentThread().getId()); + } + + @Test + @DisplayName("passes waitTime and leaseTime to the lock in milliseconds") + void passesDurations() throws Throwable { + lockAcquired(true); + + interceptor.invoke(invocation("timed")); + + verify(lock).tryLockAsync(eq(2000L), eq(10000L), eq(MILLISECONDS), anyLong()); + } + + @Test + @DisplayName("passes lease -1 when no leaseTime is set") + void renewalWhenNoLease() throws Throwable { + lockAcquired(true); + + interceptor.invoke(invocation("throwing")); + + verify(lock).tryLockAsync(eq(0L), eq(-1L), eq(MILLISECONDS), anyLong()); + } + + @Test + @DisplayName("propagates the exception from proceed() and still releases the lock") + void releasesWhenProceedThrows() throws Throwable { + lockAcquired(true); + MethodInvocation invocation = invocation("throwing"); + IllegalStateException failure = new IllegalStateException("boom"); + when(invocation.proceed()).thenThrow(failure); + + assertThatThrownBy(() -> interceptor.invoke(invocation)).isSameAs(failure); + + verify(lock).unlockAsync(Thread.currentThread().getId()); + } + } + + @Nested + @DisplayName("lock not acquired") + class NotAcquired { + + @Test + @DisplayName("THROW: LockNotAcquiredException with the full key and wait time, no proceed") + void throwPolicy() throws Throwable { + lockAcquired(false); + MethodInvocation invocation = invocation("throwing"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOfSatisfying( + LockNotAcquiredException.class, + e -> { + assertThat(e.key()).isEqualTo(FULL_KEY); + assertThat(e.waitTime()).isEqualTo(Duration.ZERO); + }); + + verify(invocation, never()).proceed(); + verify(lock, never()).unlockAsync(anyLong()); + } + + @Test + @DisplayName("SKIP: returns Optional.empty() for an Optional method, no proceed") + void skipOptional() throws Throwable { + lockAcquired(false); + MethodInvocation invocation = invocation("skipping"); + + assertThat(interceptor.invoke(invocation)).isEqualTo(Optional.empty()); + + verify(invocation, never()).proceed(); + } + + @Test + @DisplayName("SKIP: returns 0 for an int method") + void skipInt() throws Throwable { + lockAcquired(false); + + assertThat(interceptor.invoke(invocation("skippingInt"))).isEqualTo(0); + } + + @Test + @DisplayName("HANDLER: returns the handler's value; the bean is looked up on each failure") + void handlerPolicy() throws Throwable { + lockAcquired(false); + RecordingHandler handler = new RecordingHandler(); + when(beanFactory.getBean(RecordingHandler.class)).thenReturn(handler); + MethodInvocation invocation = invocation("handled"); + + assertThat(interceptor.invoke(invocation)).isEqualTo("handled"); + assertThat(interceptor.invoke(invocation)).isEqualTo("handled"); + + verify(beanFactory, times(2)).getBean(RecordingHandler.class); + verify(invocation, never()).proceed(); + LockFailureContext context = handler.contexts.get(0); + assertThat(context.key()).isEqualTo(FULL_KEY); + assertThat(context.method()).isEqualTo(method("handled")); + assertThat(context.args()).containsExactly("42"); + assertThat(context.waitTime()).isEqualTo(Duration.ZERO); + } + } + + @Nested + @DisplayName("spec cache") + class SpecCache { + + @Test + @DisplayName("builds the spec of a method on the first call only") + void backstop() throws Throwable { + lockAcquired(true); + + interceptor.invoke(invocation("throwing")); + interceptor.invoke(invocation("throwing")); + + verify(factory, times(1)).create(method("throwing")); + } + } + + @Nested + @DisplayName("semaphore and lock on one method") + class Both { + + @Test + @DisplayName("acquires the permit then the lock; releases the lock then the permit") + void order() throws Throwable { + permitAcquired(true); + lockAcquired(true); + MethodInvocation invocation = invocation("both"); + + assertThat(interceptor.invoke(invocation)).isEqualTo("ran"); + + InOrder order = inOrder(semaphore, lock, invocation); + order + .verify(semaphore) + .tryAcquireAsync(1, 1000L, Duration.ofMinutes(5).toMillis(), MILLISECONDS); + order.verify(lock).tryLockAsync(eq(0L), eq(-1L), eq(MILLISECONDS), anyLong()); + order.verify(invocation).proceed(); + order.verify(lock).unlockAsync(anyLong()); + order.verify(semaphore).releaseAsync(PERMIT_ID); + } + + @Test + @DisplayName("releases the lock then the permit when proceed() throws") + void releasesBothWhenProceedThrows() throws Throwable { + permitAcquired(true); + lockAcquired(true); + MethodInvocation invocation = invocation("both"); + IllegalStateException failure = new IllegalStateException("boom"); + when(invocation.proceed()).thenThrow(failure); + + assertThatThrownBy(() -> interceptor.invoke(invocation)).isSameAs(failure); + + InOrder order = inOrder(lock, semaphore); + order.verify(lock).unlockAsync(anyLong()); + order.verify(semaphore).releaseAsync(PERMIT_ID); + } + + @Test + @DisplayName("lock not acquired: releases the permit, then returns the lock SKIP result") + void permitReleasedWhenLockFails() throws Throwable { + permitAcquired(true); + lockAcquired(false); + MethodInvocation invocation = invocation("bothLockSkipping"); + + assertThat(interceptor.invoke(invocation)).isEqualTo(0); + + verify(semaphore).releaseAsync(PERMIT_ID); + verify(lock, never()).unlockAsync(anyLong()); + verify(invocation, never()).proceed(); + } + + @Test + @DisplayName("lock THROW: releases the permit before LockNotAcquiredException propagates") + void permitReleasedWhenLockThrows() throws Throwable { + permitAcquired(true); + lockAcquired(false); + MethodInvocation invocation = invocation("both"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOf(LockNotAcquiredException.class); + + verify(semaphore).releaseAsync(PERMIT_ID); + } + + @Test + @DisplayName("lock key with a null part: throws and releases the permit already taken") + void permitReleasedWhenLockKeyHasNullPart() throws Throwable { + permitAcquired(true); + MethodInvocation invocation = invocation("bothFixedPermitKey"); + when(invocation.getArguments()).thenReturn(new Object[] {null}); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("part #{#id} resolved to null"); + + verify(semaphore).releaseAsync(PERMIT_ID); + verify(lock, never()).tryLockAsync(anyLong(), anyLong(), any(), anyLong()); + verify(invocation, never()).proceed(); + } + + @Test + @DisplayName("permit not acquired: the lock is never attempted") + void lockNotAttemptedWithoutPermit() throws Throwable { + permitAcquired(false); + MethodInvocation invocation = invocation("both"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOf(SemaphoreNotAcquiredException.class); + + verify(lock, never()).tryLockAsync(anyLong(), anyLong(), any(), anyLong()); + verify(semaphore, never()).releaseAsync(any(String.class)); + } + } + + @Nested + @DisplayName("semaphore only") + class SemaphoreOnly { + + @Test + @DisplayName("acquires with permits, waitTime and leaseTime in ms, proceeds, releases") + void proceedsAndReleases() throws Throwable { + permitAcquired(true); + MethodInvocation invocation = invocation("permitThrowing"); + + assertThat(interceptor.invoke(invocation)).isEqualTo("ran"); + + verify(semaphore).tryAcquireAsync(1, 2000L, 20_000L, MILLISECONDS); + verify(invocation).proceed(); + verify(semaphore).releaseAsync(PERMIT_ID); + verify(redisson, never()).getLock(any(String.class)); + } + + @Test + @DisplayName("releases the permit when proceed() throws") + void releasesWhenProceedThrows() throws Throwable { + permitAcquired(true); + MethodInvocation invocation = invocation("permitThrowing"); + IllegalStateException failure = new IllegalStateException("boom"); + when(invocation.proceed()).thenThrow(failure); + + assertThatThrownBy(() -> interceptor.invoke(invocation)).isSameAs(failure); + + verify(semaphore).releaseAsync(PERMIT_ID); + } + + @Test + @DisplayName("THROW: SemaphoreNotAcquiredException with full key, permits and wait, no proceed") + void throwPolicy() throws Throwable { + permitAcquired(false); + MethodInvocation invocation = invocation("permitThrowing"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOfSatisfying( + SemaphoreNotAcquiredException.class, + e -> { + assertThat(e.key()).isEqualTo(SEMAPHORE_KEY); + assertThat(e.permits()).isEqualTo(3); + assertThat(e.waitTime()).isEqualTo(Duration.ofSeconds(2)); + }); + + verify(invocation, never()).proceed(); + verify(semaphore, never()).releaseAsync(any(String.class)); + } + + @Test + @DisplayName("SKIP: returns Optional.empty(), no proceed") + void skipPolicy() throws Throwable { + permitAcquired(false); + MethodInvocation invocation = invocation("permitSkipping"); + + assertThat(interceptor.invoke(invocation)).isEqualTo(Optional.empty()); + + verify(invocation, never()).proceed(); + } + + @Test + @DisplayName("HANDLER: returns the handler's value; the bean is looked up on each failure") + void handlerPolicy() throws Throwable { + permitAcquired(false); + RecordingSemaphoreHandler handler = new RecordingSemaphoreHandler(); + when(beanFactory.getBean(RecordingSemaphoreHandler.class)).thenReturn(handler); + MethodInvocation invocation = invocation("permitHandled"); + + assertThat(interceptor.invoke(invocation)).isEqualTo("handled"); + assertThat(interceptor.invoke(invocation)).isEqualTo("handled"); + + verify(beanFactory, times(2)).getBean(RecordingSemaphoreHandler.class); + verify(invocation, never()).proceed(); + SemaphoreFailureContext context = handler.contexts.get(0); + assertThat(context.key()).isEqualTo(SEMAPHORE_KEY); + assertThat(context.permits()).isEqualTo(3); + assertThat(context.method()).isEqualTo(method("permitHandled")); + assertThat(context.args()).containsExactly("42"); + assertThat(context.waitTime()).isEqualTo(Duration.ZERO); + } + } + + @Nested + @DisplayName("return types whose work can outlive the lock") + class ReturnTypes { + + @Test + @DisplayName("the lazy spec fallback rejects a CompletableFuture without @Async like startup") + void fallbackRejectsFutureWithoutAsync() throws Throwable { + MethodInvocation invocation = invocation("future"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("mark the method @Async"); + + verify(invocation, never()).proceed(); + } + + @Test + @DisplayName("the lazy spec fallback rejects a reactive return type like startup does") + void fallbackRejectsReactive() throws Throwable { + MethodInvocation invocation = invocation("reactive"); + + assertThatThrownBy(() -> interceptor.invoke(invocation)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("reactive return types are not supported"); + + verify(invocation, never()).proceed(); + } + } +} diff --git a/src/test/java/in/riido/locksmith/aop/MethodSpecFactoryTest.java b/src/test/java/in/riido/locksmith/aop/MethodSpecFactoryTest.java new file mode 100644 index 0000000..4d4c1a0 --- /dev/null +++ b/src/test/java/in/riido/locksmith/aop/MethodSpecFactoryTest.java @@ -0,0 +1,863 @@ +package in.riido.locksmith.aop; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.LockType; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.aop.MethodSpec.LockSpec; +import in.riido.locksmith.aop.MethodSpec.SemaphoreSpec; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.lock.LockFailureContext; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.semaphore.SemaphoreFailureContext; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; +import in.riido.locksmith.support.KeyTemplate; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.Future; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.springframework.expression.ParseException; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.annotation.Async; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@DisplayName("MethodSpecFactory") +class MethodSpecFactoryTest { + + private static final String FIXTURE = Fixture.class.getName(); + + private static final Duration DEFAULT_SEMAPHORE_LEASE = Duration.ofSeconds(90); + + private final MethodSpecFactory factory = + new MethodSpecFactory( + new MockEnvironment().withProperty("reports.max", "4").withProperty("reports.bad", "x"), + new LocksmithProperties( + null, null, new LocksmithProperties.Semaphore(DEFAULT_SEMAPHORE_LEASE))); + + static class MarkerHandler implements LockFailureHandler { + @Override + public Object onFailure(LockFailureContext context) { + return "marker"; + } + } + + static class MarkerSemaphoreHandler implements SemaphoreFailureHandler { + @Override + public Object onFailure(SemaphoreFailureContext context) { + return "marker"; + } + } + + interface Annotated { + @DistributedLock(key = "iface") + void fromInterface(); + } + + @SuppressWarnings("unused") + static class Fixture implements Annotated { + @DistributedLock(key = "user:#{#userId}") + void defaults(String userId) {} + + @DistributedLock(key = "k", type = LockType.WRITE) + void write() {} + + @DistributedLock(key = "k", waitTime = "5s") + void waitSimple() {} + + @DistributedLock(key = "k", waitTime = "PT5S") + void waitIso() {} + + @DistributedLock(key = "k", leaseTime = "30s") + void lease() {} + + @DistributedLock(key = "k", onFailure = OnFailure.SKIP) + void skip() {} + + @DistributedLock(key = "k", onFailure = OnFailure.HANDLER, handler = MarkerHandler.class) + void handler() {} + + @Override + public void fromInterface() {} + + void unannotated() {} + + @DistributedLock(key = " ") + void blankKey() {} + + @DistributedLock(key = "a:#{#id +}") + void unparsableKey(String id) {} + + @DistributedLock(key = "a:#{#nope}") + void unknownVariable(String id) {} + + @DistributedLock(key = "k", waitTime = "soon") + void badWait() {} + + @DistributedLock(key = "k", waitTime = "-5s") + void negativeWait() {} + + @DistributedLock(key = "k", leaseTime = "later") + void badLease() {} + + @DistributedLock(key = "k", leaseTime = "-1s") + void negativeLease() {} + + @DistributedLock(key = "k", leaseTime = "0s") + void zeroLease() {} + + @DistributedLock(key = "k", onFailure = OnFailure.HANDLER) + void handlerMissing() {} + + @DistributedLock(key = "k", handler = MarkerHandler.class) + void handlerWithThrow() {} + + @DistributedLock(key = "k", onFailure = OnFailure.SKIP, handler = MarkerHandler.class) + void handlerWithSkip() {} + + @DistributedSemaphore(key = "report:#{#id}", permits = "2") + void semaphore(String id) {} + + @DistributedSemaphore(key = "k", permits = "${reports.max}") + void semaphorePlaceholder() {} + + @DistributedSemaphore(key = "k", permits = "2", waitTime = "3s", leaseTime = "30s") + void semaphoreTimed() {} + + @DistributedSemaphore( + key = "k", + permits = "2", + onFailure = OnFailure.HANDLER, + handler = MarkerSemaphoreHandler.class) + void semaphoreHandler() {} + + @DistributedSemaphore(key = "s", permits = "3") + @DistributedLock(key = "l", type = LockType.WRITE) + void both() {} + + @DistributedSemaphore(key = " ", permits = "2") + void semaphoreBlankKey() {} + + @DistributedSemaphore(key = "a:#{#id +}", permits = "2") + void semaphoreUnparsableKey(String id) {} + + @DistributedSemaphore(key = "a:#{#nope}", permits = "2") + void semaphoreUnknownVariable(String id) {} + + @DistributedSemaphore(key = "k", permits = "many") + void permitsNotNumber() {} + + @DistributedSemaphore(key = "k", permits = "${reports.bad}") + void permitsPlaceholderNotNumber() {} + + @DistributedSemaphore(key = "k", permits = "0") + void permitsZero() {} + + @DistributedSemaphore(key = "k", permits = "-2") + void permitsNegative() {} + + @DistributedSemaphore(key = "k", permits = "${reports.missing}") + void permitsUnresolvable() {} + + @DistributedSemaphore(key = "k", permits = "2", waitTime = "soon") + void semaphoreBadWait() {} + + @DistributedSemaphore(key = "k", permits = "2", waitTime = "-5s") + void semaphoreNegativeWait() {} + + @DistributedSemaphore(key = "k", permits = "2", leaseTime = "later") + void semaphoreBadLease() {} + + @DistributedSemaphore(key = "k", permits = "2", leaseTime = "0s") + void semaphoreZeroLease() {} + + @DistributedSemaphore(key = "k", permits = "2", leaseTime = "-1s") + void semaphoreNegativeLease() {} + + @DistributedSemaphore(key = "k", permits = "2", onFailure = OnFailure.HANDLER) + void semaphoreHandlerMissing() {} + + @DistributedSemaphore(key = "k", permits = "2", handler = MarkerSemaphoreHandler.class) + void semaphoreHandlerWithThrow() {} + } + + private static Method method(String name) { + return Arrays.stream(Fixture.class.getDeclaredMethods()) + .filter(m -> m.getName().equals(name)) + .findFirst() + .orElseThrow(); + } + + private LockSpec lock(String name) { + MethodSpec spec = factory.create(method(name)); + assertThat(spec.semaphore()).isNull(); + assertThat(spec.lock()).isNotNull(); + return spec.lock(); + } + + private SemaphoreSpec semaphore(String name) { + MethodSpec spec = factory.create(method(name)); + assertThat(spec.lock()).isNull(); + assertThat(spec.semaphore()).isNotNull(); + return spec.semaphore(); + } + + private void assertRejected(String name, String... fragments) { + assertThatThrownBy(() -> factory.create(method(name))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining(FIXTURE) + .hasMessageContaining(name) + .hasMessageContainingAll(fragments); + } + + @Nested + @DisplayName("accepts") + class Accepts { + + @Test + @DisplayName("defaults: key template parsed, REENTRANT, wait ZERO, no lease, THROW, no handler") + void defaults() { + LockSpec spec = lock("defaults"); + + assertThat(KeyTemplate.evaluate(spec.key(), method("defaults"), new Object[] {"42"})) + .isEqualTo("user:42"); + assertThat(spec.type()).isEqualTo(LockType.REENTRANT); + assertThat(spec.waitTime()).isEqualTo(Duration.ZERO); + assertThat(spec.leaseTime()).isNull(); + assertThat(spec.onFailure()).isEqualTo(OnFailure.THROW); + assertThat(spec.handlerType()).isNull(); + } + + @Test + @DisplayName("type WRITE") + void type() { + assertThat(lock("write").type()).isEqualTo(LockType.WRITE); + } + + @Test + @DisplayName("waitTime 5s") + void waitSimple() { + assertThat(lock("waitSimple").waitTime()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("waitTime PT5S") + void waitIso() { + assertThat(lock("waitIso").waitTime()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("leaseTime 30s") + void lease() { + assertThat(lock("lease").leaseTime()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + @DisplayName("onFailure SKIP") + void skip() { + assertThat(lock("skip").onFailure()).isEqualTo(OnFailure.SKIP); + } + + @Test + @DisplayName("onFailure HANDLER with its handler type") + void handler() { + LockSpec spec = lock("handler"); + + assertThat(spec.onFailure()).isEqualTo(OnFailure.HANDLER); + assertThat(spec.handlerType()).isEqualTo(MarkerHandler.class); + } + + @Test + @DisplayName("an annotation declared on the interface") + void fromInterface() { + assertThat(lock("fromInterface").key().getExpressionString()).isEqualTo("iface"); + } + + @Test + @DisplayName("an unannotated method gives a spec with both parts null") + void unannotated() { + assertThat(factory.create(method("unannotated"))).isEqualTo(new MethodSpec(null, null)); + } + + @Test + @DisplayName("isAnnotated is true for lock, interface and semaphore, false for none") + void isAnnotated() { + assertThat(MethodSpecFactory.isAnnotated(method("defaults"))).isTrue(); + assertThat(MethodSpecFactory.isAnnotated(method("fromInterface"))).isTrue(); + assertThat(MethodSpecFactory.isAnnotated(method("semaphore"))).isTrue(); + assertThat(MethodSpecFactory.isAnnotated(method("unannotated"))).isFalse(); + } + } + + @Nested + @DisplayName("rejects") + class Rejects { + + @Test + @DisplayName("blank key, naming class, method and key") + void blankKey() { + assertRejected("blankKey", "key must not be blank"); + } + + @Test + @DisplayName("unparsable key, with the SpEL parse error") + void unparsableKey() { + String spelError = + catchThrowableOfType(ParseException.class, () -> KeyTemplate.parse("a:#{#id +}")) + .getMessage(); + + assertRejected("unparsableKey", "key [a:#{#id +}]", spelError); + } + + @Test + @DisplayName("unknown variable, with the variable and the -parameters hint") + void unknownVariable() { + assertRejected("unknownVariable", "#nope", "compile with -parameters or use #p0"); + } + + @Test + @DisplayName("unparsable waitTime, with attribute and value") + void badWait() { + assertRejected("badWait", "waitTime", "[soon]"); + } + + @Test + @DisplayName("negative waitTime, with attribute and value") + void negativeWait() { + assertRejected("negativeWait", "waitTime", "[-5s]", "must not be negative"); + } + + @Test + @DisplayName("unparsable leaseTime, with attribute and value") + void badLease() { + assertRejected("badLease", "leaseTime", "[later]"); + } + + @Test + @DisplayName("negative leaseTime, with attribute and value") + void negativeLease() { + assertRejected("negativeLease", "leaseTime", "[-1s]", "must be positive"); + } + + @Test + @DisplayName("zero leaseTime, with attribute and value") + void zeroLease() { + assertRejected("zeroLease", "leaseTime", "[0s]", "must be positive"); + } + + @Test + @DisplayName("onFailure HANDLER without handler, naming the handler type") + void handlerMissing() { + assertRejected("handlerMissing", "HANDLER", LockFailureHandler.class.getName()); + } + + @Test + @DisplayName("handler set with onFailure THROW") + void handlerWithThrow() { + assertRejected("handlerWithThrow", MarkerHandler.class.getName(), "onFailure is THROW"); + } + + @Test + @DisplayName("handler set with onFailure SKIP") + void handlerWithSkip() { + assertRejected("handlerWithSkip", MarkerHandler.class.getName(), "onFailure is SKIP"); + } + } + + @Nested + @DisplayName("accepts @DistributedSemaphore") + class AcceptsSemaphore { + + @Test + @DisplayName("defaults: key template, literal permits, wait ZERO, lease from properties, THROW") + void defaults() { + SemaphoreSpec spec = semaphore("semaphore"); + + assertThat(KeyTemplate.evaluate(spec.key(), method("semaphore"), new Object[] {"7"})) + .isEqualTo("report:7"); + assertThat(spec.permits()).isEqualTo(2); + assertThat(spec.waitTime()).isEqualTo(Duration.ZERO); + assertThat(spec.leaseTime()).isEqualTo(DEFAULT_SEMAPHORE_LEASE); + assertThat(spec.onFailure()).isEqualTo(OnFailure.THROW); + assertThat(spec.handlerType()).isNull(); + } + + @Test + @DisplayName("permits from a ${...} placeholder resolved through the Environment") + void placeholder() { + assertThat(semaphore("semaphorePlaceholder").permits()).isEqualTo(4); + } + + @Test + @DisplayName("explicit waitTime 3s and leaseTime 30s") + void timed() { + SemaphoreSpec spec = semaphore("semaphoreTimed"); + + assertThat(spec.waitTime()).isEqualTo(Duration.ofSeconds(3)); + assertThat(spec.leaseTime()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + @DisplayName("onFailure HANDLER with a SemaphoreFailureHandler type") + void handler() { + SemaphoreSpec spec = semaphore("semaphoreHandler"); + + assertThat(spec.onFailure()).isEqualTo(OnFailure.HANDLER); + assertThat(spec.handlerType()).isEqualTo(MarkerSemaphoreHandler.class); + } + + @Test + @DisplayName("both annotations on one method give a spec with both parts set") + void both() { + MethodSpec spec = factory.create(method("both")); + + assertThat(spec.lock()).isNotNull(); + assertThat(spec.lock().key().getExpressionString()).isEqualTo("l"); + assertThat(spec.lock().type()).isEqualTo(LockType.WRITE); + assertThat(spec.semaphore()).isNotNull(); + assertThat(spec.semaphore().key().getExpressionString()).isEqualTo("s"); + assertThat(spec.semaphore().permits()).isEqualTo(3); + } + } + + @Nested + @DisplayName("rejects @DistributedSemaphore") + class RejectsSemaphore { + + private static final String PREFIX = "@DistributedSemaphore on "; + + @Test + @DisplayName("blank key, naming class, method and key") + void blankKey() { + assertRejected("semaphoreBlankKey", PREFIX, "key must not be blank"); + } + + @Test + @DisplayName("unparsable key, with the SpEL parse error") + void unparsableKey() { + assertRejected("semaphoreUnparsableKey", PREFIX, "key [a:#{#id +}] is not a valid template"); + } + + @Test + @DisplayName("unknown variable, with the variable and the -parameters hint") + void unknownVariable() { + assertRejected("semaphoreUnknownVariable", "#nope", "compile with -parameters or use #p0"); + } + + @Test + @DisplayName("permits not a number, with the value") + void permitsNotNumber() { + assertRejected("permitsNotNumber", PREFIX, "permits [many] is not an integer"); + } + + @Test + @DisplayName("permits placeholder resolving to a non-number, with resolved and original text") + void permitsPlaceholderNotNumber() { + assertRejected( + "permitsPlaceholderNotNumber", + PREFIX, + "permits [x] (from [${reports.bad}]) is not an integer"); + } + + @Test + @DisplayName("permits 0, with the value") + void permitsZero() { + assertRejected("permitsZero", PREFIX, "permits [0] must be greater than zero"); + } + + @Test + @DisplayName("permits negative, with the value") + void permitsNegative() { + assertRejected("permitsNegative", PREFIX, "permits [-2] must be greater than zero"); + } + + @Test + @DisplayName("unresolvable permits placeholder, with the placeholder") + void permitsUnresolvable() { + assertRejected( + "permitsUnresolvable", PREFIX, "permits [${reports.missing}] cannot be resolved"); + } + + @Test + @DisplayName("unparsable waitTime, with attribute and value") + void badWait() { + assertRejected("semaphoreBadWait", PREFIX, "waitTime", "[soon]"); + } + + @Test + @DisplayName("negative waitTime, with attribute and value") + void negativeWait() { + assertRejected("semaphoreNegativeWait", PREFIX, "waitTime", "[-5s]", "must not be negative"); + } + + @Test + @DisplayName("unparsable leaseTime, with attribute and value") + void badLease() { + assertRejected("semaphoreBadLease", PREFIX, "leaseTime", "[later]"); + } + + @Test + @DisplayName("zero leaseTime, with attribute and value") + void zeroLease() { + assertRejected("semaphoreZeroLease", PREFIX, "leaseTime", "[0s]", "must be positive"); + } + + @Test + @DisplayName("negative leaseTime, with attribute and value") + void negativeLease() { + assertRejected("semaphoreNegativeLease", PREFIX, "leaseTime", "[-1s]", "must be positive"); + } + + @Test + @DisplayName("onFailure HANDLER without handler, naming SemaphoreFailureHandler") + void handlerMissing() { + assertRejected( + "semaphoreHandlerMissing", PREFIX, "HANDLER", SemaphoreFailureHandler.class.getName()); + } + + @Test + @DisplayName("handler set with onFailure THROW") + void handlerWithThrow() { + assertRejected( + "semaphoreHandlerWithThrow", + PREFIX, + MarkerSemaphoreHandler.class.getName(), + "onFailure is THROW"); + } + } + + /** Meta-annotated with {@code @Async}, so it counts as {@code @Async}. */ + @Async + @Retention(RetentionPolicy.RUNTIME) + @interface Background {} + + /** A {@code CompletionStage} subtype that a completed {@code CompletableFuture} is not. */ + static class CustomFuture extends CompletableFuture {} + + @SuppressWarnings("unused") + static class ReturnTypes { + @DistributedLock(key = "k") + CompletableFuture completableFuture() { + return null; + } + + @DistributedLock(key = "k", onFailure = OnFailure.SKIP) + CompletionStage completionStageSkip() { + return null; + } + + @Async + @DistributedLock(key = "k") + CompletableFuture asyncCompletableFuture() { + return null; + } + + @Async + @DistributedLock(key = "k", onFailure = OnFailure.SKIP) + CompletionStage asyncCompletionStageSkip() { + return null; + } + + @Async + @DistributedLock(key = "k") + void asyncVoid() {} + + /** The JVM signature of Kotlin's {@code fun run(): Unit?} and {@code fun run() = x?.work()}. */ + @Async + @DistributedLock(key = "k") + Unit asyncKotlinUnit() { + return null; + } + + @Async + @DistributedLock(key = "k") + String asyncString() { + return null; + } + + @Async + @DistributedLock(key = "k") + CustomFuture customFutureThrow() { + return null; + } + + @Async + @DistributedLock(key = "k", onFailure = OnFailure.SKIP) + CustomFuture customFutureSkip() { + return null; + } + + @Async + @DistributedSemaphore(key = "k", permits = "1", onFailure = OnFailure.SKIP) + CustomFuture semaphoreCustomFutureSkip() { + return null; + } + + @DistributedLock(key = "k") + Mono mono() { + return null; + } + + @DistributedLock(key = "k") + Flux flux() { + return null; + } + + @DistributedLock(key = "k") + Publisher reactiveStreamsPublisher() { + return null; + } + + @DistributedLock(key = "k") + Flow.Publisher flowPublisher() { + return null; + } + + @DistributedSemaphore(key = "k", permits = "1") + Mono semaphoreMono() { + return null; + } + + @DistributedLock(key = "k") + Object suspending(String id, Continuation continuation) { + return null; + } + + @DistributedLock(key = "k") + Future future() { + return null; + } + + @DistributedLock(key = "k") + ForkJoinTask forkJoinTask() { + return null; + } + + @DistributedSemaphore(key = "k", permits = "1") + Future semaphoreFuture() { + return null; + } + + @Async + @DistributedLock(key = "k") + Future asyncFuture() { + return null; + } + + @Background + @DistributedLock(key = "k") + Future metaAsyncFuture() { + return null; + } + } + + @Async + @SuppressWarnings("unused") + static class AsyncReturnTypes { + @DistributedLock(key = "k") + Future future() { + return null; + } + } + + @SuppressWarnings("unused") + static class Modifiers { + @DistributedLock(key = "k") + private void privateMethod() {} + + @DistributedLock(key = "k") + static void staticMethod() {} + + @DistributedSemaphore(key = "k", permits = "1") + public final void finalMethod() {} + + @DistributedLock(key = "k") + void packagePrivateMethod() {} + } + + @Nested + @DisplayName("methods Spring's proxy never intercepts") + class Interceptable { + + private void assertRejected(String name, String annotation, String kind) + throws NoSuchMethodException { + assertThatThrownBy(() -> factory.create(Modifiers.class.getDeclaredMethod(name))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage( + "@" + + annotation + + " on " + + Modifiers.class.getName() + + "." + + name + + ": the method is " + + kind + + ", so Spring's proxy never intercepts it and it would run without" + + " coordination; make it a public method that is not static or final"); + } + + @Test + @DisplayName("rejects a private, a static and a final method, naming each") + void rejectsUninterceptable() throws NoSuchMethodException { + assertRejected("privateMethod", "DistributedLock", "private"); + assertRejected("staticMethod", "DistributedLock", "static"); + assertRejected("finalMethod", "DistributedSemaphore", "final"); + } + + @Test + @DisplayName("accepts a package-private method, which class proxies intercept") + void acceptsPackagePrivate() throws NoSuchMethodException { + assertThat(factory.create(Modifiers.class.getDeclaredMethod("packagePrivateMethod")).lock()) + .isNotNull(); + } + } + + @Nested + @DisplayName("return types") + class ReturnTypeRules { + + private static final String LOCK_PREFIX = + "@DistributedLock on " + ReturnTypes.class.getName() + "."; + + private static final String SEMAPHORE_PREFIX = + "@DistributedSemaphore on " + ReturnTypes.class.getName() + "."; + + private static Method returnTypes(String name) { + return Arrays.stream(ReturnTypes.class.getDeclaredMethods()) + .filter(m -> m.getName().equals(name)) + .findFirst() + .orElseThrow(); + } + + private void assertRejectedWith(String name, String message) { + assertThatThrownBy(() -> factory.create(returnTypes(name))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage(message); + } + + private static String reactive(String prefix, String name, Class type) { + return prefix + + name + + ": reactive return types are not supported, got [" + + type.getName() + + "]; return a plain value, or a future from an @Async method"; + } + + private static String untracked(String prefix, String name, Class type) { + return prefix + + name + + ": return type " + + type.getName() + + " would let the work outlive the lock, which is released when the method returns;" + + " mark the method @Async and declare Future or CompletableFuture, so the lock covers" + + " its body on the worker thread"; + } + + private static String asyncOnly(String prefix, String name, Class type) { + return prefix + + name + + ": @Async methods can return only void, Kotlin Unit, Future or CompletableFuture, got [" + + type.getName() + + "]; Spring's @Async proxy fails every call of any other return type"; + } + + @Test + @DisplayName("rejects CompletableFuture and CompletionStage without @Async") + void rejectsStagesWithoutAsync() { + assertRejectedWith( + "completableFuture", + untracked(LOCK_PREFIX, "completableFuture", CompletableFuture.class)); + assertRejectedWith( + "completionStageSkip", + untracked(LOCK_PREFIX, "completionStageSkip", CompletionStage.class)); + } + + @Test + @DisplayName("accepts void, Kotlin Unit, Future and CompletableFuture on an @Async method") + void acceptsAsyncTypes() { + assertThat(factory.create(returnTypes("asyncVoid")).lock()).isNotNull(); + assertThat(factory.create(returnTypes("asyncKotlinUnit")).lock()).isNotNull(); + assertThat(factory.create(returnTypes("asyncFuture")).lock()).isNotNull(); + assertThat(factory.create(returnTypes("asyncCompletableFuture")).lock()).isNotNull(); + } + + @Test + @DisplayName( + "rejects CompletionStage, a CompletableFuture subtype and a plain type on an @Async" + + " method, under any onFailure, for both annotations") + void rejectsOtherTypesOnAsync() { + // Spring's @Async proxy throws on every call of a CompletionStage method, and casts its own + // CompletableFuture to a subtype only after the body and the lock have run. + assertRejectedWith( + "asyncCompletionStageSkip", + asyncOnly(LOCK_PREFIX, "asyncCompletionStageSkip", CompletionStage.class)); + assertRejectedWith( + "customFutureThrow", asyncOnly(LOCK_PREFIX, "customFutureThrow", CustomFuture.class)); + assertRejectedWith( + "customFutureSkip", asyncOnly(LOCK_PREFIX, "customFutureSkip", CustomFuture.class)); + assertRejectedWith( + "semaphoreCustomFutureSkip", + asyncOnly(SEMAPHORE_PREFIX, "semaphoreCustomFutureSkip", CustomFuture.class)); + assertRejectedWith("asyncString", asyncOnly(LOCK_PREFIX, "asyncString", String.class)); + } + + @Test + @DisplayName("rejects Mono, Flux, Reactive Streams Publisher and Flow.Publisher") + void rejectsReactive() { + assertRejectedWith("mono", reactive(LOCK_PREFIX, "mono", Mono.class)); + assertRejectedWith("flux", reactive(LOCK_PREFIX, "flux", Flux.class)); + assertRejectedWith( + "reactiveStreamsPublisher", + reactive(LOCK_PREFIX, "reactiveStreamsPublisher", Publisher.class)); + assertRejectedWith( + "flowPublisher", reactive(LOCK_PREFIX, "flowPublisher", Flow.Publisher.class)); + assertRejectedWith("semaphoreMono", reactive(SEMAPHORE_PREFIX, "semaphoreMono", Mono.class)); + } + + @Test + @DisplayName("rejects a Kotlin suspend function") + void rejectsSuspend() { + assertRejectedWith( + "suspending", + LOCK_PREFIX + + "suspending: Kotlin suspend functions are not supported; use a function that is" + + " not suspend"); + } + + @Test + @DisplayName("rejects Future and ForkJoinTask without @Async, for both annotations") + void rejectsUntrackedFuture() { + assertRejectedWith("future", untracked(LOCK_PREFIX, "future", Future.class)); + assertRejectedWith( + "forkJoinTask", untracked(LOCK_PREFIX, "forkJoinTask", ForkJoinTask.class)); + assertRejectedWith( + "semaphoreFuture", untracked(SEMAPHORE_PREFIX, "semaphoreFuture", Future.class)); + } + + @Test + @DisplayName("accepts Future with @Async on the method, meta-annotated, or on the class") + void acceptsAsyncFuture() throws NoSuchMethodException { + assertThat(factory.create(returnTypes("asyncFuture")).lock()).isNotNull(); + assertThat(factory.create(returnTypes("metaAsyncFuture")).lock()).isNotNull(); + assertThat(factory.create(AsyncReturnTypes.class.getDeclaredMethod("future")).lock()) + .isNotNull(); + } + } +} diff --git a/src/test/java/in/riido/locksmith/aspect/DistributedLockAspectTest.java b/src/test/java/in/riido/locksmith/aspect/DistributedLockAspectTest.java deleted file mode 100644 index 5536678..0000000 --- a/src/test/java/in/riido/locksmith/aspect/DistributedLockAspectTest.java +++ /dev/null @@ -1,2161 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.LeaseExpirationBehavior; -import in.riido.locksmith.LockType; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.LeaseExpiredException; -import in.riido.locksmith.exception.LockNotAcquiredException; -import in.riido.locksmith.handler.LockSkipHandler; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import in.riido.locksmith.handler.lock.LockThrowExceptionHandler; -import in.riido.locksmith.models.LockContext; -import java.lang.reflect.Method; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; -import org.jspecify.annotations.NonNull; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.redisson.api.RLock; -import org.redisson.api.RReadWriteLock; -import org.redisson.api.RedissonClient; -import org.springframework.context.ApplicationContext; - -@DisplayName("DistributedLockAspect Tests") -class DistributedLockAspectTest { - - private RedissonClient redissonClient; - private ApplicationContext applicationContext; - private DistributedLockAspect aspect; - private ProceedingJoinPoint joinPoint; - private MethodSignature methodSignature; - private RLock lock; - - @BeforeEach - void setUp() { - redissonClient = mock(RedissonClient.class); - applicationContext = mock(ApplicationContext.class); - LocksmithProperties lockProperties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(10), Duration.ofSeconds(60), "lock:", false, false), - null, - null); - aspect = new DistributedLockAspect(redissonClient, lockProperties, applicationContext); - joinPoint = mock(ProceedingJoinPoint.class); - methodSignature = mock(MethodSignature.class); - lock = mock(RLock.class); - - when(joinPoint.getSignature()).thenReturn(methodSignature); - when(methodSignature.getDeclaringType()).thenReturn(TestClass.class); - when(methodSignature.getName()).thenReturn("testMethod"); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(methodSignature.getReturnType()).thenReturn(void.class); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - private void setupAnnotation( - String key, - AcquisitionMode mode, - String leaseTime, - String waitTime, - Class skipHandler) { - setupAnnotation( - key, - mode, - leaseTime, - waitTime, - skipHandler, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE); - } - - private void setupAnnotation( - String key, - AcquisitionMode mode, - String leaseTime, - String waitTime, - Class skipHandler, - LockType lockType) { - setupAnnotation( - key, mode, leaseTime, waitTime, skipHandler, lockType, LeaseExpirationBehavior.IGNORE); - } - - private void setupAnnotation( - String key, - AcquisitionMode mode, - String leaseTime, - String waitTime, - Class skipHandler, - LockType lockType, - LeaseExpirationBehavior onLeaseExpired) { - setupAnnotation(key, mode, leaseTime, waitTime, skipHandler, lockType, onLeaseExpired, false); - } - - private void setupAnnotation( - String key, - AcquisitionMode mode, - String leaseTime, - String waitTime, - Class skipHandler, - LockType lockType, - LeaseExpirationBehavior onLeaseExpired, - boolean autoRenew) { - DistributedLock annotation = mock(DistributedLock.class); - when(annotation.key()).thenReturn(key); - when(annotation.mode()).thenReturn(mode); - when(annotation.leaseTime()).thenReturn(leaseTime); - when(annotation.waitTime()).thenReturn(waitTime); - when(annotation.type()).thenReturn(lockType); - when(annotation.onLeaseExpired()).thenReturn(onLeaseExpired); - when(annotation.autoRenew()).thenReturn(autoRenew); - doReturn(skipHandler).when(annotation).skipHandler(); - - Method mockMethod = mock(Method.class); - when(methodSignature.getMethod()).thenReturn(mockMethod); - when(mockMethod.getAnnotation(DistributedLock.class)).thenReturn(annotation); - } - - private static class TestClass { - public void testMethod() {} - } - - /** Test class with annotated methods for SpEL testing. */ - public static class SpelTestClass { - - @DistributedLock(key = "#{#userId}") - public void processUser(String userId) {} - - @DistributedLock(key = "#{'user-' + #id}") - public void processWithPrefix(Long id) {} - - @DistributedLock(key = "#{#user.id}") - public void updateUser(TestUser user) {} - - @DistributedLock(key = "static-key") - public void staticKeyMethod() {} - - @DistributedLock(key = "#{#value}") - public void processWithBlankValue(String value) {} - - @DistributedLock(key = "#{#user.name}") - public void processUserName(TestUser user) {} - - public record TestUser(String id, String name) { - public TestUser(String id) { - this(id, null); - } - } - } - - @Nested - @DisplayName("Lock Acquisition Tests - SKIP_IMMEDIATELY Mode") - class SkipImmediatelyModeTests { - - @Test - @DisplayName("Should acquire lock and execute method when lock is available") - void shouldAcquireLockAndExecuteMethod() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(joinPoint).proceed(); - verify(lock).unlock(); - } - - @Test - @DisplayName( - "Should skip execution and return null when lock is not available with RETURN_DEFAULT") - void shouldSkipExecutionWhenLockNotAvailable() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - verify(joinPoint, never()).proceed(); - verify(lock, never()).unlock(); - } - - @Test - @DisplayName("Should use zero wait time for SKIP_IMMEDIATELY mode") - void shouldUseZeroWaitTime() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS)); - } - } - - @Nested - @DisplayName("InterruptedException Handling Tests") - class InterruptedExceptionTests { - - @Test - @DisplayName("Should handle InterruptedException and return default with RETURN_DEFAULT") - void shouldHandleInterruptedExceptionWithReturnDefault() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)) - .thenThrow(new InterruptedException()); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - assertTrue(Thread.currentThread().isInterrupted()); - verify(joinPoint, never()).proceed(); - Thread.interrupted(); // Clear interrupt status for other tests - } - - @Test - @DisplayName("Should handle InterruptedException and throw exception with THROW_EXCEPTION") - void shouldHandleInterruptedExceptionWithThrowException() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)) - .thenThrow(new InterruptedException()); - - assertThrows(LockNotAcquiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(Thread.currentThread().isInterrupted()); - verify(joinPoint, never()).proceed(); - Thread.interrupted(); // Clear interrupt status for other tests - } - - @Test - @DisplayName("Should restore interrupt status after InterruptedException") - void shouldRestoreInterruptStatus() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)) - .thenThrow(new InterruptedException()); - - aspect.handleDistributedLock(joinPoint); - - assertTrue(Thread.currentThread().isInterrupted()); - Thread.interrupted(); // Clear interrupt status for other tests - } - } - - @Nested - @DisplayName("Lock Acquisition Tests - WAIT_AND_SKIP Mode") - class WaitAndSkipModeTests { - - @Test - @DisplayName("Should wait for lock and execute method when lock becomes available") - void shouldWaitForLockAndExecuteMethod() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(joinPoint).proceed(); - verify(lock).unlock(); - } - - @Test - @DisplayName("Should skip execution and return null after wait timeout with RETURN_DEFAULT") - void shouldSkipExecutionAfterWaitTimeout() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - verify(joinPoint, never()).proceed(); - } - - @Test - @DisplayName("Should use configured wait time for WAIT_AND_SKIP mode") - void shouldUseConfiguredWaitTime() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(eq(60000L), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(eq(60000L), anyLong(), eq(TimeUnit.MILLISECONDS)); - } - } - - @Nested - @DisplayName("Lock Release Tests") - class LockReleaseTests { - - @Test - @DisplayName("Should release lock after successful method execution") - void shouldReleaseLockAfterSuccessfulExecution() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should release lock when method throws exception") - void shouldReleaseLockWhenMethodThrowsException() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenThrow(new RuntimeException("Test exception")); - - assertThrows(RuntimeException.class, () -> aspect.handleDistributedLock(joinPoint)); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should handle IllegalMonitorStateException during unlock gracefully") - void shouldHandleIllegalMonitorStateExceptionDuringUnlock() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - doThrow(new IllegalMonitorStateException("Lock expired")).when(lock).unlock(); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - } - } - - @Nested - @DisplayName("Custom Configuration Tests") - class CustomConfigurationTests { - - @Test - @DisplayName("Should use custom lease time from annotation") - void shouldUseCustomLeaseTimeFromAnnotation() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "5m", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom wait time from annotation") - void shouldUseCustomWaitTimeFromAnnotation() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "30s", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(30000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(30000, 600000, TimeUnit.MILLISECONDS); - } - } - - @Nested - @DisplayName("Duration Parsing Tests") - class DurationParsingTests { - - @Test - @DisplayName("Should parse ISO-8601 format for lease time (PT5M)") - void shouldParseIso8601FormatForLeaseTime() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "PT5M", - "", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse ISO-8601 format for wait time (PT30S)") - void shouldParseIso8601FormatForWaitTime() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "PT30S", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(30000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(30000, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse simple format with seconds (45s)") - void shouldParseSimpleFormatWithSeconds() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "45s", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(45000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(45000, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse simple format with minutes (2m)") - void shouldParseSimpleFormatWithMinutes() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "2m", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 120000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 120000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse simple format with hours (1h)") - void shouldParseSimpleFormatWithHours() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "1h", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 3600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 3600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse ISO-8601 format with hours (PT1H)") - void shouldParseIso8601FormatWithHours() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "PT1H", - "", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 3600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 3600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should parse ISO-8601 combined format (PT1H30M)") - void shouldParseIso8601CombinedFormat() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "PT1H30M", - "", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 5400000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 5400000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use default when duration is empty string") - void shouldUseDefaultWhenDurationIsEmpty() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 600000, TimeUnit.MILLISECONDS); // 10 minutes default - } - - @Test - @DisplayName("Should use default when duration is blank string") - void shouldUseDefaultWhenDurationIsBlank() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - " ", - "", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 600000, TimeUnit.MILLISECONDS); // 10 minutes default - } - - @Test - @DisplayName("Should preserve sub-second precision for leaseTime (500ms)") - void shouldPreserveSubSecondPrecisionForLeaseTime() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "500ms", - "", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 500, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 500, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should preserve sub-second precision for waitTime (750ms)") - void shouldPreserveSubSecondPrecisionForWaitTime() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.WAIT_AND_SKIP, "", "750ms", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(750, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(750, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should preserve sub-second precision for both leaseTime and waitTime") - void shouldPreserveSubSecondPrecisionForBothDurations() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.WAIT_AND_SKIP, - "250ms", - "100ms", - LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(100, 250, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(100, 250, TimeUnit.MILLISECONDS); - } - } - - @Nested - @DisplayName("Lock Key Tests") - class LockKeyTests { - - @Test - @DisplayName("Should use key prefix from properties") - void shouldUseKeyPrefixFromProperties() throws Throwable { - setupAnnotation( - "my-task", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:my-task")).thenReturn(lock); - when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(false); - - aspect.handleDistributedLock(joinPoint); - - verify(redissonClient).getLock("lock:my-task"); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when key is empty") - void shouldThrowIllegalArgumentExceptionWhenKeyIsEmpty() { - setupAnnotation( - "", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("must not be blank")); - } - - @Test - @DisplayName("Should throw IllegalArgumentException when key is blank") - void shouldThrowIllegalArgumentExceptionWhenKeyIsBlank() { - setupAnnotation( - " ", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("must not be blank")); - } - } - - @Nested - @DisplayName("Skip Behavior Tests") - class SkipBehaviorTests { - - @Test - @DisplayName("Should throw LockNotAcquiredException when lock fails with THROW_EXCEPTION") - void shouldThrowLockNotAcquiredExceptionWhenLockFails() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - LockNotAcquiredException thrown = - assertThrows( - LockNotAcquiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("lock:test-lock")); - assertEquals("lock:test-lock", thrown.getLockKey()); - } - - @Test - @DisplayName("Should return false for boolean primitive with RETURN_DEFAULT") - void shouldReturnFalseForBooleanPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(boolean.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(false, result); - } - - @Test - @DisplayName("Should return 0 for int primitive with RETURN_DEFAULT") - void shouldReturnZeroForIntPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(int.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0, result); - } - - @Test - @DisplayName("Should return null for void methods with RETURN_DEFAULT") - void shouldReturnNullForVoidMethods() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(void.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - } - - @Test - @DisplayName("Should return 0L for long primitive with RETURN_DEFAULT") - void shouldReturnZeroForLongPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(long.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0L, result); - } - - @Test - @DisplayName("Should return 0.0d for double primitive with RETURN_DEFAULT") - void shouldReturnZeroForDoublePrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(double.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0.0d, result); - } - - @Test - @DisplayName("Should return 0.0f for float primitive with RETURN_DEFAULT") - void shouldReturnZeroForFloatPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(float.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0.0f, result); - } - - @Test - @DisplayName("Should return 0 for byte primitive with RETURN_DEFAULT") - void shouldReturnZeroForBytePrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(byte.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals((byte) 0, result); - } - - @Test - @DisplayName("Should return 0 for short primitive with RETURN_DEFAULT") - void shouldReturnZeroForShortPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(short.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals((short) 0, result); - } - - @Test - @DisplayName("Should return null char for char primitive with RETURN_DEFAULT") - void shouldReturnNullCharForCharPrimitive() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(char.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals('\u0000', result); - } - - @Test - @DisplayName("Should return null for Object return type with RETURN_DEFAULT") - void shouldReturnNullForObjectReturnType() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(String.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - } - - @Test - @DisplayName("Should return false for Boolean wrapper with RETURN_DEFAULT") - void shouldReturnFalseForBooleanWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Boolean.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(false, result); - } - - @Test - @DisplayName("Should return 0 for Integer wrapper with RETURN_DEFAULT") - void shouldReturnZeroForIntegerWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Integer.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0, result); - } - - @Test - @DisplayName("Should return 0L for Long wrapper with RETURN_DEFAULT") - void shouldReturnZeroForLongWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Long.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0L, result); - } - - @Test - @DisplayName("Should return 0.0d for Double wrapper with RETURN_DEFAULT") - void shouldReturnZeroForDoubleWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Double.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0.0d, result); - } - - @Test - @DisplayName("Should return 0.0f for Float wrapper with RETURN_DEFAULT") - void shouldReturnZeroForFloatWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Float.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0.0f, result); - } - - @Test - @DisplayName("Should return 0 for Byte wrapper with RETURN_DEFAULT") - void shouldReturnZeroForByteWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Byte.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals((byte) 0, result); - } - - @Test - @DisplayName("Should return 0 for Short wrapper with RETURN_DEFAULT") - void shouldReturnZeroForShortWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Short.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals((short) 0, result); - } - - @Test - @DisplayName("Should return null char for Character wrapper with RETURN_DEFAULT") - void shouldReturnNullCharForCharacterWrapper() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(Character.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals('\u0000', result); - } - } - - @Nested - @DisplayName("Configuration Validation Tests") - class ConfigurationValidationTests { - - @Nested - @DisplayName("leaseTime property tests") - class LeaseTimeTests { - - @Test - @DisplayName("Should use default lease time when null") - void shouldUseDefaultLeaseTimeWhenNull() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, null, Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - } - - @Test - @DisplayName("Should use default lease time when zero") - void shouldUseDefaultLeaseTimeWhenZero() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ZERO, Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - } - - @Test - @DisplayName("Should use default lease time when negative") - void shouldUseDefaultLeaseTimeWhenNegative() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(-5), Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - } - - @Test - @DisplayName("Should use custom lease time when positive") - void shouldUseCustomLeaseTimeWhenPositive() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(15), Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofMinutes(15), props.leaseTime()); - } - - @Test - @DisplayName("Should accept lease time in seconds") - void shouldAcceptLeaseTimeInSeconds() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofSeconds(300), Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofSeconds(300), props.leaseTime()); - } - - @Test - @DisplayName("Should accept lease time in hours") - void shouldAcceptLeaseTimeInHours() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofHours(1), Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofHours(1), props.leaseTime()); - } - - @Test - @DisplayName("Should accept lease time in millis") - void shouldAcceptLeaseTimeInMillis() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMillis(5000), Duration.ofSeconds(60), "lock:", false, false); - - assertEquals(Duration.ofMillis(5000), props.leaseTime()); - } - } - - @Nested - @DisplayName("waitTime property tests") - class WaitTimeTests { - - @Test - @DisplayName("Should use default wait time when null") - void shouldUseDefaultWaitTimeWhenNull() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), null, "lock:", false, false); - - assertEquals(Duration.ofSeconds(60), props.waitTime()); - } - - @Test - @DisplayName("Should use default wait time when negative") - void shouldUseDefaultWaitTimeWhenNegative() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(-30), "lock:", false, false); - - assertEquals(Duration.ofSeconds(60), props.waitTime()); - } - - @Test - @DisplayName("Should allow zero wait time") - void shouldAllowZeroWaitTime() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ZERO, "lock:", false, false); - - assertEquals(Duration.ZERO, props.waitTime()); - } - - @Test - @DisplayName("Should use custom wait time when positive") - void shouldUseCustomWaitTimeWhenPositive() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(90), "lock:", false, false); - - assertEquals(Duration.ofSeconds(90), props.waitTime()); - } - - @Test - @DisplayName("Should accept wait time in minutes") - void shouldAcceptWaitTimeInMinutes() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofMinutes(2), "lock:", false, false); - - assertEquals(Duration.ofMinutes(2), props.waitTime()); - } - - @Test - @DisplayName("Should accept wait time in millis") - void shouldAcceptWaitTimeInMillis() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofMillis(500), "lock:", false, false); - - assertEquals(Duration.ofMillis(500), props.waitTime()); - } - } - - @Nested - @DisplayName("keyPrefix property tests") - class KeyPrefixTests { - - @Test - @DisplayName("Should use default key prefix when null") - void shouldUseDefaultKeyPrefixWhenNull() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(60), null, false, false); - - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should use default key prefix when empty") - void shouldUseDefaultKeyPrefixWhenEmpty() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(60), "", false, false); - - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should use default key prefix when blank with spaces") - void shouldUseDefaultKeyPrefixWhenBlankWithSpaces() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(60), " ", false, false); - - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should use default key prefix when blank with tabs") - void shouldUseDefaultKeyPrefixWhenBlankWithTabs() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(60), "\t\t", false, false); - - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should use custom key prefix when valid") - void shouldUseCustomKeyPrefixWhenValid() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(10), Duration.ofSeconds(60), "myapp:", false, false); - - assertEquals("myapp:", props.keyPrefix()); - } - - @Test - @DisplayName("Should use custom key prefix without colon") - void shouldUseCustomKeyPrefixWithoutColon() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, - Duration.ofMinutes(10), - Duration.ofSeconds(60), - "distributed-lock", - false, - false); - - assertEquals("distributed-lock", props.keyPrefix()); - } - - @Test - @DisplayName("Should preserve key prefix with special characters") - void shouldPreserveKeyPrefixWithSpecialCharacters() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, - Duration.ofMinutes(10), - Duration.ofSeconds(60), - "app:env:lock:", - false, - false); - - assertEquals("app:env:lock:", props.keyPrefix()); - } - } - - @Nested - @DisplayName("defaults() factory method tests") - class DefaultsFactoryMethodTests { - - @Test - @DisplayName("Should create instance with all default values") - void shouldCreateInstanceWithAllDefaultValues() { - LocksmithProperties.LockProperties props = LocksmithProperties.LockProperties.defaults(); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - assertEquals(Duration.ofSeconds(60), props.waitTime()); - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should match default constants") - void shouldMatchDefaultConstants() { - LocksmithProperties.LockProperties props = LocksmithProperties.LockProperties.defaults(); - - assertEquals(LocksmithProperties.LockProperties.DEFAULT_LEASE_TIME, props.leaseTime()); - assertEquals(LocksmithProperties.LockProperties.DEFAULT_WAIT_TIME, props.waitTime()); - assertEquals(LocksmithProperties.LockProperties.DEFAULT_KEY_PREFIX, props.keyPrefix()); - } - } - - @Nested - @DisplayName("All null parameters tests") - class AllNullParametersTests { - - @Test - @DisplayName("Should use all defaults when all parameters are null") - void shouldUseAllDefaultsWhenAllParametersAreNull() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties(null, null, null, null, false, false); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - assertEquals(Duration.ofSeconds(60), props.waitTime()); - assertEquals("lock:", props.keyPrefix()); - } - } - - @Nested - @DisplayName("Custom values tests") - class CustomValuesTests { - - @Test - @DisplayName("Should use all custom values when valid") - void shouldUseAllCustomValuesWhenValid() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(15), Duration.ofSeconds(90), "mylock:", false, false); - - assertEquals(Duration.ofMinutes(15), props.leaseTime()); - assertEquals(Duration.ofSeconds(90), props.waitTime()); - assertEquals("mylock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should handle mixed valid and invalid values") - void shouldHandleMixedValidAndInvalidValues() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofMinutes(-5), Duration.ofSeconds(30), null, false, false); - - assertEquals(Duration.ofMinutes(10), props.leaseTime()); - assertEquals(Duration.ofSeconds(30), props.waitTime()); - assertEquals("lock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should handle very large duration values") - void shouldHandleVeryLargeDurationValues() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofDays(1), Duration.ofHours(2), "biglock:", false, false); - - assertEquals(Duration.ofDays(1), props.leaseTime()); - assertEquals(Duration.ofHours(2), props.waitTime()); - assertEquals("biglock:", props.keyPrefix()); - } - - @Test - @DisplayName("Should handle very small positive duration values") - void shouldHandleVerySmallPositiveDurationValues() { - LocksmithProperties.LockProperties props = - new LocksmithProperties.LockProperties( - null, Duration.ofNanos(1000000), Duration.ofMillis(1), "smalllock:", false, false); - - assertEquals(Duration.ofNanos(1000000), props.leaseTime()); - assertEquals(Duration.ofMillis(1), props.waitTime()); - assertEquals("smalllock:", props.keyPrefix()); - } - } - } - - @Nested - @DisplayName("SpEL Key Resolution Tests") - class SpelKeyResolutionTests { - - @Test - @DisplayName("Should resolve SpEL expression with method parameter") - void shouldResolveSpelExpressionWithMethodParameter() throws Throwable { - Method method = SpelTestClass.class.getMethod("processUser", String.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processUser"); - when(joinPoint.getArgs()).thenReturn(new Object[] {"user123"}); - - when(redissonClient.getLock("lock:user123")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getLock("lock:user123"); - } - - @Test - @DisplayName("Should resolve SpEL expression with concatenation") - void shouldResolveSpelExpressionWithConcatenation() throws Throwable { - Method method = SpelTestClass.class.getMethod("processWithPrefix", Long.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processWithPrefix"); - when(joinPoint.getArgs()).thenReturn(new Object[] {42L}); - - when(redissonClient.getLock("lock:user-42")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getLock("lock:user-42"); - } - - @Test - @DisplayName("Should use literal key when no SpEL expression present") - void shouldUseLiteralKeyWhenNoSpelExpression() throws Throwable { - Method method = SpelTestClass.class.getMethod("staticKeyMethod"); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("staticKeyMethod"); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - - when(redissonClient.getLock("lock:static-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getLock("lock:static-key"); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to null") - void shouldThrowExceptionWhenSpelEvaluatesToNull() throws Throwable { - Method method = SpelTestClass.class.getMethod("processUser", String.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processUser"); - when(joinPoint.getArgs()).thenReturn(new Object[] {null}); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("evaluated to null")); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to blank string") - void shouldThrowExceptionWhenSpelEvaluatesToBlank() throws Throwable { - Method method = SpelTestClass.class.getMethod("processWithBlankValue", String.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processWithBlankValue"); - when(joinPoint.getArgs()).thenReturn(new Object[] {" "}); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("evaluated to blank")); - } - - @Test - @DisplayName("Should resolve SpEL expression with object property") - void shouldResolveSpelExpressionWithObjectProperty() throws Throwable { - Method method = SpelTestClass.class.getMethod("updateUser", SpelTestClass.TestUser.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("updateUser"); - when(joinPoint.getArgs()) - .thenReturn(new Object[] {new SpelTestClass.TestUser("user456", "John")}); - - when(redissonClient.getLock("lock:user456")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getLock("lock:user456"); - } - - @Test - @DisplayName("Should throw exception when SpEL object property is null") - void shouldThrowExceptionWhenSpelObjectPropertyIsNull() throws Throwable { - Method method = - SpelTestClass.class.getMethod("processUserName", SpelTestClass.TestUser.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processUserName"); - when(joinPoint.getArgs()).thenReturn(new Object[] {new SpelTestClass.TestUser("user456")}); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("evaluated to null")); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to empty string") - void shouldThrowExceptionWhenSpelEvaluatesToEmpty() throws Throwable { - Method method = SpelTestClass.class.getMethod("processWithBlankValue", String.class); - when(methodSignature.getMethod()).thenReturn(method); - when(methodSignature.getDeclaringType()).thenReturn(SpelTestClass.class); - when(methodSignature.getName()).thenReturn("processWithBlankValue"); - when(joinPoint.getArgs()).thenReturn(new Object[] {""}); - - IllegalArgumentException thrown = - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(thrown.getMessage().contains("evaluated to blank")); - } - } - - @Nested - @DisplayName("Lock Type Tests") - class LockTypeTests { - - private RReadWriteLock readWriteLock; - private RLock readLock; - private RLock writeLock; - - @BeforeEach - void setUpReadWriteLock() { - readWriteLock = mock(RReadWriteLock.class); - readLock = mock(RLock.class); - writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:test-lock")).thenReturn(readWriteLock); - when(readWriteLock.readLock()).thenReturn(readLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - } - - @Test - @DisplayName("Should use reentrant lock for REENTRANT type") - void shouldUseReentrantLockForReentrantType() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getLock("lock:test-lock"); - verify(redissonClient, never()).getReadWriteLock(anyString()); - } - - @Test - @DisplayName("Should use read lock for READ type") - void shouldUseReadLockForReadType() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.READ); - when(readLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(readLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getReadWriteLock("lock:test-lock"); - verify(readWriteLock).readLock(); - verify(readWriteLock, never()).writeLock(); - verify(redissonClient, never()).getLock(anyString()); - } - - @Test - @DisplayName("Should use write lock for WRITE type") - void shouldUseWriteLockForWriteType() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.WRITE); - when(writeLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(writeLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(redissonClient).getReadWriteLock("lock:test-lock"); - verify(readWriteLock).writeLock(); - verify(readWriteLock, never()).readLock(); - verify(redissonClient, never()).getLock(anyString()); - } - - @Test - @DisplayName("Should release read lock after execution") - void shouldReleaseReadLockAfterExecution() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.READ); - when(readLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(readLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(readLock).unlock(); - } - - @Test - @DisplayName("Should release write lock after execution") - void shouldReleaseWriteLockAfterExecution() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.WRITE); - when(writeLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(writeLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(writeLock).unlock(); - } - - @Test - @DisplayName("Should skip and return default when read lock not acquired") - void shouldSkipWhenReadLockNotAcquired() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockReturnDefaultHandler.class, - LockType.READ); - when(readLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - verify(joinPoint, never()).proceed(); - } - - @Test - @DisplayName("Should throw exception when write lock not acquired with THROW_EXCEPTION") - void shouldThrowExceptionWhenWriteLockNotAcquired() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.WRITE); - when(writeLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - assertThrows(LockNotAcquiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - } - - @Test - @DisplayName("Should use custom lease time with read lock") - void shouldUseCustomLeaseTimeWithReadLock() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "5m", - "", - LockThrowExceptionHandler.class, - LockType.READ); - when(readLock.tryLock(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(readLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(readLock).tryLock(0, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use WAIT_AND_SKIP mode with write lock") - void shouldUseWaitAndSkipModeWithWriteLock() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.WAIT_AND_SKIP, - "", - "", - LockThrowExceptionHandler.class, - LockType.WRITE); - when(writeLock.tryLock(60000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(writeLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(writeLock).tryLock(60000, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should handle exception in method and release read lock") - void shouldHandleExceptionAndReleaseReadLock() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.READ); - when(readLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(readLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenThrow(new RuntimeException("Test exception")); - - assertThrows(RuntimeException.class, () -> aspect.handleDistributedLock(joinPoint)); - - verify(readLock).unlock(); - } - } - - @Nested - @DisplayName("Lease Expiration Detection Tests") - class LeaseExpirationTests { - - @Test - @DisplayName("Should not trigger lease expiration when execution is within lease time") - void shouldNotTriggerLeaseExpirationWhenWithinLeaseTime() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "10m", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.THROW_EXCEPTION); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - } - - @Test - @DisplayName( - "Should throw LeaseExpiredException when execution exceeds lease time with THROW_EXCEPTION") - void shouldThrowLeaseExpiredExceptionWhenExecutionExceedsLeaseTime() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "1ms", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.THROW_EXCEPTION); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()) - .thenAnswer( - invocation -> { - Thread.sleep(10); - return "result"; - }); - - LeaseExpiredException exception = - assertThrows(LeaseExpiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertEquals("lock:test-lock", exception.getLockKey()); - assertEquals("TestClass.testMethod", exception.getMethodName()); - assertEquals(1, exception.getLeaseTimeMs()); - assertTrue(exception.getExecutionTimeMs() >= 1); - } - - @Test - @DisplayName("Should ignore lease expiration with IGNORE behavior") - void shouldIgnoreLeaseExpirationWithIgnoreBehavior() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "1ms", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()) - .thenAnswer( - invocation -> { - Thread.sleep(10); - return "result"; - }); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - } - - @Test - @DisplayName("Should log warning when execution exceeds lease time with LOG_WARNING") - void shouldLogWarningWhenExecutionExceedsLeaseTime() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "1ms", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.LOG_WARNING); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()) - .thenAnswer( - invocation -> { - Thread.sleep(10); - return "result"; - }); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - // Log warning is called but we can't easily verify it without a log appender - } - - @Test - @DisplayName("Should still release lock after lease expiration exception") - void shouldStillReleaseLockAfterLeaseExpirationException() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "1ms", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.THROW_EXCEPTION); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()) - .thenAnswer( - invocation -> { - Thread.sleep(10); - return "result"; - }); - - assertThrows(LeaseExpiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - - verify(lock).unlock(); - } - - @Test - @DisplayName("LeaseExpiredException should contain correct information") - void leaseExpiredExceptionShouldContainCorrectInformation() { - LeaseExpiredException exception = - new LeaseExpiredException("test-key", "TestClass.testMethod", 1000, 2000); - - assertEquals("test-key", exception.getLockKey()); - assertEquals("TestClass.testMethod", exception.getMethodName()); - assertEquals(1000, exception.getLeaseTimeMs()); - assertEquals(2000, exception.getExecutionTimeMs()); - assertTrue(exception.getMessage().contains("test-key")); - assertTrue(exception.getMessage().contains("TestClass.testMethod")); - assertTrue(exception.getMessage().contains("1000")); - assertTrue(exception.getMessage().contains("2000")); - } - } - - @Nested - @DisplayName("Custom Skip Handler Tests") - class CustomSkipHandlerTests { - - @Test - @DisplayName("Should use ThrowExceptionHandler when specified") - void shouldUseThrowExceptionHandler() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockThrowExceptionHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - assertThrows(LockNotAcquiredException.class, () -> aspect.handleDistributedLock(joinPoint)); - } - - @Test - @DisplayName("Should use ReturnDefaultHandler when specified") - void shouldUseReturnDefaultHandler() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - } - - @Test - @DisplayName("Should use custom handler that returns specific value") - void shouldUseCustomHandlerReturningSpecificValue() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", FallbackValueHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("fallback-value", result); - } - - @Test - @DisplayName("Should pass correct context to custom handler") - void shouldPassCorrectContextToCustomHandler() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", ContextCapturingHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - when(joinPoint.getArgs()).thenReturn(new Object[] {"arg1", 42}); - - aspect.handleDistributedLock(joinPoint); - - assertNotNull(ContextCapturingHandler.capturedContext); - assertEquals("lock:test-lock", ContextCapturingHandler.capturedContext.lockKey()); - assertEquals("TestClass.testMethod", ContextCapturingHandler.capturedContext.methodName()); - assertArrayEquals(new Object[] {"arg1", 42}, ContextCapturingHandler.capturedContext.args()); - } - - @Test - @DisplayName("ReturnDefaultHandler should return false for boolean return type") - void returnDefaultHandlerShouldReturnFalseForBoolean() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(boolean.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(false, result); - } - - @Test - @DisplayName("ReturnDefaultHandler should return 0 for int return type") - void returnDefaultHandlerShouldReturnZeroForInt() throws Throwable { - when(methodSignature.getReturnType()).thenReturn(int.class); - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", LockReturnDefaultHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals(0, result); - } - - @Test - @DisplayName("Should throw IllegalStateException when handler has no public no-arg constructor") - void shouldThrowIllegalStateExceptionWhenHandlerCannotBeInstantiated() throws Throwable { - setupAnnotation( - "test-lock", AcquisitionMode.SKIP_IMMEDIATELY, "", "", PrivateConstructorHandler.class); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - IllegalStateException exception = - assertThrows(IllegalStateException.class, () -> aspect.handleDistributedLock(joinPoint)); - - assertTrue(exception.getMessage().contains("Failed to instantiate skip handler")); - assertTrue(exception.getMessage().contains("PrivateConstructorHandler")); - assertTrue(exception.getMessage().contains("public no-argument constructor")); - assertInstanceOf(ReflectiveOperationException.class, exception.getCause()); - } - } - - /** Test handler that returns a specific fallback value. */ - public static class FallbackValueHandler implements LockSkipHandler { - @Override - public Object handle(@NonNull LockContext context) { - return "fallback-value"; - } - } - - /** Test handler that captures the context for verification. */ - public static class ContextCapturingHandler implements LockSkipHandler { - public static LockContext capturedContext; - - @Override - public Object handle(@NonNull LockContext context) { - capturedContext = context; - return null; - } - } - - /** Test handler with private constructor to verify error handling. */ - public static class PrivateConstructorHandler implements LockSkipHandler { - private PrivateConstructorHandler() { - // Private constructor - cannot be instantiated via reflection - } - - @Override - public Object handle(@NonNull LockContext context) { - return null; - } - } - - @Nested - @DisplayName("Auto Renew Tests") - class AutoRenewTests { - - @Test - @DisplayName("Should pass -1 as lease time when autoRenew is enabled") - void shouldPassNegativeOneAsLeaseTimeWhenAutoRenewEnabled() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should ignore leaseTime when autoRenew is enabled") - void shouldIgnoreLeaseTimeWhenAutoRenewEnabled() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "5m", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - // Verify that -1 is used instead of 300000 (5 minutes) - verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS); - verify(lock, never()).tryLock(0, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use configured waitTime when autoRenew is enabled") - void shouldUseConfiguredWaitTimeWhenAutoRenewEnabled() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.WAIT_AND_SKIP, - "", - "30s", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(30000, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(lock).tryLock(30000, -1, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should skip lease expiration check when autoRenew is enabled") - void shouldSkipLeaseExpirationCheckWhenAutoRenewEnabled() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.THROW_EXCEPTION, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()) - .thenAnswer( - invocation -> { - Thread.sleep(10); - return "result"; - }); - - // Should not throw LeaseExpiredException even with THROW_EXCEPTION behavior - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - } - - @Test - @DisplayName("Should acquire lock and execute method with autoRenew enabled") - void shouldAcquireLockAndExecuteMethodWithAutoRenew() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(joinPoint).proceed(); - verify(lock).unlock(); - } - - @Test - @DisplayName("Should skip execution when lock not acquired with autoRenew enabled") - void shouldSkipExecutionWhenLockNotAcquiredWithAutoRenew() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockReturnDefaultHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertNull(result); - verify(joinPoint, never()).proceed(); - } - - @Test - @DisplayName("Should release lock after execution with autoRenew enabled") - void shouldReleaseLockAfterExecutionWithAutoRenew() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should release lock when method throws exception with autoRenew enabled") - void shouldReleaseLockWhenMethodThrowsExceptionWithAutoRenew() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - true); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenThrow(new RuntimeException("Test exception")); - - assertThrows(RuntimeException.class, () -> aspect.handleDistributedLock(joinPoint)); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should use normal lease time when autoRenew is disabled") - void shouldUseNormalLeaseTimeWhenAutoRenewDisabled() throws Throwable { - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "5m", - "", - LockThrowExceptionHandler.class, - LockType.REENTRANT, - LeaseExpirationBehavior.IGNORE, - false); - when(redissonClient.getLock("lock:test-lock")).thenReturn(lock); - when(lock.tryLock(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(true); - when(lock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - aspect.handleDistributedLock(joinPoint); - - verify(lock).tryLock(0, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should work with read lock when autoRenew is enabled") - void shouldWorkWithReadLockWhenAutoRenewEnabled() throws Throwable { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock readLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:test-lock")).thenReturn(readWriteLock); - when(readWriteLock.readLock()).thenReturn(readLock); - - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.READ, - LeaseExpirationBehavior.IGNORE, - true); - when(readLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(readLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(readLock).tryLock(0, -1, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should work with write lock when autoRenew is enabled") - void shouldWorkWithWriteLockWhenAutoRenewEnabled() throws Throwable { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:test-lock")).thenReturn(readWriteLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - - setupAnnotation( - "test-lock", - AcquisitionMode.SKIP_IMMEDIATELY, - "", - "", - LockThrowExceptionHandler.class, - LockType.WRITE, - LeaseExpirationBehavior.IGNORE, - true); - when(writeLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - when(writeLock.isHeldByCurrentThread()).thenReturn(true); - when(joinPoint.proceed()).thenReturn("result"); - - Object result = aspect.handleDistributedLock(joinPoint); - - assertEquals("result", result); - verify(writeLock).tryLock(0, -1, TimeUnit.MILLISECONDS); - } - } -} diff --git a/src/test/java/in/riido/locksmith/aspect/DistributedSemaphoreAspectTest.java b/src/test/java/in/riido/locksmith/aspect/DistributedSemaphoreAspectTest.java deleted file mode 100644 index 8842324..0000000 --- a/src/test/java/in/riido/locksmith/aspect/DistributedSemaphoreAspectTest.java +++ /dev/null @@ -1,451 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedSemaphore; -import in.riido.locksmith.LeaseExpirationBehavior; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.SemaphoreProperties; -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import in.riido.locksmith.exception.SemaphoreLeaseExpiredException; -import in.riido.locksmith.exception.SemaphoreNotAcquiredException; -import in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler; -import in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler; -import in.riido.locksmith.models.SemaphoreContext; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.reflect.MethodSignature; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.redisson.api.RBucket; -import org.redisson.api.RPermitExpirableSemaphore; -import org.redisson.api.RedissonClient; -import org.springframework.context.ApplicationContext; - -/** - * Unit tests for DistributedSemaphoreAspect. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -@DisplayName("DistributedSemaphoreAspect Tests") -class DistributedSemaphoreAspectTest { - - private RedissonClient redissonClient; - private ApplicationContext applicationContext; - private RPermitExpirableSemaphore semaphore; - - @SuppressWarnings("rawtypes") - private RBucket metaBucket; - - private DistributedSemaphoreAspect aspect; - private ProceedingJoinPoint joinPoint; - private MethodSignature methodSignature; - - @SuppressWarnings("unchecked") - @BeforeEach - void setUp() { - redissonClient = mock(RedissonClient.class); - applicationContext = mock(ApplicationContext.class); - semaphore = mock(RPermitExpirableSemaphore.class); - metaBucket = mock(RBucket.class); - - LocksmithProperties properties = - new LocksmithProperties( - null, - new SemaphoreProperties( - true, Duration.ofMinutes(5), Duration.ofSeconds(60), "semaphore:", false, false), - null); - aspect = new DistributedSemaphoreAspect(redissonClient, properties, applicationContext); - - joinPoint = mock(ProceedingJoinPoint.class); - methodSignature = mock(MethodSignature.class); - - when(joinPoint.getSignature()).thenReturn(methodSignature); - when(methodSignature.getDeclaringType()).thenReturn(TestService.class); - when(methodSignature.getName()).thenReturn("processResource"); - when(redissonClient.getPermitExpirableSemaphore(anyString())).thenReturn(semaphore); - doReturn(metaBucket).when(redissonClient).getBucket(anyString()); - when(metaBucket.get()).thenReturn(null); - when(semaphore.trySetPermits(anyInt())).thenReturn(true); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - public static class TestService { - @DistributedSemaphore(key = "test-resource", permits = 3) - public String processResource() { - return "result"; - } - - @DistributedSemaphore(key = "#{#resourceId}", permits = 5) - public void processById(String resourceId) {} - - @DistributedSemaphore(key = "test-resource", permits = 3, mode = AcquisitionMode.WAIT_AND_SKIP) - public String processWithWait() { - return "result"; - } - - @DistributedSemaphore( - key = "test-resource", - permits = 3, - skipHandler = SemaphoreReturnDefaultHandler.class) - public String processWithDefaultHandler() { - return "result"; - } - - @DistributedSemaphore(key = "", permits = 3) - public void processBlankKey() {} - - @DistributedSemaphore(key = "test-resource", permits = -1) - public void processNegativePermits() {} - - @DistributedSemaphore( - key = "test-resource", - permits = 3, - onLeaseExpired = LeaseExpirationBehavior.THROW_EXCEPTION) - public void processWithThrowOnExpire() {} - - @DistributedSemaphore( - key = "test-resource", - permits = 3, - onLeaseExpired = LeaseExpirationBehavior.IGNORE) - public void processWithIgnoreOnExpire() {} - } - - @Nested - @DisplayName("Basic Permit Acquisition Tests") - class BasicPermitAcquisitionTests { - - @Test - @DisplayName("Should acquire permit and execute method successfully") - void shouldAcquirePermitAndExecuteMethod() throws Throwable { - when(methodSignature.getMethod()).thenReturn(TestService.class.getMethod("processResource")); - when(methodSignature.getReturnType()).thenReturn(String.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(joinPoint.proceed()).thenReturn("result"); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - Object result = aspect.handleDistributedSemaphore(joinPoint); - - assertEquals("result", result); - verify(semaphore).release("permit-123"); - } - - @Test - @DisplayName("Should throw exception when permit not acquired with default handler") - void shouldThrowExceptionWhenPermitNotAcquired() throws Throwable { - when(methodSignature.getMethod()).thenReturn(TestService.class.getMethod("processResource")); - when(methodSignature.getReturnType()).thenReturn(String.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(null); - - assertThrows( - SemaphoreNotAcquiredException.class, () -> aspect.handleDistributedSemaphore(joinPoint)); - - verify(semaphore, never()).release(anyString()); - } - - @Test - @DisplayName("Should return default value with ReturnDefaultHandler when permit not acquired") - void shouldReturnDefaultWhenPermitNotAcquired() throws Throwable { - when(methodSignature.getMethod()) - .thenReturn(TestService.class.getMethod("processWithDefaultHandler")); - when(methodSignature.getReturnType()).thenReturn(String.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(null); - - Object result = aspect.handleDistributedSemaphore(joinPoint); - - assertNull(result); - verify(semaphore, never()).release(anyString()); - } - } - - @Nested - @DisplayName("SpEL Key Resolution Tests") - class SpELKeyResolutionTests { - - @Test - @DisplayName("Should resolve SpEL expression in key") - void shouldResolveSpELExpression() throws Throwable { - when(methodSignature.getMethod()) - .thenReturn(TestService.class.getMethod("processById", String.class)); - when(methodSignature.getReturnType()).thenReturn(void.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {"resource-123"}); - when(joinPoint.proceed()).thenReturn(null); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-456"); - - aspect.handleDistributedSemaphore(joinPoint); - - verify(redissonClient, atLeastOnce()).getPermitExpirableSemaphore("semaphore:resource-123"); - } - } - - @Nested - @DisplayName("Configuration Validation Tests") - class ConfigurationValidationTests { - - @Test - @DisplayName("Should throw IllegalArgumentException for blank key") - void shouldThrowForBlankKey() throws Throwable { - when(methodSignature.getMethod()).thenReturn(TestService.class.getMethod("processBlankKey")); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - - assertThrows( - IllegalArgumentException.class, () -> aspect.handleDistributedSemaphore(joinPoint)); - } - - @Test - @DisplayName("Should throw SemaphoreConfigurationException for negative permits") - void shouldThrowForNegativePermits() throws Throwable { - when(methodSignature.getMethod()) - .thenReturn(TestService.class.getMethod("processNegativePermits")); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - - assertThrows( - SemaphoreConfigurationException.class, - () -> aspect.handleDistributedSemaphore(joinPoint)); - } - } - - @Nested - @DisplayName("Wait Mode Tests") - class WaitModeTests { - - @Test - @DisplayName("Should wait for permit in WAIT_AND_SKIP mode") - void shouldWaitForPermit() throws Throwable { - when(methodSignature.getMethod()).thenReturn(TestService.class.getMethod("processWithWait")); - when(methodSignature.getReturnType()).thenReturn(String.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(joinPoint.proceed()).thenReturn("result"); - when(semaphore.tryAcquire(eq(60000L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-789"); - - Object result = aspect.handleDistributedSemaphore(joinPoint); - - assertEquals("result", result); - verify(semaphore).tryAcquire(eq(60000L), anyLong(), eq(TimeUnit.MILLISECONDS)); - } - } - - @Nested - @DisplayName("Lease Expiration Behavior Tests") - class LeaseExpirationBehaviorTests { - - @Test - @DisplayName("Should throw exception when lease expires with THROW_EXCEPTION behavior") - void shouldThrowOnLeaseExpiration() throws Throwable { - when(methodSignature.getMethod()) - .thenReturn(TestService.class.getMethod("processWithThrowOnExpire")); - when(methodSignature.getReturnType()).thenReturn(void.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(joinPoint.proceed()) - .thenAnswer( - inv -> { - Thread.sleep(10); // Simulate execution longer than lease - return null; - }); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - // Use very short lease time (1ms) to trigger expiration - LocksmithProperties shortLeaseProps = - new LocksmithProperties( - null, - new SemaphoreProperties( - true, Duration.ofMillis(1), Duration.ofSeconds(60), "semaphore:", false, false), - null); - DistributedSemaphoreAspect shortLeaseAspect = - new DistributedSemaphoreAspect(redissonClient, shortLeaseProps, applicationContext); - - assertThrows( - SemaphoreLeaseExpiredException.class, - () -> shortLeaseAspect.handleDistributedSemaphore(joinPoint)); - } - - @Test - @DisplayName("Should not throw when lease expires with IGNORE behavior") - void shouldIgnoreLeaseExpiration() throws Throwable { - when(methodSignature.getMethod()) - .thenReturn(TestService.class.getMethod("processWithIgnoreOnExpire")); - when(methodSignature.getReturnType()).thenReturn(void.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(joinPoint.proceed()) - .thenAnswer( - inv -> { - Thread.sleep(10); // Simulate execution longer than lease - return null; - }); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - // Use very short lease time (1ms) to trigger expiration - LocksmithProperties shortLeaseProps = - new LocksmithProperties( - null, - new SemaphoreProperties( - true, Duration.ofMillis(1), Duration.ofSeconds(60), "semaphore:", false, false), - null); - DistributedSemaphoreAspect shortLeaseAspect = - new DistributedSemaphoreAspect(redissonClient, shortLeaseProps, applicationContext); - - // Should not throw - assertDoesNotThrow(() -> shortLeaseAspect.handleDistributedSemaphore(joinPoint)); - } - } - - @Nested - @DisplayName("Permit Consistency Tests") - class PermitConsistencyTests { - - @Test - @DisplayName("Should throw exception when same key used with different permits") - void shouldThrowForInconsistentPermits() throws Throwable { - // First call with 3 permits - when(methodSignature.getMethod()).thenReturn(TestService.class.getMethod("processResource")); - when(methodSignature.getReturnType()).thenReturn(String.class); - when(joinPoint.getArgs()).thenReturn(new Object[] {}); - when(joinPoint.proceed()).thenReturn("result"); - when(semaphore.tryAcquire(eq(0L), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - // First call succeeds - aspect.handleDistributedSemaphore(joinPoint); - - // Create a new annotation mock with different permits (would normally be a different method) - // For testing, we use reflection to simulate this scenario by creating a new aspect - // with a cached key but trying to use different permits - // This is tested through the internal keyToPermits map - } - } - - @Nested - @DisplayName("Handler Tests") - class HandlerTests { - - @Test - @DisplayName("SemaphoreThrowExceptionHandler should throw SemaphoreNotAcquiredException") - void shouldThrowFromHandler() throws NoSuchMethodException { - SemaphoreThrowExceptionHandler handler = new SemaphoreThrowExceptionHandler(); - java.lang.reflect.Method method = TestService.class.getMethod("processResource"); - - assertThrows( - SemaphoreNotAcquiredException.class, - () -> - handler.handle( - new SemaphoreContext( - "test-key", - "TestClass.testMethod", - method, - new Object[] {}, - String.class, - null))); - } - - @Test - @DisplayName("SemaphoreReturnDefaultHandler should return null for object type") - void shouldReturnNullForObject() throws NoSuchMethodException { - SemaphoreReturnDefaultHandler handler = new SemaphoreReturnDefaultHandler(); - java.lang.reflect.Method method = TestService.class.getMethod("processResource"); - - Object result = - handler.handle( - new SemaphoreContext( - "test-key", "TestClass.testMethod", method, new Object[] {}, String.class, null)); - - assertNull(result); - } - - @Test - @DisplayName("SemaphoreReturnDefaultHandler should return false for boolean type") - void shouldReturnFalseForBoolean() throws NoSuchMethodException { - SemaphoreReturnDefaultHandler handler = new SemaphoreReturnDefaultHandler(); - java.lang.reflect.Method method = TestService.class.getMethod("processResource"); - - Object result = - handler.handle( - new SemaphoreContext( - "test-key", - "TestClass.testMethod", - method, - new Object[] {}, - boolean.class, - null)); - - assertEquals(false, result); - } - - @Test - @DisplayName("SemaphoreReturnDefaultHandler should return 0 for int type") - void shouldReturnZeroForInt() throws NoSuchMethodException { - SemaphoreReturnDefaultHandler handler = new SemaphoreReturnDefaultHandler(); - java.lang.reflect.Method method = TestService.class.getMethod("processResource"); - - Object result = - handler.handle( - new SemaphoreContext( - "test-key", "TestClass.testMethod", method, new Object[] {}, int.class, null)); - - assertEquals(0, result); - } - - @Test - @DisplayName("SemaphoreReturnDefaultHandler should return 0L for long type") - void shouldReturnZeroForLong() throws NoSuchMethodException { - SemaphoreReturnDefaultHandler handler = new SemaphoreReturnDefaultHandler(); - java.lang.reflect.Method method = TestService.class.getMethod("processResource"); - - Object result = - handler.handle( - new SemaphoreContext( - "test-key", "TestClass.testMethod", method, new Object[] {}, long.class, null)); - - assertEquals(0L, result); - } - } - - @Nested - @DisplayName("SemaphoreProperties Validation Tests") - class SemaphorePropertiesValidationTests { - - @Test - @DisplayName("Should use default lease time when null") - void shouldUseDefaultLeaseTime() { - SemaphoreProperties props = new SemaphoreProperties(null, null, null, null, null, null); - - assertEquals(SemaphoreProperties.DEFAULT_ENABLED, props.enabled()); - assertEquals(SemaphoreProperties.DEFAULT_LEASE_TIME, props.leaseTime()); - assertEquals(SemaphoreProperties.DEFAULT_WAIT_TIME, props.waitTime()); - assertEquals(SemaphoreProperties.DEFAULT_KEY_PREFIX, props.keyPrefix()); - assertEquals(SemaphoreProperties.DEFAULT_DEBUG, props.debug()); - } - - @Test - @DisplayName("Should use default lease time when negative") - void shouldUseDefaultLeaseTimeWhenNegative() { - SemaphoreProperties props = - new SemaphoreProperties(null, Duration.ofSeconds(-1), null, null, null, null); - - assertEquals(SemaphoreProperties.DEFAULT_LEASE_TIME, props.leaseTime()); - } - - @Test - @DisplayName("Should use default key prefix when blank") - void shouldUseDefaultKeyPrefixWhenBlank() { - SemaphoreProperties props = new SemaphoreProperties(null, null, null, " ", null, null); - - assertEquals(SemaphoreProperties.DEFAULT_KEY_PREFIX, props.keyPrefix()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/aspect/RateLimitAspectTest.java b/src/test/java/in/riido/locksmith/aspect/RateLimitAspectTest.java deleted file mode 100644 index 44094e0..0000000 --- a/src/test/java/in/riido/locksmith/aspect/RateLimitAspectTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.RateLimit; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.handler.ratelimit.RateLimitReturnDefaultHandler; -import in.riido.locksmith.handler.ratelimit.RateLimitThrowExceptionHandler; -import java.time.Duration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.redisson.api.RateType; -import org.redisson.api.RedissonClient; -import org.springframework.context.ApplicationContext; - -/** - * Unit tests for RateLimitAspect construction and configuration. - * - *

Full functional tests are in RateLimitIntegrationTest which tests against real Redis. - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("RateLimitAspect Unit Tests") -class RateLimitAspectTest { - - @Mock private RedissonClient redissonClient; - @Mock private ApplicationContext applicationContext; - - private RateLimitAspect aspect; - private LocksmithProperties properties; - - @BeforeEach - void setUp() { - properties = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit:", false, false)); - aspect = new RateLimitAspect(redissonClient, properties, applicationContext); - } - - @Nested - @DisplayName("Construction Tests") - class ConstructionTests { - - @Test - @DisplayName("Should create aspect with required dependencies") - void shouldCreateAspectWithRequiredDependencies() { - assertNotNull(aspect); - } - - @Test - @DisplayName("Should create aspect with optional metrics") - void shouldCreateAspectWithOptionalMetrics() { - LocksmithProperties metricsProps = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit:", false, true)); - RateLimitAspect aspectWithMetrics = - new RateLimitAspect(redissonClient, metricsProps, applicationContext, null); - - assertNotNull(aspectWithMetrics); - } - - @Test - @DisplayName("Should use properties from configuration") - void shouldUsePropertiesFromConfiguration() { - LocksmithProperties customProps = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofSeconds(30), "custom:", true, true)); - RateLimitAspect customAspect = - new RateLimitAspect(redissonClient, customProps, applicationContext); - - assertNotNull(customAspect); - } - } - - @Nested - @DisplayName("Annotation Configuration Tests") - class AnnotationConfigurationTests { - - @Test - @DisplayName("Should have default values in annotation") - void annotationShouldHaveDefaultValues() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("defaultsMethod").getAnnotation(RateLimit.class); - - assertEquals("default-key", annotation.key()); - assertEquals(10, annotation.permits()); - assertEquals("1s", annotation.interval()); - assertEquals(RateType.OVERALL, annotation.type()); - assertEquals(AcquisitionMode.SKIP_IMMEDIATELY, annotation.mode()); - assertEquals("", annotation.waitTime()); - assertEquals(RateLimitThrowExceptionHandler.class, annotation.skipHandler()); - } - - @Test - @DisplayName("Should support custom permits and interval") - void annotationShouldSupportCustomPermitsAndInterval() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("customRateMethod").getAnnotation(RateLimit.class); - - assertEquals("custom-rate", annotation.key()); - assertEquals(100, annotation.permits()); - assertEquals("1m", annotation.interval()); - } - - @Test - @DisplayName("Should support WAIT_AND_SKIP mode") - void annotationShouldSupportWaitMode() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("waitModeMethod").getAnnotation(RateLimit.class); - - assertEquals(AcquisitionMode.WAIT_AND_SKIP, annotation.mode()); - assertEquals("5s", annotation.waitTime()); - } - - @Test - @DisplayName("Should support PER_CLIENT rate type") - void annotationShouldSupportPerClientRateType() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("perClientMethod").getAnnotation(RateLimit.class); - - assertEquals(RateType.PER_CLIENT, annotation.type()); - } - - @Test - @DisplayName("Should support ReturnDefaultHandler") - void annotationShouldSupportReturnDefaultHandler() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("returnDefaultMethod").getAnnotation(RateLimit.class); - - assertEquals(RateLimitReturnDefaultHandler.class, annotation.skipHandler()); - } - - @Test - @DisplayName("Should support SpEL expression in key") - void annotationShouldSupportSpelExpressionInKey() throws NoSuchMethodException { - RateLimit annotation = - TestService.class.getMethod("spelKeyMethod", String.class).getAnnotation(RateLimit.class); - - assertEquals("#{#userId}", annotation.key()); - } - } - - // Test service class for annotation inspection - public static class TestService { - - @RateLimit(key = "default-key") - public String defaultsMethod() { - return "result"; - } - - @RateLimit(key = "custom-rate", permits = 100, interval = "1m") - public String customRateMethod() { - return "result"; - } - - @RateLimit(key = "wait-mode", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s") - public String waitModeMethod() { - return "result"; - } - - @RateLimit(key = "per-client", type = RateType.PER_CLIENT) - public String perClientMethod() { - return "result"; - } - - @RateLimit(key = "return-default", skipHandler = RateLimitReturnDefaultHandler.class) - public String returnDefaultMethod() { - return "result"; - } - - @RateLimit(key = "#{#userId}") - public String spelKeyMethod(String userId) { - return "result"; - } - } -} diff --git a/src/test/java/in/riido/locksmith/aspect/SpELExpressionPerformanceTest.java b/src/test/java/in/riido/locksmith/aspect/SpELExpressionPerformanceTest.java deleted file mode 100644 index 96aad5c..0000000 --- a/src/test/java/in/riido/locksmith/aspect/SpELExpressionPerformanceTest.java +++ /dev/null @@ -1,568 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.support.SpELKeyResolver; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; -import java.util.LongSummaryStatistics; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Performance stress tests for SpEL expression evaluation in SpELKeyResolver. - * - *

These tests demonstrate the performance impact of repeatedly parsing SpEL expressions without - * caching. The results can be used to justify and measure the effectiveness of implementing - * expression caching as described in GitHub Issue #28. - * - * @see GitHub Issue #28 - Performance - * optimization: SpEL expression caching - */ -@DisplayName("SpEL Expression Performance Tests (Issue #28)") -class SpELExpressionPerformanceTest { - - private static final Logger LOG = LoggerFactory.getLogger(SpELExpressionPerformanceTest.class); - - private Method testMethod; - - @BeforeEach - void setUp() throws NoSuchMethodException { - testMethod = TestService.class.getMethod("processUser", String.class); - } - - public static class TestService { - @DistributedLock(key = "#{#userId}") - public void processUser(String userId) {} - - @DistributedLock(key = "#{'user-' + #id + '-order-' + #orderId}") - public void processOrder(Long id, String orderId) {} - - @DistributedLock(key = "#{#user.name + '-' + #user.email}") - public void processUserData(User user) {} - - public record User(String name, String email, int age) {} - } - - @Nested - @DisplayName("Single-threaded Performance Tests") - class SingleThreadedPerformanceTests { - - @Test - @DisplayName("Should measure overhead of parsing simple expression repeatedly") - void shouldMeasureSimpleExpressionParsingOverhead() { - int iterations = 10_000; - String expression = "#{#userId}"; - Object[] args = new Object[] {"user123"}; - - long startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - String result = SpELKeyResolver.resolve(expression, testMethod, args); - assertEquals("user123", result); - } - long endTime = System.nanoTime(); - - long totalTimeMs = (endTime - startTime) / 1_000_000; - double avgTimePerCallNs = (endTime - startTime) / (double) iterations; - - LOG.info( - "Simple expression (#userId) - {} iterations: total={}ms, avg={} ns/call", - iterations, - totalTimeMs, - String.format("%.2f", avgTimePerCallNs)); - - assertTrue( - totalTimeMs < 5000, "10k iterations should complete in under 5 seconds (current design)"); - } - - @Test - @DisplayName("Should measure overhead of parsing complex expression repeatedly") - void shouldMeasureComplexExpressionParsingOverhead() throws NoSuchMethodException { - int iterations = 10_000; - String expression = "#{'user-' + #id + '-order-' + #orderId}"; - - Method method = TestService.class.getMethod("processOrder", Long.class, String.class); - Object[] args = new Object[] {123L, "ORD456"}; - - long startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - String result = SpELKeyResolver.resolve(expression, method, args); - assertEquals("user-123-order-ORD456", result); - } - long endTime = System.nanoTime(); - - long totalTimeMs = (endTime - startTime) / 1_000_000; - double avgTimePerCallNs = (endTime - startTime) / (double) iterations; - - LOG.info( - "Complex expression ('user-' + #id + '-order-' + #orderId) - {} iterations: total={}ms," - + " avg={} ns/call", - iterations, - totalTimeMs, - String.format("%.2f", avgTimePerCallNs)); - - assertTrue( - totalTimeMs < 10000, - "10k iterations of complex expression should complete in under 10 seconds"); - } - - @Test - @DisplayName("Should measure overhead of parsing property access expression repeatedly") - void shouldMeasurePropertyAccessParsingOverhead() throws NoSuchMethodException { - int iterations = 10_000; - String expression = "#{#user.name + '-' + #user.email}"; - - Method method = TestService.class.getMethod("processUserData", TestService.User.class); - Object[] args = new Object[] {new TestService.User("Alice", "alice@example.com", 30)}; - - long startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - String result = SpELKeyResolver.resolve(expression, method, args); - assertEquals("Alice-alice@example.com", result); - } - long endTime = System.nanoTime(); - - long totalTimeMs = (endTime - startTime) / 1_000_000; - double avgTimePerCallNs = (endTime - startTime) / (double) iterations; - - LOG.info( - "Property access expression (#user.name + '-' + #user.email) - {} iterations:" - + " total={}ms, avg={} ns/call", - iterations, - totalTimeMs, - String.format("%.2f", avgTimePerCallNs)); - - assertTrue( - totalTimeMs < 10000, - "10k iterations of property access should complete in under 10 seconds"); - } - - @Test - @DisplayName("Should measure parsing overhead across different expression types") - void shouldCompareParsingOverheadAcrossDifferentExpressions() throws NoSuchMethodException { - int iterations = 5_000; - - // Test different expression types - List tests = - List.of( - new ExpressionTest( - "Simple parameter", - "#{#userId}", - testMethod, - new Object[] {"user123"}, - "user123"), - new ExpressionTest( - "String concatenation", - "#{'user-' + #userId}", - testMethod, - new Object[] {"user123"}, - "user-user123"), - new ExpressionTest( - "Multiple parameters", - "#{#id + '-' + #orderId}", - TestService.class.getMethod("processOrder", Long.class, String.class), - new Object[] {123L, "ORD456"}, - "123-ORD456"), - new ExpressionTest( - "Property access", - "#{#user.name}", - TestService.class.getMethod("processUserData", TestService.User.class), - new Object[] {new TestService.User("Bob", "bob@test.com", 25)}, - "Bob"), - new ExpressionTest( - "Complex property access", - "#{#user.name + '-' + #user.email + '-' + #user.age}", - TestService.class.getMethod("processUserData", TestService.User.class), - new Object[] {new TestService.User("Charlie", "charlie@test.com", 35)}, - "Charlie-charlie@test.com-35")); - - LOG.info( - "Comparing parsing overhead for different expression types ({} iterations each):", - iterations); - LOG.info("─".repeat(80)); - - for (ExpressionTest test : tests) { - long startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - String result = SpELKeyResolver.resolve(test.expression, test.method, test.args); - assertEquals(test.expectedResult, result); - } - long endTime = System.nanoTime(); - - long totalTimeMs = (endTime - startTime) / 1_000_000; - double avgTimePerCallNs = (endTime - startTime) / (double) iterations; - - LOG.info( - "{}: total={}ms, avg={} ns/call", - String.format("%-25s", test.name), - String.format("%5d", totalTimeMs), - String.format("%8.2f", avgTimePerCallNs)); - } - - LOG.info("─".repeat(80)); - } - } - - @Nested - @DisplayName("Multi-threaded Stress Tests") - class MultiThreadedStressTests { - - @Test - @DisplayName("Should measure overhead under concurrent load with same expression") - void shouldMeasureConcurrentParsingOverheadSameExpression() throws InterruptedException { - int threadCount = 10; - int iterationsPerThread = 5_000; - String expression = "#{#userId}"; - Object[] args = new Object[] {"user123"}; - - AtomicLong totalNanoseconds = new AtomicLong(0); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - long overallStart = System.nanoTime(); - - for (int t = 0; t < threadCount; t++) { - executor.submit( - () -> { - try { - startSignal.await(); - - long threadStart = System.nanoTime(); - for (int i = 0; i < iterationsPerThread; i++) { - String result = SpELKeyResolver.resolve(expression, testMethod, args); - assertEquals("user123", result); - } - long threadEnd = System.nanoTime(); - - totalNanoseconds.addAndGet(threadEnd - threadStart); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue(allComplete.await(60, TimeUnit.SECONDS), "All threads should complete"); - long overallEnd = System.nanoTime(); - - executor.shutdown(); - - long overallTimeMs = (overallEnd - overallStart) / 1_000_000; - long totalTimeMs = totalNanoseconds.get() / 1_000_000; - int totalIterations = threadCount * iterationsPerThread; - double avgTimePerCallNs = totalNanoseconds.get() / (double) totalIterations; - double throughput = (totalIterations * 1000.0) / overallTimeMs; - - LOG.info( - "Concurrent parsing ({} threads, {} iterations each):", threadCount, iterationsPerThread); - LOG.info(" Total iterations: {}", totalIterations); - LOG.info(" Overall time: {}ms", overallTimeMs); - LOG.info(" Total CPU time: {}ms", totalTimeMs); - LOG.info(" Avg time per call: {} ns", String.format("%.2f", avgTimePerCallNs)); - LOG.info(" Throughput: {} ops/sec", String.format("%.2f", throughput)); - - assertTrue(overallTimeMs < 30000, "50k total iterations should complete in under 30 seconds"); - } - - @Test - @DisplayName("Should measure overhead under concurrent load with different expressions") - void shouldMeasureConcurrentParsingOverheadDifferentExpressions() throws InterruptedException { - int threadCount = 5; - int iterationsPerThread = 2_000; - - // Different expressions for different threads - List expressions = - List.of( - new ExpressionTest( - "Simple", "#{#userId}", testMethod, new Object[] {"user1"}, "user1"), - new ExpressionTest( - "Concatenation", - "#{'user-' + #userId}", - testMethod, - new Object[] {"user2"}, - "user-user2"), - new ExpressionTest( - "Multiple params", - "#{#userId + '-test'}", - testMethod, - new Object[] {"user3"}, - "user3-test"), - new ExpressionTest( - "Complex concat", - "#{'prefix-' + #userId + '-suffix'}", - testMethod, - new Object[] {"user4"}, - "prefix-user4-suffix"), - new ExpressionTest( - "Upper case", - "#{#userId.toUpperCase()}", - testMethod, - new Object[] {"user5"}, - "USER5")); - - AtomicLong totalNanoseconds = new AtomicLong(0); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - long overallStart = System.nanoTime(); - - for (int t = 0; t < threadCount; t++) { - final ExpressionTest test = expressions.get(t); - executor.submit( - () -> { - try { - startSignal.await(); - - long threadStart = System.nanoTime(); - for (int i = 0; i < iterationsPerThread; i++) { - String result = SpELKeyResolver.resolve(test.expression, test.method, test.args); - assertEquals(test.expectedResult, result); - } - long threadEnd = System.nanoTime(); - - totalNanoseconds.addAndGet(threadEnd - threadStart); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue(allComplete.await(60, TimeUnit.SECONDS), "All threads should complete"); - long overallEnd = System.nanoTime(); - - executor.shutdown(); - - long overallTimeMs = (overallEnd - overallStart) / 1_000_000; - long totalTimeMs = totalNanoseconds.get() / 1_000_000; - int totalIterations = threadCount * iterationsPerThread; - double avgTimePerCallNs = totalNanoseconds.get() / (double) totalIterations; - double throughput = (totalIterations * 1000.0) / overallTimeMs; - - LOG.info( - "Concurrent parsing with different expressions ({} threads, {} iterations each):", - threadCount, - iterationsPerThread); - LOG.info(" Total iterations: {}", totalIterations); - LOG.info(" Overall time: {}ms", overallTimeMs); - LOG.info(" Total CPU time: {}ms", totalTimeMs); - LOG.info(" Avg time per call: {} ns", String.format("%.2f", avgTimePerCallNs)); - LOG.info(" Throughput: {} ops/sec", String.format("%.2f", throughput)); - } - - @Test - @DisplayName("Should measure latency distribution under concurrent load") - void shouldMeasureLatencyDistribution() throws InterruptedException { - int threadCount = 8; - int iterationsPerThread = 1_000; - String expression = "#{#userId}"; - Object[] args = new Object[] {"user123"}; - - ConcurrentLinkedQueue latencies = new ConcurrentLinkedQueue<>(); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int t = 0; t < threadCount; t++) { - executor.submit( - () -> { - try { - startSignal.await(); - - for (int i = 0; i < iterationsPerThread; i++) { - long start = System.nanoTime(); - String result = SpELKeyResolver.resolve(expression, testMethod, args); - long latency = System.nanoTime() - start; - latencies.add(latency); - assertEquals("user123", result); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue(allComplete.await(60, TimeUnit.SECONDS), "All threads should complete"); - - executor.shutdown(); - - List sortedLatencies = new ArrayList<>(latencies); - sortedLatencies.sort(Long::compareTo); - - LongSummaryStatistics stats = - sortedLatencies.stream().mapToLong(Long::longValue).summaryStatistics(); - - long p50 = sortedLatencies.get(sortedLatencies.size() / 2); - long p75 = sortedLatencies.get((int) (sortedLatencies.size() * 0.75)); - long p90 = sortedLatencies.get((int) (sortedLatencies.size() * 0.90)); - long p95 = sortedLatencies.get((int) (sortedLatencies.size() * 0.95)); - long p99 = sortedLatencies.get((int) (sortedLatencies.size() * 0.99)); - - LOG.info("Latency distribution for SpEL expression parsing (nanoseconds):"); - LOG.info(" Count: {}", stats.getCount()); - LOG.info(" Min: {} ns", stats.getMin()); - LOG.info(" P50: {} ns", p50); - LOG.info(" P75: {} ns", p75); - LOG.info(" P90: {} ns", p90); - LOG.info(" P95: {} ns", p95); - LOG.info(" P99: {} ns", p99); - LOG.info(" Max: {} ns", stats.getMax()); - LOG.info(" Avg: {} ns", String.format("%.2f", stats.getAverage())); - - // These are baseline numbers - after caching, we expect significant improvement - LOG.info( - "\nNote: These are BASELINE numbers without caching. " - + "After implementing expression caching (Issue #28), " - + "we expect significant improvements in all percentiles."); - } - } - - @Nested - @DisplayName("Sustained Load Tests") - class SustainedLoadTests { - - @Test - @DisplayName("Should measure performance degradation under sustained load") - void shouldMeasurePerformanceUnderSustainedLoad() throws InterruptedException { - int threadCount = 5; - int phaseDurationSeconds = 2; - int phaseCount = 5; - String expression = "#{#userId}"; - Object[] args = new Object[] {"user123"}; - - List phaseResults = new ArrayList<>(); - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - LOG.info( - "Running sustained load test ({} phases of {}s each):", phaseCount, phaseDurationSeconds); - LOG.info("─".repeat(80)); - - for (int phase = 0; phase < phaseCount; phase++) { - AtomicLong operationCount = new AtomicLong(0); - AtomicLong totalLatency = new AtomicLong(0); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch phaseComplete = new CountDownLatch(threadCount); - - long phaseStart = System.nanoTime(); - - for (int t = 0; t < threadCount; t++) { - executor.submit( - () -> { - try { - startSignal.await(); - long endTime = System.currentTimeMillis() + (phaseDurationSeconds * 1000L); - - while (System.currentTimeMillis() < endTime) { - long start = System.nanoTime(); - String result = SpELKeyResolver.resolve(expression, testMethod, args); - long latency = System.nanoTime() - start; - - totalLatency.addAndGet(latency); - operationCount.incrementAndGet(); - assertEquals("user123", result); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - phaseComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue( - phaseComplete.await(phaseDurationSeconds + 10, TimeUnit.SECONDS), - "Phase should complete"); - - long phaseEnd = System.nanoTime(); - long phaseDurationMs = (phaseEnd - phaseStart) / 1_000_000; - - long ops = operationCount.get(); - double throughput = (ops * 1000.0) / phaseDurationMs; - double avgLatencyNs = totalLatency.get() / (double) ops; - - PhaseResult result = new PhaseResult(phase + 1, ops, throughput, avgLatencyNs); - phaseResults.add(result); - - LOG.info( - "Phase {}: {} ops, {} ops/sec, avg latency {} ns", - phase + 1, - ops, - String.format("%.2f", throughput), - String.format("%.2f", avgLatencyNs)); - - Thread.sleep(100); // Small gap between phases - } - - executor.shutdown(); - - LOG.info("─".repeat(80)); - - // Analyze performance degradation - double avgThroughput = - phaseResults.stream().mapToDouble(r -> r.throughput).average().orElse(0); - double minThroughput = phaseResults.stream().mapToDouble(r -> r.throughput).min().orElse(0); - double maxThroughput = phaseResults.stream().mapToDouble(r -> r.throughput).max().orElse(0); - - double avgLatency = - phaseResults.stream().mapToDouble(r -> r.avgLatencyNs).average().orElse(0); - double minLatency = phaseResults.stream().mapToDouble(r -> r.avgLatencyNs).min().orElse(0); - double maxLatency = phaseResults.stream().mapToDouble(r -> r.avgLatencyNs).max().orElse(0); - - LOG.info("Summary:"); - LOG.info( - " Throughput: avg={} ops/sec, min={} ops/sec, max={} ops/sec", - String.format("%.2f", avgThroughput), - String.format("%.2f", minThroughput), - String.format("%.2f", maxThroughput)); - LOG.info( - " Latency: avg={} ns, min={} ns, max={} ns", - String.format("%.2f", avgLatency), - String.format("%.2f", minLatency), - String.format("%.2f", maxLatency)); - - double throughputVariation = ((maxThroughput - minThroughput) / avgThroughput) * 100; - LOG.info(" Throughput variation: {}%", String.format("%.2f", throughputVariation)); - - // Allow up to 100% variation (without caching, high variation is expected and demonstrates - // the performance issue). After implementing caching (Issue #28), we expect this to drop - // significantly (< 20%). - assertTrue( - throughputVariation < 100, - "Throughput variation should be under 100% for baseline (current: " - + String.format("%.2f", throughputVariation) - + "%). High variation demonstrates the need for caching."); - } - } - - /** Helper record for expression test cases. */ - private record ExpressionTest( - String name, String expression, Method method, Object[] args, String expectedResult) {} - - /** Helper record for phase results. */ - private record PhaseResult(int phase, long operations, double throughput, double avgLatencyNs) {} -} diff --git a/src/test/java/in/riido/locksmith/aspect/SpELKeyResolutionTest.java b/src/test/java/in/riido/locksmith/aspect/SpELKeyResolutionTest.java deleted file mode 100644 index 9bbcb04..0000000 --- a/src/test/java/in/riido/locksmith/aspect/SpELKeyResolutionTest.java +++ /dev/null @@ -1,562 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.support.SpELKeyResolver; -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -/** - * Comprehensive tests for SpEL key resolution using SpELKeyResolver. - * - *

Tests both the new recommended #{...} syntax and legacy syntax (with deprecation warnings). - * - * @see GitHub Issue #13 - */ -@DisplayName("SpEL Key Resolution Tests") -class SpELKeyResolutionTest { - - /** Test class with various SpEL annotation examples. */ - public static class TestClass { - @DistributedLock(key = "#{#userId}") - public void processUser(String userId) {} - - @DistributedLock(key = "#{#orderId}") - public void processOrder(Long orderId) {} - - @DistributedLock(key = "#{'user-' + #id}") - public void processWithPrefix(Long id) {} - - @DistributedLock(key = "#{#user.name}") - public void updateUser(User user) {} - - @DistributedLock(key = "#{#user.email}") - public void processUserEmail(User user) {} - - @DistributedLock(key = "#{#p0}") - public void processWithP0(String value) {} - - @DistributedLock(key = "#{#a0}") - public void processWithA0(String value) {} - - @DistributedLock(key = "#{#userId + '-' + #role}") - public void processUserRole(String userId, String role) {} - - @DistributedLock(key = "#{T(java.lang.String).valueOf(#id)}") - public void processWithStaticMethod(Integer id) {} - - @DistributedLock(key = "#{#users.size()}") - public void processUsersList(List users) {} - - @DistributedLock(key = "#{#users[0].name}") - public void processFirstUser(List users) {} - - @DistributedLock(key = "#{#id > 100 ? 'large' : 'small'}") - public void processConditional(int id) {} - - @DistributedLock(key = "order#123") - public void processLiteralWithHash() {} - - @DistributedLock(key = "item-#1") - public void processLiteralWithHashPrefix() {} - - @DistributedLock(key = "task#end") - public void processLiteralWithHashSuffix() {} - - @DistributedLock(key = "prefix#middle#suffix") - public void processMultipleHashes() {} - - @DistributedLock(key = "valid:key:123") - public void processLiteralNoHash() {} - - public record User(String name, String email, int age) {} - } - - @Nested - @DisplayName("New #{...} Syntax Tests (Recommended)") - class NewSyntaxTests { - - @Test - @DisplayName("Should resolve simple parameter reference #{#userId}") - void shouldResolveSimpleParameter() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user123"}; - - String result = SpELKeyResolver.resolve("#{#userId}", method, args); - - assertEquals("user123", result); - } - - @Test - @DisplayName("Should resolve Long parameter #{#orderId}") - void shouldResolveLongParameter() throws Exception { - Method method = TestClass.class.getMethod("processOrder", Long.class); - Object[] args = new Object[] {42L}; - - String result = SpELKeyResolver.resolve("#{#orderId}", method, args); - - assertEquals("42", result); - } - - @Test - @DisplayName("Should resolve string concatenation #{'user-' + #id}") - void shouldResolveStringConcatenation() throws Exception { - Method method = TestClass.class.getMethod("processWithPrefix", Long.class); - Object[] args = new Object[] {99L}; - - String result = SpELKeyResolver.resolve("#{'user-' + #id}", method, args); - - assertEquals("user-99", result); - } - - @Test - @DisplayName("Should resolve object property #{#user.name}") - void shouldResolveObjectProperty() throws Exception { - Method method = TestClass.class.getMethod("updateUser", TestClass.User.class); - Object[] args = new Object[] {new TestClass.User("Alice", "alice@example.com", 30)}; - - String result = SpELKeyResolver.resolve("#{#user.name}", method, args); - - assertEquals("Alice", result); - } - - @Test - @DisplayName("Should resolve nested property #{#user.email}") - void shouldResolveNestedProperty() throws Exception { - Method method = TestClass.class.getMethod("processUserEmail", TestClass.User.class); - Object[] args = new Object[] {new TestClass.User("Bob", "bob@test.com", 25)}; - - String result = SpELKeyResolver.resolve("#{#user.email}", method, args); - - assertEquals("bob@test.com", result); - } - - @Test - @DisplayName("Should resolve parameter by position #{#p0}") - void shouldResolveByPositionP0() throws Exception { - Method method = TestClass.class.getMethod("processWithP0", String.class); - Object[] args = new Object[] {"value-p0"}; - - String result = SpELKeyResolver.resolve("#{#p0}", method, args); - - assertEquals("value-p0", result); - } - - @Test - @DisplayName("Should resolve parameter by position #{#a0}") - void shouldResolveByPositionA0() throws Exception { - Method method = TestClass.class.getMethod("processWithA0", String.class); - Object[] args = new Object[] {"value-a0"}; - - String result = SpELKeyResolver.resolve("#{#a0}", method, args); - - assertEquals("value-a0", result); - } - - @Test - @DisplayName("Should resolve multiple parameters #{#userId + '-' + #role}") - void shouldResolveMultipleParameters() throws Exception { - Method method = TestClass.class.getMethod("processUserRole", String.class, String.class); - Object[] args = new Object[] {"user456", "admin"}; - - String result = SpELKeyResolver.resolve("#{#userId + '-' + #role}", method, args); - - assertEquals("user456-admin", result); - } - - @Test - @DisplayName("Should resolve static method call #{T(String).valueOf(#id)}") - void shouldResolveStaticMethodCall() throws Exception { - Method method = TestClass.class.getMethod("processWithStaticMethod", Integer.class); - Object[] args = new Object[] {789}; - - String result = SpELKeyResolver.resolve("#{T(java.lang.String).valueOf(#id)}", method, args); - - assertEquals("789", result); - } - - @Test - @DisplayName("Should resolve collection method #{#users.size()}") - void shouldResolveCollectionMethod() throws Exception { - Method method = TestClass.class.getMethod("processUsersList", List.class); - List users = - Arrays.asList( - new TestClass.User("Alice", "alice@test.com", 30), - new TestClass.User("Bob", "bob@test.com", 25), - new TestClass.User("Charlie", "charlie@test.com", 35)); - Object[] args = new Object[] {users}; - - String result = SpELKeyResolver.resolve("#{#users.size()}", method, args); - - assertEquals("3", result); - } - - @Test - @DisplayName("Should resolve collection indexing #{#users[0].name}") - void shouldResolveCollectionIndexing() throws Exception { - Method method = TestClass.class.getMethod("processFirstUser", List.class); - List users = - Arrays.asList(new TestClass.User("FirstUser", "first@test.com", 28)); - Object[] args = new Object[] {users}; - - String result = SpELKeyResolver.resolve("#{#users[0].name}", method, args); - - assertEquals("FirstUser", result); - } - - @Test - @DisplayName("Should resolve conditional expression #{#id > 100 ? 'large' : 'small'}") - void shouldResolveConditionalExpression() throws Exception { - Method method = TestClass.class.getMethod("processConditional", int.class); - - // Test with large value - String result1 = - SpELKeyResolver.resolve("#{#id > 100 ? 'large' : 'small'}", method, new Object[] {150}); - assertEquals("large", result1); - - // Test with small value - String result2 = - SpELKeyResolver.resolve("#{#id > 100 ? 'large' : 'small'}", method, new Object[] {50}); - assertEquals("small", result2); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to null") - void shouldThrowWhenEvaluatesToNull() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {null}; - - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> SpELKeyResolver.resolve("#{#userId}", method, args)); - - assertTrue(exception.getMessage().contains("evaluated to null")); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to blank string") - void shouldThrowWhenEvaluatesToBlank() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {" "}; - - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> SpELKeyResolver.resolve("#{#userId}", method, args)); - - assertTrue(exception.getMessage().contains("evaluated to blank")); - } - - @Test - @DisplayName("Should throw exception when SpEL evaluates to empty string") - void shouldThrowWhenEvaluatesToEmpty() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {""}; - - IllegalArgumentException exception = - assertThrows( - IllegalArgumentException.class, - () -> SpELKeyResolver.resolve("#{#userId}", method, args)); - - assertTrue(exception.getMessage().contains("evaluated to blank")); - } - } - - @Nested - @DisplayName("Legacy Syntax Tests (No Longer Supported - Treated as Literals)") - class LegacySyntaxTests { - - @Test - @DisplayName("Should treat #userId as literal (not SpEL)") - void shouldTreatLegacyAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user789"}; - - // Without #{} wrapper, it's treated as literal - String result = SpELKeyResolver.resolve("#userId", method, args); - - assertEquals("#userId", result); // Returns literal, not evaluated - } - - @Test - @DisplayName("Should treat 'user-' + #id as literal (not SpEL)") - void shouldTreatLegacyConcatenationAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processWithPrefix", Long.class); - Object[] args = new Object[] {333L}; - - String result = SpELKeyResolver.resolve("'user-' + #id", method, args); - - assertEquals("'user-' + #id", result); // Returns literal - } - - @Test - @DisplayName("Should treat #user.name as literal (not SpEL)") - void shouldTreatLegacyObjectPropertyAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("updateUser", TestClass.User.class); - Object[] args = new Object[] {new TestClass.User("David", "david@test.com", 40)}; - - String result = SpELKeyResolver.resolve("#user.name", method, args); - - assertEquals("#user.name", result); // Returns literal - } - - @Test - @DisplayName("Should treat #p0 as literal (not SpEL)") - void shouldTreatLegacyP0AsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processWithP0", String.class); - Object[] args = new Object[] {"legacy-p0"}; - - String result = SpELKeyResolver.resolve("#p0", method, args); - - assertEquals("#p0", result); // Returns literal - } - } - - @Nested - @DisplayName("Literal Key Tests (Issue #13 Fixed)") - class LiteralKeyTests { - - @Test - @DisplayName("Should treat 'order#123' as literal key") - void shouldTreatOrderHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralWithHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("order#123", method, args); - - assertEquals("order#123", result); - } - - @Test - @DisplayName("Should treat 'item-#1' as literal key") - void shouldTreatItemHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralWithHashPrefix"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("item-#1", method, args); - - assertEquals("item-#1", result); - } - - @Test - @DisplayName("Should treat 'task#end' as literal key") - void shouldTreatTaskHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralWithHashSuffix"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("task#end", method, args); - - assertEquals("task#end", result); - } - - @Test - @DisplayName("Should treat 'prefix#middle#suffix' as literal key") - void shouldTreatMultipleHashesAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processMultipleHashes"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("prefix#middle#suffix", method, args); - - assertEquals("prefix#middle#suffix", result); - } - - @Test - @DisplayName("Should treat keys without # as literal") - void shouldTreatNoHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("valid:key:123", method, args); - - assertEquals("valid:key:123", result); - } - - @Test - @DisplayName("Should handle single # as literal (not SpEL)") - void shouldHandleSingleHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("#", method, args); - - assertEquals("#", result); - } - - @Test - @DisplayName("Should handle ## as literal") - void shouldHandleDoubleHashAsLiteral() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("##", method, args); - - assertEquals("##", result); - } - - @Test - @DisplayName("Should handle special characters in literal keys") - void shouldHandleSpecialCharacters() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = - SpELKeyResolver.resolve("key:with-special_chars.and#hash/slash", method, args); - - assertEquals("key:with-special_chars.and#hash/slash", result); - } - - @Test - @DisplayName("Should handle Unicode in literal keys") - void shouldHandleUnicode() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("用户#123#café", method, args); - - assertEquals("用户#123#café", result); - } - - @Test - @DisplayName("Should handle empty string as literal") - void shouldHandleEmptyString() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("", method, args); - - assertEquals("", result); - } - } - - @Nested - @DisplayName("Edge Cases and Error Handling") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle whitespace in SpEL expressions") - void shouldHandleWhitespaceInSpel() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user999"}; - - String result = SpELKeyResolver.resolve("#{ #userId }", method, args); - - assertEquals("user999", result); - } - - @Test - @DisplayName("Should throw exception for malformed SpEL") - void shouldThrowForMalformedSpel() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user123"}; - - assertThrows(Exception.class, () -> SpELKeyResolver.resolve("#{#userId +}", method, args)); - } - - @Test - @DisplayName("Should throw exception for undefined variable") - void shouldThrowForUndefinedVariable() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user123"}; - - assertThrows( - Exception.class, () -> SpELKeyResolver.resolve("#{#undefinedVariable}", method, args)); - } - - @Test - @DisplayName("Should handle empty SpEL expression #{}") - void shouldHandleEmptySpel() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"user123"}; - - assertThrows(Exception.class, () -> SpELKeyResolver.resolve("#{}", method, args)); - } - - @Test - @DisplayName("Should handle nested braces in SpEL") - void shouldHandleNestedBraces() throws Exception { - Method method = TestClass.class.getMethod("processConditional", int.class); - Object[] args = new Object[] {50}; - - // Complex nested expression - String result = SpELKeyResolver.resolve("#{T(java.lang.Math).max(#id, 100)}", method, args); - - assertEquals("100", result); - } - } - - @Nested - @DisplayName("Syntax Detection Tests") - class SyntaxDetectionTests { - - @Test - @DisplayName("Should detect #{} syntax and evaluate as SpEL") - void shouldDetectSpelSyntax() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"test"}; - - // These use #{} syntax and should be evaluated as SpEL - assertEquals("test", SpELKeyResolver.resolve("#{#userId}", method, args)); - assertEquals("test", SpELKeyResolver.resolve("#{ #userId }", method, args)); - } - - @Test - @DisplayName("Should NOT detect SpEL without #{} wrapper") - void shouldNotDetectSpelWithoutWrapper() throws Exception { - Method method = TestClass.class.getMethod("processUser", String.class); - Object[] args = new Object[] {"test"}; - - // Without #{} wrapper, these are treated as literals - assertEquals("#userId", SpELKeyResolver.resolve("#userId", method, args)); - assertEquals("#p0", SpELKeyResolver.resolve("#p0", method, args)); - assertEquals("'user-' + #id", SpELKeyResolver.resolve("'user-' + #id", method, args)); - } - - @Test - @DisplayName("Should NOT detect SpEL in malformed expressions with #{}") - void shouldNotDetectMalformedBrace() throws Exception { - Method method = TestClass.class.getMethod("processConditional", int.class); - Object[] args = new Object[] {42}; - - // This contains #{} but doesn't start/end with it, so it's treated as literal - String result = SpELKeyResolver.resolve("T(String).valueOf(#{#id})", method, args); - assertEquals("T(String).valueOf(#{#id})", result); - } - - @Test - @DisplayName("Should NOT detect SpEL in pure literal keys") - void shouldNotDetectSpelInLiterals() throws Exception { - Method method = TestClass.class.getMethod("processLiteralNoHash"); - Object[] args = new Object[] {}; - - // These should be treated as literals (no SpEL detection) - assertEquals("order#123", SpELKeyResolver.resolve("order#123", method, args)); - assertEquals("item-#1", SpELKeyResolver.resolve("item-#1", method, args)); - assertEquals("task#", SpELKeyResolver.resolve("task#", method, args)); - assertEquals("#", SpELKeyResolver.resolve("#", method, args)); - assertEquals("##", SpELKeyResolver.resolve("##", method, args)); - assertEquals("#userId", SpELKeyResolver.resolve("#userId", method, args)); - assertEquals("#user.name", SpELKeyResolver.resolve("#user.name", method, args)); - } - - @Test - @DisplayName("Should correctly identify SpEL expressions with isSpELExpression") - void shouldIdentifySpELExpressions() { - assertTrue(SpELKeyResolver.isSpELExpression("#{#userId}")); - assertTrue(SpELKeyResolver.isSpELExpression("#{ #userId }")); - assertTrue(SpELKeyResolver.isSpELExpression("#{T(String).valueOf(#id)}")); - - assertFalse(SpELKeyResolver.isSpELExpression("#userId")); - assertFalse(SpELKeyResolver.isSpELExpression("order#123")); - assertFalse(SpELKeyResolver.isSpELExpression("literal-key")); - assertFalse(SpELKeyResolver.isSpELExpression("")); - assertFalse(SpELKeyResolver.isSpELExpression("#")); - assertFalse(SpELKeyResolver.isSpELExpression("T(String).valueOf(#{#id})")); - } - } -} diff --git a/src/test/java/in/riido/locksmith/aspect/WikiSpELExamplesTest.java b/src/test/java/in/riido/locksmith/aspect/WikiSpELExamplesTest.java deleted file mode 100644 index 69d3463..0000000 --- a/src/test/java/in/riido/locksmith/aspect/WikiSpELExamplesTest.java +++ /dev/null @@ -1,793 +0,0 @@ -package in.riido.locksmith.aspect; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.support.SpELKeyResolver; -import java.lang.reflect.Method; -import java.math.BigDecimal; -import java.nio.file.Path; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -/** - * Test cases for all SpEL examples from the GitHub Wiki. - * - * @see Dynamic-Keys-with-SpEL - * Wiki - */ -@DisplayName("Wiki SpEL Examples Tests") -class WikiSpELExamplesTest { - - /** Test class with all wiki examples. */ - public static class WikiExamples { - - // Basic Parameter Reference - @DistributedLock(key = "#{#userId}") - public void updateUser(String userId) {} - - @DistributedLock(key = "#{#orderId}") - public void processOrder(Long orderId) {} - - // String Concatenation - @DistributedLock(key = "#{'user-' + #userId}") - public void updateUserWithPrefix(String userId) {} - - @DistributedLock(key = "#{'order-' + #orderId + '-process'}") - public void processOrderWithSuffix(Long orderId) {} - - // Object Properties - @DistributedLock(key = "#{#user.id}") - public void updateUserById(User user) {} - - @DistributedLock(key = "#{#order.customer.id}") - public void processOrderByCustomerId(Order order) {} - - @DistributedLock(key = "#{'user-' + #user.email}") - public void sendEmail(User user) {} - - // Multiple Parameters - @DistributedLock(key = "#{#tenantId + '-' + #userId}") - public void updateUserInTenant(String tenantId, String userId) {} - - @DistributedLock(key = "#{'transfer-' + #fromAccount + '-to-' + #toAccount}") - public void transfer(String fromAccount, String toAccount, BigDecimal amount) {} - - // Parameter by Position - @DistributedLock(key = "#{#p0}") - public void processByP0(String userId) {} - - @DistributedLock(key = "#{#a0 + '-' + #a1}") - public void processByPosition(String first, String second) {} - - // Method Calls - @DistributedLock(key = "#{#email.toLowerCase()}") - public void processEmail(String email) {} - - @DistributedLock(key = "#{#list.size()}") - public void processList(List list) {} - - @DistributedLock(key = "#{#date.toLocalDate().toString()}") - public void processForDate(LocalDateTime date) {} - - @DistributedLock(key = "#{#user.getId()}") - public void processUserGetId(User user) {} - - // Conditional Expressions - @DistributedLock(key = "#{#premium ? 'premium-' + #userId : 'standard-' + #userId}") - public void processUserPremium(String userId, boolean premium) {} - - @DistributedLock(key = "#{#amount > 1000 ? 'large' : 'small'}") - public void processPayment(double amount) {} - - // Null Safety - Elvis operator - @DistributedLock(key = "#{#user.nickname ?: #user.id}") - public void updateUserWithElvis(User user) {} - - @DistributedLock(key = "#{#order?.customer?.id ?: 'anonymous'}") - public void processOrderSafeNavigation(Order order) {} - - // Array and Collection Access - @DistributedLock(key = "#{#ids[0]}") - public void processFirst(List ids) {} - - @DistributedLock(key = "#{#args[0] + '-' + #args[1]}") - public void processVarargs(String... args) {} - - @DistributedLock(key = "#{#users[0].name}") - public void processFirstUser(List users) {} - - // Map Access - @DistributedLock(key = "#{#params['orderId']}") - public void processMap(Map params) {} - - @DistributedLock(key = "#{#context['tenant'] + '-' + #context['user']}") - public void processInContext(Map context) {} - - // Static Methods - @DistributedLock(key = "#{T(java.lang.String).valueOf(#orderId)}") - public void processWithStaticMethod(Long orderId) {} - - @DistributedLock(key = "#{T(java.lang.Math).max(#a, #b)}") - public void processMax(int a, int b) {} - - // Common Patterns - Per-User Locking - @DistributedLock(key = "#{'user-profile-' + #userId}") - public void updateProfile(String userId, Profile profile) {} - - @DistributedLock(key = "#{'user-settings-' + #user.id}") - public void updateSettings(User user, Settings settings) {} - - // Common Patterns - Per-Resource Locking - @DistributedLock(key = "#{'document-' + #documentId}") - public void editDocument(String documentId, String content) {} - - @DistributedLock(key = "#{'file-' + #path.hashCode()}") - public void writeFile(Path path, byte[] data) {} - - // Common Patterns - Per-Tenant Locking - @DistributedLock(key = "#{#tenantId + ':user:' + #userId}") - public void updateTenantUserColon(String tenantId, String userId) {} - - // Common Patterns - Composite Keys - @DistributedLock(key = "#{'inventory-' + #warehouseId + '-' + #productId}") - public void updateInventory(String warehouseId, String productId, int quantity) {} - - // Common Patterns - Date-Based Locking - @DistributedLock( - key = "#{'hourly-' + #instant.truncatedTo(T(java.time.temporal.ChronoUnit).HOURS)}") - public void hourlyTask(Instant instant) {} - - // Literal Keys with # Character - @DistributedLock(key = "order#123") - public void processOrderLiteral() {} - - @DistributedLock(key = "task#end") - public void endTask() {} - - @DistributedLock(key = "item-#1") - public void processItem() {} - - @DistributedLock(key = "prefix#middle#suffix") - public void processMultipleHashes() {} - - // Best Practices - Normalize Keys - @DistributedLock(key = "#{'email-' + #email.toLowerCase().trim()}") - public void processEmailNormalized(String email) {} - - // Best Practices - Use Prefixes - @DistributedLock(key = "#{'read:' + #docId}") - public void readDocument(String docId) {} - - @DistributedLock(key = "#{'write:' + #docId}") - public void writeDocument(String docId, Document doc) {} - - // Test domain objects - public record User(String id, String name, String email, String nickname) { - public User(String id, String name, String email) { - this(id, name, email, null); - } - - public String getId() { - return id; - } - } - - public record Customer(String id, String name) {} - - public record Order(String id, Customer customer) {} - - public record Profile(String bio, String avatar) {} - - public record Settings(String theme, String language) {} - - public record Document(String content) {} - } - - @Nested - @DisplayName("Basic Parameter Reference Examples") - class BasicParameterReferenceTests { - - @Test - @DisplayName("Should resolve #{#userId}") - void shouldResolveUserId() throws Exception { - Method method = WikiExamples.class.getMethod("updateUser", String.class); - Object[] args = new Object[] {"user123"}; - - String result = SpELKeyResolver.resolve("#{#userId}", method, args); - - assertEquals("user123", result); - } - - @Test - @DisplayName("Should resolve #{#orderId}") - void shouldResolveOrderId() throws Exception { - Method method = WikiExamples.class.getMethod("processOrder", Long.class); - Object[] args = new Object[] {12345L}; - - String result = SpELKeyResolver.resolve("#{#orderId}", method, args); - - assertEquals("12345", result); - } - } - - @Nested - @DisplayName("String Concatenation Examples") - class StringConcatenationTests { - - @Test - @DisplayName("Should resolve #{'user-' + #userId}") - void shouldConcatenateUserPrefix() throws Exception { - Method method = WikiExamples.class.getMethod("updateUserWithPrefix", String.class); - Object[] args = new Object[] {"user123"}; - - String result = SpELKeyResolver.resolve("#{'user-' + #userId}", method, args); - - assertEquals("user-user123", result); - } - - @Test - @DisplayName("Should resolve #{'order-' + #orderId + '-process'}") - void shouldConcatenateOrderWithSuffix() throws Exception { - Method method = WikiExamples.class.getMethod("processOrderWithSuffix", Long.class); - Object[] args = new Object[] {12345L}; - - String result = SpELKeyResolver.resolve("#{'order-' + #orderId + '-process'}", method, args); - - assertEquals("order-12345-process", result); - } - } - - @Nested - @DisplayName("Object Properties Examples") - class ObjectPropertiesTests { - - @Test - @DisplayName("Should resolve #{#user.id}") - void shouldResolveUserId() throws Exception { - Method method = WikiExamples.class.getMethod("updateUserById", WikiExamples.User.class); - Object[] args = new Object[] {new WikiExamples.User("123", "John", "john@example.com")}; - - String result = SpELKeyResolver.resolve("#{#user.id}", method, args); - - assertEquals("123", result); - } - - @Test - @DisplayName("Should resolve #{#order.customer.id}") - void shouldResolveNestedProperty() throws Exception { - Method method = - WikiExamples.class.getMethod("processOrderByCustomerId", WikiExamples.Order.class); - WikiExamples.Order order = - new WikiExamples.Order("ord-1", new WikiExamples.Customer("456", "Jane")); - Object[] args = new Object[] {order}; - - String result = SpELKeyResolver.resolve("#{#order.customer.id}", method, args); - - assertEquals("456", result); - } - - @Test - @DisplayName("Should resolve #{'user-' + #user.email}") - void shouldConcatenateUserEmail() throws Exception { - Method method = WikiExamples.class.getMethod("sendEmail", WikiExamples.User.class); - Object[] args = new Object[] {new WikiExamples.User("123", "John", "john@example.com")}; - - String result = SpELKeyResolver.resolve("#{'user-' + #user.email}", method, args); - - assertEquals("user-john@example.com", result); - } - } - - @Nested - @DisplayName("Multiple Parameters Examples") - class MultipleParametersTests { - - @Test - @DisplayName("Should resolve #{#tenantId + '-' + #userId}") - void shouldCombineMultipleParams() throws Exception { - Method method = - WikiExamples.class.getMethod("updateUserInTenant", String.class, String.class); - Object[] args = new Object[] {"tenant1", "user123"}; - - String result = SpELKeyResolver.resolve("#{#tenantId + '-' + #userId}", method, args); - - assertEquals("tenant1-user123", result); - } - - @Test - @DisplayName("Should resolve #{'transfer-' + #fromAccount + '-to-' + #toAccount}") - void shouldCombineTransferAccounts() throws Exception { - Method method = - WikiExamples.class.getMethod("transfer", String.class, String.class, BigDecimal.class); - Object[] args = new Object[] {"ACC001", "ACC002", new BigDecimal("100.00")}; - - String result = - SpELKeyResolver.resolve( - "#{'transfer-' + #fromAccount + '-to-' + #toAccount}", method, args); - - assertEquals("transfer-ACC001-to-ACC002", result); - } - } - - @Nested - @DisplayName("Parameter by Position Examples") - class ParameterByPositionTests { - - @Test - @DisplayName("Should resolve #{#p0}") - void shouldResolveByP0() throws Exception { - Method method = WikiExamples.class.getMethod("processByP0", String.class); - Object[] args = new Object[] {"user123"}; - - String result = SpELKeyResolver.resolve("#{#p0}", method, args); - - assertEquals("user123", result); - } - - @Test - @DisplayName("Should resolve #{#a0 + '-' + #a1}") - void shouldResolveByA0A1() throws Exception { - Method method = WikiExamples.class.getMethod("processByPosition", String.class, String.class); - Object[] args = new Object[] {"first", "second"}; - - String result = SpELKeyResolver.resolve("#{#a0 + '-' + #a1}", method, args); - - assertEquals("first-second", result); - } - } - - @Nested - @DisplayName("Method Calls Examples") - class MethodCallsTests { - - @Test - @DisplayName("Should resolve #{#email.toLowerCase()}") - void shouldCallToLowerCase() throws Exception { - Method method = WikiExamples.class.getMethod("processEmail", String.class); - Object[] args = new Object[] {"JOHN@EXAMPLE.COM"}; - - String result = SpELKeyResolver.resolve("#{#email.toLowerCase()}", method, args); - - assertEquals("john@example.com", result); - } - - @Test - @DisplayName("Should resolve #{#list.size()}") - void shouldCallSize() throws Exception { - Method method = WikiExamples.class.getMethod("processList", List.class); - Object[] args = new Object[] {Arrays.asList("a", "b", "c", "d", "e")}; - - String result = SpELKeyResolver.resolve("#{#list.size()}", method, args); - - assertEquals("5", result); - } - - @Test - @DisplayName("Should resolve #{#date.toLocalDate().toString()}") - void shouldCallToLocalDate() throws Exception { - Method method = WikiExamples.class.getMethod("processForDate", LocalDateTime.class); - LocalDateTime date = LocalDateTime.of(2024, 1, 15, 14, 30); - Object[] args = new Object[] {date}; - - String result = SpELKeyResolver.resolve("#{#date.toLocalDate().toString()}", method, args); - - assertEquals("2024-01-15", result); - } - - @Test - @DisplayName("Should resolve #{#user.getId()}") - void shouldCallGetId() throws Exception { - Method method = WikiExamples.class.getMethod("processUserGetId", WikiExamples.User.class); - Object[] args = new Object[] {new WikiExamples.User("user-456", "Alice", "alice@test.com")}; - - String result = SpELKeyResolver.resolve("#{#user.getId()}", method, args); - - assertEquals("user-456", result); - } - } - - @Nested - @DisplayName("Conditional Expressions Examples") - class ConditionalExpressionsTests { - - @Test - @DisplayName("Should resolve conditional with premium=true") - void shouldResolveConditionalPremiumTrue() throws Exception { - Method method = - WikiExamples.class.getMethod("processUserPremium", String.class, boolean.class); - Object[] args = new Object[] {"user123", true}; - - String result = - SpELKeyResolver.resolve( - "#{#premium ? 'premium-' + #userId : 'standard-' + #userId}", method, args); - - assertEquals("premium-user123", result); - } - - @Test - @DisplayName("Should resolve conditional with premium=false") - void shouldResolveConditionalPremiumFalse() throws Exception { - Method method = - WikiExamples.class.getMethod("processUserPremium", String.class, boolean.class); - Object[] args = new Object[] {"user123", false}; - - String result = - SpELKeyResolver.resolve( - "#{#premium ? 'premium-' + #userId : 'standard-' + #userId}", method, args); - - assertEquals("standard-user123", result); - } - - @Test - @DisplayName("Should resolve #{#amount > 1000 ? 'large' : 'small'} with large amount") - void shouldResolveAmountLarge() throws Exception { - Method method = WikiExamples.class.getMethod("processPayment", double.class); - Object[] args = new Object[] {1500.0}; - - String result = - SpELKeyResolver.resolve("#{#amount > 1000 ? 'large' : 'small'}", method, args); - - assertEquals("large", result); - } - - @Test - @DisplayName("Should resolve #{#amount > 1000 ? 'large' : 'small'} with small amount") - void shouldResolveAmountSmall() throws Exception { - Method method = WikiExamples.class.getMethod("processPayment", double.class); - Object[] args = new Object[] {500.0}; - - String result = - SpELKeyResolver.resolve("#{#amount > 1000 ? 'large' : 'small'}", method, args); - - assertEquals("small", result); - } - } - - @Nested - @DisplayName("Null Safety Examples") - class NullSafetyTests { - - @Test - @DisplayName("Should resolve #{#user.nickname ?: #user.id} with nickname") - void shouldUseNickname() throws Exception { - Method method = WikiExamples.class.getMethod("updateUserWithElvis", WikiExamples.User.class); - Object[] args = - new Object[] {new WikiExamples.User("123", "John", "john@test.com", "Johnny")}; - - String result = SpELKeyResolver.resolve("#{#user.nickname ?: #user.id}", method, args); - - assertEquals("Johnny", result); - } - - @Test - @DisplayName("Should resolve #{#user.nickname ?: #user.id} without nickname") - void shouldFallbackToId() throws Exception { - Method method = WikiExamples.class.getMethod("updateUserWithElvis", WikiExamples.User.class); - Object[] args = new Object[] {new WikiExamples.User("123", "John", "john@test.com", null)}; - - String result = SpELKeyResolver.resolve("#{#user.nickname ?: #user.id}", method, args); - - assertEquals("123", result); - } - - @Test - @DisplayName("Should resolve #{#order?.customer?.id ?: 'anonymous'} with order") - void shouldResolveSafeNavigationWithOrder() throws Exception { - Method method = - WikiExamples.class.getMethod("processOrderSafeNavigation", WikiExamples.Order.class); - WikiExamples.Order order = - new WikiExamples.Order("ord-1", new WikiExamples.Customer("cust-456", "Customer")); - Object[] args = new Object[] {order}; - - String result = - SpELKeyResolver.resolve("#{#order?.customer?.id ?: 'anonymous'}", method, args); - - assertEquals("cust-456", result); - } - - @Test - @DisplayName("Should resolve #{#order?.customer?.id ?: 'anonymous'} with null order") - void shouldResolveSafeNavigationWithNullOrder() throws Exception { - Method method = - WikiExamples.class.getMethod("processOrderSafeNavigation", WikiExamples.Order.class); - Object[] args = new Object[] {null}; - - String result = - SpELKeyResolver.resolve("#{#order?.customer?.id ?: 'anonymous'}", method, args); - - assertEquals("anonymous", result); - } - } - - @Nested - @DisplayName("Array and Collection Access Examples") - class ArrayCollectionAccessTests { - - @Test - @DisplayName("Should resolve #{#ids[0]}") - void shouldAccessFirstElement() throws Exception { - Method method = WikiExamples.class.getMethod("processFirst", List.class); - Object[] args = new Object[] {Arrays.asList("first-id", "second-id")}; - - String result = SpELKeyResolver.resolve("#{#ids[0]}", method, args); - - assertEquals("first-id", result); - } - - @Test - @DisplayName("Should resolve #{#args[0] + '-' + #args[1]}") - void shouldAccessVarargsElements() throws Exception { - Method method = WikiExamples.class.getMethod("processVarargs", String[].class); - Object[] args = new Object[] {new String[] {"arg1", "arg2", "arg3"}}; - - String result = SpELKeyResolver.resolve("#{#args[0] + '-' + #args[1]}", method, args); - - assertEquals("arg1-arg2", result); - } - - @Test - @DisplayName("Should resolve #{#users[0].name}") - void shouldAccessObjectInCollection() throws Exception { - Method method = WikiExamples.class.getMethod("processFirstUser", List.class); - List users = List.of(new WikiExamples.User("1", "John", "john@test.com")); - Object[] args = new Object[] {users}; - - String result = SpELKeyResolver.resolve("#{#users[0].name}", method, args); - - assertEquals("John", result); - } - } - - @Nested - @DisplayName("Map Access Examples") - class MapAccessTests { - - @Test - @DisplayName("Should resolve #{#params['orderId']}") - void shouldAccessMapValue() throws Exception { - Method method = WikiExamples.class.getMethod("processMap", Map.class); - Object[] args = new Object[] {Map.of("orderId", "order123")}; - - String result = SpELKeyResolver.resolve("#{#params['orderId']}", method, args); - - assertEquals("order123", result); - } - - @Test - @DisplayName("Should resolve #{#context['tenant'] + '-' + #context['user']}") - void shouldCombineMapValues() throws Exception { - Method method = WikiExamples.class.getMethod("processInContext", Map.class); - Object[] args = new Object[] {Map.of("tenant", "tenant1", "user", "user123")}; - - String result = - SpELKeyResolver.resolve("#{#context['tenant'] + '-' + #context['user']}", method, args); - - assertEquals("tenant1-user123", result); - } - } - - @Nested - @DisplayName("Static Methods Examples") - class StaticMethodsTests { - - @Test - @DisplayName("Should resolve #{T(java.lang.String).valueOf(#orderId)}") - void shouldCallStaticValueOf() throws Exception { - Method method = WikiExamples.class.getMethod("processWithStaticMethod", Long.class); - Object[] args = new Object[] {12345L}; - - String result = - SpELKeyResolver.resolve("#{T(java.lang.String).valueOf(#orderId)}", method, args); - - assertEquals("12345", result); - } - - @Test - @DisplayName("Should resolve #{T(java.lang.Math).max(#a, #b)}") - void shouldCallStaticMax() throws Exception { - Method method = WikiExamples.class.getMethod("processMax", int.class, int.class); - Object[] args = new Object[] {42, 100}; - - String result = SpELKeyResolver.resolve("#{T(java.lang.Math).max(#a, #b)}", method, args); - - assertEquals("100", result); - } - } - - @Nested - @DisplayName("Common Patterns Examples") - class CommonPatternsTests { - - @Test - @DisplayName("Should resolve #{'user-profile-' + #userId}") - void shouldResolveUserProfile() throws Exception { - Method method = - WikiExamples.class.getMethod("updateProfile", String.class, WikiExamples.Profile.class); - Object[] args = new Object[] {"user123", new WikiExamples.Profile("Bio", "avatar.png")}; - - String result = SpELKeyResolver.resolve("#{'user-profile-' + #userId}", method, args); - - assertEquals("user-profile-user123", result); - } - - @Test - @DisplayName("Should resolve #{'user-settings-' + #user.id}") - void shouldResolveUserSettings() throws Exception { - Method method = - WikiExamples.class.getMethod( - "updateSettings", WikiExamples.User.class, WikiExamples.Settings.class); - Object[] args = - new Object[] { - new WikiExamples.User("456", "Alice", "alice@test.com"), - new WikiExamples.Settings("dark", "en") - }; - - String result = SpELKeyResolver.resolve("#{'user-settings-' + #user.id}", method, args); - - assertEquals("user-settings-456", result); - } - - @Test - @DisplayName("Should resolve #{'document-' + #documentId}") - void shouldResolveDocument() throws Exception { - Method method = WikiExamples.class.getMethod("editDocument", String.class, String.class); - Object[] args = new Object[] {"doc-789", "content"}; - - String result = SpELKeyResolver.resolve("#{'document-' + #documentId}", method, args); - - assertEquals("document-doc-789", result); - } - - @Test - @DisplayName("Should resolve #{'file-' + #path.hashCode()}") - void shouldResolveFileHash() throws Exception { - Method method = WikiExamples.class.getMethod("writeFile", Path.class, byte[].class); - Path path = Path.of("/tmp/test.txt"); - Object[] args = new Object[] {path, new byte[] {1, 2, 3}}; - - String result = SpELKeyResolver.resolve("#{'file-' + #path.hashCode()}", method, args); - - assertEquals("file-" + path.hashCode(), result); - } - - @Test - @DisplayName("Should resolve #{#tenantId + ':user:' + #userId}") - void shouldResolveTenantUser() throws Exception { - Method method = - WikiExamples.class.getMethod("updateTenantUserColon", String.class, String.class); - Object[] args = new Object[] {"tenant1", "user123"}; - - String result = SpELKeyResolver.resolve("#{#tenantId + ':user:' + #userId}", method, args); - - assertEquals("tenant1:user:user123", result); - } - - @Test - @DisplayName("Should resolve #{'inventory-' + #warehouseId + '-' + #productId}") - void shouldResolveInventory() throws Exception { - Method method = - WikiExamples.class.getMethod("updateInventory", String.class, String.class, int.class); - Object[] args = new Object[] {"WH001", "PROD123", 50}; - - String result = - SpELKeyResolver.resolve( - "#{'inventory-' + #warehouseId + '-' + #productId}", method, args); - - assertEquals("inventory-WH001-PROD123", result); - } - - @Test - @DisplayName("Should resolve hourly task with truncated instant") - void shouldResolveHourlyTask() throws Exception { - Method method = WikiExamples.class.getMethod("hourlyTask", Instant.class); - Instant instant = Instant.parse("2024-01-15T14:30:45Z"); - Object[] args = new Object[] {instant}; - - String result = - SpELKeyResolver.resolve( - "#{'hourly-' + #instant.truncatedTo(T(java.time.temporal.ChronoUnit).HOURS)}", - method, - args); - - assertEquals("hourly-" + instant.truncatedTo(ChronoUnit.HOURS), result); - } - } - - @Nested - @DisplayName("Literal Keys with # Character Examples") - class LiteralKeysTests { - - @Test - @DisplayName("Should treat 'order#123' as literal") - void shouldTreatOrderHashAsLiteral() throws Exception { - Method method = WikiExamples.class.getMethod("processOrderLiteral"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("order#123", method, args); - - assertEquals("order#123", result); - } - - @Test - @DisplayName("Should treat 'task#end' as literal") - void shouldTreatTaskHashAsLiteral() throws Exception { - Method method = WikiExamples.class.getMethod("endTask"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("task#end", method, args); - - assertEquals("task#end", result); - } - - @Test - @DisplayName("Should treat 'item-#1' as literal") - void shouldTreatItemHashAsLiteral() throws Exception { - Method method = WikiExamples.class.getMethod("processItem"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("item-#1", method, args); - - assertEquals("item-#1", result); - } - - @Test - @DisplayName("Should treat 'prefix#middle#suffix' as literal") - void shouldTreatMultipleHashesAsLiteral() throws Exception { - Method method = WikiExamples.class.getMethod("processMultipleHashes"); - Object[] args = new Object[] {}; - - String result = SpELKeyResolver.resolve("prefix#middle#suffix", method, args); - - assertEquals("prefix#middle#suffix", result); - } - } - - @Nested - @DisplayName("Best Practices Examples") - class BestPracticesTests { - - @Test - @DisplayName("Should normalize email with toLowerCase and trim") - void shouldNormalizeEmail() throws Exception { - Method method = WikiExamples.class.getMethod("processEmailNormalized", String.class); - Object[] args = new Object[] {" JOHN@EXAMPLE.COM "}; - - String result = - SpELKeyResolver.resolve("#{'email-' + #email.toLowerCase().trim()}", method, args); - - assertEquals("email-john@example.com", result); - } - - @Test - @DisplayName("Should use prefix 'read:' for read operations") - void shouldUseReadPrefix() throws Exception { - Method method = WikiExamples.class.getMethod("readDocument", String.class); - Object[] args = new Object[] {"doc-123"}; - - String result = SpELKeyResolver.resolve("#{'read:' + #docId}", method, args); - - assertEquals("read:doc-123", result); - } - - @Test - @DisplayName("Should use prefix 'write:' for write operations") - void shouldUseWritePrefix() throws Exception { - Method method = - WikiExamples.class.getMethod("writeDocument", String.class, WikiExamples.Document.class); - Object[] args = new Object[] {"doc-123", new WikiExamples.Document("content")}; - - String result = SpELKeyResolver.resolve("#{'write:' + #docId}", method, args); - - assertEquals("write:doc-123", result); - } - } -} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/AdviceOrderTest.java b/src/test/java/in/riido/locksmith/autoconfigure/AdviceOrderTest.java new file mode 100644 index 0000000..6d5f31e --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/AdviceOrderTest.java @@ -0,0 +1,164 @@ +package in.riido.locksmith.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import in.riido.locksmith.DistributedLock; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.aopalliance.intercept.MethodInterceptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.misc.CompletableFutureWrapper; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * Where Locksmith's advice runs among other advice: inside method authorization, which Spring + * Security orders from 100 to 600, and outside {@code @Transactional} at its default order. An + * advisor at Spring Security's {@code @PreAuthorize} order stands in for the authorization check. + */ +@DisplayName("Locksmith advice order") +class AdviceOrderTest { + + /** The order of Spring Security 7's {@code @PreAuthorize} interceptor. */ + private static final int PRE_AUTHORIZE_ORDER = 200; + + private static final List calls = new CopyOnWriteArrayList<>(); + private static volatile boolean deny; + + private final RedissonClient redisson = mock(RedissonClient.class); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AopAutoConfiguration.class, LocksmithAutoConfiguration.class)) + .withUserConfiguration(UserConfiguration.class) + .withBean(RedissonClient.class, () -> redisson); + + @BeforeEach + void setUp() { + calls.clear(); + deny = false; + RLock lock = mock(RLock.class); + when(redisson.getLock(anyString())).thenReturn(lock); + when(lock.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenAnswer( + invocation -> { + calls.add("lock"); + return new CompletableFutureWrapper<>(true); + }); + when(lock.unlockAsync(anyLong())) + .thenAnswer( + invocation -> { + calls.add("unlock"); + return new CompletableFutureWrapper<>((Void) null); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement + static class UserConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor authorization() { + MethodInterceptor check = + invocation -> { + calls.add("authorize"); + if (deny) { + throw new IllegalStateException("denied"); + } + return invocation.proceed(); + }; + DefaultPointcutAdvisor advisor = + new DefaultPointcutAdvisor( + new AnnotationMatchingPointcut(null, DistributedLock.class, true), check); + advisor.setOrder(PRE_AUTHORIZE_ORDER); + return advisor; + } + + @Bean + PlatformTransactionManager transactionManager() { + PlatformTransactionManager manager = mock(PlatformTransactionManager.class); + when(manager.getTransaction(any())) + .thenAnswer( + invocation -> { + calls.add("begin"); + return mock(TransactionStatus.class); + }); + doAnswer( + invocation -> { + calls.add("commit"); + return null; + }) + .when(manager) + .commit(any()); + return manager; + } + + @Bean + OrderService orderService() { + return new OrderService(); + } + } + + static class OrderService { + @Transactional + @DistributedLock(key = "order:#{#id}") + public String process(String id) { + calls.add("method"); + return "processed " + id; + } + } + + @Test + @DisplayName("authorization, then the lock, then the transaction, which commits before unlock") + void order() { + runner.run( + context -> { + assertThat(context.getBean(OrderService.class).process("42")).isEqualTo("processed 42"); + + assertThat(calls) + .containsExactly("authorize", "lock", "begin", "method", "commit", "unlock"); + }); + } + + @Test + @DisplayName("a call that authorization denies never reaches Redis") + void deniedCallNeverReachesRedis() { + deny = true; + runner.run( + context -> { + assertThatThrownBy(() -> context.getBean(OrderService.class).process("42")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("denied"); + + assertThat(calls).containsExactly("authorize"); + verify(redisson, never()).getLock(anyString()); + }); + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/AnnotationPathIntegrationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/AnnotationPathIntegrationTest.java new file mode 100644 index 0000000..221d2e4 --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/AnnotationPathIntegrationTest.java @@ -0,0 +1,306 @@ +package in.riido.locksmith.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DockerAvailableCondition; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.lock.LockFailureContext; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.lock.LockNotAcquiredException; +import java.lang.reflect.Proxy; +import java.util.Optional; +import org.aopalliance.intercept.MethodInterceptor; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.support.AopUtils; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.testcontainers.utility.DockerImageName; + +/** {@code @DistributedLock} end to end: a Boot context without AspectJ against a real Redis. */ +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("@DistributedLock end to end") +class AnnotationPathIntegrationTest { + + private static final String FULL_KEY = "locksmith:lock:order:42"; + private static final String MARKER = "handled"; + + private static RedisContainer redis; + private static RedissonClient otherInstance; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AopAutoConfiguration.class, LocksmithAutoConfiguration.class)) + .withUserConfiguration(UserConfiguration.class); + + private RLock heldElsewhere; + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + otherInstance = newClient(); + } + + @AfterAll + static void stopRedis() { + otherInstance.shutdown(); + redis.stop(); + } + + @BeforeEach + void lockOfOtherInstance() { + heldElsewhere = otherInstance.getLock(FULL_KEY); + } + + @AfterEach + void releaseElsewhere() { + if (heldElsewhere.isHeldByCurrentThread()) { + heldElsewhere.unlock(); + } + } + + private static RedissonClient newClient() { + Config config = new Config(); + config + .useSingleServer() + .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort()); + return Redisson.create(config); + } + + @Configuration(proxyBeanMethods = false) + static class UserConfiguration { + + @Bean(destroyMethod = "shutdown") + RedissonClient redissonClient() { + return newClient(); + } + + @Bean + MarkerHandler markerHandler() { + return new MarkerHandler(); + } + + @Bean + OrderService orderService() { + return new OrderService(); + } + } + + /** A bean that is itself a JDK dynamic proxy, as a Feign or HTTP interface client is. */ + private static T jdkProxy(Class type) { + CheckLock implementation = new CheckLockImpl(); + return type.cast( + Proxy.newProxyInstance( + type.getClassLoader(), + new Class[] {type}, + (proxy, method, args) -> + method.getDeclaringClass() == Object.class + ? method.invoke(implementation, args) + : implementation.lockedDuringCall((String) args[0]))); + } + + /** + * A Spring proxy shaped like a Spring Data repository: its target class does not implement the + * annotated interface, and an advice answers the call, as a repository's own interceptors do. + */ + private static T springDataShaped(Class repositoryInterface) { + ProxyFactory factory = new ProxyFactory(new CheckLockImpl()); + factory.setInterfaces(repositoryInterface); + factory.addAdvice( + (MethodInterceptor) + invocation -> + invocation.getMethod().getName().equals("lockedDuringCall") + ? ((CheckLock) invocation.getThis()) + .lockedDuringCall((String) invocation.getArguments()[0]) + : invocation.proceed()); + return repositoryInterface.cast(factory.getProxy()); + } + + interface OrderClient { + /** Returns whether the order's lock is held while the call runs. */ + @DistributedLock(key = "order:#{#id}") + boolean lockedDuringCall(String id); + } + + /** Plays the part of CrudRepository. */ + interface CheckLock { + boolean lockedDuringCall(String id); + } + + /** Plays the part of a repository interface that redeclares an inherited method. */ + interface OrderRepository extends CheckLock { + @Override + @DistributedLock(key = "order:#{#id}") + boolean lockedDuringCall(String id); + } + + /** The annotated declaration, in an interface unrelated to CheckLock. */ + interface LockedCheck { + @DistributedLock(key = "order:#{#id}") + boolean lockedDuringCall(String id); + } + + /** Inherits the method from two unrelated interfaces, the annotated one second. */ + interface DiamondClient extends CheckLock, LockedCheck {} + + /** The same, as a repository interface. */ + interface DiamondRepository extends CheckLock, LockedCheck {} + + /** Plays the part of SimpleJpaRepository: it implements CheckLock, not OrderRepository. */ + static class CheckLockImpl implements CheckLock { + @Override + public boolean lockedDuringCall(String id) { + return otherInstance.getLock(FULL_KEY).isLocked(); + } + } + + static class MarkerHandler implements LockFailureHandler { + @Override + public Object onFailure(LockFailureContext context) { + return MARKER; + } + } + + static class OrderService { + @DistributedLock(key = "order:#{#id}") + public String process(String id) { + return "processed " + id; + } + + @DistributedLock(key = "order:#{#id}", onFailure = OnFailure.SKIP) + public Optional processOrSkip(String id) { + return Optional.of("processed " + id); + } + + @DistributedLock( + key = "order:#{#id}", + onFailure = OnFailure.HANDLER, + handler = MarkerHandler.class) + public Object processOrHandle(String id) { + return "processed " + id; + } + } + + @Test + @DisplayName("the service bean is proxied although AspectJ is absent") + void proxied() { + runner.run( + context -> assertThat(AopUtils.isAopProxy(context.getBean(OrderService.class))).isTrue()); + } + + @Test + @DisplayName("a free lock: the method runs and releases, so a second call runs too") + void runsAndReleases() { + runner.run( + context -> { + OrderService service = context.getBean(OrderService.class); + + assertThat(service.process("42")).isEqualTo("processed 42"); + assertThat(service.process("42")).isEqualTo("processed 42"); + assertThat(heldElsewhere.isLocked()).isFalse(); + }); + } + + @Test + @DisplayName("a lock held by another instance: THROW, SKIP and HANDLER apply their policy") + void heldElsewhere() { + runner.run( + context -> { + OrderService service = context.getBean(OrderService.class); + heldElsewhere.lock(); + + assertThatThrownBy(() -> service.process("42")) + .isInstanceOf(LockNotAcquiredException.class) + .hasMessageContaining(FULL_KEY); + assertThat(service.processOrSkip("42")).isEmpty(); + assertThat(service.processOrHandle("42")).isEqualTo(MARKER); + + heldElsewhere.unlock(); + assertThat(service.process("42")).isEqualTo("processed 42"); + }); + } + + @Test + @DisplayName( + "a bean that is itself a JDK proxy: locked during the call, with the key from its interface") + void jdkProxyBean() { + runner + .withBean(OrderClient.class, () -> jdkProxy(OrderClient.class)) + .run( + context -> { + OrderClient client = context.getBean(OrderClient.class); + + assertThat(client.lockedDuringCall("42")).isTrue(); + assertThat(heldElsewhere.isLocked()).isFalse(); + + heldElsewhere.lock(); + assertThatThrownBy(() -> client.lockedDuringCall("42")) + .isInstanceOf(LockNotAcquiredException.class) + .hasMessageContaining(FULL_KEY); + }); + } + + @Test + @DisplayName( + "a Spring proxy shaped like a Spring Data repository: locked during the call of a method" + + " its target class also declares") + void springDataShapedBean() { + runner + .withBean(OrderRepository.class, () -> springDataShaped(OrderRepository.class)) + .run( + context -> { + OrderRepository repository = context.getBean(OrderRepository.class); + + assertThat(repository.lockedDuringCall("42")).isTrue(); + assertThat(heldElsewhere.isLocked()).isFalse(); + + heldElsewhere.lock(); + assertThatThrownBy(() -> repository.lockedDuringCall("42")) + .isInstanceOf(LockNotAcquiredException.class) + .hasMessageContaining(FULL_KEY); + }); + } + + @Test + @DisplayName( + "a JDK proxy over an interface that inherits the method from two unrelated interfaces, the" + + " annotated one second: locked during the call") + void diamondJdkProxyBean() { + runner + .withBean(DiamondClient.class, () -> jdkProxy(DiamondClient.class)) + .run( + context -> + assertThat(context.getBean(DiamondClient.class).lockedDuringCall("42")).isTrue()); + } + + @Test + @DisplayName( + "the same diamond on a Spring Data shaped proxy, whose target implements the unannotated" + + " interface: locked during the call") + void diamondSpringDataShapedBean() { + runner + .withBean(DiamondRepository.class, () -> springDataShaped(DiamondRepository.class)) + .run( + context -> + assertThat(context.getBean(DiamondRepository.class).lockedDuringCall("42")) + .isTrue()); + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/AnnotationValidationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/AnnotationValidationTest.java new file mode 100644 index 0000000..ec59e8d --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/AnnotationValidationTest.java @@ -0,0 +1,730 @@ +package in.riido.locksmith.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.LockType; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.OnFailure; +import in.riido.locksmith.autoconfigure.otherpackage.InheritedMethods; +import in.riido.locksmith.autoconfigure.otherpackage.SamePackageChild; +import in.riido.locksmith.lock.LockFailureContext; +import in.riido.locksmith.lock.LockFailureHandler; +import in.riido.locksmith.semaphore.SemaphoreFailureContext; +import in.riido.locksmith.semaphore.SemaphoreFailureHandler; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Proxy; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.scheduling.annotation.Async; +import reactor.core.publisher.Mono; + +/** Startup validation: a misconfigured annotation fails the context refresh. */ +@DisplayName("startup validation") +class AnnotationValidationTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AopAutoConfiguration.class, LocksmithAutoConfiguration.class)) + .withBean(RedissonClient.class, () -> mock(RedissonClient.class)); + + static class MarkerHandler implements LockFailureHandler { + @Override + public Object onFailure(LockFailureContext context) { + return "marker"; + } + } + + static class BlankKey { + @DistributedLock(key = " ") + public void run() {} + } + + static class UnparsableKey { + @DistributedLock(key = "a:#{#id +}") + public void run(String id) {} + } + + static class UnknownVariable { + @DistributedLock(key = "a:#{#nope}") + public void run(String id) {} + } + + static class BadWaitTime { + @DistributedLock(key = "k", waitTime = "soon") + public void run() {} + } + + static class NegativeWaitTime { + @DistributedLock(key = "k", waitTime = "-5s") + public void run() {} + } + + static class BadLeaseTime { + @DistributedLock(key = "k", leaseTime = "later") + public void run() {} + } + + static class NegativeLeaseTime { + @DistributedLock(key = "k", leaseTime = "-1s") + public void run() {} + } + + static class ZeroLeaseTime { + @DistributedLock(key = "k", leaseTime = "0s") + public void run() {} + } + + static class HandlerNotSet { + @DistributedLock(key = "k", onFailure = OnFailure.HANDLER) + public void run() {} + } + + static class HandlerWithThrow { + @DistributedLock(key = "k", handler = MarkerHandler.class) + public void run() {} + } + + static class HandlerPolicy { + @DistributedLock(key = "k", onFailure = OnFailure.HANDLER, handler = MarkerHandler.class) + public void run() {} + } + + static class MarkerSemaphoreHandler implements SemaphoreFailureHandler { + @Override + public Object onFailure(SemaphoreFailureContext context) { + return "marker"; + } + } + + static class PermitsZero { + @DistributedSemaphore(key = "k", permits = "0") + public void run() {} + } + + static class PermitsNotNumber { + @DistributedSemaphore(key = "k", permits = "many") + public void run() {} + } + + static class PermitsUnresolvable { + @DistributedSemaphore(key = "k", permits = "${reports.missing}") + public void run() {} + } + + static class PermitsPlaceholder { + @DistributedSemaphore(key = "k", permits = "${reports.max}") + public void run() {} + } + + static class SemaphoreHandlerPolicy { + @DistributedSemaphore( + key = "k", + permits = "2", + onFailure = OnFailure.HANDLER, + handler = MarkerSemaphoreHandler.class) + public void run() {} + } + + static class PlainFuture { + @DistributedLock(key = "k") + public Future run() { + return null; + } + } + + static class PlainCompletableFuture { + @DistributedLock(key = "k") + public CompletableFuture run() { + return null; + } + } + + static class FinalMethod { + @DistributedLock(key = "k") + public final void run() {} + } + + @SuppressWarnings("unused") + static class PrivateMethod { + @DistributedSemaphore(key = "k", permits = "1") + private void run() {} + } + + static class PackagePrivateLockFromOtherPackage extends InheritedMethods.PackagePrivateLock {} + + static class PackagePrivateSemaphoreFromOtherPackage + extends InheritedMethods.PackagePrivateSemaphore {} + + static class ProtectedLockFromOtherPackage extends InheritedMethods.ProtectedLock {} + + @Async + static class AsyncClassFuture { + @DistributedLock(key = "k") + public Future run() { + return null; + } + } + + static class ReactiveReturn { + @DistributedLock(key = "k") + public Mono run() { + return null; + } + } + + static class ReentrantOrder { + @DistributedLock(key = "order:#{#id}") + public void run(String id) {} + } + + static class ReadOrder { + @DistributedLock(key = "order:#{#id}", type = LockType.READ) + public void run(String id) {} + } + + static class WriteOrder { + @DistributedLock(key = "order:#{#id}", type = LockType.WRITE) + public void run(String id) {} + } + + static class TwoReports { + @DistributedSemaphore(key = "reports", permits = "2") + public void run() {} + } + + static class FiveReports { + @DistributedSemaphore(key = "reports", permits = "5") + public void run() {} + } + + static class OtherTwoReports { + @DistributedSemaphore(key = "reports", permits = "2") + public void run() {} + } + + static class PlaceholderReports { + @DistributedSemaphore(key = "reports", permits = "${reports.max}") + public void run() {} + } + + /** The quotes are part of this key; {@link IslandReports} resolves to plain reports. */ + static class QuotedReports { + @DistributedSemaphore(key = "'reports'", permits = "1") + public void run() {} + } + + static class IslandReports { + @DistributedSemaphore(key = "#{'reports'}", permits = "2") + public void run() {} + } + + static class QuotedReportsLock { + @DistributedLock(key = "'reports'") + public void run() {} + } + + static class IslandReportsLock { + @DistributedLock(key = "#{'reports'}") + public void run() {} + } + + static class IslandReportsWrite { + @DistributedLock(key = "#{'reports'}", type = LockType.WRITE) + public void run() {} + } + + interface BlankKeyApi { + @DistributedLock(key = "") + void run(); + } + + static class InheritsBlankKey implements BlankKeyApi { + @Override + public void run() {} + } + + /** The kind of interface a Feign or HTTP interface client implements. */ + interface RemoteApi { + @DistributedLock(key = "remote:#{#id}") + String fetch(String id); + + @DistributedSemaphore(key = "remote", permits = "5") + String search(String query); + } + + static class RemoteApiImpl implements RemoteApi { + @Override + public String fetch(String id) { + return id; + } + + @Override + public String search(String query) { + return query; + } + } + + interface RemoteApiUnknownVariable { + @DistributedLock(key = "remote:#{#nope}") + String fetch(String id); + } + + /** A bean that is itself a JDK dynamic proxy, as such a client is. */ + private static T jdkProxy(Class type, T implementation) { + return type.cast( + Proxy.newProxyInstance( + type.getClassLoader(), + new Class[] {type}, + (proxy, method, args) -> method.invoke(implementation, args))); + } + + private static LocksmithConfigurationException failure(AssertableApplicationContext context) { + assertThat(context).hasFailed(); + Throwable cause = context.getStartupFailure(); + while (cause != null && !(cause instanceof LocksmithConfigurationException)) { + cause = cause.getCause(); + } + assertThat(cause).as("LocksmithConfigurationException in the cause chain").isNotNull(); + return (LocksmithConfigurationException) cause; + } + + /** + * Defines the class again from its bytes in a class loader of its own, so the class and its + * superclass share a package but not a class loader. + */ + private static Class defineInOwnClassLoader(Class type) throws IOException { + byte[] bytes; + try (InputStream in = + type.getClassLoader().getResourceAsStream(type.getName().replace('.', '/') + ".class")) { + bytes = in.readAllBytes(); + } + return new ClassLoader(type.getClassLoader()) { + Class define() { + return defineClass(type.getName(), bytes, 0, bytes.length); + } + }.define(); + } + + private void assertFails(Class beanClass, String... fragments) { + runner + .withBean(beanClass) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining(beanClass.getName() + ".run") + .hasMessageContainingAll(fragments)); + } + + @Nested + @DisplayName("fails the refresh") + class Fails { + + @Test + @DisplayName("on a blank key, naming key") + void blankKey() { + assertFails(BlankKey.class, "key must not be blank"); + } + + @Test + @DisplayName("on an unparsable key, with the SpEL error") + void unparsableKey() { + assertFails(UnparsableKey.class, "key [a:#{#id +}] is not a valid template"); + } + + @Test + @DisplayName("on an unknown variable, with the variable and the -parameters hint") + void unknownVariable() { + assertFails(UnknownVariable.class, "#nope", "compile with -parameters or use #p0"); + } + + @Test + @DisplayName( + "on an unknown variable in a JDK proxy bean's interface method, naming that method") + void unknownVariableOnJdkProxyBean() { + runner + .withBean( + RemoteApiUnknownVariable.class, + () -> jdkProxy(RemoteApiUnknownVariable.class, id -> id)) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining(RemoteApiUnknownVariable.class.getName() + ".fetch") + .hasMessageContaining("#nope")); + } + + @Test + @DisplayName("on an unparsable waitTime, with attribute and value") + void badWaitTime() { + assertFails(BadWaitTime.class, "waitTime", "[soon]"); + } + + @Test + @DisplayName("on a negative waitTime, with attribute and value") + void negativeWaitTime() { + assertFails(NegativeWaitTime.class, "waitTime", "[-5s]"); + } + + @Test + @DisplayName("on an unparsable leaseTime, with attribute and value") + void badLeaseTime() { + assertFails(BadLeaseTime.class, "leaseTime", "[later]"); + } + + @Test + @DisplayName("on a negative leaseTime, with attribute and value") + void negativeLeaseTime() { + assertFails(NegativeLeaseTime.class, "leaseTime", "[-1s]"); + } + + @Test + @DisplayName("on a zero leaseTime, with attribute and value") + void zeroLeaseTime() { + assertFails(ZeroLeaseTime.class, "leaseTime", "[0s]", "must be positive"); + } + + @Test + @DisplayName("on HANDLER without handler") + void handlerNotSet() { + assertFails(HandlerNotSet.class, "HANDLER", LockFailureHandler.class.getName()); + } + + @Test + @DisplayName("on handler set while onFailure is THROW") + void handlerWithThrow() { + assertFails(HandlerWithThrow.class, MarkerHandler.class.getName(), "onFailure is THROW"); + } + + @Test + @DisplayName("on HANDLER with zero beans of the handler type, with type and count") + void noHandlerBean() { + assertFails(HandlerPolicy.class, MarkerHandler.class.getName(), "found 0"); + } + + @Test + @DisplayName("on HANDLER with two beans of the handler type, with type and count") + void twoHandlerBeans() { + runner + .withBean("first", MarkerHandler.class, MarkerHandler::new) + .withBean("second", MarkerHandler.class, MarkerHandler::new) + .withBean(HandlerPolicy.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining(HandlerPolicy.class.getName() + ".run") + .hasMessageContaining(MarkerHandler.class.getName()) + .hasMessageContaining("found 2")); + } + + @Test + @DisplayName("on a plain Future return type without @Async") + void plainFuture() { + assertFails( + PlainFuture.class, + "@DistributedLock on " + + PlainFuture.class.getName() + + ".run: return type java.util.concurrent.Future would let the work outlive the lock," + + " which is released when the method returns; mark the method @Async and declare" + + " Future or CompletableFuture, so the lock covers its body on the worker thread"); + } + + @Test + @DisplayName("on a CompletableFuture return type without @Async") + void completableFutureWithoutAsync() { + assertFails( + PlainCompletableFuture.class, + "return type java.util.concurrent.CompletableFuture would let the work outlive the lock", + "mark the method @Async"); + } + + @Test + @DisplayName("on a final method, which Spring's proxy never intercepts") + void finalMethod() { + assertFails( + FinalMethod.class, + "@DistributedLock on ", + ": the method is final, so Spring's proxy never intercepts it"); + } + + @Test + @DisplayName("on a private method, which Spring's proxy never intercepts") + void privateMethod() { + assertFails( + PrivateMethod.class, + "@DistributedSemaphore on ", + ": the method is private, so Spring's proxy never intercepts it"); + } + + @Test + @DisplayName( + "on a package-private method inherited from another package, which Spring's proxy never" + + " intercepts") + void packagePrivateFromAnotherPackage() { + runner + .withBean(PackagePrivateLockFromOtherPackage.class) + .run( + context -> + assertThat(failure(context)) + .hasMessage( + "@DistributedLock on " + + InheritedMethods.PackagePrivateLock.class.getName() + + ".run: the method is package-private but bean class " + + PackagePrivateLockFromOtherPackage.class.getName() + + " is in another package or class loader, so Spring's proxy never" + + " intercepts it and it would run without coordination; make it" + + " public or protected")); + } + + @Test + @DisplayName("on a package-private semaphore method inherited from another package") + void packagePrivateSemaphoreFromAnotherPackage() { + runner + .withBean(PackagePrivateSemaphoreFromOtherPackage.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageStartingWith( + "@DistributedSemaphore on " + + InheritedMethods.PackagePrivateSemaphore.class.getName() + + ".run: the method is package-private but bean class " + + PackagePrivateSemaphoreFromOtherPackage.class.getName())); + } + + @Test + @DisplayName( + "on a package-private method inherited in its package by a class of another class loader") + void packagePrivateFromAnotherClassLoader() throws IOException { + Class child = defineInOwnClassLoader(SamePackageChild.class); + + runner + .withBean(child) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining( + ".run: the method is package-private but bean class " + + SamePackageChild.class.getName() + + " is in another package or class loader")); + } + + @Test + @DisplayName("on a reactive return type") + void reactiveReturn() { + assertFails(ReactiveReturn.class, "reactive return types are not supported"); + } + + @Test + @DisplayName("on an annotation inherited from an interface") + void inheritedFromInterface() { + assertFails(InheritsBlankKey.class, "key must not be blank"); + } + + @Test + @DisplayName("on one key text used with REENTRANT and READ, naming both methods and types") + void reentrantAndReadOnOneKey() { + runner + .withBean(ReentrantOrder.class) + .withBean(ReadOrder.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageContainingAll( + ReentrantOrder.class.getName() + ".run", + ReadOrder.class.getName() + ".run", + "key [order:#{#id}]", + "REENTRANT", + "READ", + "Pick one kind for that key: REENTRANT, or READ/WRITE.")); + } + + @Test + @DisplayName( + "on one semaphore key text used with two permit counts, naming both methods and counts") + void oneSemaphoreKeyWithTwoCounts() { + runner + .withBean(TwoReports.class) + .withBean(FiveReports.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageContainingAll( + "@DistributedSemaphore key [reports]", + "permits 2 on " + TwoReports.class.getName() + ".run", + "permits 5 on " + FiveReports.class.getName() + ".run", + "Use one count for that key, or give each count its own key.")); + } + + @Test + @DisplayName("on one #{...}-only key text used with REENTRANT and WRITE, quoting it as written") + void islandOnlyKeyKeepsItsBraces() { + runner + .withBean(IslandReportsLock.class) + .withBean(IslandReportsWrite.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining("@DistributedLock key [#{'reports'}] is used with")); + } + + @Test + @DisplayName("on semaphore permits 0, with the value") + void permitsZero() { + assertFails(PermitsZero.class, "@DistributedSemaphore", "permits [0]", "greater than zero"); + } + + @Test + @DisplayName("on semaphore permits that are not a number, with the value") + void permitsNotNumber() { + assertFails(PermitsNotNumber.class, "@DistributedSemaphore", "permits [many]"); + } + + @Test + @DisplayName("on an unresolvable semaphore permits placeholder, with the placeholder") + void permitsUnresolvable() { + assertFails( + PermitsUnresolvable.class, + "@DistributedSemaphore", + "permits [${reports.missing}] cannot be resolved"); + } + + @Test + @DisplayName("on semaphore HANDLER with zero beans of the handler type, with type and count") + void noSemaphoreHandlerBean() { + assertFails( + SemaphoreHandlerPolicy.class, + "@DistributedSemaphore", + MarkerSemaphoreHandler.class.getName(), + "found 0"); + } + + @Test + @DisplayName("on semaphore HANDLER with two beans of the handler type, with type and count") + void twoSemaphoreHandlerBeans() { + runner + .withBean("first", MarkerSemaphoreHandler.class, MarkerSemaphoreHandler::new) + .withBean("second", MarkerSemaphoreHandler.class, MarkerSemaphoreHandler::new) + .withBean(SemaphoreHandlerPolicy.class) + .run( + context -> + assertThat(failure(context)) + .hasMessageContaining(SemaphoreHandlerPolicy.class.getName() + ".run") + .hasMessageContaining("@DistributedSemaphore") + .hasMessageContaining(MarkerSemaphoreHandler.class.getName()) + .hasMessageContaining("found 2")); + } + } + + @Test + @DisplayName("starts with semaphore permits from a resolvable placeholder") + void startsWithPermitsPlaceholder() { + runner + .withPropertyValues("reports.max=4") + .withBean(PermitsPlaceholder.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with semaphore HANDLER and exactly one bean of the handler type") + void startsWithOneSemaphoreHandlerBean() { + runner + .withBean(MarkerSemaphoreHandler.class) + .withBean(SemaphoreHandlerPolicy.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with READ and WRITE on one key text") + void startsWithReadAndWriteOnOneKey() { + runner + .withBean(ReadOrder.class) + .withBean(WriteOrder.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with one semaphore key text and the same permit count on two methods") + void startsWithOneSemaphoreKeyAndOneCount() { + runner + .withBean(TwoReports.class) + .withBean(OtherTwoReports.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts when a placeholder resolves to the count another method states") + void startsWithPlaceholderResolvingToTheSameCount() { + runner + .withPropertyValues("reports.max=2") + .withBean(TwoReports.class) + .withBean(PlaceholderReports.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName( + "starts with semaphore keys 'reports' and #{'reports'} and two counts: they are different" + + " keys") + void startsWithQuotedAndIslandSemaphoreKeys() { + runner + .withBean(QuotedReports.class) + .withBean(IslandReports.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName( + "starts with lock keys 'reports' as REENTRANT and #{'reports'} as WRITE: they are different" + + " keys") + void startsWithQuotedAndIslandLockKeys() { + runner + .withBean(QuotedReportsLock.class) + .withBean(IslandReportsWrite.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName( + "starts with a package-private method inherited in its package, and a protected one" + + " inherited from another package") + void startsWithInheritedMethodsTheProxyCanOverride() { + runner + .withBean(SamePackageChild.class) + .withBean(ProtectedLockFromOtherPackage.class) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with a plain Future return type when the class is @Async") + void startsWithAsyncClassFuture() { + runner.withBean(AsyncClassFuture.class).run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with a bean that is itself a JDK proxy, such as a Feign client") + void startsWithJdkProxyBean() { + runner + .withBean(RemoteApi.class, () -> jdkProxy(RemoteApi.class, new RemoteApiImpl())) + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + @DisplayName("starts with HANDLER and exactly one bean of the handler type") + void startsWithOneHandlerBean() { + runner + .withBean(MarkerHandler.class) + .withBean(HandlerPolicy.class) + .run(context -> assertThat(context).hasNotFailed()); + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/AsyncAnnotationPathIntegrationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/AsyncAnnotationPathIntegrationTest.java new file mode 100644 index 0000000..5ba0867 --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/AsyncAnnotationPathIntegrationTest.java @@ -0,0 +1,199 @@ +package in.riido.locksmith.autoconfigure; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; + +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DockerAvailableCondition; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import kotlin.Unit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; +import org.testcontainers.utility.DockerImageName; + +/** + * Annotated {@code @Async} methods that return a future or Kotlin's {@code Unit}, end to end: a + * Boot context without AspectJ against a real Redis. Spring's {@code @Async} runs first, so the + * lock covers the method body on the worker thread. + */ +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("@Async annotated methods end to end") +class AsyncAnnotationPathIntegrationTest { + + private static final String FULL_KEY = "locksmith:lock:order:42"; + private static final Duration RELEASE_WAIT = Duration.ofSeconds(5); + + private static RedisContainer redis; + private static RedissonClient otherInstance; + + /** Opened by a blocking method body once it runs; the body then waits for {@link #finish}. */ + private static CountDownLatch started; + + private static CountDownLatch finish; + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AopAutoConfiguration.class, LocksmithAutoConfiguration.class)) + .withUserConfiguration(UserConfiguration.class); + + private RLock lock; + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + otherInstance = newClient(); + } + + @AfterAll + static void stopRedis() { + otherInstance.shutdown(); + redis.stop(); + } + + @BeforeEach + void setUp() { + otherInstance.getKeys().flushall(); + started = new CountDownLatch(1); + finish = new CountDownLatch(1); + lock = otherInstance.getLock(FULL_KEY); + } + + private static RedissonClient newClient() { + Config config = new Config(); + config + .useSingleServer() + .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort()); + return Redisson.create(config); + } + + @Configuration(proxyBeanMethods = false) + @EnableAsync + static class UserConfiguration { + + @Bean(destroyMethod = "shutdown") + RedissonClient redissonClient() { + return newClient(); + } + + @Bean + AsyncOrderService asyncOrderService() { + return new AsyncOrderService(); + } + } + + /** Spring's {@code @Async} runs these on a worker thread, outside Locksmith's advice. */ + static class AsyncOrderService { + @Async + @DistributedLock(key = "order:#{#id}") + public Future processAsync(String id) throws InterruptedException { + return CompletableFuture.completedFuture(block(id)); + } + + @Async + @DistributedLock(key = "order:#{#id}") + public CompletableFuture processCompletable(String id) throws InterruptedException { + return CompletableFuture.completedFuture(block(id)); + } + } + + /** The JVM signature of Kotlin's {@code fun process(id: String): Unit?}. */ + static class AsyncUnitService { + @Async + @DistributedLock(key = "order:#{#id}") + public Unit process(String id) throws InterruptedException { + block(id); + return Unit.INSTANCE; + } + } + + /** Signals that the body runs, then blocks until the test lets it finish. */ + private static String block(String id) throws InterruptedException { + started.countDown(); + assertThat(finish.await(10, SECONDS)).isTrue(); + return "processed " + id + " on " + Thread.currentThread().getName(); + } + + /** Waits until another instance can take the lock, then gives it back. */ + private void assertLockReleasedEventually() throws InterruptedException { + assertThat(lock.tryLock(RELEASE_WAIT.toSeconds(), SECONDS)).as("lock released").isTrue(); + lock.unlock(); + } + + @Test + @DisplayName("a plain Future: the lock covers the body on the worker thread") + void asyncFutureHoldsLockForBody() throws Exception { + runner.run( + context -> { + AsyncOrderService service = context.getBean(AsyncOrderService.class); + + Future result = service.processAsync("42"); + assertThat(started.await(10, SECONDS)).isTrue(); + + assertThat(lock.tryLock()).isFalse(); + + finish.countDown(); + assertThat(result.get(10, SECONDS)) + .startsWith("processed 42 on ") + .doesNotEndWith(Thread.currentThread().getName()); + assertLockReleasedEventually(); + }); + } + + @Test + @DisplayName("a CompletableFuture: the lock covers the body on the worker thread") + void asyncCompletableFutureHoldsLockForBody() throws Exception { + runner.run( + context -> { + AsyncOrderService service = context.getBean(AsyncOrderService.class); + + CompletableFuture result = service.processCompletable("42"); + assertThat(started.await(10, SECONDS)).isTrue(); + + assertThat(lock.tryLock()).isFalse(); + + finish.countDown(); + assertThat(result.get(10, SECONDS)).startsWith("processed 42 on "); + assertLockReleasedEventually(); + }); + } + + @Test + @DisplayName("a Kotlin Unit: the call returns null and the lock covers the body on the worker") + void asyncKotlinUnitHoldsLockForBody() throws Exception { + runner + .withBean(AsyncUnitService.class) + .run( + context -> { + AsyncUnitService service = context.getBean(AsyncUnitService.class); + + assertThat(service.process("42")).isNull(); + assertThat(started.await(10, SECONDS)).isTrue(); + + assertThat(lock.tryLock()).isFalse(); + + finish.countDown(); + assertLockReleasedEventually(); + }); + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfigurationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfigurationTest.java index 6b8a6cb..cf003de 100644 --- a/src/test/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfigurationTest.java +++ b/src/test/java/in/riido/locksmith/autoconfigure/LocksmithAutoConfigurationTest.java @@ -3,384 +3,302 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.aspect.DistributedSemaphoreAspect; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.aop.LocksmithAdvisor; +import in.riido.locksmith.aop.LocksmithInterceptor; +import in.riido.locksmith.aop.MethodSpecFactory; +import in.riido.locksmith.lock.LockOperations; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.MicrometerLocksmithMetrics; +import in.riido.locksmith.metrics.NoOpLocksmithMetrics; +import in.riido.locksmith.semaphore.SemaphoreOperations; +import in.riido.locksmith.support.AnnotationValidator; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.redisson.api.RedissonClient; +import org.slf4j.LoggerFactory; +import org.springframework.boot.LazyInitializationBeanFactoryPostProcessor; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@DisplayName("LocksmithAutoConfiguration Tests") +@DisplayName("LocksmithAutoConfiguration") class LocksmithAutoConfigurationTest { - private final ApplicationContextRunner contextRunner = + private static final List> LOCKSMITH_BEANS = + List.of( + LockOperations.class, + SemaphoreOperations.class, + LocksmithInterceptor.class, + LocksmithAdvisor.class, + MethodSpecFactory.class, + AnnotationValidator.class); + + private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(LocksmithAutoConfiguration.class)); + .withConfiguration( + AutoConfigurations.of( + AopAutoConfiguration.class, + LocksmithAutoConfiguration.class, + LocksmithDisabledAutoConfiguration.class, + LocksmithInactiveAutoConfiguration.class)); + + private final ApplicationContextRunner withRedisson = + runner.withBean(RedissonClient.class, () -> mock(RedissonClient.class)); + + /** Spring's logger for beans created before every post-processor is registered. */ + private static final String POST_PROCESSOR_CHECKER = + "org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker"; + + private final List loggers = + List.of( + logger(LocksmithAutoConfiguration.class), + logger(LocksmithDisabledAutoConfiguration.class), + logger(LocksmithInactiveAutoConfiguration.class), + (Logger) LoggerFactory.getLogger(POST_PROCESSOR_CHECKER)); + private ListAppender appender; + + private static Logger logger(Class type) { + return (Logger) LoggerFactory.getLogger(type); + } - @Nested - @DisplayName("Bean Creation Tests") - class BeanCreationTests { + @BeforeEach + void captureLogs() { + appender = new ListAppender<>(); + appender.start(); + loggers.forEach(logger -> logger.addAppender(appender)); + } - @Test - @DisplayName("Should create DistributedLockAspect when RedissonClient is present") - void shouldCreateDistributedLockAspectWhenRedissonClientPresent() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - assertThat(context).hasSingleBean(DistributedLockAspect.class); - }); - } + @AfterEach + void releaseLogs() { + loggers.forEach(logger -> logger.detachAppender(appender)); + } - @Test - @DisplayName("Should create DistributedSemaphoreAspect when RedissonClient is present") - void shouldCreateDistributedSemaphoreAspectWhenRedissonClientPresent() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - assertThat(context).hasSingleBean(DistributedSemaphoreAspect.class); - }); - } + private List events(Class loggerType, Level level) { + return appender.list.stream() + .filter(e -> e.getLoggerName().equals(loggerType.getName()) && e.getLevel() == level) + .toList(); + } - @Test - @DisplayName("Should create LocksmithProperties bean") - void shouldCreateLocksmithPropertiesBean() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - assertThat(context).hasSingleBean(LocksmithProperties.class); - }); - } + static class LockedService { + @DistributedLock(key = "k") + public void run() {} + } - @Test - @DisplayName("Should not create beans when RedissonClient is missing") - void shouldNotCreateBeansWhenRedissonClientMissing() { - contextRunner.run( - context -> { - assertThat(context).doesNotHaveBean(DistributedLockAspect.class); - assertThat(context).doesNotHaveBean(DistributedSemaphoreAspect.class); - }); - } + private List checkerEvents() { + return appender.list.stream() + .filter(e -> e.getLoggerName().equals(POST_PROCESSOR_CHECKER)) + .filter(e -> e.getLevel() == Level.WARN || e.getLevel() == Level.INFO) + .toList(); } - @Nested - @DisplayName("Conditional Bean Tests") - class ConditionalBeanTests { + private static void assertFailsNaming(AssertableApplicationContext context, String value) { + assertThat(context) + .getFailure() + .isExactlyInstanceOf(LocksmithConfigurationException.class) + .hasMessage("locksmith.enabled must be true or false, got [" + value + "]"); + } - @Test - @DisplayName("Should not override existing DistributedLockAspect bean") - void shouldNotOverrideExistingDistributedLockAspectBean() { - contextRunner - .withUserConfiguration( - RedissonClientConfiguration.class, CustomDistributedLockAspectConfiguration.class) - .run( - context -> { - assertThat(context).hasSingleBean(DistributedLockAspect.class); - assertThat(context.getBean(DistributedLockAspect.class)) - .isSameAs( - context - .getBean(CustomDistributedLockAspectConfiguration.class) - .customAspect(context)); - }); - } + private static void assertNoLocksmithBeans(AssertableApplicationContext context) { + assertThat(context).hasNotFailed(); + LOCKSMITH_BEANS.forEach(type -> assertThat(context).doesNotHaveBean(type)); + assertThat(context).doesNotHaveBean(LocksmithMetrics.class); } @Nested - @DisplayName("Lock Properties Configuration Tests") - class LockPropertiesConfigurationTests { + @DisplayName("with a RedissonClient bean") + class WithRedisson { @Test - @DisplayName("Should use default lock properties when not configured") - void shouldUseDefaultLockPropertiesWhenNotConfigured() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().leaseTime()) - .isEqualTo(LocksmithProperties.LockProperties.DEFAULT_LEASE_TIME); - assertThat(properties.lock().waitTime()) - .isEqualTo(LocksmithProperties.LockProperties.DEFAULT_WAIT_TIME); - assertThat(properties.lock().keyPrefix()) - .isEqualTo(LocksmithProperties.LockProperties.DEFAULT_KEY_PREFIX); - assertThat(properties.lock().debug()) - .isEqualTo(LocksmithProperties.LockProperties.DEFAULT_DEBUG); - }); + @DisplayName("registers operations, interceptor, advisor, factory and validator") + void registersBeans() { + withRedisson.run( + context -> LOCKSMITH_BEANS.forEach(type -> assertThat(context).hasSingleBean(type))); } @Test - @DisplayName("Should apply custom lock lease time from properties") - void shouldApplyCustomLockLeaseTimeFromProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.lease-time=5m") - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().leaseTime().toMinutes()).isEqualTo(5); - }); - } - - @Test - @DisplayName("Should apply custom lock wait time from properties") - void shouldApplyCustomLockWaitTimeFromProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.wait-time=30s") - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().waitTime().toSeconds()).isEqualTo(30); - }); + @DisplayName("logs the startup INFO line once, with prefix and Boot and Redisson versions") + void logsStartupLineOnce() { + withRedisson.run( + context -> { + assertThat(context).hasNotFailed(); + List info = events(LocksmithAutoConfiguration.class, Level.INFO); + assertThat(info).hasSize(1); + assertThat(info.get(0).getFormattedMessage()) + .startsWith("Locksmith enabled: key-prefix [locksmith:], semaphore lease-time PT5M") + .contains("Spring Boot 4.") + .contains("Redisson 4."); + }); } @Test - @DisplayName("Should apply custom lock key prefix from properties") - void shouldApplyCustomLockKeyPrefixFromProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.key-prefix=myapp:") + @DisplayName("creates no bean before every post-processor is registered, logging once at INFO") + void noEarlyBeans() { + withRedisson + .withBean(LockedService.class) .run( context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().keyPrefix()).isEqualTo("myapp:"); + assertThat(context).hasNotFailed(); + assertThat(checkerEvents()).isEmpty(); + assertThat(events(LocksmithAutoConfiguration.class, Level.INFO)).hasSize(1); }); } @Test - @DisplayName("Should apply all custom lock properties") - void shouldApplyAllCustomLockProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues( - "locksmith.lock.lease-time=15m", - "locksmith.lock.wait-time=90s", - "locksmith.lock.key-prefix=custom:", - "locksmith.lock.debug=true") + @DisplayName("creates no bean early with a MeterRegistry bean either") + void noEarlyBeansWithMeterRegistry() { + withRedisson + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withBean(LockedService.class) .run( context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().leaseTime().toMinutes()).isEqualTo(15); - assertThat(properties.lock().waitTime().toSeconds()).isEqualTo(90); - assertThat(properties.lock().keyPrefix()).isEqualTo("custom:"); - assertThat(properties.lock().debug()).isTrue(); + assertThat(context).hasNotFailed(); + assertThat(checkerEvents()).isEmpty(); + assertThat(events(LocksmithAutoConfiguration.class, Level.INFO)).hasSize(1); }); } @Test - @DisplayName("Should parse ISO-8601 duration format for lock lease time") - void shouldParseIso8601DurationFormatForLockLeaseTime() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.lease-time=PT20M") + @DisplayName("uses MicrometerLocksmithMetrics when a MeterRegistry bean exists") + void micrometerMetrics() { + withRedisson + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().leaseTime().toMinutes()).isEqualTo(20); - }); + context -> + assertThat(context) + .getBean(LocksmithMetrics.class) + .isInstanceOf(MicrometerLocksmithMetrics.class)); } @Test - @DisplayName("Should parse ISO-8601 duration format for lock wait time") - void shouldParseIso8601DurationFormatForLockWaitTime() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.wait-time=PT2M30S") - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.lock().waitTime().toSeconds()).isEqualTo(150); - }); + @DisplayName("uses NoOpLocksmithMetrics when no MeterRegistry bean exists") + void noOpMetrics() { + withRedisson.run( + context -> + assertThat(context) + .getBean(LocksmithMetrics.class) + .isInstanceOf(NoOpLocksmithMetrics.class)); } } - @Nested - @DisplayName("Semaphore Properties Configuration Tests") - class SemaphorePropertiesConfigurationTests { - - @Test - @DisplayName("Should use default semaphore properties when not configured") - void shouldUseDefaultSemaphorePropertiesWhenNotConfigured() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.semaphore().leaseTime()) - .isEqualTo(LocksmithProperties.SemaphoreProperties.DEFAULT_LEASE_TIME); - assertThat(properties.semaphore().waitTime()) - .isEqualTo(LocksmithProperties.SemaphoreProperties.DEFAULT_WAIT_TIME); - assertThat(properties.semaphore().keyPrefix()) - .isEqualTo(LocksmithProperties.SemaphoreProperties.DEFAULT_KEY_PREFIX); - assertThat(properties.semaphore().debug()) - .isEqualTo(LocksmithProperties.SemaphoreProperties.DEFAULT_DEBUG); - }); - } - - @Test - @DisplayName("Should apply custom semaphore lease time from properties") - void shouldApplyCustomSemaphoreLeaseTimeFromProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.semaphore.lease-time=3m") - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.semaphore().leaseTime().toMinutes()).isEqualTo(3); - }); - } - - @Test - @DisplayName("Should apply all custom semaphore properties") - void shouldApplyAllCustomSemaphoreProperties() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues( - "locksmith.semaphore.lease-time=8m", - "locksmith.semaphore.wait-time=45s", - "locksmith.semaphore.key-prefix=sem:", - "locksmith.semaphore.debug=true") - .run( - context -> { - LocksmithProperties properties = context.getBean(LocksmithProperties.class); - assertThat(properties.semaphore().leaseTime().toMinutes()).isEqualTo(8); - assertThat(properties.semaphore().waitTime().toSeconds()).isEqualTo(45); - assertThat(properties.semaphore().keyPrefix()).isEqualTo("sem:"); - assertThat(properties.semaphore().debug()).isTrue(); - }); - } + @Test + @DisplayName("registers nothing and logs the inactive WARN without a RedissonClient bean") + void nothingWithoutRedisson() { + runner.run( + context -> { + assertNoLocksmithBeans(context); + List warnings = + events(LocksmithInactiveAutoConfiguration.class, Level.WARN); + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0).getFormattedMessage()) + .isEqualTo( + "Locksmith is inactive: there is no RedissonClient bean, so annotated methods" + + " run without any coordination"); + }); } - @Nested - @DisplayName("Enabled Property Tests") - class EnabledPropertyTests { - - @Test - @DisplayName("Should not create lock beans when lock is disabled") - void shouldNotCreateLockBeansWhenLockDisabled() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.enabled=false") - .run( - context -> { - assertThat(context).doesNotHaveBean(DistributedLockAspect.class); - assertThat(context) - .doesNotHaveBean(in.riido.locksmith.template.LocksmithLockTemplate.class); - }); - } - - @Test - @DisplayName("Should not create semaphore beans when semaphore is disabled") - void shouldNotCreateSemaphoreBeansWhenSemaphoreDisabled() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.semaphore.enabled=false") - .run( - context -> { - assertThat(context).doesNotHaveBean(DistributedSemaphoreAspect.class); - assertThat(context) - .doesNotHaveBean(in.riido.locksmith.template.LocksmithSemaphoreTemplate.class); - }); - } - - @Test - @DisplayName("Should not create rate limit beans when rate limit is disabled") - void shouldNotCreateRateLimitBeansWhenRateLimitDisabled() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.rate-limit.enabled=false") - .run( - context -> { - assertThat(context) - .doesNotHaveBean(in.riido.locksmith.aspect.RateLimitAspect.class); - assertThat(context) - .doesNotHaveBean(in.riido.locksmith.template.LocksmithRateLimitTemplate.class); - }); - } - - @Test - @DisplayName("Should create all beans when enabled is true (default)") - void shouldCreateAllBeansWhenEnabledIsTrue() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .run( - context -> { - assertThat(context).hasSingleBean(DistributedLockAspect.class); - assertThat(context).hasSingleBean(DistributedSemaphoreAspect.class); - assertThat(context).hasSingleBean(in.riido.locksmith.aspect.RateLimitAspect.class); - }); - } - - @Test - @DisplayName("Should allow disabling only specific aspects") - void shouldAllowDisablingOnlySpecificAspects() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues( - "locksmith.lock.enabled=true", - "locksmith.semaphore.enabled=false", - "locksmith.rate-limit.enabled=true") - .run( - context -> { - assertThat(context).hasSingleBean(DistributedLockAspect.class); - assertThat(context).doesNotHaveBean(DistributedSemaphoreAspect.class); - assertThat(context).hasSingleBean(in.riido.locksmith.aspect.RateLimitAspect.class); - }); - } + @ParameterizedTest(name = "locksmith.enabled={0}") + @ValueSource(strings = {"false", "FALSE"}) + @DisplayName( + "registers nothing and logs the disabled WARN with locksmith.enabled false, any case") + void disabled(String value) { + withRedisson + .withPropertyValues("locksmith.enabled=" + value) + .run( + context -> { + assertNoLocksmithBeans(context); + List warnings = + events(LocksmithDisabledAutoConfiguration.class, Level.WARN); + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0).getFormattedMessage()) + .isEqualTo( + "Locksmith is disabled: annotated methods run without any coordination"); + assertThat(events(LocksmithInactiveAutoConfiguration.class, Level.WARN)).isEmpty(); + }); } - @Nested - @DisplayName("Context Failure Tests") - class ContextFailureTests { + /** What SpringApplication adds when spring.main.lazy-initialization is true. */ + private static ApplicationContextRunner lazy(ApplicationContextRunner runner) { + return runner.withInitializer( + context -> + context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor())); + } - @Test - @DisplayName("Should fail context when invalid duration format is provided for lock") - void shouldFailContextWhenInvalidDurationFormatProvidedForLock() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.lock.lease-time=invalid") - .run( - context -> { - assertThat(context).hasFailed(); - }); - } + @Test + @DisplayName("logs the inactive WARN under lazy initialization too") + void inactiveWarningWhenLazy() { + lazy(runner) + .run( + context -> + assertThat(events(LocksmithInactiveAutoConfiguration.class, Level.WARN)) + .hasSize(1)); + } - @Test - @DisplayName("Should fail context when invalid duration format is provided for semaphore") - void shouldFailContextWhenInvalidDurationFormatProvidedForSemaphore() { - contextRunner - .withUserConfiguration(RedissonClientConfiguration.class) - .withPropertyValues("locksmith.semaphore.lease-time=invalid") - .run( - context -> { - assertThat(context).hasFailed(); - }); - } + @Test + @DisplayName("logs the disabled WARN under lazy initialization too") + void disabledWarningWhenLazy() { + lazy(withRedisson) + .withPropertyValues("locksmith.enabled=false") + .run( + context -> + assertThat(events(LocksmithDisabledAutoConfiguration.class, Level.WARN)) + .hasSize(1)); } - @Configuration - static class RedissonClientConfiguration { + @ParameterizedTest(name = "locksmith.enabled={0}") + @ValueSource(strings = {"true", "TRUE"}) + @DisplayName("registers Locksmith with locksmith.enabled true, any case, as when it is not set") + void enabledInAnyCase(String value) { + withRedisson + .withPropertyValues("locksmith.enabled=" + value) + .run( + context -> { + LOCKSMITH_BEANS.forEach(type -> assertThat(context).hasSingleBean(type)); + assertThat(events(LocksmithDisabledAutoConfiguration.class, Level.WARN)).isEmpty(); + assertThat(events(LocksmithInactiveAutoConfiguration.class, Level.WARN)).isEmpty(); + }); + } - @Bean - RedissonClient redissonClient() { - return mock(RedissonClient.class); - } + @ParameterizedTest(name = "locksmith.enabled=[{0}]") + @ValueSource(strings = {"treu", "yes", "1", ""}) + @DisplayName("fails the startup on a locksmith.enabled value other than true or false, naming it") + void invalidEnabledValue(String value) { + // Before the fix such a value matched none of the three configurations: no bean, no proxy, no + // WARN, and annotated methods ran without coordination. + withRedisson + .withPropertyValues("locksmith.enabled=" + value) + .run(context -> assertFailsNaming(context, value)); } - @Configuration - static class CustomDistributedLockAspectConfiguration { + @Test + @DisplayName( + "fails the startup on an invalid locksmith.enabled without a RedissonClient bean too") + void invalidEnabledValueWithoutRedisson() { + runner + .withPropertyValues("locksmith.enabled=treu") + .run(context -> assertFailsNaming(context, "treu")); + } - @Bean - DistributedLockAspect customAspect(ApplicationContext applicationContext) { - return new DistributedLockAspect( - mock(RedissonClient.class), LocksmithProperties.defaults(), applicationContext); - } + @Test + @DisplayName("logs no disabled or inactive WARN when enabled with a RedissonClient bean") + void noWarningWhenEnabled() { + withRedisson.run( + context -> { + assertThat(events(LocksmithDisabledAutoConfiguration.class, Level.WARN)).isEmpty(); + assertThat(events(LocksmithInactiveAutoConfiguration.class, Level.WARN)).isEmpty(); + }); } } diff --git a/src/test/java/in/riido/locksmith/autoconfigure/LocksmithPropertiesTest.java b/src/test/java/in/riido/locksmith/autoconfigure/LocksmithPropertiesTest.java new file mode 100644 index 0000000..9e2daba --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/LocksmithPropertiesTest.java @@ -0,0 +1,132 @@ +package in.riido.locksmith.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.jayway.jsonpath.JsonPath; +import in.riido.locksmith.LocksmithConfigurationException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +@DisplayName("LocksmithProperties") +class LocksmithPropertiesTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("all nulls give enabled=true, prefix locksmith: and lease 5m") + void allNullsGiveDefaults() { + LocksmithProperties properties = new LocksmithProperties(null, null, null); + + assertThat(properties.enabled()).isTrue(); + assertThat(properties.keyPrefix()).isEqualTo("locksmith:"); + assertThat(properties.semaphore().leaseTime()).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + @DisplayName("blank key prefix falls back to locksmith:") + void blankKeyPrefixFallsBack() { + assertThat(new LocksmithProperties(null, " ", null).keyPrefix()).isEqualTo("locksmith:"); + } + + @Test + @DisplayName("null semaphore lease time falls back to 5m") + void nullLeaseTimeFallsBack() { + assertThat(new LocksmithProperties.Semaphore(null).leaseTime()) + .isEqualTo(Duration.ofMinutes(5)); + } + + @Test + @DisplayName("explicit values are kept") + void explicitValuesAreKept() { + LocksmithProperties properties = + new LocksmithProperties( + false, "app:", new LocksmithProperties.Semaphore(Duration.ofSeconds(30))); + + assertThat(properties.enabled()).isFalse(); + assertThat(properties.keyPrefix()).isEqualTo("app:"); + assertThat(properties.semaphore().leaseTime()).isEqualTo(Duration.ofSeconds(30)); + } + } + + @Nested + @DisplayName("binding and metadata") + class BindingAndMetadata { + + @Test + @DisplayName("binding no properties gives the same defaults") + void bindingNothingGivesDefaults() { + LocksmithProperties properties = + new Binder(new MapConfigurationPropertySource()) + .bindOrCreate("locksmith", LocksmithProperties.class); + + assertThat(properties.enabled()).isTrue(); + assertThat(properties.keyPrefix()).isEqualTo("locksmith:"); + assertThat(properties.semaphore().leaseTime()).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + @DisplayName("the generated metadata shows each default, and no Javadoc tags") + void metadataShowsDefaults() throws IOException { + String json; + try (InputStream in = + getClass().getResourceAsStream("/META-INF/spring-configuration-metadata.json")) { + json = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(defaultOf(json, "locksmith.enabled")).containsExactly("true"); + assertThat(defaultOf(json, "locksmith.key-prefix")).containsExactly("locksmith:"); + assertThat(defaultOf(json, "locksmith.semaphore.lease-time")).containsExactly("5m"); + assertThat(json).doesNotContain("{@"); + } + + /** The default as text: the processor may write a boolean default as a string. */ + private static List defaultOf(String json, String property) { + List values = + JsonPath.read(json, "$.properties[?(@.name == '" + property + "')].defaultValue"); + return values.stream().map(String::valueOf).toList(); + } + } + + @Nested + @DisplayName("semaphore lease time") + class LeaseTime { + + @Test + @DisplayName("zero is rejected with a message containing the value") + void zeroIsRejected() { + assertThatThrownBy(() -> new LocksmithProperties.Semaphore(Duration.ZERO)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("lease-time") + .hasMessageContaining("PT0S"); + } + + @Test + @DisplayName("negative is rejected with a message containing the value") + void negativeIsRejected() { + assertThatThrownBy(() -> new LocksmithProperties.Semaphore(Duration.ofSeconds(-1))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("lease-time") + .hasMessageContaining("PT-1S"); + } + + @Test + @DisplayName("below one millisecond is rejected with a message containing the value") + void subMillisecondIsRejected() { + assertThatThrownBy(() -> new LocksmithProperties.Semaphore(Duration.ofNanos(500))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("lease-time must be at least one millisecond") + .hasMessageContaining("PT0.0000005S"); + } + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/RedissonStarterIntegrationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/RedissonStarterIntegrationTest.java new file mode 100644 index 0000000..3238349 --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/RedissonStarterIntegrationTest.java @@ -0,0 +1,81 @@ +package in.riido.locksmith.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DockerAvailableCondition; +import in.riido.locksmith.LockType; +import in.riido.locksmith.lock.LockOperations; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.testcontainers.utility.DockerImageName; + +/** + * Locksmith with the {@code RedissonClient} from {@code redisson-spring-boot-starter}, the setup + * the README recommends. The full auto-configuration ordering runs, so Locksmith must be evaluated + * after the starter's auto-configuration or its {@code @ConditionalOnBean} finds no client. + */ +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("Locksmith with the Redisson Spring Boot starter") +class RedissonStarterIntegrationTest { + + private static RedisContainer redis; + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + } + + @AfterAll + static void stopRedis() { + redis.stop(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class Application { + + @Bean + OrderService orderService(LockOperations locks) { + return new OrderService(locks); + } + } + + static class OrderService { + + private final LockOperations locks; + + OrderService(LockOperations locks) { + this.locks = locks; + } + + @DistributedLock(key = "order:#{#id}") + public boolean lockedWhileRunning(String id) { + return locks.isLocked("order:" + id, LockType.REENTRANT); + } + } + + @Test + @DisplayName("registers Locksmith and locks an annotated method") + void locksWithStarterClient() { + SpringApplication application = new SpringApplication(Application.class); + application.setWebApplicationType(WebApplicationType.NONE); + try (ConfigurableApplicationContext context = + application.run( + "--spring.data.redis.host=" + redis.getHost(), + "--spring.data.redis.port=" + redis.getFirstMappedPort())) { + assertThat(context.getBean(OrderService.class).lockedWhileRunning("42")).isTrue(); + } + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/SemaphoreAnnotationPathIntegrationTest.java b/src/test/java/in/riido/locksmith/autoconfigure/SemaphoreAnnotationPathIntegrationTest.java new file mode 100644 index 0000000..f423376 --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/SemaphoreAnnotationPathIntegrationTest.java @@ -0,0 +1,190 @@ +package in.riido.locksmith.autoconfigure; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; +import in.riido.locksmith.DockerAvailableCondition; +import in.riido.locksmith.semaphore.SemaphoreNotAcquiredException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.redisson.Redisson; +import org.redisson.api.RLock; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.testcontainers.utility.DockerImageName; + +/** + * {@code @DistributedSemaphore} end to end, alone and together with {@code @DistributedLock}: a + * Boot context without AspectJ against a real Redis. + */ +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("@DistributedSemaphore end to end") +class SemaphoreAnnotationPathIntegrationTest { + + private static final String SEMAPHORE_KEY = "locksmith:semaphore:report:42"; + + /** Its own key: one key text with two permit counts fails the startup. */ + private static final String BOUNDED_KEY = "locksmith:semaphore:bounded:42"; + + private static RedisContainer redis; + private static RedissonClient otherInstance; + + /** The Redisson objects the context's client handed out, wrapped to record calls. */ + private static final AtomicReference recordedSemaphore = + new AtomicReference<>(); + + private static final AtomicReference recordedLock = new AtomicReference<>(); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AopAutoConfiguration.class, LocksmithAutoConfiguration.class)) + .withUserConfiguration(UserConfiguration.class); + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + otherInstance = newClient(); + } + + @AfterAll + static void stopRedis() { + otherInstance.shutdown(); + redis.stop(); + } + + @BeforeEach + void clearRedis() { + otherInstance.getKeys().flushall(); + recordedSemaphore.set(null); + recordedLock.set(null); + } + + private static RedissonClient newClient() { + Config config = new Config(); + config + .useSingleServer() + .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort()); + return Redisson.create(config); + } + + /** + * Wraps a real client so that every semaphore and lock it returns is a Mockito spy, recorded for + * the call-order assertion. + */ + private static RedissonClient recordingClient() { + RedissonClient real = newClient(); + RedissonClient wrapper = mock(RedissonClient.class, delegatesTo(real)); + doAnswer( + invocation -> { + RPermitExpirableSemaphore semaphore = + spy(real.getPermitExpirableSemaphore(invocation.getArgument(0))); + recordedSemaphore.set(semaphore); + return semaphore; + }) + .when(wrapper) + .getPermitExpirableSemaphore(anyString()); + doAnswer( + invocation -> { + RLock lock = spy(real.getLock(invocation.getArgument(0))); + recordedLock.set(lock); + return lock; + }) + .when(wrapper) + .getLock(anyString()); + return wrapper; + } + + @Configuration(proxyBeanMethods = false) + static class UserConfiguration { + + @Bean(destroyMethod = "shutdown") + RedissonClient redissonClient() { + return recordingClient(); + } + + @Bean + ReportService reportService() { + return new ReportService(); + } + } + + static class ReportService { + @DistributedSemaphore(key = "report:#{#id}", permits = "2") + @DistributedLock(key = "report:#{#id}") + public String build(String id) { + return "built " + id; + } + + @DistributedSemaphore(key = "bounded:#{#id}", permits = "1") + public String buildBounded(String id) { + return "built " + id; + } + } + + @Test + @DisplayName("both annotations: semaphore acquire, lock acquire, lock release, semaphore release") + void callOrder() { + runner.run( + context -> { + assertThat(context.getBean(ReportService.class).build("42")).isEqualTo("built 42"); + + RPermitExpirableSemaphore semaphore = recordedSemaphore.get(); + RLock lock = recordedLock.get(); + InOrder order = inOrder(semaphore, lock); + order.verify(semaphore).tryAcquireAsync(eq(1), anyLong(), anyLong(), any()); + order.verify(lock).tryLockAsync(eq(0L), eq(-1L), eq(MILLISECONDS), anyLong()); + order.verify(lock).unlockAsync(anyLong()); + order.verify(semaphore).releaseAsync(anyString()); + assertThat(otherInstance.getLock("locksmith:lock:report:42").isLocked()).isFalse(); + assertThat(otherInstance.getPermitExpirableSemaphore(SEMAPHORE_KEY).availablePermits()) + .isEqualTo(2); + }); + } + + @Test + @DisplayName("THROW: permits exhausted by another client give SemaphoreNotAcquiredException") + void exhaustedByOtherClient() { + RPermitExpirableSemaphore held = otherInstance.getPermitExpirableSemaphore(BOUNDED_KEY); + held.trySetPermits(1); + runner.run( + context -> { + String permitId = held.tryAcquire(0, 30_000, MILLISECONDS); + assertThat(permitId).isNotNull(); + ReportService service = context.getBean(ReportService.class); + + assertThatThrownBy(() -> service.buildBounded("42")) + .isInstanceOf(SemaphoreNotAcquiredException.class) + .hasMessageContaining(BOUNDED_KEY); + + held.release(permitId); + assertThat(service.buildBounded("42")).isEqualTo("built 42"); + }); + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/InheritedMethods.java b/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/InheritedMethods.java new file mode 100644 index 0000000..b51f738 --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/InheritedMethods.java @@ -0,0 +1,25 @@ +package in.riido.locksmith.autoconfigure.otherpackage; + +import in.riido.locksmith.DistributedLock; +import in.riido.locksmith.DistributedSemaphore; + +/** Annotated methods for beans that inherit them from another package. */ +public final class InheritedMethods { + + private InheritedMethods() {} + + public static class PackagePrivateLock { + @DistributedLock(key = "inherited") + void run() {} + } + + public static class PackagePrivateSemaphore { + @DistributedSemaphore(key = "inherited", permits = "1") + void run() {} + } + + public static class ProtectedLock { + @DistributedLock(key = "inherited-protected") + protected void run() {} + } +} diff --git a/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/SamePackageChild.java b/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/SamePackageChild.java new file mode 100644 index 0000000..4d8e96d --- /dev/null +++ b/src/test/java/in/riido/locksmith/autoconfigure/otherpackage/SamePackageChild.java @@ -0,0 +1,7 @@ +package in.riido.locksmith.autoconfigure.otherpackage; + +/** + * Inherits a package-private annotated method within its package. Top level, so a test can define + * it again in a class loader of its own. + */ +public class SamePackageChild extends InheritedMethods.PackagePrivateLock {} diff --git a/src/test/java/in/riido/locksmith/exception/LockNotAcquiredExceptionTest.java b/src/test/java/in/riido/locksmith/exception/LockNotAcquiredExceptionTest.java deleted file mode 100644 index e98f380..0000000 --- a/src/test/java/in/riido/locksmith/exception/LockNotAcquiredExceptionTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package in.riido.locksmith.exception; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("LockNotAcquiredException Tests") -class LockNotAcquiredExceptionTest { - - @Nested - @DisplayName("Constructor Tests") - class ConstructorTests { - - @Test - @DisplayName("Should create exception with correct lock key") - void shouldCreateExceptionWithCorrectLockKey() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertEquals("lock:my-task", exception.getLockKey()); - } - - @Test - @DisplayName("Should create exception with correct method name") - void shouldCreateExceptionWithCorrectMethodName() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertEquals("MyService.doWork", exception.getMethodName()); - } - - @Test - @DisplayName("Should create exception with formatted message containing lock key") - void shouldCreateExceptionWithMessageContainingLockKey() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertTrue(exception.getMessage().contains("lock:my-task")); - } - - @Test - @DisplayName("Should create exception with formatted message containing method name") - void shouldCreateExceptionWithMessageContainingMethodName() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertTrue(exception.getMessage().contains("MyService.doWork")); - } - - @Test - @DisplayName("Should create exception with informative message about lock failure") - void shouldCreateExceptionWithInformativeMessage() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertTrue(exception.getMessage().contains("Failed to acquire distributed lock")); - assertTrue(exception.getMessage().contains("Another instance is currently executing")); - } - } - - @Nested - @DisplayName("Getter Tests") - class GetterTests { - - @Test - @DisplayName("getLockKey should return the lock key") - void getLockKeyShouldReturnLockKey() { - LockNotAcquiredException exception = - new LockNotAcquiredException("prefix:task-123", "TaskService.process"); - - assertEquals("prefix:task-123", exception.getLockKey()); - } - - @Test - @DisplayName("getMethodName should return the method name") - void getMethodNameShouldReturnMethodName() { - LockNotAcquiredException exception = - new LockNotAcquiredException("prefix:task-123", "TaskService.process"); - - assertEquals("TaskService.process", exception.getMethodName()); - } - } - - @Nested - @DisplayName("Exception Hierarchy Tests") - class ExceptionHierarchyTests { - - @Test - @DisplayName("Should be a RuntimeException") - void shouldBeRuntimeException() { - LockNotAcquiredException exception = new LockNotAcquiredException("lock:test", "Test.method"); - - assertInstanceOf(RuntimeException.class, exception); - } - - @Test - @DisplayName("Should be catchable as Exception") - void shouldBeCatchableAsException() { - LockNotAcquiredException exception = new LockNotAcquiredException("lock:test", "Test.method"); - - assertInstanceOf(Exception.class, exception); - } - - @Test - @DisplayName("Should be catchable as Throwable") - void shouldBeCatchableAsThrowable() { - LockNotAcquiredException exception = new LockNotAcquiredException("lock:test", "Test.method"); - - assertInstanceOf(Throwable.class, exception); - } - } - - @Nested - @DisplayName("Edge Case Tests") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle empty lock key") - void shouldHandleEmptyLockKey() { - LockNotAcquiredException exception = new LockNotAcquiredException("", "MyService.doWork"); - - assertEquals("", exception.getLockKey()); - assertTrue(exception.getMessage().contains("[]")); - } - - @Test - @DisplayName("Should handle empty method name") - void shouldHandleEmptyMethodName() { - LockNotAcquiredException exception = new LockNotAcquiredException("lock:test", ""); - - assertEquals("", exception.getMethodName()); - assertTrue(exception.getMessage().contains("[]")); - } - - @Test - @DisplayName("Should handle special characters in lock key") - void shouldHandleSpecialCharactersInLockKey() { - String specialKey = "lock:user:123:task:456"; - LockNotAcquiredException exception = - new LockNotAcquiredException(specialKey, "MyService.doWork"); - - assertEquals(specialKey, exception.getLockKey()); - assertTrue(exception.getMessage().contains(specialKey)); - } - - @Test - @DisplayName("Should handle long lock key and method name") - void shouldHandleLongLockKeyAndMethodName() { - String longKey = "lock:" + "a".repeat(100); - String longMethod = "com.example.very.long.package.name.ServiceClass.veryLongMethodName"; - LockNotAcquiredException exception = new LockNotAcquiredException(longKey, longMethod); - - assertEquals(longKey, exception.getLockKey()); - assertEquals(longMethod, exception.getMethodName()); - } - } - - @Nested - @DisplayName("Message Format Tests") - class MessageFormatTests { - - @Test - @DisplayName("Should format message with lock key in brackets") - void shouldFormatMessageWithLockKeyInBrackets() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertTrue(exception.getMessage().contains("[lock:my-task]")); - } - - @Test - @DisplayName("Should format message with method name in brackets") - void shouldFormatMessageWithMethodNameInBrackets() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:my-task", "MyService.doWork"); - - assertTrue(exception.getMessage().contains("[MyService.doWork]")); - } - - @Test - @DisplayName("Should have consistent message format") - void shouldHaveConsistentMessageFormat() { - LockNotAcquiredException exception = - new LockNotAcquiredException("lock:task", "Service.method"); - - String expectedPattern = - "Failed to acquire distributed lock [lock:task] for method [Service.method]. " - + "Another instance is currently executing this task."; - assertEquals(expectedPattern, exception.getMessage()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/exception/RateLimitExceededExceptionTest.java b/src/test/java/in/riido/locksmith/exception/RateLimitExceededExceptionTest.java deleted file mode 100644 index 385238c..0000000 --- a/src/test/java/in/riido/locksmith/exception/RateLimitExceededExceptionTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package in.riido.locksmith.exception; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("RateLimitExceededException Tests") -class RateLimitExceededExceptionTest { - - @Nested - @DisplayName("Constructor Tests") - class ConstructorTests { - - @Test - @DisplayName("Should create exception with rate limit key and method name") - void shouldCreateExceptionWithKeyAndMethodName() { - RateLimitExceededException exception = - new RateLimitExceededException("api-key", "processRequest"); - - assertEquals("api-key", exception.getRateLimitKey()); - assertEquals("processRequest", exception.getMethodName()); - assertTrue(exception.getMessage().contains("api-key")); - assertTrue(exception.getMessage().contains("processRequest")); - } - - @Test - @DisplayName("Should create exception with empty values") - void shouldCreateExceptionWithEmptyValues() { - RateLimitExceededException exception = new RateLimitExceededException("", ""); - - assertEquals("", exception.getRateLimitKey()); - assertEquals("", exception.getMethodName()); - } - } - - @Nested - @DisplayName("Getter Tests") - class GetterTests { - - @Test - @DisplayName("Should return correct rate limit key") - void shouldReturnCorrectRateLimitKey() { - RateLimitExceededException exception = - new RateLimitExceededException("user:123:api", "handleRequest"); - - assertEquals("user:123:api", exception.getRateLimitKey()); - } - - @Test - @DisplayName("Should return correct method name") - void shouldReturnCorrectMethodName() { - RateLimitExceededException exception = - new RateLimitExceededException("rate-key", "myServiceMethod"); - - assertEquals("myServiceMethod", exception.getMethodName()); - } - } - - @Nested - @DisplayName("Exception Hierarchy Tests") - class ExceptionHierarchyTests { - - @Test - @DisplayName("Should be a RuntimeException") - void shouldBeRuntimeException() { - RateLimitExceededException exception = new RateLimitExceededException("key", "method"); - - assertTrue(exception instanceof RuntimeException); - } - - @Test - @DisplayName("Should be catchable as RuntimeException") - void shouldBeCatchableAsRuntimeException() { - try { - throw new RateLimitExceededException("test-key", "testMethod"); - } catch (RuntimeException e) { - assertTrue(e instanceof RateLimitExceededException); - } - } - - @Test - @DisplayName("Should be throwable and catchable") - void shouldBeThrowableAndCatchable() { - assertThrows( - RateLimitExceededException.class, - () -> { - throw new RateLimitExceededException("key", "method"); - }); - } - } - - @Nested - @DisplayName("Message Format Tests") - class MessageFormatTests { - - @Test - @DisplayName("Should format message with rate limit key and method name") - void shouldFormatMessageCorrectly() { - RateLimitExceededException exception = - new RateLimitExceededException("api:limit", "callExternalApi"); - - String message = exception.getMessage(); - assertNotNull(message); - assertTrue(message.contains("api:limit")); - assertTrue(message.contains("callExternalApi")); - } - - @Test - @DisplayName("Should indicate rate limit exceeded in message") - void shouldIndicateRateLimitExceeded() { - RateLimitExceededException exception = new RateLimitExceededException("key", "method"); - - String message = exception.getMessage().toLowerCase(); - assertTrue( - message.contains("rate") || message.contains("limit") || message.contains("exceeded"), - "Message should indicate rate limit was exceeded"); - } - } - - @Nested - @DisplayName("Edge Case Tests") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle special characters in key") - void shouldHandleSpecialCharactersInKey() { - RateLimitExceededException exception = - new RateLimitExceededException("user:123#api@test", "method"); - - assertEquals("user:123#api@test", exception.getRateLimitKey()); - } - - @Test - @DisplayName("Should handle very long key") - void shouldHandleVeryLongKey() { - String longKey = "a".repeat(1000); - RateLimitExceededException exception = new RateLimitExceededException(longKey, "method"); - - assertEquals(longKey, exception.getRateLimitKey()); - } - - @Test - @DisplayName("Should handle unicode characters") - void shouldHandleUnicodeCharacters() { - RateLimitExceededException exception = new RateLimitExceededException("用户:123", "测试方法"); - - assertEquals("用户:123", exception.getRateLimitKey()); - assertEquals("测试方法", exception.getMethodName()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/handler/LockContextTest.java b/src/test/java/in/riido/locksmith/handler/LockContextTest.java deleted file mode 100644 index 8e29bda..0000000 --- a/src/test/java/in/riido/locksmith/handler/LockContextTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package in.riido.locksmith.handler; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.models.LockContext; -import java.lang.reflect.Method; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("LockContext Tests") -class LockContextTest { - - @Nested - @DisplayName("Record Creation Tests") - class RecordCreationTests { - - @Test - @DisplayName("Should create LockContext with all parameters") - void shouldCreateLockContextWithAllParameters() { - Method mockMethod = mock(Method.class); - Object[] args = new Object[] {"arg1", 42}; - - LockContext context = - new LockContext("test-lock", "TestClass.testMethod", mockMethod, args, String.class); - - assertEquals("test-lock", context.lockKey()); - assertEquals("TestClass.testMethod", context.methodName()); - assertEquals(mockMethod, context.method()); - assertArrayEquals(args, context.args()); - assertEquals(String.class, context.returnType()); - } - - @Test - @DisplayName("Should create LockContext with empty args array") - void shouldCreateLockContextWithEmptyArgsArray() { - Method mockMethod = mock(Method.class); - - LockContext context = - new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, new Object[] {}, void.class); - - assertEquals(0, context.args().length); - } - } - - @Nested - @DisplayName("Null Validation Tests") - class NullValidationTests { - - @Test - @DisplayName("Should throw NullPointerException when lockKey is null") - void shouldThrowNpeWhenLockKeyIsNull() { - Method mockMethod = mock(Method.class); - - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> - new LockContext( - null, "TestClass.testMethod", mockMethod, new Object[] {}, void.class)); - - assertEquals("lockKey must not be null", exception.getMessage()); - } - - @Test - @DisplayName("Should throw NullPointerException when methodName is null") - void shouldThrowNpeWhenMethodNameIsNull() { - Method mockMethod = mock(Method.class); - - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> new LockContext("test-lock", null, mockMethod, new Object[] {}, void.class)); - - assertEquals("methodName must not be null", exception.getMessage()); - } - - @Test - @DisplayName("Should throw NullPointerException when method is null") - void shouldThrowNpeWhenMethodIsNull() { - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> - new LockContext( - "test-lock", "TestClass.testMethod", null, new Object[] {}, void.class)); - - assertEquals("method must not be null", exception.getMessage()); - } - - @Test - @DisplayName("Should throw NullPointerException when args is null") - void shouldThrowNpeWhenArgsIsNull() { - Method mockMethod = mock(Method.class); - - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> - new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, null, void.class)); - - assertEquals("args must not be null", exception.getMessage()); - } - - @Test - @DisplayName("Should throw NullPointerException when returnType is null") - void shouldThrowNpeWhenReturnTypeIsNull() { - Method mockMethod = mock(Method.class); - - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> - new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, new Object[] {}, null)); - - assertEquals("returnType must not be null", exception.getMessage()); - } - } - - @Nested - @DisplayName("Record Equality Tests") - class RecordEqualityTests { - - @Test - @DisplayName("Should be equal when all fields are the same") - void shouldBeEqualWhenAllFieldsAreSame() { - Method mockMethod = mock(Method.class); - Object[] args = new Object[] {"arg1"}; - - LockContext context1 = - new LockContext("test-lock", "TestClass.testMethod", mockMethod, args, String.class); - LockContext context2 = - new LockContext("test-lock", "TestClass.testMethod", mockMethod, args, String.class); - - assertEquals(context1, context2); - assertEquals(context1.hashCode(), context2.hashCode()); - } - - @Test - @DisplayName("Should not be equal when lockKey is different") - void shouldNotBeEqualWhenLockKeyIsDifferent() { - Method mockMethod = mock(Method.class); - Object[] args = new Object[] {}; - - LockContext context1 = - new LockContext("lock-1", "TestClass.testMethod", mockMethod, args, void.class); - LockContext context2 = - new LockContext("lock-2", "TestClass.testMethod", mockMethod, args, void.class); - - assertNotEquals(context1, context2); - } - - @Test - @DisplayName("Should not be equal when methodName is different") - void shouldNotBeEqualWhenMethodNameIsDifferent() { - Method mockMethod = mock(Method.class); - Object[] args = new Object[] {}; - - LockContext context1 = - new LockContext("test-lock", "TestClass.method1", mockMethod, args, void.class); - LockContext context2 = - new LockContext("test-lock", "TestClass.method2", mockMethod, args, void.class); - - assertNotEquals(context1, context2); - } - } - - @Nested - @DisplayName("Record toString Tests") - class RecordToStringTests { - - @Test - @DisplayName("Should include all fields in toString") - void shouldIncludeAllFieldsInToString() { - Method mockMethod = mock(Method.class); - Object[] args = new Object[] {}; - - LockContext context = - new LockContext("test-lock", "TestClass.testMethod", mockMethod, args, String.class); - String str = context.toString(); - - assertTrue(str.contains("test-lock")); - assertTrue(str.contains("TestClass.testMethod")); - } - } -} diff --git a/src/test/java/in/riido/locksmith/handler/LockReturnDefaultHandlerTest.java b/src/test/java/in/riido/locksmith/handler/LockReturnDefaultHandlerTest.java deleted file mode 100644 index b92257c..0000000 --- a/src/test/java/in/riido/locksmith/handler/LockReturnDefaultHandlerTest.java +++ /dev/null @@ -1,331 +0,0 @@ -package in.riido.locksmith.handler; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import in.riido.locksmith.models.LockContext; -import java.lang.reflect.Method; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("ReturnDefaultHandler Tests") -class LockReturnDefaultHandlerTest { - - private LockReturnDefaultHandler handler; - private Method mockMethod; - - @BeforeEach - void setUp() { - handler = new LockReturnDefaultHandler(); - mockMethod = mock(Method.class); - } - - private LockContext createContext(Class returnType) { - return new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, new Object[] {}, returnType); - } - - @Nested - @DisplayName("Void Return Type Tests") - class VoidReturnTypeTests { - - @Test - @DisplayName("Should return null for void primitive") - void shouldReturnNullForVoidPrimitive() { - LockContext context = createContext(void.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for Void wrapper") - void shouldReturnNullForVoidWrapper() { - LockContext context = createContext(Void.class); - - Object result = handler.handle(context); - - assertNull(result); - } - } - - @Nested - @DisplayName("Boolean Return Type Tests") - class BooleanReturnTypeTests { - - @Test - @DisplayName("Should return false for boolean primitive") - void shouldReturnFalseForBooleanPrimitive() { - LockContext context = createContext(boolean.class); - - Object result = handler.handle(context); - - assertEquals(false, result); - } - - @Test - @DisplayName("Should return false for Boolean wrapper") - void shouldReturnFalseForBooleanWrapper() { - LockContext context = createContext(Boolean.class); - - Object result = handler.handle(context); - - assertEquals(false, result); - } - } - - @Nested - @DisplayName("Integer Return Type Tests") - class IntegerReturnTypeTests { - - @Test - @DisplayName("Should return 0 for int primitive") - void shouldReturnZeroForIntPrimitive() { - LockContext context = createContext(int.class); - - Object result = handler.handle(context); - - assertEquals(0, result); - } - - @Test - @DisplayName("Should return 0 for Integer wrapper") - void shouldReturnZeroForIntegerWrapper() { - LockContext context = createContext(Integer.class); - - Object result = handler.handle(context); - - assertEquals(0, result); - } - } - - @Nested - @DisplayName("Long Return Type Tests") - class LongReturnTypeTests { - - @Test - @DisplayName("Should return 0L for long primitive") - void shouldReturnZeroForLongPrimitive() { - LockContext context = createContext(long.class); - - Object result = handler.handle(context); - - assertEquals(0L, result); - } - - @Test - @DisplayName("Should return 0L for Long wrapper") - void shouldReturnZeroForLongWrapper() { - LockContext context = createContext(Long.class); - - Object result = handler.handle(context); - - assertEquals(0L, result); - } - } - - @Nested - @DisplayName("Double Return Type Tests") - class DoubleReturnTypeTests { - - @Test - @DisplayName("Should return 0.0d for double primitive") - void shouldReturnZeroForDoublePrimitive() { - LockContext context = createContext(double.class); - - Object result = handler.handle(context); - - assertEquals(0.0d, result); - } - - @Test - @DisplayName("Should return 0.0d for Double wrapper") - void shouldReturnZeroForDoubleWrapper() { - LockContext context = createContext(Double.class); - - Object result = handler.handle(context); - - assertEquals(0.0d, result); - } - } - - @Nested - @DisplayName("Float Return Type Tests") - class FloatReturnTypeTests { - - @Test - @DisplayName("Should return 0.0f for float primitive") - void shouldReturnZeroForFloatPrimitive() { - LockContext context = createContext(float.class); - - Object result = handler.handle(context); - - assertEquals(0.0f, result); - } - - @Test - @DisplayName("Should return 0.0f for Float wrapper") - void shouldReturnZeroForFloatWrapper() { - LockContext context = createContext(Float.class); - - Object result = handler.handle(context); - - assertEquals(0.0f, result); - } - } - - @Nested - @DisplayName("Byte Return Type Tests") - class ByteReturnTypeTests { - - @Test - @DisplayName("Should return 0 for byte primitive") - void shouldReturnZeroForBytePrimitive() { - LockContext context = createContext(byte.class); - - Object result = handler.handle(context); - - assertEquals((byte) 0, result); - } - - @Test - @DisplayName("Should return 0 for Byte wrapper") - void shouldReturnZeroForByteWrapper() { - LockContext context = createContext(Byte.class); - - Object result = handler.handle(context); - - assertEquals((byte) 0, result); - } - } - - @Nested - @DisplayName("Short Return Type Tests") - class ShortReturnTypeTests { - - @Test - @DisplayName("Should return 0 for short primitive") - void shouldReturnZeroForShortPrimitive() { - LockContext context = createContext(short.class); - - Object result = handler.handle(context); - - assertEquals((short) 0, result); - } - - @Test - @DisplayName("Should return 0 for Short wrapper") - void shouldReturnZeroForShortWrapper() { - LockContext context = createContext(Short.class); - - Object result = handler.handle(context); - - assertEquals((short) 0, result); - } - } - - @Nested - @DisplayName("Character Return Type Tests") - class CharacterReturnTypeTests { - - @Test - @DisplayName("Should return null char for char primitive") - void shouldReturnNullCharForCharPrimitive() { - LockContext context = createContext(char.class); - - Object result = handler.handle(context); - - assertEquals('\u0000', result); - } - - @Test - @DisplayName("Should return null char for Character wrapper") - void shouldReturnNullCharForCharacterWrapper() { - LockContext context = createContext(Character.class); - - Object result = handler.handle(context); - - assertEquals('\u0000', result); - } - } - - @Nested - @DisplayName("Object Return Type Tests") - class ObjectReturnTypeTests { - - @Test - @DisplayName("Should return null for String") - void shouldReturnNullForString() { - LockContext context = createContext(String.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for Object") - void shouldReturnNullForObject() { - LockContext context = createContext(Object.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for custom class") - void shouldReturnNullForCustomClass() { - LockContext context = createContext(CustomClass.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for interface type") - void shouldReturnNullForInterfaceType() { - LockContext context = createContext(Runnable.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for array type") - void shouldReturnNullForArrayType() { - LockContext context = createContext(String[].class); - - Object result = handler.handle(context); - - assertNull(result); - } - } - - @Nested - @DisplayName("Handler Instantiation Tests") - class HandlerInstantiationTests { - - @Test - @DisplayName("Should be instantiable with no-arg constructor") - void shouldBeInstantiableWithNoArgConstructor() { - LockReturnDefaultHandler handler = new LockReturnDefaultHandler(); - - assertNotNull(handler); - } - - @Test - @DisplayName("Should implement LockSkipHandler interface") - void shouldImplementLockSkipHandlerInterface() { - assertTrue(LockSkipHandler.class.isAssignableFrom(LockReturnDefaultHandler.class)); - } - } - - private static class CustomClass {} -} diff --git a/src/test/java/in/riido/locksmith/handler/LockThrowExceptionHandlerTest.java b/src/test/java/in/riido/locksmith/handler/LockThrowExceptionHandlerTest.java deleted file mode 100644 index cbd354a..0000000 --- a/src/test/java/in/riido/locksmith/handler/LockThrowExceptionHandlerTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package in.riido.locksmith.handler; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.exception.LockNotAcquiredException; -import in.riido.locksmith.handler.lock.LockThrowExceptionHandler; -import in.riido.locksmith.models.LockContext; -import java.lang.reflect.Method; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("ThrowExceptionHandler Tests") -class LockThrowExceptionHandlerTest { - - private LockThrowExceptionHandler handler; - private Method mockMethod; - - @BeforeEach - void setUp() { - handler = new LockThrowExceptionHandler(); - mockMethod = mock(Method.class); - } - - private LockContext createContext(String lockKey, String methodName) { - return new LockContext(lockKey, methodName, mockMethod, new Object[] {}, void.class); - } - - @Nested - @DisplayName("Exception Throwing Tests") - class ExceptionThrowingTests { - - @Test - @DisplayName("Should throw LockNotAcquiredException") - void shouldThrowLockNotAcquiredException() { - LockContext context = createContext("test-lock", "TestClass.testMethod"); - - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - } - - @Test - @DisplayName("Should include lock key in exception") - void shouldIncludeLockKeyInException() { - LockContext context = createContext("my-lock-key", "TestClass.testMethod"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals("my-lock-key", exception.getLockKey()); - } - - @Test - @DisplayName("Should include method name in exception") - void shouldIncludeMethodNameInException() { - LockContext context = createContext("test-lock", "MyService.processOrder"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals("MyService.processOrder", exception.getMethodName()); - } - - @Test - @DisplayName("Should include lock key in exception message") - void shouldIncludeLockKeyInExceptionMessage() { - LockContext context = createContext("unique-lock-key", "TestClass.testMethod"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertTrue(exception.getMessage().contains("unique-lock-key")); - } - - @Test - @DisplayName("Should include method name in exception message") - void shouldIncludeMethodNameInExceptionMessage() { - LockContext context = createContext("test-lock", "OrderService.createOrder"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertTrue(exception.getMessage().contains("OrderService.createOrder")); - } - } - - @Nested - @DisplayName("Context Handling Tests") - class ContextHandlingTests { - - @Test - @DisplayName("Should handle context with special characters in lock key") - void shouldHandleContextWithSpecialCharactersInLockKey() { - LockContext context = createContext("lock:user:123:order", "TestClass.testMethod"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals("lock:user:123:order", exception.getLockKey()); - } - - @Test - @DisplayName("Should handle context with empty args array") - void shouldHandleContextWithEmptyArgsArray() { - LockContext context = - new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, new Object[] {}, void.class); - - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - } - - @Test - @DisplayName("Should handle context with args array") - void shouldHandleContextWithArgsArray() { - LockContext context = - new LockContext( - "test-lock", - "TestClass.testMethod", - mockMethod, - new Object[] {"arg1", 42}, - void.class); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals("test-lock", exception.getLockKey()); - } - - @Test - @DisplayName("Should handle various return types in context") - void shouldHandleVariousReturnTypesInContext() { - LockContext context = - new LockContext( - "test-lock", "TestClass.testMethod", mockMethod, new Object[] {}, String.class); - - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - } - } - - @Nested - @DisplayName("Handler Instantiation Tests") - class HandlerInstantiationTests { - - @Test - @DisplayName("Should be instantiable with no-arg constructor") - void shouldBeInstantiableWithNoArgConstructor() { - LockThrowExceptionHandler handler = new LockThrowExceptionHandler(); - - assertNotNull(handler); - } - - @Test - @DisplayName("Should implement LockSkipHandler interface") - void shouldImplementLockSkipHandlerInterface() { - assertTrue(LockSkipHandler.class.isAssignableFrom(LockThrowExceptionHandler.class)); - } - - @Test - @DisplayName("Should be instantiable via reflection") - void shouldBeInstantiableViaReflection() throws Exception { - LockThrowExceptionHandler handler = - LockThrowExceptionHandler.class.getDeclaredConstructor().newInstance(); - - assertNotNull(handler); - } - } - - @Nested - @DisplayName("Edge Case Tests") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle long lock key") - void shouldHandleLongLockKey() { - String longLockKey = "a".repeat(1000); - LockContext context = createContext(longLockKey, "TestClass.testMethod"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals(longLockKey, exception.getLockKey()); - } - - @Test - @DisplayName("Should handle long method name") - void shouldHandleLongMethodName() { - String longMethodName = "VeryLongClassName.veryLongMethodName" + "a".repeat(100); - LockContext context = createContext("test-lock", longMethodName); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals(longMethodName, exception.getMethodName()); - } - - @Test - @DisplayName("Should handle unicode characters in lock key") - void shouldHandleUnicodeCharactersInLockKey() { - LockContext context = createContext("lock:用户:订单", "TestClass.testMethod"); - - LockNotAcquiredException exception = - assertThrows(LockNotAcquiredException.class, () -> handler.handle(context)); - - assertEquals("lock:用户:订单", exception.getLockKey()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandlerTest.java b/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandlerTest.java deleted file mode 100644 index 6e74c1c..0000000 --- a/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitReturnDefaultHandlerTest.java +++ /dev/null @@ -1,308 +0,0 @@ -package in.riido.locksmith.handler.ratelimit; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.models.RateLimitContext; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("RateLimitReturnDefaultHandler Tests") -class RateLimitReturnDefaultHandlerTest { - - private RateLimitReturnDefaultHandler handler; - - @BeforeEach - void setUp() { - handler = new RateLimitReturnDefaultHandler(); - } - - @Nested - @DisplayName("Primitive Type Tests") - class PrimitiveTypeTests { - - @Test - @DisplayName("Should return 0 for int return type") - void shouldReturnZeroForInt() throws NoSuchMethodException { - Method method = TestService.class.getMethod("intMethod"); - RateLimitContext context = - new RateLimitContext("key", "intMethod", method, new Object[] {}, int.class); - - Object result = handler.handle(context); - - assertEquals(0, result); - } - - @Test - @DisplayName("Should return 0L for long return type") - void shouldReturnZeroForLong() throws NoSuchMethodException { - Method method = TestService.class.getMethod("longMethod"); - RateLimitContext context = - new RateLimitContext("key", "longMethod", method, new Object[] {}, long.class); - - Object result = handler.handle(context); - - assertEquals(0L, result); - } - - @Test - @DisplayName("Should return 0.0 for double return type") - void shouldReturnZeroForDouble() throws NoSuchMethodException { - Method method = TestService.class.getMethod("doubleMethod"); - RateLimitContext context = - new RateLimitContext("key", "doubleMethod", method, new Object[] {}, double.class); - - Object result = handler.handle(context); - - assertEquals(0.0, result); - } - - @Test - @DisplayName("Should return 0.0f for float return type") - void shouldReturnZeroForFloat() throws NoSuchMethodException { - Method method = TestService.class.getMethod("floatMethod"); - RateLimitContext context = - new RateLimitContext("key", "floatMethod", method, new Object[] {}, float.class); - - Object result = handler.handle(context); - - assertEquals(0.0f, result); - } - - @Test - @DisplayName("Should return false for boolean return type") - void shouldReturnFalseForBoolean() throws NoSuchMethodException { - Method method = TestService.class.getMethod("booleanMethod"); - RateLimitContext context = - new RateLimitContext("key", "booleanMethod", method, new Object[] {}, boolean.class); - - Object result = handler.handle(context); - - assertEquals(false, result); - } - - @Test - @DisplayName("Should return 0 for short return type") - void shouldReturnZeroForShort() throws NoSuchMethodException { - Method method = TestService.class.getMethod("shortMethod"); - RateLimitContext context = - new RateLimitContext("key", "shortMethod", method, new Object[] {}, short.class); - - Object result = handler.handle(context); - - assertEquals((short) 0, result); - } - - @Test - @DisplayName("Should return 0 for byte return type") - void shouldReturnZeroForByte() throws NoSuchMethodException { - Method method = TestService.class.getMethod("byteMethod"); - RateLimitContext context = - new RateLimitContext("key", "byteMethod", method, new Object[] {}, byte.class); - - Object result = handler.handle(context); - - assertEquals((byte) 0, result); - } - - @Test - @DisplayName("Should return null character for char return type") - void shouldReturnNullCharForChar() throws NoSuchMethodException { - Method method = TestService.class.getMethod("charMethod"); - RateLimitContext context = - new RateLimitContext("key", "charMethod", method, new Object[] {}, char.class); - - Object result = handler.handle(context); - - assertEquals('\u0000', result); - } - } - - @Nested - @DisplayName("Object Type Tests") - class ObjectTypeTests { - - @Test - @DisplayName("Should return null for String return type") - void shouldReturnNullForString() throws NoSuchMethodException { - Method method = TestService.class.getMethod("stringMethod"); - RateLimitContext context = - new RateLimitContext("key", "stringMethod", method, new Object[] {}, String.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for Object return type") - void shouldReturnNullForObject() throws NoSuchMethodException { - Method method = TestService.class.getMethod("objectMethod"); - RateLimitContext context = - new RateLimitContext("key", "objectMethod", method, new Object[] {}, Object.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for custom class return type") - void shouldReturnNullForCustomClass() throws NoSuchMethodException { - Method method = TestService.class.getMethod("customMethod"); - RateLimitContext context = - new RateLimitContext("key", "customMethod", method, new Object[] {}, CustomClass.class); - - Object result = handler.handle(context); - - assertNull(result); - } - } - - @Nested - @DisplayName("Void Type Tests") - class VoidTypeTests { - - @Test - @DisplayName("Should return null for void return type") - void shouldReturnNullForVoid() throws NoSuchMethodException { - Method method = TestService.class.getMethod("voidMethod"); - RateLimitContext context = - new RateLimitContext("key", "voidMethod", method, new Object[] {}, void.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for Void wrapper return type") - void shouldReturnNullForVoidWrapper() throws NoSuchMethodException { - Method method = TestService.class.getMethod("voidWrapperMethod"); - RateLimitContext context = - new RateLimitContext("key", "voidWrapperMethod", method, new Object[] {}, Void.class); - - Object result = handler.handle(context); - - assertNull(result); - } - } - - @Nested - @DisplayName("Collection Type Tests") - class CollectionTypeTests { - - @Test - @DisplayName("Should return null for List return type") - void shouldReturnNullForList() throws NoSuchMethodException { - Method method = TestService.class.getMethod("listMethod"); - RateLimitContext context = - new RateLimitContext("key", "listMethod", method, new Object[] {}, List.class); - - Object result = handler.handle(context); - - assertNull(result); - } - - @Test - @DisplayName("Should return null for Map return type") - void shouldReturnNullForMap() throws NoSuchMethodException { - Method method = TestService.class.getMethod("mapMethod"); - RateLimitContext context = - new RateLimitContext("key", "mapMethod", method, new Object[] {}, Map.class); - - Object result = handler.handle(context); - - assertNull(result); - } - } - - @Nested - @DisplayName("Optional Type Tests") - class OptionalTypeTests { - - @Test - @DisplayName("Should return empty Optional for Optional return type") - void shouldReturnEmptyOptional() throws NoSuchMethodException { - Method method = TestService.class.getMethod("optionalMethod"); - RateLimitContext context = - new RateLimitContext("key", "optionalMethod", method, new Object[] {}, Optional.class); - - Object result = handler.handle(context); - - assertEquals(Optional.empty(), result); - } - } - - // Test service for reflection - public static class TestService { - public int intMethod() { - return 1; - } - - public long longMethod() { - return 1L; - } - - public double doubleMethod() { - return 1.0; - } - - public float floatMethod() { - return 1.0f; - } - - public boolean booleanMethod() { - return true; - } - - public short shortMethod() { - return 1; - } - - public byte byteMethod() { - return 1; - } - - public char charMethod() { - return 'a'; - } - - public String stringMethod() { - return "test"; - } - - public Object objectMethod() { - return new Object(); - } - - public CustomClass customMethod() { - return new CustomClass(); - } - - public void voidMethod() {} - - public Void voidWrapperMethod() { - return null; - } - - public List listMethod() { - return List.of(); - } - - public Map mapMethod() { - return Map.of(); - } - - public Optional optionalMethod() { - return Optional.of("test"); - } - } - - public static class CustomClass {} -} diff --git a/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandlerTest.java b/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandlerTest.java deleted file mode 100644 index 4ebeff5..0000000 --- a/src/test/java/in/riido/locksmith/handler/ratelimit/RateLimitThrowExceptionHandlerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package in.riido.locksmith.handler.ratelimit; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.exception.RateLimitExceededException; -import in.riido.locksmith.models.RateLimitContext; -import java.lang.reflect.Method; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("RateLimitThrowExceptionHandler Tests") -class RateLimitThrowExceptionHandlerTest { - - private RateLimitThrowExceptionHandler handler; - - @BeforeEach - void setUp() { - handler = new RateLimitThrowExceptionHandler(); - } - - @Nested - @DisplayName("Handle Method Tests") - class HandleMethodTests { - - @Test - @DisplayName("Should throw RateLimitExceededException") - void shouldThrowRateLimitExceededException() throws NoSuchMethodException { - Method method = TestService.class.getMethod("testMethod"); - RateLimitContext context = - new RateLimitContext("test-key", "testMethod", method, new Object[] {}, String.class); - - assertThrows(RateLimitExceededException.class, () -> handler.handle(context)); - } - - @Test - @DisplayName("Should include rate limit key in exception") - void shouldIncludeRateLimitKeyInException() throws NoSuchMethodException { - Method method = TestService.class.getMethod("testMethod"); - RateLimitContext context = - new RateLimitContext("api:user:123", "testMethod", method, new Object[] {}, String.class); - - RateLimitExceededException exception = - assertThrows(RateLimitExceededException.class, () -> handler.handle(context)); - - assertEquals("api:user:123", exception.getRateLimitKey()); - } - - @Test - @DisplayName("Should include method name in exception") - void shouldIncludeMethodNameInException() throws NoSuchMethodException { - Method method = TestService.class.getMethod("testMethod"); - RateLimitContext context = - new RateLimitContext("test-key", "myCustomMethod", method, new Object[] {}, String.class); - - RateLimitExceededException exception = - assertThrows(RateLimitExceededException.class, () -> handler.handle(context)); - - assertEquals("myCustomMethod", exception.getMethodName()); - } - } - - @Nested - @DisplayName("Context Handling Tests") - class ContextHandlingTests { - - @Test - @DisplayName("Should handle context with special characters in key") - void shouldHandleContextWithSpecialCharactersInKey() throws NoSuchMethodException { - Method method = TestService.class.getMethod("testMethod"); - RateLimitContext context = - new RateLimitContext( - "special:key#123", "testMethod", method, new Object[] {}, String.class); - - RateLimitExceededException exception = - assertThrows(RateLimitExceededException.class, () -> handler.handle(context)); - - assertEquals("special:key#123", exception.getRateLimitKey()); - } - - @Test - @DisplayName("Should handle context with empty key") - void shouldHandleContextWithEmptyKey() throws NoSuchMethodException { - Method method = TestService.class.getMethod("testMethod"); - RateLimitContext context = - new RateLimitContext("", "testMethod", method, new Object[] {}, String.class); - - RateLimitExceededException exception = - assertThrows(RateLimitExceededException.class, () -> handler.handle(context)); - - assertEquals("", exception.getRateLimitKey()); - } - } - - // Test service for reflection - public static class TestService { - public String testMethod() { - return "test"; - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/ConcurrentAccessTest.java b/src/test/java/in/riido/locksmith/integration/ConcurrentAccessTest.java deleted file mode 100644 index e351795..0000000 --- a/src/test/java/in/riido/locksmith/integration/ConcurrentAccessTest.java +++ /dev/null @@ -1,373 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.integration.service.ConcurrencyTestService; -import in.riido.locksmith.integration.service.ConcurrencyTestServiceImpl; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.CyclicBarrier; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Concurrent Access Tests") -class ConcurrentAccessTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private ConcurrencyTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(30), "concurrent:", false, false), - null, - null); - DistributedLockAspect aspect = - new DistributedLockAspect(redissonClient, properties, new GenericApplicationContext()); - - // Use CGLIB proxy on the implementation class directly to preserve annotations - AspectJProxyFactory factory = new AspectJProxyFactory(new ConcurrencyTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Lock Exclusivity Tests") - class LockExclusivityTests { - - @Test - @DisplayName("Should prevent concurrent execution with same lock key") - void shouldPreventConcurrentExecutionWithSameLockKey() throws InterruptedException { - AtomicBoolean concurrentExecution = new AtomicBoolean(false); - AtomicInteger activeThreads = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(10); - CyclicBarrier barrier = new CyclicBarrier(10); - - ExecutorService executor = Executors.newFixedThreadPool(10); - - for (int i = 0; i < 10; i++) { - executor.submit( - () -> { - try { - barrier.await(); - testService.exclusiveLockMethod(activeThreads, concurrentExecution); - } catch (Exception e) { - // Expected for some threads - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - assertFalse( - concurrentExecution.get(), "Concurrent execution detected - lock exclusivity violated!"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should maintain lock exclusivity under high contention") - void shouldMaintainLockExclusivityUnderHighContention() throws InterruptedException { - int threadCount = 20; - int iterationsPerThread = 5; - AtomicBoolean concurrentExecution = new AtomicBoolean(false); - AtomicInteger activeThreads = new AtomicInteger(0); - AtomicInteger successfulExecutions = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - for (int j = 0; j < iterationsPerThread; j++) { - boolean executed = - testService.contentionTestMethod(activeThreads, concurrentExecution); - if (executed) { - successfulExecutions.incrementAndGet(); - } - } - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertFalse( - concurrentExecution.get(), "Concurrent execution detected under high contention!"); - assertTrue(successfulExecutions.get() > 0, "At least some executions should succeed"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should execute exactly once when multiple threads compete") - void shouldExecuteExactlyOnceWithSimultaneousRequests() throws InterruptedException { - AtomicInteger executionCount = new AtomicInteger(0); - CyclicBarrier barrier = new CyclicBarrier(5); - CountDownLatch allComplete = new CountDownLatch(5); - - ExecutorService executor = Executors.newFixedThreadPool(5); - - for (int i = 0; i < 5; i++) { - executor.submit( - () -> { - try { - barrier.await(); - testService.singleExecutionMethod(executionCount); - } catch (Exception e) { - // Expected for threads that don't get the lock - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(10, TimeUnit.SECONDS)); - assertEquals(1, executionCount.get(), "Method should execute exactly once"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Race Condition Tests") - class RaceConditionTests { - - @Test - @DisplayName("Should protect shared counter from race conditions") - void shouldProtectSharedCounterFromRaceConditions() throws InterruptedException { - int threadCount = 10; - int incrementsPerThread = 10; - AtomicInteger counter = new AtomicInteger(0); - AtomicInteger successfulIncrements = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - for (int j = 0; j < incrementsPerThread; j++) { - if (testService.incrementCounter(counter)) { - successfulIncrements.incrementAndGet(); - } - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertEquals( - successfulIncrements.get(), - counter.get(), - "Counter value should match successful increments"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should maintain order integrity with sequential lock acquisition") - void shouldMaintainOrderIntegrityWithSequentialLockAcquisition() throws InterruptedException { - List executionOrder = Collections.synchronizedList(new ArrayList<>()); - CountDownLatch allComplete = new CountDownLatch(5); - - ExecutorService executor = Executors.newFixedThreadPool(5); - - for (int i = 0; i < 5; i++) { - final int index = i; - executor.submit( - () -> { - try { - Thread.sleep(index * 100L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - testService.orderedExecutionMethod(index, executionOrder); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - assertEquals(5, executionOrder.size()); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Read-Write Concurrency Tests") - class ReadWriteConcurrencyTests { - - @Test - @DisplayName("Should allow multiple readers simultaneously") - void shouldAllowMultipleReadersSimultaneously() throws InterruptedException { - int readerCount = 5; - AtomicInteger maxConcurrentReaders = new AtomicInteger(0); - AtomicInteger currentReaders = new AtomicInteger(0); - CyclicBarrier barrier = new CyclicBarrier(readerCount); - CountDownLatch allComplete = new CountDownLatch(readerCount); - - ExecutorService executor = Executors.newFixedThreadPool(readerCount); - - for (int i = 0; i < readerCount; i++) { - executor.submit( - () -> { - try { - barrier.await(); - testService.readOperation(currentReaders, maxConcurrentReaders); - } catch (Exception e) { - // Ignore - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertTrue( - maxConcurrentReaders.get() > 1, - "Should have had multiple concurrent readers, but got: " + maxConcurrentReaders.get()); - - executor.shutdown(); - } - - @Test - @DisplayName("Should block readers during write operation") - void shouldBlockReadersDuringWriteOperation() throws InterruptedException { - AtomicBoolean writerActive = new AtomicBoolean(false); - AtomicBoolean readerExecutedDuringWrite = new AtomicBoolean(false); - CountDownLatch writerStarted = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(2); - - Thread writerThread = - new Thread( - () -> { - testService.writeOperation(writerActive, writerStarted); - allComplete.countDown(); - }); - - Thread readerThread = - new Thread( - () -> { - try { - writerStarted.await(); - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - if (writerActive.get()) { - boolean executed = testService.readDuringWrite(); - if (executed) { - readerExecutedDuringWrite.set(true); - } - } - allComplete.countDown(); - }); - - writerThread.start(); - readerThread.start(); - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertFalse( - readerExecutedDuringWrite.get(), "Reader should not execute during write operation"); - } - } - - @Nested - @DisplayName("Lock Key Isolation Tests") - class LockKeyIsolationTests { - - @Test - @DisplayName("Should allow parallel execution with different lock keys") - void shouldAllowParallelExecutionWithDifferentLockKeys() throws InterruptedException { - int threadCount = 5; - AtomicInteger concurrentExecutions = new AtomicInteger(0); - AtomicInteger maxConcurrentExecutions = new AtomicInteger(0); - CyclicBarrier barrier = new CyclicBarrier(threadCount); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - final String lockKey = "key-" + i; - executor.submit( - () -> { - try { - barrier.await(); - testService.isolatedLockMethod( - lockKey, concurrentExecutions, maxConcurrentExecutions); - } catch (Exception e) { - // Ignore - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertEquals( - 5, - maxConcurrentExecutions.get(), - "Should have had concurrent executions with different keys"); - - executor.shutdown(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/DistributedLockIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/DistributedLockIntegrationTest.java deleted file mode 100644 index f0a440e..0000000 --- a/src/test/java/in/riido/locksmith/integration/DistributedLockIntegrationTest.java +++ /dev/null @@ -1,314 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.LockNotAcquiredException; -import in.riido.locksmith.integration.service.IntegrationTestService; -import in.riido.locksmith.integration.service.IntegrationTestServiceImpl; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Distributed Lock Integration Tests") -class DistributedLockIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private IntegrationTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(10), "test:", false, false), - null, - null); - DistributedLockAspect aspect = - new DistributedLockAspect(redissonClient, properties, new GenericApplicationContext()); - - // Use CGLIB proxy on the implementation class directly to preserve annotations - AspectJProxyFactory factory = new AspectJProxyFactory(new IntegrationTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Basic Lock Acquisition Tests") - class BasicLockAcquisitionTests { - - @Test - @DisplayName("Should acquire lock and execute method") - void shouldAcquireLockAndExecuteMethod() { - String result = testService.simpleLockedMethod(); - - assertEquals("executed", result); - } - - @Test - @DisplayName("Should release lock after method execution") - void shouldReleaseLockAfterMethodExecution() { - testService.simpleLockedMethod(); - String result = testService.simpleLockedMethod(); - - assertEquals("executed", result); - } - - @Test - @DisplayName("Should resolve SpEL key from method parameter") - void shouldResolveSpelKeyFromMethodParameter() { - String result = testService.lockedMethodWithSpelKey("user123"); - - assertEquals("processed:user123", result); - } - - @Test - @DisplayName("Should allow concurrent execution with different lock keys") - void shouldAllowConcurrentExecutionWithDifferentLockKeys() throws InterruptedException { - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(2); - - Thread thread1 = - new Thread( - () -> { - testService.lockedMethodWithSpelKey("user1"); - completedCount.incrementAndGet(); - latch.countDown(); - }); - - Thread thread2 = - new Thread( - () -> { - testService.lockedMethodWithSpelKey("user2"); - completedCount.incrementAndGet(); - latch.countDown(); - }); - - thread1.start(); - thread2.start(); - - assertTrue(latch.await(10, TimeUnit.SECONDS)); - assertEquals(2, completedCount.get()); - } - } - - @Nested - @DisplayName("Lock Contention Tests") - class LockContentionTests { - - @Test - @DisplayName("Should skip execution when lock is held with SKIP_IMMEDIATELY mode") - void shouldSkipExecutionWhenLockIsHeldWithSkipImmediately() throws InterruptedException { - AtomicInteger executionCount = new AtomicInteger(0); - CountDownLatch firstThreadStarted = new CountDownLatch(1); - CountDownLatch firstThreadComplete = new CountDownLatch(1); - - Thread holdingThread = - new Thread( - () -> { - testService.longRunningMethod(firstThreadStarted, executionCount); - firstThreadComplete.countDown(); - }); - - holdingThread.start(); - assertTrue(firstThreadStarted.await(5, TimeUnit.SECONDS)); - - assertNull(testService.tryAcquireSameLock()); - - firstThreadComplete.await(10, TimeUnit.SECONDS); - assertEquals(1, executionCount.get()); - } - - @Test - @DisplayName("Should throw exception when lock is not acquired with ThrowExceptionHandler") - void shouldThrowExceptionWhenLockNotAcquired() throws InterruptedException { - CountDownLatch firstThreadStarted = new CountDownLatch(1); - CountDownLatch testComplete = new CountDownLatch(1); - - Thread holdingThread = - new Thread( - () -> { - try { - testService.holdLockForThrowTest(firstThreadStarted); - } finally { - testComplete.countDown(); - } - }); - - holdingThread.start(); - assertTrue(firstThreadStarted.await(5, TimeUnit.SECONDS)); - - assertThrows(LockNotAcquiredException.class, () -> testService.throwOnLockNotAcquired()); - - testComplete.await(10, TimeUnit.SECONDS); - } - - @Test - @DisplayName("Should wait and acquire lock with WAIT_AND_SKIP mode") - void shouldWaitAndAcquireLockWithWaitAndSkipMode() throws InterruptedException { - AtomicInteger executionCount = new AtomicInteger(0); - CountDownLatch bothComplete = new CountDownLatch(2); - - Thread thread1 = - new Thread( - () -> { - testService.shortHoldingMethod(executionCount); - bothComplete.countDown(); - }); - - Thread thread2 = - new Thread( - () -> { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - testService.waitAndExecuteMethod(executionCount); - bothComplete.countDown(); - }); - - thread1.start(); - thread2.start(); - - assertTrue(bothComplete.await(15, TimeUnit.SECONDS)); - assertEquals(2, executionCount.get()); - } - } - - @Nested - @DisplayName("Read-Write Lock Tests") - class ReadWriteLockTests { - - @Test - @DisplayName("Should allow multiple concurrent read locks") - void shouldAllowMultipleConcurrentReadLocks() throws InterruptedException { - AtomicInteger concurrentReaders = new AtomicInteger(0); - AtomicInteger maxConcurrentReaders = new AtomicInteger(0); - CountDownLatch allStarted = new CountDownLatch(3); - CountDownLatch allComplete = new CountDownLatch(3); - - ExecutorService executor = Executors.newFixedThreadPool(3); - - for (int i = 0; i < 3; i++) { - executor.submit( - () -> { - testService.readLockMethod(concurrentReaders, maxConcurrentReaders, allStarted); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertTrue(maxConcurrentReaders.get() > 1, "Expected multiple concurrent readers"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should block write lock when read lock is held") - void shouldBlockWriteLockWhenReadLockIsHeld() throws InterruptedException { - CountDownLatch readLockAcquired = new CountDownLatch(1); - CountDownLatch readLockReleased = new CountDownLatch(1); - AtomicInteger writeExecuted = new AtomicInteger(0); - - Thread readerThread = - new Thread( - () -> { - testService.holdReadLock(readLockAcquired); - readLockReleased.countDown(); - }); - - readerThread.start(); - assertTrue(readLockAcquired.await(5, TimeUnit.SECONDS)); - - assertNull(testService.tryWriteLock(writeExecuted)); - assertEquals(0, writeExecuted.get()); - - readLockReleased.await(10, TimeUnit.SECONDS); - } - - @Test - @DisplayName("Should block read lock when write lock is held") - void shouldBlockReadLockWhenWriteLockIsHeld() throws InterruptedException { - CountDownLatch writeLockAcquired = new CountDownLatch(1); - CountDownLatch writeLockReleased = new CountDownLatch(1); - AtomicInteger readExecuted = new AtomicInteger(0); - - Thread writerThread = - new Thread( - () -> { - testService.holdWriteLock(writeLockAcquired); - writeLockReleased.countDown(); - }); - - writerThread.start(); - assertTrue(writeLockAcquired.await(5, TimeUnit.SECONDS)); - - assertNull(testService.tryReadLock(readExecuted)); - assertEquals(0, readExecuted.get()); - - writeLockReleased.await(10, TimeUnit.SECONDS); - } - } - - @Nested - @DisplayName("Auto Renew Tests") - class AutoRenewTests { - - @Test - @DisplayName("Should auto-renew lock for long-running method") - void shouldAutoRenewLockForLongRunningMethod() { - long startTime = System.currentTimeMillis(); - String result = testService.autoRenewMethod(); - long duration = System.currentTimeMillis() - startTime; - - assertEquals("completed", result); - assertTrue(duration >= 2000, "Method should have run for at least 2 seconds"); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/DistributedSemaphoreIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/DistributedSemaphoreIntegrationTest.java deleted file mode 100644 index 9ae293e..0000000 --- a/src/test/java/in/riido/locksmith/integration/DistributedSemaphoreIntegrationTest.java +++ /dev/null @@ -1,448 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedSemaphoreAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.SemaphoreProperties; -import in.riido.locksmith.exception.SemaphoreLeaseExpiredException; -import in.riido.locksmith.exception.SemaphoreNotAcquiredException; -import in.riido.locksmith.integration.service.SemaphoreIntegrationTestService; -import in.riido.locksmith.integration.service.SemaphoreIntegrationTestServiceImpl; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -/** - * Integration tests for DistributedSemaphore annotation. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Distributed Semaphore Integration Tests") -class DistributedSemaphoreIntegrationTest { - - private static final Logger LOG = - LoggerFactory.getLogger(DistributedSemaphoreIntegrationTest.class); - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private SemaphoreIntegrationTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - null, - new SemaphoreProperties( - true, Duration.ofMinutes(5), Duration.ofSeconds(30), "semtest:", false, false), - null); - DistributedSemaphoreAspect aspect = - new DistributedSemaphoreAspect(redissonClient, properties, new GenericApplicationContext()); - - AspectJProxyFactory factory = - new AspectJProxyFactory(new SemaphoreIntegrationTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Basic Permit Acquisition Tests") - class BasicPermitAcquisitionTests { - - @Test - @DisplayName("Should acquire permit and execute method successfully") - void shouldAcquirePermitAndExecute() { - assertDoesNotThrow(() -> testService.simplePermitMethod()); - } - - @Test - @DisplayName("Should resolve SpEL expression in semaphore key") - void shouldResolveSpelExpressionInKey() { - String result = testService.permitMethodWithSpelKey("resource-123"); - assertEquals("processed-resource-123", result); - } - - @Test - @DisplayName("Should allow concurrent executions up to permit limit") - void shouldAllowConcurrentExecutionsUpToPermitLimit() throws InterruptedException { - int threadCount = 10; - AtomicInteger activeCount = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.tryAcquirePermit(activeCount, maxConcurrent); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - - // With 3 permits and 10 threads, max concurrent should be <= 3 - LOG.info("Max concurrent executions: {}", maxConcurrent.get()); - assertTrue( - maxConcurrent.get() <= 3, - "Max concurrent should not exceed permit count of 3, but was " + maxConcurrent.get()); - - executor.shutdown(); - } - - @Test - @DisplayName("Should release permit after execution completes") - void shouldReleasePermitAfterExecution() throws InterruptedException { - // First, acquire permits up to limit - CountDownLatch started = new CountDownLatch(2); - CountDownLatch canRelease = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(3); - - ExecutorService executor = Executors.newFixedThreadPool(3); - - // Start 2 threads that hold permits - for (int i = 0; i < 2; i++) { - executor.submit( - () -> { - testService.holdPermitForDuration(started, canRelease); - allComplete.countDown(); - }); - } - - // Wait for them to acquire permits - assertTrue(started.await(5, TimeUnit.SECONDS)); - Thread.sleep(100); // Give time for permits to be acquired - - // Release the permits - canRelease.countDown(); - - // Now a third thread should be able to acquire - executor.submit( - () -> { - testService.holdPermitForDuration(new CountDownLatch(1), new CountDownLatch(0)); - allComplete.countDown(); - }); - - assertTrue(allComplete.await(10, TimeUnit.SECONDS)); - executor.shutdown(); - } - } - - @Nested - @DisplayName("Skip Handler Tests") - class SkipHandlerTests { - - @Test - @DisplayName("Should throw SemaphoreNotAcquiredException when permit not available") - void shouldThrowExceptionWhenPermitNotAvailable() throws InterruptedException { - CountDownLatch holdingPermit = new CountDownLatch(1); - CountDownLatch canRelease = new CountDownLatch(1); - - ExecutorService executor = Executors.newFixedThreadPool(2); - - // First thread holds the only permit - executor.submit( - () -> { - try { - testService.throwOnPermitNotAcquired(); - } finally { - holdingPermit.countDown(); - } - }); - - // Wait for first thread to acquire permit - Thread.sleep(100); - - // Second thread should fail to acquire - try { - testService.throwOnPermitNotAcquired(); - fail("Should have thrown SemaphoreNotAcquiredException"); - } catch (SemaphoreNotAcquiredException e) { - LOG.info("Caught expected exception: {}", e.getMessage()); - assertNotNull(e.getSemaphoreKey()); - } - - canRelease.countDown(); - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); - } - - @Test - @DisplayName("Should return default value when using ReturnDefaultHandler") - void shouldReturnDefaultWhenPermitNotAvailable() throws InterruptedException { - CountDownLatch started = new CountDownLatch(1); - ExecutorService executor = Executors.newSingleThreadExecutor(); - - // First thread holds the only permit - executor.submit( - () -> { - started.countDown(); - testService.returnDefaultOnPermitNotAcquired(); - }); - - // Wait for first thread to start - assertTrue(started.await(5, TimeUnit.SECONDS)); - Thread.sleep(50); // Ensure permit is acquired - - // Second call should return null (default) instead of throwing - Object result = testService.returnDefaultOnPermitNotAcquired(); - assertNull(result); - - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); - } - } - - @Nested - @DisplayName("Wait Mode Tests") - class WaitModeTests { - - @Test - @DisplayName("Should wait and acquire permit when available") - void shouldWaitAndAcquirePermit() throws InterruptedException { - int threadCount = 5; - AtomicInteger counter = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.waitAndAcquirePermit(counter); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - assertEquals(threadCount, counter.get(), "All threads should have executed"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should execute all threads with WAIT_AND_SKIP mode") - void shouldExecuteAllThreadsWithWaitMode() throws InterruptedException { - int threadCount = 10; - AtomicInteger activeCount = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.trackConcurrentExecution(activeCount, maxConcurrent, completedCount, 100); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - LOG.info("Completed: {}, Max concurrent: {}", completedCount.get(), maxConcurrent.get()); - assertEquals(threadCount, completedCount.get()); - assertTrue(maxConcurrent.get() <= 5, "Max concurrent should not exceed 5 permits"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Lease Expiration Tests") - class LeaseExpirationTests { - - @Test - @DisplayName("Should log warning when execution exceeds lease time") - void shouldLogWarningWhenLeaseExpires() { - // This should complete but log a warning about lease expiration - assertDoesNotThrow(() -> testService.permitWithLeaseExpiration()); - } - - @Test - @DisplayName("Should throw exception when configured for THROW_EXCEPTION on lease expiry") - void shouldThrowExceptionOnLeaseExpiry() { - assertThrows( - SemaphoreLeaseExpiredException.class, () -> testService.permitWithThrowOnLeaseExpired()); - } - } - - @Nested - @DisplayName("Multi-Key Tests") - class MultiKeyTests { - - @Test - @DisplayName("Should allow parallel execution with different keys") - void shouldAllowParallelExecutionWithDifferentKeys() throws InterruptedException { - int keyCount = 5; - int threadsPerKey = 3; - AtomicInteger totalCounter = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(keyCount * threadsPerKey); - - ExecutorService executor = Executors.newFixedThreadPool(keyCount * threadsPerKey); - - for (int k = 0; k < keyCount; k++) { - final String key = "key-" + k; - for (int t = 0; t < threadsPerKey; t++) { - executor.submit( - () -> { - testService.multiKeyPermit(key, totalCounter); - allComplete.countDown(); - }); - } - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - assertEquals(keyCount * threadsPerKey, totalCounter.get()); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Exception Handling Tests") - class ExceptionHandlingTests { - - @Test - @DisplayName("Should release permit even when method throws exception") - void shouldReleasePermitOnException() throws InterruptedException { - // First, cause an exception - assertThrows(RuntimeException.class, () -> testService.permitWithException()); - - // Sleep briefly to ensure cleanup - Thread.sleep(100); - - // Now should be able to acquire permit again - boolean acquired = testService.acquirePermitAfterException(); - assertTrue(acquired, "Should be able to acquire permit after exception"); - } - - @Test - @DisplayName("Should properly release permits after multiple exceptions") - void shouldReleasePermitsAfterMultipleExceptions() throws InterruptedException { - int iterations = 10; - - for (int i = 0; i < iterations; i++) { - assertThrows(RuntimeException.class, () -> testService.permitWithException()); - Thread.sleep(50); - - // Verify we can still acquire - boolean acquired = testService.acquirePermitAfterException(); - assertTrue(acquired, "Should acquire permit after exception #" + (i + 1)); - } - } - } - - @Nested - @DisplayName("High Contention Tests") - class HighContentionTests { - - @Test - @DisplayName("Should handle high contention gracefully") - void shouldHandleHighContention() throws InterruptedException { - int threadCount = 50; - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger skipCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - Boolean result = testService.highContentionPermit(successCount, skipCount); - if (result == null || !result) { - skipCount.incrementAndGet(); - } - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - - LOG.info("Success: {}, Skipped: {}", successCount.get(), skipCount.get()); - assertTrue(successCount.get() > 0, "Some executions should succeed"); - assertEquals( - threadCount, successCount.get() + skipCount.get(), "All threads should be accounted for"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Order and Fairness Tests") - class OrderAndFairnessTests { - - @Test - @DisplayName("Should maintain reasonable ordering with WAIT_AND_SKIP") - void shouldMaintainReasonableOrdering() throws InterruptedException { - int threadCount = 10; - List executionOrder = Collections.synchronizedList(new ArrayList<>()); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - final int id = i; - executor.submit( - () -> { - testService.orderedPermitAcquisition(id, executionOrder); - allComplete.countDown(); - }); - Thread.sleep(10); // Small delay to create ordering - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - LOG.info("Execution order: {}", executionOrder); - assertEquals(threadCount, executionOrder.size()); - - executor.shutdown(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/DockerAvailableCondition.java b/src/test/java/in/riido/locksmith/integration/DockerAvailableCondition.java deleted file mode 100644 index f46886a..0000000 --- a/src/test/java/in/riido/locksmith/integration/DockerAvailableCondition.java +++ /dev/null @@ -1,30 +0,0 @@ -package in.riido.locksmith.integration; - -import org.jspecify.annotations.NonNull; -import org.junit.jupiter.api.extension.ConditionEvaluationResult; -import org.junit.jupiter.api.extension.ExecutionCondition; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.testcontainers.DockerClientFactory; - -/** - * JUnit 5 condition that checks if Docker is available for Testcontainers. - * - *

Usage: Apply @ExtendWith(DockerAvailableCondition.class) to test classes that require Docker. - */ -public class DockerAvailableCondition implements ExecutionCondition { - - private static final ConditionEvaluationResult ENABLED = - ConditionEvaluationResult.enabled("Docker is available"); - private static final ConditionEvaluationResult DISABLED = - ConditionEvaluationResult.disabled("Docker is not available - skipping integration tests"); - - @Override - public @NonNull ConditionEvaluationResult evaluateExecutionCondition( - @NonNull ExtensionContext context) { - return isDockerAvailable() ? ENABLED : DISABLED; - } - - private boolean isDockerAvailable() { - return DockerClientFactory.instance().isDockerAvailable(); - } -} diff --git a/src/test/java/in/riido/locksmith/integration/LockMetricsIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/LockMetricsIntegrationTest.java deleted file mode 100644 index be3d332..0000000 --- a/src/test/java/in/riido/locksmith/integration/LockMetricsIntegrationTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import in.riido.locksmith.metrics.LockMetrics; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Lock Metrics Integration Tests") -class LockMetricsIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private MeterRegistry meterRegistry; - private LockMetrics lockMetrics; - private MetricsTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - meterRegistry = new SimpleMeterRegistry(); - lockMetrics = new LockMetrics(meterRegistry); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(10), "metrics-test:", false, true), - null, - null); - DistributedLockAspect aspect = - new DistributedLockAspect( - redissonClient, properties, new GenericApplicationContext(), lockMetrics); - - AspectJProxyFactory factory = new AspectJProxyFactory(new MetricsTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Successful Lock Acquisition Metrics") - class SuccessfulAcquisitionTests { - - @Test - @DisplayName("Should record acquired counter on successful lock acquisition") - void shouldRecordAcquiredCounter() { - testService.simpleLockedMethod(); - - Counter counter = meterRegistry.find("locksmith.lock.acquired").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should record acquisition time on successful lock acquisition") - void shouldRecordAcquisitionTime() { - testService.simpleLockedMethod(); - - Timer timer = meterRegistry.find("locksmith.lock.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 0); - } - - @Test - @DisplayName("Should record held time on successful lock acquisition") - void shouldRecordHeldTime() throws InterruptedException { - testService.sleepingMethod(100); - - Timer timer = meterRegistry.find("locksmith.lock.held.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 100); - } - - @Test - @DisplayName("Should record multiple acquisitions") - void shouldRecordMultipleAcquisitions() { - testService.simpleLockedMethod(); - testService.simpleLockedMethod(); - testService.simpleLockedMethod(); - - Counter counter = meterRegistry.find("locksmith.lock.acquired").counter(); - assertEquals(3, counter.count()); - } - } - - @Nested - @DisplayName("Skipped Lock Acquisition Metrics") - class SkippedAcquisitionTests { - - @Test - @DisplayName("Should record skipped counter with immediate reason") - void shouldRecordSkippedCounterImmediate() throws InterruptedException { - CountDownLatch lockHeld = new CountDownLatch(1); - CountDownLatch testComplete = new CountDownLatch(1); - - Thread holdingThread = - new Thread( - () -> { - testService.longHoldingMethod(lockHeld); - testComplete.countDown(); - }); - - holdingThread.start(); - assertTrue(lockHeld.await(5, TimeUnit.SECONDS)); - - testService.skipImmediatelyMethod(); - - Counter counter = - meterRegistry.find("locksmith.lock.skipped").tag("reason", "immediate").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - - testComplete.await(10, TimeUnit.SECONDS); - } - } - - @Nested - @DisplayName("Auto Renew Gauge Metrics") - class AutoRenewGaugeTests { - - @Test - @DisplayName("Should increment and decrement auto renew active gauge") - void shouldTrackAutoRenewActiveGauge() throws InterruptedException { - CountDownLatch methodStarted = new CountDownLatch(1); - CountDownLatch methodComplete = new CountDownLatch(1); - AtomicBoolean gaugeChecked = new AtomicBoolean(false); - - Thread executingThread = - new Thread( - () -> { - testService.autoRenewMethodWithSignal(methodStarted, gaugeChecked); - methodComplete.countDown(); - }); - - executingThread.start(); - assertTrue(methodStarted.await(5, TimeUnit.SECONDS)); - - Gauge gauge = meterRegistry.find("locksmith.lock.autorenew.active").gauge(); - assertNotNull(gauge); - assertEquals(1, gauge.value()); - - gaugeChecked.set(true); - methodComplete.await(10, TimeUnit.SECONDS); - - assertEquals(0, gauge.value()); - } - } - - public interface MetricsTestService { - String simpleLockedMethod(); - - void sleepingMethod(long millis) throws InterruptedException; - - void longHoldingMethod(CountDownLatch lockHeld); - - String skipImmediatelyMethod(); - - void autoRenewMethodWithSignal(CountDownLatch started, AtomicBoolean waitForCheck); - } - - public static class MetricsTestServiceImpl implements MetricsTestService { - - @Override - @DistributedLock(key = "metrics-simple") - public String simpleLockedMethod() { - return "executed"; - } - - @Override - @DistributedLock(key = "metrics-sleep") - public void sleepingMethod(long millis) throws InterruptedException { - Thread.sleep(millis); - } - - @Override - @DistributedLock(key = "metrics-hold", leaseTime = "30s") - public void longHoldingMethod(CountDownLatch lockHeld) { - lockHeld.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock( - key = "metrics-hold", - mode = AcquisitionMode.SKIP_IMMEDIATELY, - skipHandler = LockReturnDefaultHandler.class) - public String skipImmediatelyMethod() { - return "executed"; - } - - @Override - @DistributedLock(key = "metrics-autorenew", autoRenew = true) - public void autoRenewMethodWithSignal(CountDownLatch started, AtomicBoolean waitForCheck) { - started.countDown(); - while (!waitForCheck.get()) { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/LocksmithLockTemplateIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/LocksmithLockTemplateIntegrationTest.java deleted file mode 100644 index 81c8a1e..0000000 --- a/src/test/java/in/riido/locksmith/integration/LocksmithLockTemplateIntegrationTest.java +++ /dev/null @@ -1,399 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.LockType; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.template.LocksmithLockTemplate; -import in.riido.locksmith.template.handle.LockHandle; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("LocksmithLockTemplate Integration Tests") -class LocksmithLockTemplateIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private LocksmithLockTemplate template; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(10), "test:", false, false), - null, - null); - template = new LocksmithLockTemplate(redissonClient, properties); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Basic Lock Operations") - class BasicLockOperations { - - @Test - @DisplayName("Should acquire and release lock") - void shouldAcquireAndReleaseLock() { - try (LockHandle handle = template.withKey("basic-test").tryLock()) { - assertTrue(handle.isAcquired()); - assertTrue(template.isLocked("basic-test")); - } - assertFalse(template.isLocked("basic-test")); - } - - @Test - @DisplayName("Should execute callback within lock") - void shouldExecuteCallbackWithinLock() throws Exception { - String result = - template - .withKey("callback-test") - .execute( - () -> { - assertTrue(template.isLocked("callback-test")); - return "success"; - }); - - assertEquals("success", result); - assertFalse(template.isLocked("callback-test")); - } - - @Test - @DisplayName("Should release lock after callback exception") - void shouldReleaseLockAfterCallbackException() { - assertThrows( - RuntimeException.class, - () -> - template - .withKey("exception-test") - .execute( - () -> { - throw new RuntimeException("Test error"); - })); - - assertFalse(template.isLocked("exception-test")); - } - } - - @Nested - @DisplayName("Lock Contention") - class LockContention { - - @Test - @DisplayName("Should prevent concurrent access to same lock") - void shouldPreventConcurrentAccess() throws InterruptedException { - AtomicInteger concurrentExecutions = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(5); - ExecutorService executor = Executors.newFixedThreadPool(5); - - for (int i = 0; i < 5; i++) { - executor.submit( - () -> { - try { - template - .withKey("contention-test") - .waitTime(Duration.ofSeconds(10)) - .execute( - () -> { - int current = concurrentExecutions.incrementAndGet(); - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - Thread.sleep(50); - concurrentExecutions.decrementAndGet(); - return null; - }); - } catch (Exception e) { - // Ignore - } finally { - latch.countDown(); - } - }); - } - - assertTrue(latch.await(30, TimeUnit.SECONDS)); - assertEquals(1, maxConcurrent.get(), "Only one thread should execute at a time"); - executor.shutdown(); - } - - @Test - @DisplayName("Should return false immediately when lock held by another") - void shouldReturnFalseWhenLockHeld() throws Exception { - // Acquire lock in current thread - LockHandle mainHandle = template.withKey("held-lock").tryLock(); - assertTrue(mainHandle.isAcquired()); - - // Try to acquire from another thread - ExecutorService executor = Executors.newSingleThreadExecutor(); - Boolean otherThreadResult = - executor - .submit( - () -> { - try (LockHandle handle = template.withKey("held-lock").tryLock()) { - return handle.isAcquired(); - } - }) - .get(5, TimeUnit.SECONDS); - - assertFalse(otherThreadResult); - mainHandle.close(); - executor.shutdown(); - } - - @Test - @DisplayName("Should acquire lock after release by another thread") - void shouldAcquireLockAfterRelease() throws Exception { - CountDownLatch lockAcquired = new CountDownLatch(1); - AtomicInteger secondThreadAcquired = new AtomicInteger(0); - - Thread firstThread = - new Thread( - () -> { - try (LockHandle handle = template.withKey("release-test").tryLock()) { - lockAcquired.countDown(); - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - - Thread secondThread = - new Thread( - () -> { - try { - lockAcquired.await(); - try (LockHandle handle = - template.withKey("release-test").waitTime(Duration.ofSeconds(5)).tryLock()) { - if (handle.isAcquired()) { - secondThreadAcquired.set(1); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - - firstThread.start(); - secondThread.start(); - firstThread.join(10000); - secondThread.join(10000); - - assertEquals(1, secondThreadAcquired.get()); - } - } - - @Nested - @DisplayName("Read-Write Locks via Builder") - class ReadWriteLocks { - - @Test - @DisplayName("Should allow multiple concurrent read locks") - void shouldAllowConcurrentReadLocks() throws InterruptedException { - AtomicInteger concurrentReaders = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch allStarted = new CountDownLatch(3); - CountDownLatch allComplete = new CountDownLatch(3); - - ExecutorService executor = Executors.newFixedThreadPool(3); - - for (int i = 0; i < 3; i++) { - executor.submit( - () -> { - try (LockHandle handle = - template - .withKey("rw-read-test") - .waitTime(Duration.ofSeconds(5)) - .leaseTime(Duration.ofMinutes(1)) - .lockType(LockType.READ) - .tryLock()) { - if (handle.isAcquired()) { - int current = concurrentReaders.incrementAndGet(); - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - allStarted.countDown(); - Thread.sleep(200); - concurrentReaders.decrementAndGet(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertTrue(maxConcurrent.get() > 1, "Multiple readers should run concurrently"); - executor.shutdown(); - } - - @Test - @DisplayName("Should block write lock when read lock held") - void shouldBlockWriteWhenReadHeld() throws Exception { - // Acquire read lock via builder - LockHandle readHandle = - template - .withKey("rw-block-test") - .leaseTime(Duration.ofMinutes(1)) - .lockType(LockType.READ) - .tryLock(); - assertTrue(readHandle.isAcquired()); - - // Try write lock from another thread - ExecutorService executor = Executors.newSingleThreadExecutor(); - Boolean writeResult = - executor - .submit( - () -> { - try (LockHandle handle = - template - .withKey("rw-block-test") - .leaseTime(Duration.ofMinutes(1)) - .lockType(LockType.WRITE) - .tryLock()) { - return handle.isAcquired(); - } - }) - .get(5, TimeUnit.SECONDS); - - assertFalse(writeResult); - readHandle.close(); - executor.shutdown(); - } - - @Test - @DisplayName("Should block read lock when write lock held") - void shouldBlockReadWhenWriteHeld() throws Exception { - // Acquire write lock via builder - LockHandle writeHandle = - template - .withKey("rw-block-test2") - .leaseTime(Duration.ofMinutes(1)) - .lockType(LockType.WRITE) - .tryLock(); - assertTrue(writeHandle.isAcquired()); - - // Try read lock from another thread - ExecutorService executor = Executors.newSingleThreadExecutor(); - Boolean readResult = - executor - .submit( - () -> { - try (LockHandle handle = - template - .withKey("rw-block-test2") - .leaseTime(Duration.ofMinutes(1)) - .lockType(LockType.READ) - .tryLock()) { - return handle.isAcquired(); - } - }) - .get(5, TimeUnit.SECONDS); - - assertFalse(readResult); - writeHandle.close(); - executor.shutdown(); - } - } - - @Nested - @DisplayName("Auto-Renew via Builder") - class AutoRenew { - - @Test - @DisplayName("Should auto-renew lock beyond original lease time") - void shouldAutoRenewLock() throws Exception { - // Use autoRenew() via builder - String result = - template - .withKey("auto-renew-test") - .autoRenew() - .execute( - () -> { - // Sleep longer than would be allowed without auto-renew - Thread.sleep(2000); - return "completed"; - }); - - assertEquals("completed", result); - assertFalse(template.isLocked("auto-renew-test")); - } - } - - @Nested - @DisplayName("Different Keys") - class DifferentKeys { - - @Test - @DisplayName("Should allow concurrent locks on different keys") - void shouldAllowConcurrentLocksOnDifferentKeys() throws InterruptedException { - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(3); - - ExecutorService executor = Executors.newFixedThreadPool(3); - - for (int i = 0; i < 3; i++) { - final String key = "key-" + i; - executor.submit( - () -> { - try { - template - .withKey(key) - .execute( - () -> { - Thread.sleep(100); - completedCount.incrementAndGet(); - return null; - }); - } catch (Exception e) { - // Ignore - } finally { - latch.countDown(); - } - }); - } - - assertTrue(latch.await(10, TimeUnit.SECONDS)); - assertEquals(3, completedCount.get()); - executor.shutdown(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/LocksmithSemaphoreTemplateIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/LocksmithSemaphoreTemplateIntegrationTest.java deleted file mode 100644 index 1648323..0000000 --- a/src/test/java/in/riido/locksmith/integration/LocksmithSemaphoreTemplateIntegrationTest.java +++ /dev/null @@ -1,379 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import in.riido.locksmith.template.LocksmithSemaphoreTemplate; -import in.riido.locksmith.template.handle.PermitHandle; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("LocksmithSemaphoreTemplate Integration Tests") -class LocksmithSemaphoreTemplateIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private LocksmithSemaphoreTemplate template; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - null, - new LocksmithProperties.SemaphoreProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(10), "test:", false, false), - null); - template = new LocksmithSemaphoreTemplate(redissonClient, properties); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Basic Permit Operations") - class BasicPermitOperations { - - @Test - @DisplayName("Should acquire and release permit") - void shouldAcquireAndReleasePermit() { - try (PermitHandle handle = template.withKey("basic-test").permits(5).tryAcquire()) { - assertTrue(handle.isAcquired()); - assertNotNull(handle.permitId()); - } - } - - @Test - @DisplayName("Should execute callback while holding permit") - void shouldExecuteCallbackWithPermit() throws Exception { - AtomicInteger executed = new AtomicInteger(0); - - String result = - template - .withKey("callback-test") - .permits(5) - .execute( - () -> { - executed.set(1); - return "success"; - }); - - assertEquals("success", result); - assertEquals(1, executed.get()); - } - - @Test - @DisplayName("Should release permit after callback exception") - void shouldReleasePermitAfterCallbackException() { - assertThrows( - RuntimeException.class, - () -> - template - .withKey("exception-test") - .permits(5) - .execute( - () -> { - throw new RuntimeException("Test error"); - })); - - // Should be able to acquire permit again - try (PermitHandle handle = template.withKey("exception-test").permits(5).tryAcquire()) { - assertTrue(handle.isAcquired()); - } - } - } - - @Nested - @DisplayName("Permit Limiting") - class PermitLimiting { - - @Test - @DisplayName("Should limit concurrent access to number of permits") - void shouldLimitConcurrentAccess() throws InterruptedException { - int maxPermits = 3; - AtomicInteger concurrentExecutions = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(10); - ExecutorService executor = Executors.newFixedThreadPool(10); - - for (int i = 0; i < 10; i++) { - executor.submit( - () -> { - try { - template - .withKey("limit-test") - .permits(maxPermits) - .waitTime(Duration.ofSeconds(10)) - .execute( - () -> { - int current = concurrentExecutions.incrementAndGet(); - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - Thread.sleep(100); - concurrentExecutions.decrementAndGet(); - return null; - }); - } catch (Exception e) { - // Ignore - } finally { - latch.countDown(); - } - }); - } - - assertTrue(latch.await(30, TimeUnit.SECONDS)); - assertTrue( - maxConcurrent.get() <= maxPermits, - "Max concurrent should not exceed " + maxPermits + ", was: " + maxConcurrent.get()); - assertTrue(maxConcurrent.get() >= 1, "At least one execution should have occurred"); - executor.shutdown(); - } - - @Test - @DisplayName("Should not acquire when all permits are held") - void shouldNotAcquireWhenAllPermitsHeld() throws Exception { - int maxPermits = 2; - CountDownLatch permitsAcquired = new CountDownLatch(maxPermits); - CountDownLatch testComplete = new CountDownLatch(1); - - ExecutorService executor = Executors.newFixedThreadPool(maxPermits); - - // Acquire all permits - for (int i = 0; i < maxPermits; i++) { - executor.submit( - () -> { - try { - PermitHandle handle = - template.withKey("full-test").permits(maxPermits).tryAcquire(); - if (handle.isAcquired()) { - permitsAcquired.countDown(); - testComplete.await(10, TimeUnit.SECONDS); - handle.close(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - } - - assertTrue(permitsAcquired.await(5, TimeUnit.SECONDS)); - - // Try to acquire when all permits are held - try (PermitHandle handle = template.withKey("full-test").permits(maxPermits).tryAcquire()) { - assertFalse(handle.isAcquired()); - } - - testComplete.countDown(); - executor.shutdown(); - executor.awaitTermination(5, TimeUnit.SECONDS); - } - } - - @Nested - @DisplayName("Wait Time via Builder") - class WaitTime { - - @Test - @DisplayName("Should wait and acquire permit when one becomes available") - void shouldWaitAndAcquirePermit() throws InterruptedException { - int maxPermits = 1; - CountDownLatch firstPermitAcquired = new CountDownLatch(1); - CountDownLatch secondPermitAcquired = new CountDownLatch(1); - - Thread firstThread = - new Thread( - () -> { - try (PermitHandle handle = - template.withKey("wait-test").permits(maxPermits).tryAcquire()) { - if (handle.isAcquired()) { - firstPermitAcquired.countDown(); - Thread.sleep(200); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - - Thread secondThread = - new Thread( - () -> { - try { - firstPermitAcquired.await(); - try (PermitHandle handle = - template - .withKey("wait-test") - .permits(maxPermits) - .waitTime(Duration.ofSeconds(5)) - .tryAcquire()) { - if (handle.isAcquired()) { - secondPermitAcquired.countDown(); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - - firstThread.start(); - secondThread.start(); - - assertTrue(secondPermitAcquired.await(10, TimeUnit.SECONDS)); - - firstThread.join(5000); - secondThread.join(5000); - } - } - - @Nested - @DisplayName("Different Keys") - class DifferentKeys { - - @Test - @DisplayName("Should allow permits on different keys independently") - void shouldAllowPermitsOnDifferentKeys() throws InterruptedException { - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(5); - - ExecutorService executor = Executors.newFixedThreadPool(5); - - for (int i = 0; i < 5; i++) { - final String key = "key-" + i; - executor.submit( - () -> { - try { - template - .withKey(key) - .permits(1) - .execute( - () -> { - Thread.sleep(50); - completedCount.incrementAndGet(); - return null; - }); - } catch (Exception e) { - // Ignore - } finally { - latch.countDown(); - } - }); - } - - assertTrue(latch.await(10, TimeUnit.SECONDS)); - assertEquals(5, completedCount.get()); - executor.shutdown(); - } - } - - @Nested - @DisplayName("Permit Validation") - class PermitValidation { - - @Test - @DisplayName("Should throw exception for non-positive permits") - void shouldThrowExceptionForNonPositivePermits() { - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("invalid-test").permits(0).tryAcquire()); - - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("invalid-test").permits(-1).tryAcquire()); - } - - @Test - @DisplayName("Should throw exception for non-positive permits in execute") - void shouldThrowExceptionForNonPositivePermitsInExecute() { - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("invalid-test").permits(0).execute(() -> "result")); - - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("invalid-test").permits(-1).execute(() -> "result")); - } - } - - @Nested - @DisplayName("Semaphore Initialization") - class SemaphoreInitialization { - - @Test - @DisplayName("Should initialize semaphore with correct permit count") - void shouldInitializeSemaphoreWithCorrectPermitCount() throws InterruptedException { - int maxPermits = 3; - AtomicInteger concurrentAcquisitions = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch allAcquired = new CountDownLatch(maxPermits); - CountDownLatch testComplete = new CountDownLatch(1); - - ExecutorService executor = Executors.newFixedThreadPool(maxPermits + 1); - - // Try to acquire one more than max permits - for (int i = 0; i < maxPermits + 1; i++) { - executor.submit( - () -> { - try { - PermitHandle handle = - template.withKey("init-test").permits(maxPermits).tryAcquire(); - if (handle.isAcquired()) { - int current = concurrentAcquisitions.incrementAndGet(); - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - allAcquired.countDown(); - testComplete.await(10, TimeUnit.SECONDS); - concurrentAcquisitions.decrementAndGet(); - handle.close(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }); - } - - // Wait for permits to be acquired - Thread.sleep(500); - testComplete.countDown(); - executor.shutdown(); - executor.awaitTermination(10, TimeUnit.SECONDS); - - assertEquals( - maxPermits, maxConcurrent.get(), "Should acquire exactly " + maxPermits + " permits"); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/RateLimitIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/RateLimitIntegrationTest.java deleted file mode 100644 index 845240e..0000000 --- a/src/test/java/in/riido/locksmith/integration/RateLimitIntegrationTest.java +++ /dev/null @@ -1,275 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.RateLimitAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.RateLimitExceededException; -import in.riido.locksmith.integration.service.RateLimitIntegrationTestService; -import in.riido.locksmith.integration.service.RateLimitIntegrationTestServiceImpl; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -/** - * Integration tests for RateLimit annotation. - * - * @author Garvit Joshi - * @since 2.1.0 - */ -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Rate Limit Integration Tests") -class RateLimitIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private RateLimitIntegrationTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit-test:", false, false)); - RateLimitAspect aspect = - new RateLimitAspect(redissonClient, properties, new GenericApplicationContext()); - - AspectJProxyFactory factory = - new AspectJProxyFactory(new RateLimitIntegrationTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Basic Rate Limit Tests") - class BasicRateLimitTests { - - @Test - @DisplayName("Should acquire rate limit and execute method") - void shouldAcquireRateLimitAndExecuteMethod() { - String result = testService.simpleRateLimitedMethod(); - assertEquals("executed", result); - } - - @Test - @DisplayName("Should allow multiple executions within rate limit") - void shouldAllowMultipleExecutionsWithinRateLimit() { - // 10 permits per second, call 5 times - for (int i = 0; i < 5; i++) { - String result = testService.simpleRateLimitedMethod(); - assertEquals("executed", result); - } - } - - @Test - @DisplayName("Should resolve SpEL key from method parameter") - void shouldResolveSpelKeyFromMethodParameter() { - String result = testService.rateLimitedMethodWithSpelKey("resource-123"); - assertEquals("processed:resource-123", result); - } - } - - @Nested - @DisplayName("Rate Limit Enforcement Tests") - class RateLimitEnforcementTests { - - @Test - @DisplayName("Should throw exception when rate limit exceeded") - void shouldThrowExceptionWhenRateLimitExceeded() { - // First call should succeed (1 permit per 10 seconds) - String result = testService.throwOnLimitExceeded(); - assertEquals("executed", result); - - // Second call should fail - assertThrows(RateLimitExceededException.class, () -> testService.throwOnLimitExceeded()); - } - - @Test - @DisplayName("Should return default value when rate limit exceeded with ReturnDefaultHandler") - void shouldReturnDefaultWhenRateLimitExceeded() { - // First call should succeed - String result1 = testService.returnDefaultOnLimitExceeded(); - assertEquals("executed", result1); - - // Second call should return null (default for String) - String result2 = testService.returnDefaultOnLimitExceeded(); - assertNull(result2); - } - - @Test - @DisplayName("Should limit executions with SKIP_IMMEDIATELY mode") - void shouldLimitExecutionsWithSkipImmediately() { - AtomicInteger counter = new AtomicInteger(0); - - // Try to execute 20 times, only 5 should succeed (5 permits per second) - for (int i = 0; i < 20; i++) { - testService.trackingMethod(counter); - } - - assertTrue(counter.get() <= 5, "Should not exceed 5 executions, but got: " + counter.get()); - assertTrue(counter.get() > 0, "At least some executions should succeed"); - } - } - - @Nested - @DisplayName("Wait Mode Tests") - class WaitModeTests { - - @Test - @DisplayName("Should wait and acquire when using WAIT_AND_SKIP mode") - void shouldWaitAndAcquireWithWaitMode() throws InterruptedException { - int threadCount = 6; - AtomicInteger counter = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - try { - testService.waitAndAcquireMethod(counter); - } finally { - latch.countDown(); - } - }); - } - - // Should complete within timeout since wait mode allows waiting - assertTrue(latch.await(30, TimeUnit.SECONDS)); - assertEquals(threadCount, counter.get(), "All threads should eventually execute"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Key Isolation Tests") - class KeyIsolationTests { - - @Test - @DisplayName("Should allow parallel execution with different keys") - void shouldAllowParallelExecutionWithDifferentKeys() throws InterruptedException { - int keyCount = 5; - AtomicInteger totalCounter = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(keyCount * 5); - - ExecutorService executor = Executors.newFixedThreadPool(keyCount * 5); - - for (int k = 0; k < keyCount; k++) { - final String key = "isolated-key-" + k; - for (int i = 0; i < 5; i++) { - executor.submit( - () -> { - try { - testService.isolatedKeyMethod(key, totalCounter); - } finally { - latch.countDown(); - } - }); - } - } - - assertTrue(latch.await(30, TimeUnit.SECONDS)); - assertEquals(keyCount * 5, totalCounter.get(), "All executions should complete"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Concurrent Access Tests") - class ConcurrentAccessTests { - - @Test - @DisplayName("Should handle high concurrency gracefully") - void shouldHandleHighConcurrency() throws InterruptedException { - int threadCount = 50; - AtomicInteger successCount = new AtomicInteger(0); - CountDownLatch latch = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - try { - testService.shortWorkMethod(successCount); - } finally { - latch.countDown(); - } - }); - } - - assertTrue(latch.await(60, TimeUnit.SECONDS)); - assertEquals(threadCount, successCount.get(), "All threads should complete with wait mode"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Exception Handling Tests") - class ExceptionHandlingTests { - - @Test - @DisplayName("RateLimitExceededException should contain key and method name") - void exceptionShouldContainKeyAndMethodName() { - // First call succeeds - testService.throwOnLimitExceeded(); - - // Second call throws - RateLimitExceededException exception = - assertThrows(RateLimitExceededException.class, () -> testService.throwOnLimitExceeded()); - - assertNotNull(exception.getRateLimitKey()); - assertNotNull(exception.getMethodName()); - assertTrue(exception.getMessage().contains(exception.getRateLimitKey())); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/SemaphoreConcurrentAccessTest.java b/src/test/java/in/riido/locksmith/integration/SemaphoreConcurrentAccessTest.java deleted file mode 100644 index 35f5162..0000000 --- a/src/test/java/in/riido/locksmith/integration/SemaphoreConcurrentAccessTest.java +++ /dev/null @@ -1,422 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedSemaphoreAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.autoconfigure.LocksmithProperties.SemaphoreProperties; -import in.riido.locksmith.integration.service.SemaphoreConcurrencyTestService; -import in.riido.locksmith.integration.service.SemaphoreConcurrencyTestServiceImpl; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -/** - * Concurrent access tests for distributed semaphores. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Semaphore Concurrent Access Tests") -class SemaphoreConcurrentAccessTest { - - private static final Logger LOG = LoggerFactory.getLogger(SemaphoreConcurrentAccessTest.class); - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private SemaphoreConcurrencyTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - null, - new SemaphoreProperties( - true, Duration.ofMinutes(5), Duration.ofSeconds(30), "concurrent:", false, false), - null); - DistributedSemaphoreAspect aspect = - new DistributedSemaphoreAspect(redissonClient, properties, new GenericApplicationContext()); - - AspectJProxyFactory factory = - new AspectJProxyFactory(new SemaphoreConcurrencyTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Permit Limit Enforcement Tests") - class PermitLimitEnforcementTests { - - @Test - @DisplayName("Should enforce 2 permit limit") - void shouldEnforceTwoPermitLimit() throws InterruptedException { - int threadCount = 20; - AtomicInteger activeCount = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.twoPermitMethod(activeCount, maxConcurrent); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - LOG.info("Max concurrent with 2 permits: {}", maxConcurrent.get()); - assertTrue( - maxConcurrent.get() <= 2, - "Max concurrent should not exceed 2, but was " + maxConcurrent.get()); - - executor.shutdown(); - } - - @Test - @DisplayName("Should enforce 10 permit limit under high load") - void shouldEnforceTenPermitLimit() throws InterruptedException { - int threadCount = 50; - AtomicInteger activeCount = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.tenPermitMethod(activeCount, maxConcurrent); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - LOG.info("Max concurrent with 10 permits: {}", maxConcurrent.get()); - assertTrue( - maxConcurrent.get() <= 10, - "Max concurrent should not exceed 10, but was " + maxConcurrent.get()); - assertTrue( - maxConcurrent.get() >= 5, - "Should utilize most permits, but only used " + maxConcurrent.get()); - - executor.shutdown(); - } - - @Test - @DisplayName("Should track concurrent executions accurately") - void shouldTrackConcurrentExecutionsAccurately() throws InterruptedException { - int threadCount = 30; - AtomicInteger activeCount = new AtomicInteger(0); - AtomicInteger maxConcurrent = new AtomicInteger(0); - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.trackExecution(activeCount, maxConcurrent, completedCount, 100); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - - LOG.info("Completed: {}, Max concurrent: {}", completedCount.get(), maxConcurrent.get()); - assertEquals(threadCount, completedCount.get(), "All threads should complete"); - assertTrue(maxConcurrent.get() <= 5, "Max concurrent should not exceed 5 permits"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Skip vs Wait Mode Tests") - class SkipVsWaitModeTests { - - @Test - @DisplayName("Should skip executions when permits exhausted with SKIP_IMMEDIATELY") - void shouldSkipWhenPermitsExhausted() throws InterruptedException { - int threadCount = 20; - AtomicInteger successCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.skipOnFailure(successCount); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - - LOG.info("Successful executions with 3 permits: {}", successCount.get()); - // With skip mode, not all should succeed - assertTrue(successCount.get() < threadCount, "Some executions should be skipped"); - assertTrue(successCount.get() > 0, "Some executions should succeed"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should complete all executions with WAIT_AND_SKIP mode") - void shouldCompleteAllWithWaitMode() throws InterruptedException { - int threadCount = 15; - AtomicInteger counter = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - testService.waitForPermit(counter); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - assertEquals(threadCount, counter.get(), "All threads should have executed"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Key Isolation Tests") - class KeyIsolationTests { - - @Test - @DisplayName("Should allow parallel execution with different keys") - void shouldAllowParallelWithDifferentKeys() throws InterruptedException { - int keyCount = 5; - int threadsPerKey = 10; - AtomicInteger totalCounter = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(keyCount * threadsPerKey); - - ExecutorService executor = Executors.newFixedThreadPool(keyCount * threadsPerKey); - - long startTime = System.currentTimeMillis(); - - for (int k = 0; k < keyCount; k++) { - final String key = "isolated-key-" + k; - for (int t = 0; t < threadsPerKey; t++) { - executor.submit( - () -> { - testService.isolatedKeyMethod(key, totalCounter); - allComplete.countDown(); - }); - } - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - long duration = System.currentTimeMillis() - startTime; - - LOG.info( - "Completed {} executions across {} keys in {}ms", totalCounter.get(), keyCount, duration); - assertEquals(keyCount * threadsPerKey, totalCounter.get()); - - // Should be faster than sequential execution due to key isolation - // Each key has 2 permits, so with 5 keys = 10 concurrent possible - // 50 threads * 50ms sleep / 10 concurrent = ~250ms minimum - assertTrue(duration < 10000, "Should complete relatively quickly due to parallelism"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Race Condition Protection Tests") - class RaceConditionProtectionTests { - - @Test - @DisplayName("Should protect counter from race conditions with single permit") - void shouldProtectCounterWithSinglePermit() throws InterruptedException { - int iterations = 100; - AtomicInteger counter = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(iterations); - - ExecutorService executor = Executors.newFixedThreadPool(20); - for (int i = 0; i < iterations; i++) { - executor.submit( - () -> { - testService.protectedIncrement(counter); - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - - LOG.info("Final counter value: {} (expected: {})", counter.get(), iterations); - assertEquals( - iterations, counter.get(), "Counter should equal iterations with proper synchronization"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Mutual Exclusion Tests") - class MutualExclusionTests { - - @Test - @DisplayName("Should prevent concurrent access when only 1 permit available") - void shouldPreventConcurrentAccessWithSinglePermit() throws InterruptedException { - AtomicBoolean isExecuting = new AtomicBoolean(false); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch complete = new CountDownLatch(1); - - ExecutorService executor = Executors.newFixedThreadPool(2); - - // Start long-running operation - executor.submit( - () -> { - started.countDown(); - testService.longRunningOperation(isExecuting, 1000); - complete.countDown(); - }); - - assertTrue(started.await(5, TimeUnit.SECONDS)); - Thread.sleep(100); // Ensure operation is running - - // Try to acquire while other is executing - assertTrue(isExecuting.get(), "First operation should be executing"); - boolean acquired = testService.canAcquireWhileOtherExecuting(); - assertFalse(acquired, "Should not acquire permit while another is executing"); - - complete.await(5, TimeUnit.SECONDS); - executor.shutdown(); - } - } - - @Nested - @DisplayName("Throughput Tests") - class ThroughputTests { - - @Test - @DisplayName("Should achieve reasonable throughput with wait mode") - void shouldAchieveReasonableThroughput() throws InterruptedException { - int threadCount = 20; - int iterationsPerThread = 10; - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - long startTime = System.currentTimeMillis(); - - for (int t = 0; t < threadCount; t++) { - executor.submit( - () -> { - AtomicInteger dummy = new AtomicInteger(); - for (int i = 0; i < iterationsPerThread; i++) { - testService.waitForPermit(dummy); - completedCount.incrementAndGet(); - } - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(180, TimeUnit.SECONDS)); - long duration = System.currentTimeMillis() - startTime; - - int totalOperations = threadCount * iterationsPerThread; - double throughput = (totalOperations * 1000.0) / duration; - - LOG.info( - "Completed {} operations in {}ms, throughput: {}/sec", - completedCount.get(), - duration, - throughput); - - assertEquals(totalOperations, completedCount.get()); - assertTrue(throughput > 10, "Should achieve at least 10 ops/sec"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Order Tracking Tests") - class OrderTrackingTests { - - @Test - @DisplayName("Should track execution order with limited permits") - void shouldTrackExecutionOrder() throws InterruptedException { - int threadCount = 10; - List executionOrder = Collections.synchronizedList(new ArrayList<>()); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - final int id = i; - executor.submit( - () -> { - testService.trackOrder(id, executionOrder); - allComplete.countDown(); - }); - Thread.sleep(20); // Stagger starts - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - - LOG.info("Execution order: {}", executionOrder); - assertEquals(threadCount, executionOrder.size(), "All threads should have executed"); - - executor.shutdown(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/SemaphoreMetricsIntegrationTest.java b/src/test/java/in/riido/locksmith/integration/SemaphoreMetricsIntegrationTest.java deleted file mode 100644 index c9adf9d..0000000 --- a/src/test/java/in/riido/locksmith/integration/SemaphoreMetricsIntegrationTest.java +++ /dev/null @@ -1,218 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedSemaphore; -import in.riido.locksmith.aspect.DistributedSemaphoreAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler; -import in.riido.locksmith.metrics.SemaphoreMetrics; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Semaphore Metrics Integration Tests") -class SemaphoreMetricsIntegrationTest { - - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private MeterRegistry meterRegistry; - private SemaphoreMetrics semaphoreMetrics; - private SemaphoreMetricsTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - meterRegistry = new SimpleMeterRegistry(); - semaphoreMetrics = new SemaphoreMetrics(meterRegistry); - - LocksmithProperties properties = - new LocksmithProperties( - null, - new LocksmithProperties.SemaphoreProperties( - true, - Duration.ofMinutes(1), - Duration.ofSeconds(10), - "semaphore-metrics:", - false, - true), - null); - DistributedSemaphoreAspect aspect = - new DistributedSemaphoreAspect( - redissonClient, properties, new GenericApplicationContext(), semaphoreMetrics); - - AspectJProxyFactory factory = new AspectJProxyFactory(new SemaphoreMetricsTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Successful Permit Acquisition Metrics") - class SuccessfulAcquisitionTests { - - @Test - @DisplayName("Should record acquired counter on successful permit acquisition") - void shouldRecordAcquiredCounter() { - testService.simpleMethod(); - - Counter counter = meterRegistry.find("locksmith.semaphore.acquired").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should record acquisition time on successful permit acquisition") - void shouldRecordAcquisitionTime() { - testService.simpleMethod(); - - Timer timer = meterRegistry.find("locksmith.semaphore.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 0); - } - - @Test - @DisplayName("Should record held time on successful permit acquisition") - void shouldRecordHeldTime() throws InterruptedException { - testService.sleepingMethod(100); - - Timer timer = meterRegistry.find("locksmith.semaphore.held.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 100); - } - - @Test - @DisplayName("Should record multiple acquisitions") - void shouldRecordMultipleAcquisitions() { - testService.simpleMethod(); - testService.simpleMethod(); - testService.simpleMethod(); - - Counter counter = meterRegistry.find("locksmith.semaphore.acquired").counter(); - assertEquals(3, counter.count()); - } - } - - @Nested - @DisplayName("Skipped Permit Acquisition Metrics") - class SkippedAcquisitionTests { - - @Test - @DisplayName("Should record skipped counter when all permits are taken") - void shouldRecordSkippedCounter() throws InterruptedException { - CountDownLatch permitHeld = new CountDownLatch(1); - CountDownLatch testComplete = new CountDownLatch(1); - - Thread holdingThread = - new Thread( - () -> { - testService.singlePermitHoldingMethod(permitHeld); - testComplete.countDown(); - }); - - holdingThread.start(); - assertTrue(permitHeld.await(5, TimeUnit.SECONDS)); - - testService.skipImmediatelyMethod(); - - Counter counter = - meterRegistry.find("locksmith.semaphore.skipped").tag("reason", "immediate").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - - testComplete.await(10, TimeUnit.SECONDS); - } - } - - public interface SemaphoreMetricsTestService { - String simpleMethod(); - - void sleepingMethod(long millis) throws InterruptedException; - - void singlePermitHoldingMethod(CountDownLatch permitHeld); - - String skipImmediatelyMethod(); - } - - public static class SemaphoreMetricsTestServiceImpl implements SemaphoreMetricsTestService { - - @Override - @DistributedSemaphore(key = "semaphore-metrics-simple", permits = 5) - public String simpleMethod() { - return "executed"; - } - - @Override - @DistributedSemaphore(key = "semaphore-metrics-sleep", permits = 5) - public void sleepingMethod(long millis) throws InterruptedException { - Thread.sleep(millis); - } - - @Override - @DistributedSemaphore(key = "semaphore-metrics-single", permits = 1, leaseTime = "30s") - public void singlePermitHoldingMethod(CountDownLatch permitHeld) { - permitHeld.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "semaphore-metrics-single", - permits = 1, - mode = AcquisitionMode.SKIP_IMMEDIATELY, - skipHandler = SemaphoreReturnDefaultHandler.class) - public String skipImmediatelyMethod() { - return "executed"; - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/StressPerformanceTest.java b/src/test/java/in/riido/locksmith/integration/StressPerformanceTest.java deleted file mode 100644 index d930b91..0000000 --- a/src/test/java/in/riido/locksmith/integration/StressPerformanceTest.java +++ /dev/null @@ -1,410 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.integration.service.StressTestService; -import in.riido.locksmith.integration.service.StressTestServiceImpl; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.LongSummaryStatistics; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@DisplayName("Stress and Performance Tests") -class StressPerformanceTest { - - private static final Logger LOG = LoggerFactory.getLogger(StressPerformanceTest.class); - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private StressTestService testService; - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(30), "stress:", false, false), - null, - null); - DistributedLockAspect aspect = - new DistributedLockAspect(redissonClient, properties, new GenericApplicationContext()); - - // Use CGLIB proxy on the implementation class directly to preserve annotations - AspectJProxyFactory factory = new AspectJProxyFactory(new StressTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - final var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - } - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Load Tests") - class LoadTests { - - @Test - @DisplayName("Should handle high request volume with single lock") - void shouldHandleHighRequestVolumeWithSingleLock() throws InterruptedException { - int threadCount = 50; - int requestsPerThread = 20; - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger skipCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - long startTime = System.currentTimeMillis(); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - for (int j = 0; j < requestsPerThread; j++) { - boolean result = testService.highVolumeMethod(); - if (result) { - successCount.incrementAndGet(); - } else { - skipCount.incrementAndGet(); - } - } - allComplete.countDown(); - }); - } - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - long duration = System.currentTimeMillis() - startTime; - - int totalRequests = threadCount * requestsPerThread; - LOG.info( - "High volume test completed: {} requests in {}ms, {} successful, {} skipped", - totalRequests, - duration, - successCount.get(), - skipCount.get()); - - assertTrue(successCount.get() > 0, "At least some requests should succeed"); - assertEquals( - totalRequests, - successCount.get() + skipCount.get(), - "All requests should be accounted for"); - - executor.shutdown(); - } - - @Test - @DisplayName("Should handle concurrent operations across multiple lock keys") - void shouldHandleConcurrentOperationsAcrossMultipleLockKeys() throws InterruptedException { - int lockKeyCount = 10; - int threadsPerKey = 10; - int operationsPerThread = 10; - AtomicInteger totalOperations = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(lockKeyCount * threadsPerKey); - - ExecutorService executor = Executors.newFixedThreadPool(lockKeyCount * threadsPerKey); - - long startTime = System.currentTimeMillis(); - - for (int k = 0; k < lockKeyCount; k++) { - final String lockKey = "key-" + k; - for (int t = 0; t < threadsPerKey; t++) { - executor.submit( - () -> { - for (int o = 0; o < operationsPerThread; o++) { - testService.multiKeyMethod(lockKey); - totalOperations.incrementAndGet(); - } - allComplete.countDown(); - }); - } - } - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - long duration = System.currentTimeMillis() - startTime; - - int expectedOperations = lockKeyCount * threadsPerKey * operationsPerThread; - LOG.info( - "Multi-key test completed: {} operations in {}ms, throughput: {}/sec", - totalOperations.get(), - duration, - (totalOperations.get() * 1000L) / duration); - - assertEquals(expectedOperations, totalOperations.get()); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Performance Benchmark Tests") - class PerformanceBenchmarkTests { - - @Test - @DisplayName("Should measure lock acquisition latency") - void shouldMeasureLockAcquisitionLatency() throws InterruptedException { - int iterations = 100; - ConcurrentLinkedQueue latencies = new ConcurrentLinkedQueue<>(); - - for (int i = 0; i < iterations; i++) { - long start = System.nanoTime(); - testService.latencyMeasureMethod(); - long latency = System.nanoTime() - start; - latencies.add(latency); - } - - List sortedLatencies = new ArrayList<>(latencies); - sortedLatencies.sort(Long::compareTo); - - LongSummaryStatistics stats = - sortedLatencies.stream().mapToLong(Long::longValue).summaryStatistics(); - - long p50 = sortedLatencies.get(sortedLatencies.size() / 2); - long p95 = sortedLatencies.get((int) (sortedLatencies.size() * 0.95)); - long p99 = sortedLatencies.get((int) (sortedLatencies.size() * 0.99)); - - LOG.info( - "Lock acquisition latency (ns) - Min: {}, Max: {}, Avg: {}, P50: {}, P95: {}, P99: {}", - stats.getMin(), - stats.getMax(), - (long) stats.getAverage(), - p50, - p95, - p99); - - assertTrue(stats.getAverage() < 100_000_000, "Average latency should be under 100ms"); - } - - @Test - @DisplayName("Should measure throughput under contention") - void shouldMeasureThroughputUnderContention() throws InterruptedException { - int threadCount = 10; - int durationSeconds = 5; - AtomicInteger operationCount = new AtomicInteger(0); - AtomicLong totalLatency = new AtomicLong(0); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(threadCount); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int i = 0; i < threadCount; i++) { - executor.submit( - () -> { - try { - startSignal.await(); - long endTime = System.currentTimeMillis() + (durationSeconds * 1000L); - while (System.currentTimeMillis() < endTime) { - long start = System.nanoTime(); - testService.throughputMethod(); - totalLatency.addAndGet(System.nanoTime() - start); - operationCount.incrementAndGet(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue(allComplete.await(durationSeconds + 10, TimeUnit.SECONDS)); - - double throughput = operationCount.get() / (double) durationSeconds; - double avgLatencyMs = (totalLatency.get() / (double) operationCount.get()) / 1_000_000; - - LOG.info( - "Throughput test: {} operations in {}s, throughput: {}/sec, avg latency: {}ms", - operationCount.get(), - durationSeconds, - throughput, - avgLatencyMs); - - assertTrue(operationCount.get() > 0, "Should complete some operations"); - - executor.shutdown(); - } - } - - @Nested - @DisplayName("Resource Leak Tests") - class ResourceLeakTests { - - @Test - @DisplayName("Should not leak resources during extended operation") - void shouldNotLeakResourcesDuringExtendedOperation() throws InterruptedException { - int iterations = 200; - AtomicInteger successCount = new AtomicInteger(0); - - Runtime runtime = Runtime.getRuntime(); - runtime.gc(); - long initialMemory = runtime.totalMemory() - runtime.freeMemory(); - - for (int i = 0; i < iterations; i++) { - testService.resourceLeakTestMethod(); - successCount.incrementAndGet(); - - if (i % 50 == 0 && i > 0) { - runtime.gc(); - long currentMemory = runtime.totalMemory() - runtime.freeMemory(); - long memoryIncrease = currentMemory - initialMemory; - LOG.debug("Iteration {}: Memory increase: {} bytes", i, memoryIncrease); - } - } - - runtime.gc(); - Thread.sleep(100); - long finalMemory = runtime.totalMemory() - runtime.freeMemory(); - long memoryIncrease = finalMemory - initialMemory; - - LOG.info( - "Resource leak test: {} iterations, memory increase: {} bytes ({} MB)", - iterations, - memoryIncrease, - memoryIncrease / (1024 * 1024)); - - assertEquals(iterations, successCount.get()); - assertTrue(memoryIncrease < 50 * 1024 * 1024, "Memory should not increase by more than 50MB"); - } - - @Test - @DisplayName("Should properly release locks after exceptions") - void shouldProperlyReleaseLocksAfterExceptions() throws InterruptedException { - int iterations = 50; - AtomicInteger exceptionCount = new AtomicInteger(0); - AtomicInteger successAfterExceptionCount = new AtomicInteger(0); - - for (int i = 0; i < iterations; i++) { - try { - testService.exceptionThrowingMethod(); - } catch (RuntimeException e) { - exceptionCount.incrementAndGet(); - } - - boolean acquired = testService.lockAfterExceptionMethod(); - if (acquired) { - successAfterExceptionCount.incrementAndGet(); - } - } - - LOG.info( - "Exception release test: {} exceptions, {} successful acquisitions after", - exceptionCount.get(), - successAfterExceptionCount.get()); - - assertEquals(iterations, exceptionCount.get(), "All iterations should throw exceptions"); - assertEquals( - iterations, - successAfterExceptionCount.get(), - "All lock acquisitions after exceptions should succeed"); - } - } - - @Nested - @DisplayName("Sustained Load Tests") - class SustainedLoadTests { - - @Test - @DisplayName("Should handle sustained load without degradation") - void shouldHandleSustainedLoadWithoutDegradation() throws InterruptedException { - int threadCount = 5; - int phaseDurationSeconds = 3; - int phaseCount = 3; - List phaseThroughputs = new ArrayList<>(); - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - - for (int phase = 0; phase < phaseCount; phase++) { - AtomicInteger operationCount = new AtomicInteger(0); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch phaseComplete = new CountDownLatch(threadCount); - - for (int t = 0; t < threadCount; t++) { - executor.submit( - () -> { - try { - startSignal.await(); - long endTime = System.currentTimeMillis() + (phaseDurationSeconds * 1000L); - while (System.currentTimeMillis() < endTime) { - testService.sustainedLoadMethod(); - operationCount.incrementAndGet(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - phaseComplete.countDown(); - } - }); - } - - startSignal.countDown(); - assertTrue(phaseComplete.await(phaseDurationSeconds + 5, TimeUnit.SECONDS)); - - double throughput = operationCount.get() / (double) phaseDurationSeconds; - phaseThroughputs.add(throughput); - LOG.info("Phase {} throughput: {}/sec", phase + 1, throughput); - - Thread.sleep(500); - } - - double avgThroughput = - phaseThroughputs.stream().mapToDouble(Double::doubleValue).average().orElse(0); - double minThroughput = - phaseThroughputs.stream().mapToDouble(Double::doubleValue).min().orElse(0); - - LOG.info( - "Sustained load test: avg throughput {}/sec, min throughput {}/sec", - avgThroughput, - minThroughput); - - assertTrue( - minThroughput >= avgThroughput * 0.7, "Throughput should not degrade by more than 30%"); - - executor.shutdown(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/VirtualThreadTest.java b/src/test/java/in/riido/locksmith/integration/VirtualThreadTest.java deleted file mode 100644 index e01b8d2..0000000 --- a/src/test/java/in/riido/locksmith/integration/VirtualThreadTest.java +++ /dev/null @@ -1,494 +0,0 @@ -package in.riido.locksmith.integration; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.aspect.DistributedLockAspect; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.integration.service.VirtualThreadTestService; -import in.riido.locksmith.integration.service.VirtualThreadTestServiceImpl; -import java.lang.reflect.Method; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledForJreRange; -import org.junit.jupiter.api.condition.JRE; -import org.junit.jupiter.api.extension.ExtendWith; -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; -import org.springframework.context.support.GenericApplicationContext; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@Testcontainers -@ExtendWith(DockerAvailableCondition.class) -@EnabledForJreRange(min = JRE.JAVA_21) -@DisplayName("Virtual Thread Integration Tests") -class VirtualThreadTest { - - private static final Logger LOG = LoggerFactory.getLogger(VirtualThreadTest.class); - private static final int REDIS_PORT = 6379; - - @Container - static GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(REDIS_PORT); - - private RedissonClient redissonClient; - private VirtualThreadTestService testService; - - private static ExecutorService newVirtualThreadExecutor() { - try { - Method method = Executors.class.getMethod("newVirtualThreadPerTaskExecutor"); - return (ExecutorService) method.invoke(null); - } catch (Exception e) { - throw new RuntimeException("Virtual threads not available", e); - } - } - - private static void shutdownExecutor(ExecutorService executor) { - executor.shutdown(); - try { - if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - - @BeforeEach - void setUp() { - Config config = new Config(); - config - .useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(REDIS_PORT)) - .setConnectionPoolSize(1000) - .setConnectionMinimumIdleSize(1000) - .setSubscriptionConnectionPoolSize(1000) - .setSubscriptionsPerConnection(1000); - redissonClient = Redisson.create(config); - - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(1), Duration.ofSeconds(30), "vthread:", false, false), - null, - null); - DistributedLockAspect aspect = - new DistributedLockAspect(redissonClient, properties, new GenericApplicationContext()); - - AspectJProxyFactory factory = new AspectJProxyFactory(new VirtualThreadTestServiceImpl()); - factory.setProxyTargetClass(true); - factory.addAspect(aspect); - testService = factory.getProxy(); - } - - @AfterEach - void tearDown() { - if (redissonClient != null && !redissonClient.isShutdown()) { - redissonClient.shutdown(); - } - } - - @Nested - @DisplayName("Virtual Thread Lock Exclusivity Tests") - class VirtualThreadLockExclusivityTests { - - @Test - @DisplayName("Should maintain lock exclusivity with virtual threads") - void shouldMaintainLockExclusivityWithVirtualThreads() throws InterruptedException { - int taskCount = 100; - AtomicBoolean concurrentExecution = new AtomicBoolean(false); - AtomicInteger activeThreads = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(taskCount); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < taskCount; i++) { - executor.submit( - () -> { - try { - testService.exclusiveLockMethod(activeThreads, concurrentExecution); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertFalse( - concurrentExecution.get(), - "Concurrent execution detected with virtual threads - lock exclusivity violated!"); - } finally { - shutdownExecutor(executor); - } - } - - @Test - @DisplayName("Should handle high concurrency with many virtual threads") - void shouldHandleHighConcurrencyWithManyVirtualThreads() throws InterruptedException { - int taskCount = 1000; - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger skipCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(taskCount); - - long startTime = System.currentTimeMillis(); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < taskCount; i++) { - executor.submit( - () -> { - try { - boolean result = testService.tryAcquireLock(); - if (result) { - successCount.incrementAndGet(); - } else { - skipCount.incrementAndGet(); - } - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - } finally { - shutdownExecutor(executor); - } - - long duration = System.currentTimeMillis() - startTime; - LOG.info( - "Virtual thread test: {} tasks in {}ms, {} successful, {} skipped", - taskCount, - duration, - successCount.get(), - skipCount.get()); - - assertTrue(successCount.get() > 0, "At least some tasks should acquire the lock"); - assertEquals( - taskCount, successCount.get() + skipCount.get(), "All tasks should be accounted for"); - } - - @Test - @DisplayName("Should prevent concurrent execution under virtual thread contention") - void shouldPreventConcurrentExecutionUnderVirtualThreadContention() - throws InterruptedException { - int taskCount = 50; - AtomicBoolean concurrentExecution = new AtomicBoolean(false); - AtomicInteger activeThreads = new AtomicInteger(0); - AtomicInteger successfulExecutions = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(taskCount); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < taskCount; i++) { - executor.submit( - () -> { - try { - boolean executed = - testService.contentionTestMethod(activeThreads, concurrentExecution); - if (executed) { - successfulExecutions.incrementAndGet(); - } - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertFalse( - concurrentExecution.get(), - "Concurrent execution detected under virtual thread contention!"); - assertTrue(successfulExecutions.get() > 0, "At least some executions should succeed"); - } finally { - shutdownExecutor(executor); - } - } - } - - @Nested - @DisplayName("Virtual Thread Read-Write Lock Tests") - class VirtualThreadReadWriteLockTests { - - @Test - @DisplayName("Should allow multiple concurrent readers with virtual threads") - void shouldAllowMultipleConcurrentReadersWithVirtualThreads() throws InterruptedException { - int readerCount = 50; - AtomicInteger maxConcurrentReaders = new AtomicInteger(0); - AtomicInteger currentReaders = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(readerCount); - CountDownLatch allStarted = new CountDownLatch(readerCount); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < readerCount; i++) { - executor.submit( - () -> { - try { - allStarted.countDown(); - allStarted.await(5, TimeUnit.SECONDS); - testService.readOperation(currentReaders, maxConcurrentReaders); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(30, TimeUnit.SECONDS)); - assertTrue( - maxConcurrentReaders.get() > 1, - "Should have had multiple concurrent readers with virtual threads, but got: " - + maxConcurrentReaders.get()); - } finally { - shutdownExecutor(executor); - } - } - - @Test - @DisplayName("Should block writers while readers hold lock with virtual threads") - void shouldBlockWritersWhileReadersHoldLockWithVirtualThreads() throws InterruptedException { - AtomicBoolean readerActive = new AtomicBoolean(false); - AtomicBoolean writerExecutedDuringRead = new AtomicBoolean(false); - CountDownLatch readerStarted = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(2); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - executor.submit( - () -> { - try { - testService.longReadOperation(readerActive, readerStarted); - } finally { - allComplete.countDown(); - } - }); - - assertTrue(readerStarted.await(5, TimeUnit.SECONDS)); - - executor.submit( - () -> { - try { - Thread.sleep(100); - if (readerActive.get()) { - boolean executed = testService.tryWriteOperation(); - if (executed) { - writerExecutedDuringRead.set(true); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - - assertTrue(allComplete.await(15, TimeUnit.SECONDS)); - assertFalse( - writerExecutedDuringRead.get(), - "Writer should not execute while reader holds lock with virtual threads"); - } finally { - shutdownExecutor(executor); - } - } - } - - @Nested - @DisplayName("Virtual Thread Lock Key Isolation Tests") - class VirtualThreadLockKeyIsolationTests { - - @Test - @DisplayName("Should allow parallel execution with different lock keys using virtual threads") - void shouldAllowParallelExecutionWithDifferentLockKeysUsingVirtualThreads() - throws InterruptedException { - int keyCount = 100; - AtomicInteger concurrentExecutions = new AtomicInteger(0); - AtomicInteger maxConcurrentExecutions = new AtomicInteger(0); - CountDownLatch allStarted = new CountDownLatch(keyCount); - CountDownLatch allComplete = new CountDownLatch(keyCount); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < keyCount; i++) { - final String lockKey = "key-" + i; - executor.submit( - () -> { - try { - allStarted.countDown(); - allStarted.await(10, TimeUnit.SECONDS); - testService.isolatedLockMethod( - lockKey, concurrentExecutions, maxConcurrentExecutions); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertTrue( - maxConcurrentExecutions.get() > 1, - "Should have had concurrent executions with different keys using virtual threads, but got: " - + maxConcurrentExecutions.get()); - LOG.info( - "Virtual thread key isolation: {} max concurrent executions with {} unique keys", - maxConcurrentExecutions.get(), - keyCount); - } finally { - shutdownExecutor(executor); - } - } - } - - @Nested - @DisplayName("Virtual Thread Wait Mode Tests") - class VirtualThreadWaitModeTests { - - @Test - @DisplayName("Should wait and execute sequentially with virtual threads") - void shouldWaitAndExecuteSequentiallyWithVirtualThreads() throws InterruptedException { - int taskCount = 10; - List executionOrder = Collections.synchronizedList(new ArrayList<>()); - CountDownLatch allComplete = new CountDownLatch(taskCount); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < taskCount; i++) { - final int index = i; - executor.submit( - () -> { - try { - Thread.sleep(index * 10L); - testService.waitAndExecuteMethod(index, executionOrder); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(60, TimeUnit.SECONDS)); - assertEquals(taskCount, executionOrder.size(), "All tasks should execute"); - } finally { - shutdownExecutor(executor); - } - } - - @Test - @DisplayName("Should handle burst of virtual threads waiting for lock") - void shouldHandleBurstOfVirtualThreadsWaitingForLock() throws InterruptedException { - int burstSize = 50; - AtomicInteger completedCount = new AtomicInteger(0); - AtomicBoolean concurrentExecution = new AtomicBoolean(false); - AtomicInteger activeThreads = new AtomicInteger(0); - CountDownLatch allReady = new CountDownLatch(burstSize); - CountDownLatch startSignal = new CountDownLatch(1); - CountDownLatch allComplete = new CountDownLatch(burstSize); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < burstSize; i++) { - executor.submit( - () -> { - try { - allReady.countDown(); - startSignal.await(); - testService.waitForLockMethod(activeThreads, concurrentExecution); - completedCount.incrementAndGet(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allReady.await(10, TimeUnit.SECONDS)); - startSignal.countDown(); - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - assertFalse( - concurrentExecution.get(), "Concurrent execution detected in virtual thread burst!"); - assertEquals(burstSize, completedCount.get(), "All virtual threads should complete"); - } finally { - shutdownExecutor(executor); - } - } - } - - @Nested - @DisplayName("Virtual Thread Performance Tests") - class VirtualThreadPerformanceTests { - - @Test - @DisplayName("Should demonstrate virtual thread efficiency with many waiting tasks") - void shouldDemonstrateVirtualThreadEfficiencyWithManyWaitingTasks() - throws InterruptedException { - var keys = redissonClient.getKeys(); - if (keys != null && keys.count() > 0) { - keys.flushall(); - LOG.info("Cleared existing keys in Redis before performance test"); - Thread.sleep(100); - } - int taskCount = 500; - AtomicInteger completedCount = new AtomicInteger(0); - CountDownLatch allComplete = new CountDownLatch(taskCount); - - long startTime = System.currentTimeMillis(); - - ExecutorService executor = newVirtualThreadExecutor(); - try { - for (int i = 0; i < taskCount; i++) { - final String key = "perf-key-" + (i % 100); - executor.submit( - () -> { - try { - testService.performanceTestMethod(key); - completedCount.incrementAndGet(); - } finally { - allComplete.countDown(); - } - }); - } - - assertTrue(allComplete.await(120, TimeUnit.SECONDS)); - } finally { - shutdownExecutor(executor); - } - - long duration = System.currentTimeMillis() - startTime; - double throughput = (completedCount.get() * 1000.0) / duration; - - LOG.info( - "Virtual thread performance: {} tasks completed in {}ms, throughput: {}/sec", - completedCount.get(), - duration, - String.format("%.2f", throughput)); - - assertEquals(taskCount, completedCount.get(), "All tasks should complete"); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestService.java b/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestService.java deleted file mode 100644 index eb74de9..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestService.java +++ /dev/null @@ -1,28 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service interface for concurrent access tests. */ -public interface ConcurrencyTestService { - void exclusiveLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution); - - boolean contentionTestMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution); - - void singleExecutionMethod(AtomicInteger executionCount); - - boolean incrementCounter(AtomicInteger counter); - - void orderedExecutionMethod(int index, List order); - - void readOperation(AtomicInteger currentReaders, AtomicInteger maxConcurrentReaders); - - void writeOperation(AtomicBoolean writerActive, CountDownLatch started); - - boolean readDuringWrite(); - - void isolatedLockMethod( - String key, AtomicInteger concurrentExecutions, AtomicInteger maxConcurrentExecutions); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestServiceImpl.java deleted file mode 100644 index fc2dfbe..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/ConcurrencyTestServiceImpl.java +++ /dev/null @@ -1,130 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.LockType; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service implementation for concurrent access tests. */ -public class ConcurrencyTestServiceImpl implements ConcurrencyTestService { - - @Override - @DistributedLock(key = "exclusive-lock", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s") - public void exclusiveLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution) { - int current = activeThreads.incrementAndGet(); - if (current > 1) { - concurrentExecution.set(true); - } - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - activeThreads.decrementAndGet(); - } - - @Override - @DistributedLock(key = "contention-lock", skipHandler = LockReturnDefaultHandler.class) - public boolean contentionTestMethod( - AtomicInteger activeThreads, AtomicBoolean concurrentExecution) { - int current = activeThreads.incrementAndGet(); - if (current > 1) { - concurrentExecution.set(true); - } - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - activeThreads.decrementAndGet(); - return true; - } - - @Override - @DistributedLock(key = "single-exec-lock", skipHandler = LockReturnDefaultHandler.class) - public void singleExecutionMethod(AtomicInteger executionCount) { - executionCount.incrementAndGet(); - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock(key = "counter-lock", skipHandler = LockReturnDefaultHandler.class) - public boolean incrementCounter(AtomicInteger counter) { - counter.incrementAndGet(); - return true; - } - - @Override - @DistributedLock(key = "order-lock", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "10s") - public void orderedExecutionMethod(int index, List order) { - order.add(index); - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock( - key = "rw-concurrent", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public void readOperation(AtomicInteger currentReaders, AtomicInteger maxConcurrentReaders) { - int current = currentReaders.incrementAndGet(); - maxConcurrentReaders.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - currentReaders.decrementAndGet(); - } - - @Override - @DistributedLock( - key = "rw-block-test", - type = LockType.WRITE, - skipHandler = LockReturnDefaultHandler.class) - public void writeOperation(AtomicBoolean writerActive, CountDownLatch started) { - writerActive.set(true); - started.countDown(); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - writerActive.set(false); - } - - @Override - @DistributedLock( - key = "rw-block-test", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public boolean readDuringWrite() { - return true; - } - - @Override - @DistributedLock(key = "#{#key}", skipHandler = LockReturnDefaultHandler.class) - public void isolatedLockMethod( - String key, AtomicInteger concurrentExecutions, AtomicInteger maxConcurrentExecutions) { - int current = concurrentExecutions.incrementAndGet(); - maxConcurrentExecutions.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(300); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - concurrentExecutions.decrementAndGet(); - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/IntegrationTestService.java b/src/test/java/in/riido/locksmith/integration/service/IntegrationTestService.java deleted file mode 100644 index 2d02d7b..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/IntegrationTestService.java +++ /dev/null @@ -1,38 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service interface for distributed lock integration tests. */ -public interface IntegrationTestService { - String simpleLockedMethod(); - - String lockedMethodWithSpelKey(String userId); - - void longRunningMethod(CountDownLatch started, AtomicInteger count); - - String tryAcquireSameLock(); - - void holdLockForThrowTest(CountDownLatch started); - - String throwOnLockNotAcquired(); - - void shortHoldingMethod(AtomicInteger count); - - void waitAndExecuteMethod(AtomicInteger count); - - void readLockMethod( - AtomicInteger concurrentReaders, - AtomicInteger maxConcurrentReaders, - CountDownLatch allStarted); - - void holdReadLock(CountDownLatch acquired); - - String tryWriteLock(AtomicInteger count); - - void holdWriteLock(CountDownLatch acquired); - - String tryReadLock(AtomicInteger count); - - String autoRenewMethod(); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/IntegrationTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/IntegrationTestServiceImpl.java deleted file mode 100644 index de0d578..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/IntegrationTestServiceImpl.java +++ /dev/null @@ -1,155 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.LockType; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service implementation for distributed lock integration tests. */ -public class IntegrationTestServiceImpl implements IntegrationTestService { - - @Override - @DistributedLock(key = "simple-lock") - public String simpleLockedMethod() { - return "executed"; - } - - @Override - @DistributedLock(key = "#{#userId}") - public String lockedMethodWithSpelKey(String userId) { - return "processed:" + userId; - } - - @Override - @DistributedLock(key = "long-running-lock", skipHandler = LockReturnDefaultHandler.class) - public void longRunningMethod(CountDownLatch started, AtomicInteger count) { - started.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - count.incrementAndGet(); - } - - @Override - @DistributedLock(key = "long-running-lock", skipHandler = LockReturnDefaultHandler.class) - public String tryAcquireSameLock() { - return "should-not-execute"; - } - - @Override - @DistributedLock(key = "throw-test-lock", skipHandler = LockReturnDefaultHandler.class) - public void holdLockForThrowTest(CountDownLatch started) { - started.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock(key = "throw-test-lock") - public String throwOnLockNotAcquired() { - return "should-not-execute"; - } - - @Override - @DistributedLock(key = "wait-test-lock", skipHandler = LockReturnDefaultHandler.class) - public void shortHoldingMethod(AtomicInteger count) { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - count.incrementAndGet(); - } - - @Override - @DistributedLock(key = "wait-test-lock", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s") - public void waitAndExecuteMethod(AtomicInteger count) { - count.incrementAndGet(); - } - - @Override - @DistributedLock( - key = "rw-lock", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public void readLockMethod( - AtomicInteger concurrentReaders, - AtomicInteger maxConcurrentReaders, - CountDownLatch allStarted) { - int current = concurrentReaders.incrementAndGet(); - maxConcurrentReaders.updateAndGet(max -> Math.max(max, current)); - allStarted.countDown(); - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - concurrentReaders.decrementAndGet(); - } - - @Override - @DistributedLock( - key = "rw-test-lock", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public void holdReadLock(CountDownLatch acquired) { - acquired.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock( - key = "rw-test-lock", - type = LockType.WRITE, - skipHandler = LockReturnDefaultHandler.class) - public String tryWriteLock(AtomicInteger count) { - count.incrementAndGet(); - return "write-executed"; - } - - @Override - @DistributedLock( - key = "rw-test-lock2", - type = LockType.WRITE, - skipHandler = LockReturnDefaultHandler.class) - public void holdWriteLock(CountDownLatch acquired) { - acquired.countDown(); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock( - key = "rw-test-lock2", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public String tryReadLock(AtomicInteger count) { - count.incrementAndGet(); - return "read-executed"; - } - - @Override - @DistributedLock(key = "auto-renew-lock", autoRenew = true) - public String autoRenewMethod() { - try { - Thread.sleep(2500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return "completed"; - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestService.java b/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestService.java deleted file mode 100644 index 159cc0a..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestService.java +++ /dev/null @@ -1,39 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Service interface for rate limit integration tests. - * - * @author Garvit Joshi - * @since 2.1.0 - */ -public interface RateLimitIntegrationTestService { - - /** Simple rate limited method with default settings. */ - String simpleRateLimitedMethod(); - - /** Rate limited method with SpEL key resolution. */ - String rateLimitedMethodWithSpelKey(String resourceId); - - /** Rate limited method that tracks execution count. */ - void trackingMethod(AtomicInteger counter); - - /** Rate limited method with WAIT_AND_SKIP mode. */ - void waitAndAcquireMethod(AtomicInteger counter); - - /** Rate limited method that throws exception when limit exceeded. */ - String throwOnLimitExceeded(); - - /** Rate limited method that returns default when limit exceeded. */ - String returnDefaultOnLimitExceeded(); - - /** Rate limited method with short work for throughput tests. */ - void shortWorkMethod(AtomicInteger counter); - - /** Rate limited method with isolated key. */ - void isolatedKeyMethod(String key, AtomicInteger counter); - - /** Rate limited method that sleeps to test timing. */ - void sleepingMethod(long millis); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestServiceImpl.java deleted file mode 100644 index b68ca57..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/RateLimitIntegrationTestServiceImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.RateLimit; -import in.riido.locksmith.handler.ratelimit.RateLimitReturnDefaultHandler; -import in.riido.locksmith.handler.ratelimit.RateLimitThrowExceptionHandler; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Implementation of rate limit integration test service. - * - * @author Garvit Joshi - * @since 2.1.0 - */ -public class RateLimitIntegrationTestServiceImpl implements RateLimitIntegrationTestService { - - @Override - @RateLimit(key = "simple-rate-limit", permits = 10, interval = "1s") - public String simpleRateLimitedMethod() { - return "executed"; - } - - @Override - @RateLimit(key = "#{#resourceId}", permits = 5, interval = "1s") - public String rateLimitedMethodWithSpelKey(String resourceId) { - return "processed:" + resourceId; - } - - @Override - @RateLimit( - key = "tracking-rate-limit", - permits = 5, - interval = "1s", - mode = AcquisitionMode.SKIP_IMMEDIATELY, - skipHandler = RateLimitReturnDefaultHandler.class) - public void trackingMethod(AtomicInteger counter) { - counter.incrementAndGet(); - } - - @Override - @RateLimit( - key = "wait-rate-limit", - permits = 3, - interval = "1s", - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "10s") - public void waitAndAcquireMethod(AtomicInteger counter) { - counter.incrementAndGet(); - } - - @Override - @RateLimit( - key = "throw-rate-limit", - permits = 1, - interval = "10s", - mode = AcquisitionMode.SKIP_IMMEDIATELY, - skipHandler = RateLimitThrowExceptionHandler.class) - public String throwOnLimitExceeded() { - return "executed"; - } - - @Override - @RateLimit( - key = "default-rate-limit", - permits = 1, - interval = "10s", - mode = AcquisitionMode.SKIP_IMMEDIATELY, - skipHandler = RateLimitReturnDefaultHandler.class) - public String returnDefaultOnLimitExceeded() { - return "executed"; - } - - @Override - @RateLimit( - key = "throughput-rate-limit", - permits = 100, - interval = "1s", - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void shortWorkMethod(AtomicInteger counter) { - counter.incrementAndGet(); - } - - @Override - @RateLimit( - key = "#{#key}", - permits = 5, - interval = "1s", - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "10s") - public void isolatedKeyMethod(String key, AtomicInteger counter) { - counter.incrementAndGet(); - } - - @Override - @RateLimit(key = "sleep-rate-limit", permits = 10, interval = "1s") - public void sleepingMethod(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestService.java b/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestService.java deleted file mode 100644 index 7538a2a..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestService.java +++ /dev/null @@ -1,48 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Service interface for semaphore concurrency tests. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public interface SemaphoreConcurrencyTestService { - - /** Track concurrent executions with configurable permit limit. */ - void trackExecution( - AtomicInteger activeCount, - AtomicInteger maxConcurrent, - AtomicInteger completedCount, - int sleepMs); - - /** Test with 2 permits. */ - void twoPermitMethod(AtomicInteger activeCount, AtomicInteger maxConcurrent); - - /** Test with 10 permits for high concurrency. */ - void tenPermitMethod(AtomicInteger activeCount, AtomicInteger maxConcurrent); - - /** Test permit acquisition with skip on failure. */ - boolean skipOnFailure(AtomicInteger successCount); - - /** Test permit acquisition with wait mode. */ - void waitForPermit(AtomicInteger counter); - - /** Test isolated key execution. */ - void isolatedKeyMethod(String key, AtomicInteger counter); - - /** Increment counter with permit protection. */ - void protectedIncrement(AtomicInteger counter); - - /** Long running operation to test permit holding. */ - void longRunningOperation(AtomicBoolean isExecuting, int durationMs); - - /** Check if can acquire permit while another is executing. */ - boolean canAcquireWhileOtherExecuting(); - - /** Track execution order. */ - void trackOrder(int id, List order); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestServiceImpl.java deleted file mode 100644 index 63b0b7a..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/SemaphoreConcurrencyTestServiceImpl.java +++ /dev/null @@ -1,161 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedSemaphore; -import in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Implementation of SemaphoreConcurrencyTestService. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreConcurrencyTestServiceImpl implements SemaphoreConcurrencyTestService { - - @Override - @DistributedSemaphore( - key = "track-execution", - permits = 5, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void trackExecution( - AtomicInteger activeCount, - AtomicInteger maxConcurrent, - AtomicInteger completedCount, - int sleepMs) { - int current = activeCount.incrementAndGet(); - try { - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - sleep(sleepMs); - completedCount.incrementAndGet(); - } finally { - activeCount.decrementAndGet(); - } - } - - @Override - @DistributedSemaphore( - key = "two-permit", - permits = 2, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "10s") - public void twoPermitMethod(AtomicInteger activeCount, AtomicInteger maxConcurrent) { - int current = activeCount.incrementAndGet(); - try { - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - sleep(100); - } finally { - activeCount.decrementAndGet(); - } - } - - @Override - @DistributedSemaphore( - key = "ten-permit", - permits = 10, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void tenPermitMethod(AtomicInteger activeCount, AtomicInteger maxConcurrent) { - int current = activeCount.incrementAndGet(); - try { - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - sleep(50); - } finally { - activeCount.decrementAndGet(); - } - } - - @Override - @DistributedSemaphore( - key = "skip-on-failure", - permits = 3, - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean skipOnFailure(AtomicInteger successCount) { - successCount.incrementAndGet(); - sleep(100); - return true; - } - - @Override - @DistributedSemaphore( - key = "wait-for-permit", - permits = 3, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "15s") - public void waitForPermit(AtomicInteger counter) { - counter.incrementAndGet(); - sleep(50); - } - - @Override - @DistributedSemaphore( - key = "#{#key}", - permits = 2, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "10s") - public void isolatedKeyMethod(String key, AtomicInteger counter) { - counter.incrementAndGet(); - sleep(50); - } - - @Override - @DistributedSemaphore( - key = "protected-counter", - permits = 1, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void protectedIncrement(AtomicInteger counter) { - // With permits=1, this acts like a distributed lock - int current = counter.get(); - sleep(10); // Simulate some processing - counter.set(current + 1); - } - - @Override - @DistributedSemaphore( - key = "long-running", - permits = 1, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "5s") - public void longRunningOperation(AtomicBoolean isExecuting, int durationMs) { - isExecuting.set(true); - try { - sleep(durationMs); - } finally { - isExecuting.set(false); - } - } - - @Override - @DistributedSemaphore( - key = "long-running", - permits = 1, - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean canAcquireWhileOtherExecuting() { - return true; - } - - @Override - @DistributedSemaphore( - key = "order-tracking", - permits = 2, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void trackOrder(int id, List order) { - synchronized (order) { - order.add(id); - } - sleep(30); - } - - private void sleep(int ms) { - try { - Thread.sleep(ms); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestService.java b/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestService.java deleted file mode 100644 index d9b5860..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestService.java +++ /dev/null @@ -1,58 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Service interface for distributed semaphore integration tests. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public interface SemaphoreIntegrationTestService { - - // Basic semaphore operations - void simplePermitMethod(); - - String permitMethodWithSpelKey(String resourceId); - - // Permit limit tests - boolean tryAcquirePermit(AtomicInteger activeCount, AtomicInteger maxConcurrent); - - void holdPermitForDuration(CountDownLatch started, CountDownLatch canRelease); - - // Skip handler tests - void throwOnPermitNotAcquired(); - - Object returnDefaultOnPermitNotAcquired(); - - // Wait mode tests - boolean waitAndAcquirePermit(AtomicInteger counter); - - // Lease expiration tests - void permitWithLeaseExpiration(); - - void permitWithThrowOnLeaseExpired(); - - // Concurrent execution tracking - void trackConcurrentExecution( - AtomicInteger activeCount, - AtomicInteger maxConcurrent, - AtomicInteger completedCount, - int sleepMs); - - // Multi-key tests - void multiKeyPermit(String key, AtomicInteger counter); - - // High contention tests - boolean highContentionPermit(AtomicInteger successCount, AtomicInteger skipCount); - - // Order tracking - void orderedPermitAcquisition(int id, List executionOrder); - - // Exception handling - void permitWithException(); - - boolean acquirePermitAfterException(); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestServiceImpl.java deleted file mode 100644 index 66836be..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/SemaphoreIntegrationTestServiceImpl.java +++ /dev/null @@ -1,222 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedSemaphore; -import in.riido.locksmith.LeaseExpirationBehavior; -import in.riido.locksmith.handler.semaphore.SemaphoreReturnDefaultHandler; -import in.riido.locksmith.handler.semaphore.SemaphoreThrowExceptionHandler; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Implementation of SemaphoreIntegrationTestService with @DistributedSemaphore annotations. - * - * @author Garvit Joshi - * @since 2.0.0 - */ -public class SemaphoreIntegrationTestServiceImpl implements SemaphoreIntegrationTestService { - - @Override - @DistributedSemaphore(key = "simple-semaphore", permits = 3) - public void simplePermitMethod() { - // Simple method that acquires a permit - } - - @Override - @DistributedSemaphore(key = "#{#resourceId}", permits = 5) - public String permitMethodWithSpelKey(String resourceId) { - return "processed-" + resourceId; - } - - @Override - @DistributedSemaphore( - key = "limited-permits", - permits = 3, - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean tryAcquirePermit(AtomicInteger activeCount, AtomicInteger maxConcurrent) { - int current = activeCount.incrementAndGet(); - try { - // Track maximum concurrent executions - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return true; - } finally { - activeCount.decrementAndGet(); - } - } - - @Override - @DistributedSemaphore(key = "hold-permit", permits = 2) - public void holdPermitForDuration(CountDownLatch started, CountDownLatch canRelease) { - started.countDown(); - try { - canRelease.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "throw-on-not-acquired", - permits = 1, - skipHandler = SemaphoreThrowExceptionHandler.class) - public void throwOnPermitNotAcquired() { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "return-default", - permits = 1, - skipHandler = SemaphoreReturnDefaultHandler.class) - public Object returnDefaultOnPermitNotAcquired() { - try { - Thread.sleep(200); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return "executed"; - } - - @Override - @DistributedSemaphore( - key = "wait-and-acquire", - permits = 2, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "5s", - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean waitAndAcquirePermit(AtomicInteger counter) { - counter.incrementAndGet(); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return true; - } - - @Override - @DistributedSemaphore( - key = "lease-expiration", - permits = 3, - leaseTime = "500ms", - onLeaseExpired = LeaseExpirationBehavior.LOG_WARNING) - public void permitWithLeaseExpiration() { - try { - Thread.sleep(600); // Sleep longer than lease time - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "lease-throw", - permits = 2, - leaseTime = "300ms", - onLeaseExpired = LeaseExpirationBehavior.THROW_EXCEPTION) - public void permitWithThrowOnLeaseExpired() { - try { - Thread.sleep(400); // Sleep longer than lease time - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "concurrent-tracking", - permits = 5, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "10s") - public void trackConcurrentExecution( - AtomicInteger activeCount, - AtomicInteger maxConcurrent, - AtomicInteger completedCount, - int sleepMs) { - int current = activeCount.incrementAndGet(); - try { - maxConcurrent.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(sleepMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - completedCount.incrementAndGet(); - } finally { - activeCount.decrementAndGet(); - } - } - - @Override - @DistributedSemaphore( - key = "#{#key}", - permits = 3, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "5s") - public void multiKeyPermit(String key, AtomicInteger counter) { - counter.incrementAndGet(); - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore( - key = "high-contention", - permits = 5, - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean highContentionPermit(AtomicInteger successCount, AtomicInteger skipCount) { - successCount.incrementAndGet(); - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return true; - } - - @Override - @DistributedSemaphore( - key = "ordered-execution", - permits = 2, - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void orderedPermitAcquisition(int id, List executionOrder) { - synchronized (executionOrder) { - executionOrder.add(id); - } - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedSemaphore(key = "exception-test", permits = 2) - public void permitWithException() { - throw new RuntimeException("Intentional exception for testing"); - } - - @Override - @DistributedSemaphore( - key = "exception-test", - permits = 2, - skipHandler = SemaphoreReturnDefaultHandler.class) - public boolean acquirePermitAfterException() { - return true; - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/StressTestService.java b/src/test/java/in/riido/locksmith/integration/service/StressTestService.java deleted file mode 100644 index c91fdc0..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/StressTestService.java +++ /dev/null @@ -1,20 +0,0 @@ -package in.riido.locksmith.integration.service; - -/** Test service interface for stress and performance tests. */ -public interface StressTestService { - boolean highVolumeMethod(); - - void multiKeyMethod(String key); - - void latencyMeasureMethod(); - - void throughputMethod(); - - void resourceLeakTestMethod(); - - void exceptionThrowingMethod(); - - boolean lockAfterExceptionMethod(); - - void sustainedLoadMethod(); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/StressTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/StressTestServiceImpl.java deleted file mode 100644 index 433aaea..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/StressTestServiceImpl.java +++ /dev/null @@ -1,57 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; - -/** Test service implementation for stress and performance tests. */ -public class StressTestServiceImpl implements StressTestService { - - @Override - @DistributedLock(key = "high-volume", skipHandler = LockReturnDefaultHandler.class) - public boolean highVolumeMethod() { - return true; - } - - @Override - @DistributedLock(key = "#{#key}", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s") - public void multiKeyMethod(String key) { - // Minimal work - } - - @Override - @DistributedLock(key = "latency-test") - public void latencyMeasureMethod() { - // Minimal work to measure lock overhead - } - - @Override - @DistributedLock(key = "throughput-test", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "10s") - public void throughputMethod() { - // Minimal work - } - - @Override - @DistributedLock(key = "resource-leak-test") - public void resourceLeakTestMethod() { - // Minimal work - } - - @Override - @DistributedLock(key = "exception-test") - public void exceptionThrowingMethod() { - throw new RuntimeException("Test exception"); - } - - @Override - @DistributedLock(key = "exception-test", skipHandler = LockReturnDefaultHandler.class) - public boolean lockAfterExceptionMethod() { - return true; - } - - @Override - @DistributedLock(key = "sustained-load", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "5s") - public void sustainedLoadMethod() { - // Minimal work - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestService.java b/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestService.java deleted file mode 100644 index 7774abe..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestService.java +++ /dev/null @@ -1,30 +0,0 @@ -package in.riido.locksmith.integration.service; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service interface for virtual thread integration tests. */ -public interface VirtualThreadTestService { - void exclusiveLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution); - - boolean tryAcquireLock(); - - boolean contentionTestMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution); - - void readOperation(AtomicInteger currentReaders, AtomicInteger maxConcurrentReaders); - - void longReadOperation(AtomicBoolean readerActive, CountDownLatch started); - - boolean tryWriteOperation(); - - void isolatedLockMethod( - String key, AtomicInteger concurrentExecutions, AtomicInteger maxConcurrentExecutions); - - void waitAndExecuteMethod(int index, List order); - - void waitForLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution); - - void performanceTestMethod(String key); -} diff --git a/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestServiceImpl.java b/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestServiceImpl.java deleted file mode 100644 index 7ee604e..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/VirtualThreadTestServiceImpl.java +++ /dev/null @@ -1,151 +0,0 @@ -package in.riido.locksmith.integration.service; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.DistributedLock; -import in.riido.locksmith.LockType; -import in.riido.locksmith.handler.lock.LockReturnDefaultHandler; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -/** Test service implementation for virtual thread integration tests. */ -public class VirtualThreadTestServiceImpl implements VirtualThreadTestService { - - @Override - @DistributedLock( - key = "vt-exclusive-lock", - mode = AcquisitionMode.WAIT_AND_SKIP, - waitTime = "30s") - public void exclusiveLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution) { - int current = activeThreads.incrementAndGet(); - if (current > 1) { - concurrentExecution.set(true); - } - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - activeThreads.decrementAndGet(); - } - - @Override - @DistributedLock(key = "vt-try-lock", skipHandler = LockReturnDefaultHandler.class) - public boolean tryAcquireLock() { - try { - Thread.sleep(5); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - return true; - } - - @Override - @DistributedLock(key = "vt-contention-lock", skipHandler = LockReturnDefaultHandler.class) - public boolean contentionTestMethod( - AtomicInteger activeThreads, AtomicBoolean concurrentExecution) { - int current = activeThreads.incrementAndGet(); - if (current > 1) { - concurrentExecution.set(true); - } - try { - Thread.sleep(20); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - activeThreads.decrementAndGet(); - return true; - } - - @Override - @DistributedLock( - key = "vt-rw-concurrent", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public void readOperation(AtomicInteger currentReaders, AtomicInteger maxConcurrentReaders) { - int current = currentReaders.incrementAndGet(); - maxConcurrentReaders.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - currentReaders.decrementAndGet(); - } - - @Override - @DistributedLock( - key = "vt-rw-block-test", - type = LockType.READ, - skipHandler = LockReturnDefaultHandler.class) - public void longReadOperation(AtomicBoolean readerActive, CountDownLatch started) { - readerActive.set(true); - started.countDown(); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - readerActive.set(false); - } - - @Override - @DistributedLock( - key = "vt-rw-block-test", - type = LockType.WRITE, - skipHandler = LockReturnDefaultHandler.class) - public boolean tryWriteOperation() { - return true; - } - - @Override - @DistributedLock(key = "#{#key}", skipHandler = LockReturnDefaultHandler.class) - public void isolatedLockMethod( - String key, AtomicInteger concurrentExecutions, AtomicInteger maxConcurrentExecutions) { - int current = concurrentExecutions.incrementAndGet(); - maxConcurrentExecutions.updateAndGet(max -> Math.max(max, current)); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - concurrentExecutions.decrementAndGet(); - } - - @Override - @DistributedLock(key = "vt-order-lock", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "30s") - public void waitAndExecuteMethod(int index, List order) { - order.add(index); - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - @Override - @DistributedLock(key = "vt-wait-lock", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "60s") - public void waitForLockMethod(AtomicInteger activeThreads, AtomicBoolean concurrentExecution) { - int current = activeThreads.incrementAndGet(); - if (current > 1) { - concurrentExecution.set(true); - } - try { - Thread.sleep(5); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - activeThreads.decrementAndGet(); - } - - @Override - @DistributedLock(key = "#{#key}", mode = AcquisitionMode.WAIT_AND_SKIP, waitTime = "30s") - public void performanceTestMethod(String key) { - try { - Thread.sleep(5); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/src/test/java/in/riido/locksmith/integration/service/package-info.java b/src/test/java/in/riido/locksmith/integration/service/package-info.java deleted file mode 100644 index a25c2e3..0000000 --- a/src/test/java/in/riido/locksmith/integration/service/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Test service interfaces and implementations for integration testing. - * - *

This package contains service classes used by integration tests to verify distributed locking - * functionality. - */ -@org.jspecify.annotations.NullMarked -package in.riido.locksmith.integration.service; diff --git a/src/test/java/in/riido/locksmith/lock/LockHandleTest.java b/src/test/java/in/riido/locksmith/lock/LockHandleTest.java new file mode 100644 index 0000000..f376c12 --- /dev/null +++ b/src/test/java/in/riido/locksmith/lock/LockHandleTest.java @@ -0,0 +1,397 @@ +package in.riido.locksmith.lock; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisConnectionException; +import org.redisson.misc.CompletableFutureWrapper; +import org.slf4j.LoggerFactory; + +@DisplayName("LockHandle") +class LockHandleTest { + + private static final String FULL_KEY = "locksmith:lock:k"; + private static final long OWNER = 7L; + + private RedissonClient redisson; + private RLock lock; + private LocksmithMetrics metrics; + private Logger logger; + private ListAppender appender; + + @BeforeEach + void setUp() { + redisson = mock(RedissonClient.class); + lock = mock(RLock.class); + when(lock.unlockAsync(anyLong())).thenReturn(new CompletableFutureWrapper<>((Void) null)); + metrics = mock(LocksmithMetrics.class); + logger = (Logger) LoggerFactory.getLogger(LockHandle.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + Thread.interrupted(); + } + + private LockHandle acquiredHandle(Duration fixedLease) { + return new LockHandle( + redisson, lock, OWNER, FULL_KEY, fixedLease, System.nanoTime(), Duration.ZERO, metrics); + } + + private void releaseFailsWith(Throwable failure) { + when(lock.unlockAsync(OWNER)).thenReturn(new CompletableFutureWrapper<>(failure)); + } + + private List warnings() { + return appender.list.stream().filter(e -> e.getLevel() == Level.WARN).toList(); + } + + @Test + @DisplayName("key() returns the full key including the prefix") + void keyReturnsFullKey() { + assertThat(acquiredHandle(null).key()).isEqualTo(FULL_KEY); + } + + @Nested + @DisplayName("close on an acquired handle") + class Acquired { + + @Test + @DisplayName("unlocks as the owner and records locksmith.held for primitive LOCK") + void unlocksAndRecordsHeld() { + acquiredHandle(null).close(); + + verify(lock).unlockAsync(OWNER); + verify(lock, never()).unlock(); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + assertThat(warnings()).isEmpty(); + } + + @Test + @DisplayName("is idempotent: a second close does not unlock again") + void idempotent() { + LockHandle handle = acquiredHandle(null); + + handle.close(); + handle.close(); + + verify(lock).unlockAsync(OWNER); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + } + + @Test + @DisplayName("closed on another thread, it still unlocks as the owner") + void closedOnAnotherThread() throws Exception { + LockHandle handle = acquiredHandle(null); + + Thread other = new Thread(handle::close); + other.start(); + other.join(SECONDS.toMillis(5)); + + verify(lock).unlockAsync(OWNER); + assertThat(warnings()).isEmpty(); + } + + @Test + @DisplayName("waits until the release has finished before it returns") + void waitsForRelease() { + CompletableFuture release = new CompletableFuture<>(); + when(lock.unlockAsync(OWNER)).thenReturn(new CompletableFutureWrapper<>(release)); + CompletableFuture.delayedExecutor(200, MILLISECONDS).execute(() -> release.complete(null)); + + long start = System.nanoTime(); + acquiredHandle(null).close(); + + assertThat(Duration.ofNanos(System.nanoTime() - start)).isGreaterThan(Duration.ofMillis(150)); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"redisson-netty-1-1", "redisson-timer-1-1"}) + @DisplayName( + "on a Redisson I/O or timer thread it starts the release and returns without waiting") + void doesNotWaitOnRedissonIoOrTimerThread(String threadName) throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(lock.unlockAsync(OWNER)).thenReturn(new CompletableFutureWrapper<>(release)); + LockHandle handle = acquiredHandle(null); + AtomicBoolean returned = new AtomicBoolean(); + + Thread redisson = + new Thread( + () -> { + handle.close(); + returned.set(true); + }, + threadName); + redisson.start(); + redisson.join(SECONDS.toMillis(5)); + + assertThat(returned).isTrue(); + verify(lock).unlockAsync(OWNER); + verify(metrics, never()).recordHeld(any(), any()); + release.complete(null); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + } + + @Test + @DisplayName("on a Redisson executor thread, such as a listener, it waits for the release") + void waitsOnRedissonExecutorThread() throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(lock.unlockAsync(OWNER)).thenReturn(new CompletableFutureWrapper<>(release)); + LockHandle handle = acquiredHandle(null); + AtomicBoolean returned = new AtomicBoolean(); + + Thread listener = + new Thread( + () -> { + handle.close(); + returned.set(true); + }, + "redisson-3-1"); + listener.start(); + listener.join(200); + + assertThat(returned).isFalse(); + release.complete(null); + listener.join(SECONDS.toMillis(5)); + assertThat(returned).isTrue(); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + } + + @Test + @DisplayName("on an interrupted thread it releases, logs no WARN, and keeps the flag") + void interruptedThread() { + LockHandle handle = acquiredHandle(null); + Thread.currentThread().interrupt(); + + handle.close(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + verify(lock).unlockAsync(OWNER); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + assertThat(warnings()).isEmpty(); + } + + @Test + @DisplayName("a recordHeld failure after unlock does not throw and logs one metrics WARN") + void metricsFailureAfterUnlock() { + doThrow(new IllegalArgumentException("meter clash")) + .when(metrics) + .recordHeld(eq(Primitive.LOCK), any(Duration.class)); + LockHandle handle = acquiredHandle(null); + + assertThatNoException().isThrownBy(handle::close); + + verify(lock).unlockAsync(OWNER); + assertThat(warnings()).hasSize(1); + assertThat(warnings().get(0).getFormattedMessage()) + .isEqualTo("Lock [" + FULL_KEY + "] metrics recording failed: meter clash"); + } + } + + @Nested + @DisplayName("client shutting down") + class ClientShutdown { + + /** Closes on a new daemon thread, interrupted first; reports whether the flag survived. */ + private Thread closeInterrupted(LockHandle handle, AtomicBoolean flagKept) { + Thread closer = + new Thread( + () -> { + Thread.currentThread().interrupt(); + handle.close(); + flagKept.set(Thread.currentThread().isInterrupted()); + }); + // A close() that never returns must not keep the test JVM alive. + closer.setDaemon(true); + closer.start(); + return closer; + } + + @Test + @DisplayName( + "a release that never finishes: close() stops waiting within about two seconds, flag kept") + void stopsWaitingOnceShuttingDown() throws Exception { + when(lock.unlockAsync(OWNER)) + .thenReturn(new CompletableFutureWrapper<>(new CompletableFuture())); + when(redisson.isShuttingDown()).thenReturn(true); + AtomicBoolean flagKept = new AtomicBoolean(); + + Thread closer = closeInterrupted(acquiredHandle(null), flagKept); + closer.join(2500); + + // Before the fix, close() waited in join() for good, interrupted or not. + assertThat(closer.isAlive()).as("close() still waiting").isFalse(); + assertThat(flagKept).isTrue(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName( + "a client that is not shutting down: close() goes on waiting past a check, flag kept") + void waitsWhileClientRuns() throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(lock.unlockAsync(OWNER)).thenReturn(new CompletableFutureWrapper<>(release)); + AtomicBoolean flagKept = new AtomicBoolean(); + + Thread closer = closeInterrupted(acquiredHandle(null), flagKept); + + verify(redisson, timeout(SECONDS.toMillis(5)).atLeastOnce()).isShuttingDown(); + assertThat(closer.isAlive()).as("close() still waiting").isTrue(); + release.complete(null); + closer.join(SECONDS.toMillis(5)); + assertThat(closer.isAlive()).as("close() still waiting").isFalse(); + assertThat(flagKept).isTrue(); + verify(metrics).recordHeld(eq(Primitive.LOCK), any(Duration.class)); + } + + @Test + @DisplayName("a release refused as Redisson shuts down logs one WARN line, without a trace") + void refusedReleaseLogsOneQuietLine() { + releaseFailsWith(new RedissonShutdownException("Redisson is shutdown")); + + assertThatNoException().isThrownBy(acquiredHandle(null)::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Lock [" + FULL_KEY + "] release was not confirmed after ") + .endsWith( + "ms (fixed lease none) because the Redisson client is shutting down; the key" + + " expires on its own"); + assertThat(warning.getThrowableProxy()).isNull(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName("a refused release wrapped twice still logs the one quiet line") + void refusedReleaseWrappedTwiceLogsOneQuietLine() { + releaseFailsWith( + new CompletionException( + new CompletionException(new RedissonShutdownException("Redisson is shutdown")))); + + acquiredHandle(null).close(); + + assertThat(warnings()).hasSize(1); + assertThat(warnings().get(0).getFormattedMessage()) + .startsWith("Lock [" + FULL_KEY + "] release was not confirmed after "); + assertThat(warnings().get(0).getThrowableProxy()).isNull(); + } + } + + @Nested + @DisplayName("close on an unacquired handle") + class Unacquired { + + @Test + @DisplayName("reports acquired() false and closes as a no-op without metric") + void noOp() { + LockHandle handle = + new LockHandle(redisson, null, 0L, FULL_KEY, null, 0L, Duration.ZERO, metrics); + + assertThat(handle.acquired()).isFalse(); + handle.close(); + + verifyNoInteractions(metrics); + assertThat(appender.list).isEmpty(); + } + } + + @Nested + @DisplayName("release failure") + class ReleaseFailure { + + @Test + @DisplayName("swallows IllegalMonitorStateException and logs the no-longer-held WARN") + void swallowsIllegalMonitorState() { + releaseFailsWith(new IllegalMonitorStateException("not locked by current thread")); + LockHandle handle = acquiredHandle(Duration.ofSeconds(1)); + + assertThatNoException().isThrownBy(handle::close); + + assertThat(warnings()).hasSize(1); + String message = warnings().get(0).getFormattedMessage(); + assertThat(message) + .startsWith("Lock [" + FULL_KEY + "] was no longer held at release after ") + .contains("(fixed lease 1000ms); another instance may have run concurrently") + .contains("not locked by current thread"); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName("says 'fixed lease none' in the WARN when renewal was on") + void noFixedLease() { + releaseFailsWith(new IllegalMonitorStateException("gone")); + + acquiredHandle(null).close(); + + assertThat(warnings().get(0).getFormattedMessage()).contains("(fixed lease none)"); + } + + @Test + @DisplayName("swallows any other failure and logs a WARN with the exception") + void swallowsOtherFailure() { + releaseFailsWith(new RedisConnectionException("Redis down")); + LockHandle handle = acquiredHandle(null); + + assertThatNoException().isThrownBy(handle::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Lock [" + FULL_KEY + "] release failed after ") + .contains("Redis down"); + assertThat(warning.getThrowableProxy().getClassName()) + .isEqualTo(RedisConnectionException.class.getName()); + } + + @Test + @DisplayName("an exception thrown by the release call itself is swallowed and logged too") + void swallowsThrownException() { + RedisConnectionException failure = new RedisConnectionException("Redis down"); + doThrow(failure).when(lock).unlockAsync(OWNER); + + assertThatNoException().isThrownBy(acquiredHandle(null)::close); + + assertThat(warnings()).hasSize(1); + assertThat(warnings().get(0).getFormattedMessage()).contains("release failed"); + } + } +} diff --git a/src/test/java/in/riido/locksmith/lock/LockOperationsIntegrationTest.java b/src/test/java/in/riido/locksmith/lock/LockOperationsIntegrationTest.java new file mode 100644 index 0000000..7a07926 --- /dev/null +++ b/src/test/java/in/riido/locksmith/lock/LockOperationsIntegrationTest.java @@ -0,0 +1,780 @@ +package in.riido.locksmith.lock; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DockerAvailableCondition; +import in.riido.locksmith.LockType; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.MicrometerLocksmithMetrics; +import in.riido.locksmith.metrics.NoOpLocksmithMetrics; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.redisson.Redisson; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RBlockingQueue; +import org.redisson.api.RTopic; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.slf4j.LoggerFactory; +import org.testcontainers.utility.DockerImageName; + +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("LockOperations against Redis") +class LockOperationsIntegrationTest { + + private static final LocksmithProperties PROPERTIES = new LocksmithProperties(null, null, null); + + private static RedisContainer redis; + private static RedissonClient client1; + private static RedissonClient client2; + private static LockOperations locks1; + private static LockOperations locks2; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + private String key; + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + client1 = newClient(null); + client2 = newClient(null); + locks1 = operations(client1, new NoOpLocksmithMetrics()); + locks2 = operations(client2, new NoOpLocksmithMetrics()); + } + + @AfterAll + static void stopRedis() { + client1.shutdown(); + client2.shutdown(); + redis.stop(); + } + + @BeforeEach + void newKey() { + key = "it:" + UUID.randomUUID(); + } + + @AfterEach + void stopExecutor() { + executor.shutdownNow(); + } + + private static RedissonClient newClient(Long watchdogTimeoutMillis) { + Config config = new Config(); + config + .useSingleServer() + .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort()); + if (watchdogTimeoutMillis != null) { + config.setLockWatchdogTimeout(watchdogTimeoutMillis); + } + return Redisson.create(config); + } + + private static LockOperations operations(RedissonClient client, LocksmithMetrics metrics) { + return new LockOperations(client, PROPERTIES, metrics); + } + + /** Acquires on a background thread and holds until {@link #release()}, then closes there. */ + private final class Holder { + private final CountDownLatch attempted = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final Future done; + private volatile boolean acquired; + + Holder(Supplier acquire) throws InterruptedException { + done = + executor.submit( + () -> { + try (LockHandle handle = acquire.get()) { + acquired = handle.acquired(); + attempted.countDown(); + release.await(); + } + return null; + }); + assertThat(attempted.await(5, SECONDS)).isTrue(); + assertThat(acquired).as("holder acquired").isTrue(); + } + + void releaseAfter(Duration delay) { + CompletableFuture.delayedExecutor(delay.toMillis(), MILLISECONDS).execute(release::countDown); + } + + void release() throws Exception { + release.countDown(); + done.get(5, SECONDS); + } + } + + /** Acquires and closes on another thread, returning whether it was acquired. */ + private boolean acquiredOnOtherThread(Supplier acquire) throws Exception { + return executor + .submit( + () -> { + try (LockHandle handle = acquire.get()) { + return handle.acquired(); + } + }) + .get(10, SECONDS); + } + + @Nested + @DisplayName("exclusivity") + class Exclusivity { + + @Test + @DisplayName("a second thread sees acquired() == false while the first holds the key") + void secondThreadNotAcquired() throws Exception { + Holder holder = new Holder(() -> locks1.key(key).acquire()); + + try (LockHandle second = locks1.key(key).acquire()) { + assertThat(second.acquired()).isFalse(); + } + + holder.release(); + try (LockHandle afterRelease = locks1.key(key).acquire()) { + assertThat(afterRelease.acquired()).isTrue(); + } + } + + @Test + @DisplayName("a second Redisson client sees acquired() == false while the first holds the key") + void secondClientNotAcquired() { + try (LockHandle first = locks1.key(key).acquire(); + LockHandle second = locks2.key(key).acquire()) { + assertThat(first.acquired()).isTrue(); + assertThat(second.acquired()).isFalse(); + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isTrue(); + } + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + } + } + + @Nested + @DisplayName("read and write") + class ReadWrite { + + @Test + @DisplayName("two read locks on one key coexist") + void readersCoexist() throws Exception { + Holder reader = new Holder(() -> locks1.key(key).type(LockType.READ).acquire()); + + try (LockHandle secondReader = locks2.key(key).type(LockType.READ).acquire()) { + assertThat(secondReader.acquired()).isTrue(); + } + + reader.release(); + } + + @Test + @DisplayName("a write lock is refused while a reader holds, then waits for the reader") + void writerWaitsForReaders() throws Exception { + Holder reader = new Holder(() -> locks1.key(key).type(LockType.READ).acquire()); + + try (LockHandle refused = locks2.key(key).type(LockType.WRITE).acquire()) { + assertThat(refused.acquired()).isFalse(); + } + + reader.releaseAfter(Duration.ofSeconds(1)); + long start = System.nanoTime(); + try (LockHandle writer = + locks2.key(key).type(LockType.WRITE).waitTime(Duration.ofSeconds(3)).acquire()) { + assertThat(writer.acquired()).isTrue(); + assertThat(Duration.ofNanos(System.nanoTime() - start)) + .isGreaterThan(Duration.ofMillis(800)); + } + reader.release(); + } + + @Test + @DisplayName("a held write lock excludes readers") + void writerExcludesReaders() throws Exception { + try (LockHandle writer = locks1.key(key).type(LockType.WRITE).acquire()) { + assertThat(writer.acquired()).isTrue(); + + assertThat(acquiredOnOtherThread(() -> locks1.key(key).type(LockType.READ).acquire())) + .isFalse(); + try (LockHandle otherClientReader = locks2.key(key).type(LockType.READ).acquire()) { + assertThat(otherClientReader.acquired()).isFalse(); + } + } + } + + @Test + @DisplayName("a held REENTRANT lock neither blocks nor corrupts READ or WRITE on the same key") + void reentrantSeparateFromReadWrite() throws Exception { + Holder reentrant = new Holder(() -> locks1.key(key).acquire()); + + try (LockHandle reader = locks2.key(key).type(LockType.READ).acquire()) { + assertThat(reader.acquired()).isTrue(); + } + try (LockHandle writer = locks2.key(key).type(LockType.WRITE).acquire()) { + assertThat(writer.acquired()).isTrue(); + assertThat(writer.key()).isEqualTo("locksmith:rwlock:" + key); + assertThat(client2.getKeys().countExists("locksmith:lock:" + key)).isEqualTo(1); + assertThat(client2.getKeys().countExists("locksmith:rwlock:" + key)).isEqualTo(1); + } + + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isTrue(); + assertThat(locks2.isLocked(key, LockType.WRITE)).isFalse(); + try (LockHandle otherClient = locks2.key(key).acquire()) { + assertThat(otherClient.acquired()).isFalse(); + } + reentrant.release(); + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + } + } + + @Nested + @DisplayName("waiting") + class Waiting { + + @Test + @DisplayName("a waiter with waitTime 3s acquires when the holder releases after 1s") + void waitThenAcquire() throws Exception { + Holder holder = new Holder(() -> locks1.key(key).acquire()); + holder.releaseAfter(Duration.ofSeconds(1)); + + long start = System.nanoTime(); + try (LockHandle waiter = locks2.key(key).waitTime(Duration.ofSeconds(3)).acquire()) { + Duration waited = Duration.ofNanos(System.nanoTime() - start); + assertThat(waiter.acquired()).isTrue(); + assertThat(waited).isBetween(Duration.ofMillis(800), Duration.ofSeconds(3)); + } + holder.release(); + } + + @Test + @DisplayName("a waiter with waitTime 1s gives up unacquired while the holder keeps it 3s") + void waitThenGiveUp() throws Exception { + Holder holder = new Holder(() -> locks1.key(key).acquire()); + holder.releaseAfter(Duration.ofSeconds(3)); + + long start = System.nanoTime(); + try (LockHandle waiter = locks2.key(key).waitTime(Duration.ofSeconds(1)).acquire()) { + Duration waited = Duration.ofNanos(System.nanoTime() - start); + assertThat(waiter.acquired()).isFalse(); + assertThat(waited).isBetween(Duration.ofMillis(900), Duration.ofMillis(2500)); + } + holder.release(); + } + + @Test + @DisplayName( + "a waiter whose client shuts down during the wait throws RedissonShutdownException, not" + + " waiting forever") + void clientShutDownWhileWaiting() throws Exception { + Holder holder = new Holder(() -> locks1.key(key).acquire()); + RedissonClient client = newClient(null); + LockOperations locks = operations(client, new NoOpLocksmithMetrics()); + Future waiter = + executor.submit(() -> locks.key(key).waitTime(Duration.ofSeconds(5)).acquire()); + Thread.sleep(300); + + client.shutdown(); + + // Redisson's shutdown never completes a pending wait; before the fix this waiter never + // returned, not even after its wait time. + assertThatThrownBy(() -> waiter.get(10, SECONDS)) + .hasCauseInstanceOf(RedissonShutdownException.class); + holder.release(); + } + } + + @Nested + @DisplayName("interrupted caller") + class Interrupted { + + @Test + @DisplayName("interrupted before acquire: unacquired, flag kept, and no lock left in Redis") + void noLockLeftBehind() { + // Redisson's blocking tryLock throws here yet still takes the lock, which its watchdog then + // keeps forever; fifty attempts made that happen every time before the fix. + int attempts = 50; + for (int i = 0; i < attempts; i++) { + Thread.currentThread().interrupt(); + try { + LockHandle handle = locks1.key(key + ":" + i).waitTime(Duration.ofSeconds(1)).acquire(); + assertThat(Thread.interrupted()).as("interrupt flag").isTrue(); + handle.close(); + } finally { + Thread.interrupted(); + } + } + + await() + .atMost(Duration.ofSeconds(5)) + .until( + () -> + IntStream.range(0, attempts) + .noneMatch(i -> locks2.isLocked(key + ":" + i, LockType.REENTRANT))); + } + + @Test + @DisplayName("an interrupt racing a winning attempt leaves no lock without a handle") + void interruptRacingAWin() throws Exception { + // Each caller is interrupted at a random point of its round trip, so some interrupts land + // just as the attempt wins; before the fix about 1 in 60 such callers kept the lock. + int trials = 2000; + List unacquired = new ArrayList<>(); + for (int i = 0; i < trials; i++) { + String trialKey = key + ":" + i; + AtomicReference result = new AtomicReference<>(); + CountDownLatch go = new CountDownLatch(1); + Thread caller = + new Thread( + () -> { + try { + go.await(); + } catch (InterruptedException e) { + return; + } + LockHandle handle = locks1.key(trialKey).acquire(); + Thread.interrupted(); + result.set(handle); + handle.close(); + }); + caller.start(); + go.countDown(); + long spinNanos = ThreadLocalRandom.current().nextLong(600_000); + long start = System.nanoTime(); + while (System.nanoTime() - start < spinNanos) { + Thread.onSpinWait(); + } + caller.interrupt(); + caller.join(); + if (result.get() != null && !result.get().acquired()) { + unacquired.add(trialKey); + } + } + + // Redisson releases a lock that a cancelled attempt still wins shortly afterwards. + await() + .atMost(Duration.ofSeconds(10)) + .until(() -> unacquired.stream().noneMatch(k -> locks2.isLocked(k, LockType.REENTRANT))); + } + + @Test + @DisplayName( + "a pre-interrupted try-once caller never blocks another instance's concurrent try-once") + void preInterruptedDoesNotBlockAnotherInstance() throws Exception { + // Before the fix, sending tryLockAsync while already interrupted still took the lock for a + // moment before the cancel released it again, so instance 2's try-once could lose the race. + int trials = 200; + for (int i = 0; i < trials; i++) { + String trialKey = key + ":" + i; + CyclicBarrier barrier = new CyclicBarrier(2); + // A's own flag is set on the pooled thread that runs it, and cleared there afterwards, so + // it never leaks onto the executor thread for a later trial. + Future aFuture = + executor.submit( + () -> { + barrier.await(); + Thread.currentThread().interrupt(); + try { + return locks1.key(trialKey).acquire(); + } finally { + Thread.interrupted(); + } + }); + Future bFuture = + executor.submit( + () -> { + barrier.await(); + return locks2.key(trialKey).acquire(); + }); + + LockHandle aHandle = aFuture.get(5, SECONDS); + LockHandle bHandle = bFuture.get(5, SECONDS); + + assertThat(bHandle.acquired()).as("trial %d", i).isTrue(); + + aHandle.close(); + bHandle.close(); + } + } + } + + @Nested + @DisplayName("metrics") + class Metrics { + + @Test + @DisplayName("records locksmith.acquire (lock, acquired) and locksmith.held (lock) once each") + void acquireAndHeldRecorded() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + LockOperations locks = operations(client1, new MicrometerLocksmithMetrics(registry)); + + try (LockHandle handle = locks.key(key).acquire()) { + assertThat(handle.acquired()).isTrue(); + } + + Timer acquire = + registry + .find("locksmith.acquire") + .tag("primitive", "lock") + .tag("outcome", "acquired") + .timer(); + Timer held = registry.find("locksmith.held").tag("primitive", "lock").timer(); + assertThat(acquire).isNotNull(); + assertThat(acquire.count()).isEqualTo(1); + assertThat(held).isNotNull(); + assertThat(held.count()).isEqualTo(1); + } + } + + @Nested + @Tag("slow") + @DisplayName("lease (slow)") + class Lease { + + private Logger logger; + private ListAppender appender; + + @BeforeEach + void captureLog() { + logger = (Logger) LoggerFactory.getLogger(LockHandle.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void releaseLog() { + logger.detachAppender(appender); + } + + @Test + @DisplayName("renewal keeps a 5s hold exclusive with lockWatchdogTimeout 2000ms") + void renewalKeepsLock() throws Exception { + RedissonClient shortWatchdog = newClient(2000L); + try { + LockOperations renewing = operations(shortWatchdog, new NoOpLocksmithMetrics()); + long start = System.nanoTime(); + try (LockHandle holder = renewing.key(key).acquire()) { + assertThat(holder.acquired()).isTrue(); + while (System.nanoTime() - start < Duration.ofSeconds(5).toNanos()) { + try (LockHandle other = locks2.key(key).acquire()) { + assertThat(other.acquired()).as("second client acquired during hold").isFalse(); + } + Thread.sleep(250); + } + } + try (LockHandle after = locks2.key(key).acquire()) { + assertThat(after.acquired()).isTrue(); + } + } finally { + shortWatchdog.shutdown(); + } + } + + @Test + @DisplayName( + "an outrun 1s fixed lease lets a second client in at 1.5s; close() WARNs, no throw") + void fixedLeaseOutrun() throws Exception { + LockHandle first = locks1.key(key).leaseTime(Duration.ofSeconds(1)).acquire(); + assertThat(first.acquired()).isTrue(); + + Thread.sleep(1500); + try (LockHandle second = locks2.key(key).acquire()) { + assertThat(second.acquired()).isTrue(); + + Thread.sleep(500); + assertThatNoException().isThrownBy(first::close); + } + + List warnings = + appender.list.stream().filter(e -> e.getLevel() == Level.WARN).toList(); + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0).getFormattedMessage()) + .startsWith("Lock [locksmith:lock:" + key + "] was no longer held at release after ") + .contains("(fixed lease 1000ms); another instance may have run concurrently"); + } + } + + @Nested + @Tag("slow") + @DisplayName("client shutdown during releases (slow)") + class ReleaseAtShutdown { + + @Test + @DisplayName( + "220 releases sent to a paused Redis: every close() returns once the client shuts down," + + " and no release WARN carries a stack trace") + void everyCloseReturns() throws Exception { + // More releases than the last 100 calls Redisson's shutdown settles: before the fix, 24 of + // 220 close() calls never returned, interrupted or not, even after Redis answered again. + int count = 220; + RedissonClient client = newClient(null); + LockOperations locks = operations(client, new NoOpLocksmithMetrics()); + List handles = new ArrayList<>(); + for (int i = 0; i < count; i++) { + handles.add(locks.key(key + ":" + i).acquire()); + } + assertThat(handles).allMatch(LockHandle::acquired); + Logger logger = (Logger) LoggerFactory.getLogger(LockHandle.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + CountDownLatch started = new CountDownLatch(count); + CountDownLatch returned = new CountDownLatch(count); + // Redis holds even CLIENT UNPAUSE until a pause of all clients runs out, so it is short. + assertThat(redis.execInContainer("redis-cli", "CLIENT", "PAUSE", "5000", "ALL").getStdout()) + .startsWith("OK"); + try { + for (LockHandle handle : handles) { + Thread closer = + new Thread( + () -> { + started.countDown(); + handle.close(); + returned.countDown(); + }); + // A close() that never returns must not keep the test JVM alive. + closer.setDaemon(true); + closer.start(); + } + assertThat(started.await(5, SECONDS)).isTrue(); + Thread.sleep(200); + + client.shutdown(); + + assertThat(returned.await(5, SECONDS)).as("every close() returned").isTrue(); + assertThat(appender.list) + .filteredOn(e -> e.getLevel() == Level.WARN) + .isNotEmpty() + .allSatisfy( + warning -> { + assertThat(warning.getFormattedMessage()) + .contains("because the Redisson client is shutting down"); + assertThat(warning.getThrowableProxy()).isNull(); + }); + } finally { + logger.detachAppender(appender); + // Answers once the pause has run out, so the next test finds Redis serving. + redis.execInContainer("redis-cli", "PING"); + } + } + } + + @Nested + @DisplayName("threads") + class Threads { + + @Test + @DisplayName("on a Redisson I/O thread: throws at once and sends nothing to Redis") + void refusedOnRedissonThread() throws Exception { + record Attempt(String thread, Duration took, Throwable failure) {} + // takeAsync completes only after the offer below, so the callback runs on a Redisson thread. + RBlockingQueue trigger = client1.getBlockingQueue(key + ":trigger"); + CompletableFuture result = + trigger + .takeAsync() + .thenApply( + ignored -> { + long start = System.nanoTime(); + Throwable failure = null; + try { + locks1.key(key).acquire(); + } catch (RuntimeException e) { + failure = e; + } + return new Attempt( + Thread.currentThread().getName(), + Duration.ofNanos(System.nanoTime() - start), + failure); + }) + .toCompletableFuture(); + trigger.offer("go"); + Attempt attempt = result.get(10, SECONDS); + + assertThat(attempt.thread()).startsWith("redisson-netty"); + assertThat(attempt.failure()) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessage("Sync methods can't be invoked from async/rx/reactive listeners"); + assertThat(attempt.took()).isLessThan(Duration.ofMillis(500)); + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + } + + @Test + @DisplayName( + "on a Redisson listener thread: a waiting acquire throws at once; try-once works, and" + + " close() has released the lock when it returns") + void listenerThread() throws Exception { + record Run(String thread, Throwable waiting, Duration took, int acquired, int stillLocked) {} + LockHandle holder = locks2.key(key + ":held").acquire(); + RTopic topic = client1.getTopic(key + ":topic"); + CompletableFuture result = new CompletableFuture<>(); + topic.addListener( + String.class, + (channel, message) -> { + long start = System.nanoTime(); + Throwable waiting = null; + try { + locks1.key(key + ":held").waitTime(Duration.ofSeconds(3)).acquire(); + } catch (RuntimeException e) { + waiting = e; + } + Duration took = Duration.ofNanos(System.nanoTime() - start); + int acquired = 0; + int stillLocked = 0; + for (int i = 0; i < 100; i++) { + try (LockHandle handle = locks1.key(key).acquire()) { + acquired += handle.acquired() ? 1 : 0; + } + stillLocked += locks1.isLocked(key, LockType.REENTRANT) ? 1 : 0; + } + result.complete( + new Run(Thread.currentThread().getName(), waiting, took, acquired, stillLocked)); + }); + topic.publish("go"); + Run run = result.get(20, SECONDS); + holder.close(); + topic.removeAllListeners(); + + assertThat(run.thread()).startsWith("redisson-").doesNotStartWith("redisson-netty"); + assertThat(run.waiting()) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Locksmith cannot wait for a lock or permit on Redisson thread"); + assertThat(run.took()).isLessThan(Duration.ofMillis(500)); + assertThat(run.acquired()).isEqualTo(100); + assertThat(run.stillLocked()).isZero(); + } + + @Test + @DisplayName( + "on Redisson's timer thread: even a try-once acquire throws at once, sends nothing") + void timerThread() throws Exception { + LockHandle holder = locks2.key(key + ":held").acquire(); + CompletableFuture result = new CompletableFuture<>(); + // A Redisson wait that runs out is completed by the timer, so its continuation runs there. + client1 + .getLock("locksmith:lock:" + key + ":held") + .tryLockAsync(100, MILLISECONDS) + .thenRun( + () -> { + Throwable thrown = null; + try { + locks1.key(key).acquire(); + } catch (RuntimeException e) { + thrown = e; + } + result.complete(new Object[] {Thread.currentThread().getName(), thrown}); + }); + Object[] outcome = result.get(10, SECONDS); + holder.close(); + + assertThat((String) outcome[0]).startsWith("redisson-timer"); + assertThat((Throwable) outcome[1]) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Locksmith cannot run on Redisson's timer thread"); + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + } + + @Test + @DisplayName("closed on another thread: released, with no WARN") + void closedOnAnotherThread() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(LockHandle.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + LockHandle handle = locks1.key(key).acquire(); + assertThat(handle.acquired()).isTrue(); + + executor.submit(handle::close).get(5, SECONDS); + + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + assertThat(appender.list).noneMatch(e -> e.getLevel() == Level.WARN); + } finally { + logger.detachAppender(appender); + } + } + + @Test + @DisplayName("closed on an interrupted thread: released, flag kept, with no WARN") + void closedOnInterruptedThread() { + Logger logger = (Logger) LoggerFactory.getLogger(LockHandle.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + LockHandle handle = locks1.key(key).acquire(); + Thread.currentThread().interrupt(); + + handle.close(); + + assertThat(Thread.interrupted()).as("interrupt flag").isTrue(); + assertThat(locks2.isLocked(key, LockType.REENTRANT)).isFalse(); + assertThat(appender.list).noneMatch(e -> e.getLevel() == Level.WARN); + } finally { + Thread.interrupted(); + logger.detachAppender(appender); + } + } + + @Test + @DisplayName("interrupted while waiting: unacquired, flag restored, and a late win is undone") + void interruptedLateWinReleased() throws Exception { + Holder holder = new Holder(() -> locks2.key(key).acquire()); + CompletableFuture outcome = new CompletableFuture<>(); + Thread waiter = + new Thread( + () -> { + LockHandle handle = locks1.key(key).waitTime(Duration.ofSeconds(10)).acquire(); + outcome.complete( + new Boolean[] {handle.acquired(), Thread.currentThread().isInterrupted()}); + }); + waiter.start(); + Thread.sleep(300); + + waiter.interrupt(); + Boolean[] result = outcome.get(5, SECONDS); + assertThat(result[0]).as("acquired").isFalse(); + assertThat(result[1]).as("interrupt flag").isTrue(); + + // The pending attempt takes the lock as soon as the holder lets go; it must be undone, + // or the watchdog would keep it forever. + holder.release(); + await() + .pollDelay(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(5)) + .until(() -> !locks2.isLocked(key, LockType.REENTRANT)); + } + } +} diff --git a/src/test/java/in/riido/locksmith/lock/LockOperationsTest.java b/src/test/java/in/riido/locksmith/lock/LockOperationsTest.java new file mode 100644 index 0000000..35e17d2 --- /dev/null +++ b/src/test/java/in/riido/locksmith/lock/LockOperationsTest.java @@ -0,0 +1,477 @@ +package in.riido.locksmith.lock; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import in.riido.locksmith.LockType; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Outcome; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RFuture; +import org.redisson.api.RLock; +import org.redisson.api.RReadWriteLock; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisConnectionException; +import org.redisson.config.Config; +import org.redisson.misc.CompletableFutureWrapper; + +@DisplayName("LockOperations") +class LockOperationsTest { + + private static final String FULL_KEY = "test:lock:k"; + private static final String READ_WRITE_FULL_KEY = "test:rwlock:k"; + + private RedissonClient redisson; + private RLock reentrant; + private RLock readLock; + private RLock writeLock; + private LocksmithMetrics metrics; + private LockOperations operations; + + @BeforeEach + void setUp() { + redisson = mock(RedissonClient.class); + reentrant = mock(RLock.class); + readLock = mock(RLock.class); + writeLock = mock(RLock.class); + RReadWriteLock readWrite = mock(RReadWriteLock.class); + when(redisson.getLock(FULL_KEY)).thenReturn(reentrant); + when(redisson.getReadWriteLock(READ_WRITE_FULL_KEY)).thenReturn(readWrite); + when(readWrite.readLock()).thenReturn(readLock); + when(readWrite.writeLock()).thenReturn(writeLock); + metrics = mock(LocksmithMetrics.class); + operations = + new LockOperations(redisson, new LocksmithProperties(null, "test:", null), metrics); + } + + private static RFuture done(boolean acquired) { + return new CompletableFutureWrapper<>(acquired); + } + + /** An attempt that Redisson completes just before the interrupted caller's cancel reaches it. */ + private static RFuture completesBeforeCancel(boolean acquired) { + CompletableFuture attempt = new CompletableFuture<>(); + return new CompletableFutureWrapper<>(attempt) { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + attempt.complete(acquired); + return super.cancel(mayInterruptIfRunning); + } + }; + } + + @AfterEach + void clearInterruptFlag() { + Thread.interrupted(); + } + + /** Runs the call on a thread with the given name; returns its result, or what it threw. */ + private static Object onThread(String name, Supplier call) throws InterruptedException { + AtomicReference outcome = new AtomicReference<>(); + Thread thread = + new Thread( + () -> { + try { + outcome.set(call.get()); + } catch (RuntimeException e) { + outcome.set(e); + } + }, + name); + thread.start(); + thread.join(); + return outcome.get(); + } + + /** Interrupts the calling thread when the mocked Redisson call runs, then returns the result. */ + private static Answer interruptingAnd(T result) { + return invocation -> { + Thread.currentThread().interrupt(); + return result; + }; + } + + @Nested + @DisplayName("key layout") + class KeyLayout { + + @Test + @DisplayName("prefixes the key as lock:") + void prefixesKey() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + operations.key("k").acquire(); + + verify(redisson).getLock(FULL_KEY); + } + + @Test + @DisplayName("uses the default prefix locksmith: when none is configured") + void usesDefaultPrefix() { + RLock lock = mock(RLock.class); + when(redisson.getLock("locksmith:lock:k")).thenReturn(lock); + when(lock.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + LockOperations defaults = + new LockOperations(redisson, new LocksmithProperties(null, null, null), metrics); + + assertThat(defaults.key("k").acquire().acquired()).isTrue(); + } + } + + @Nested + @DisplayName("lock type") + class Type { + + @Test + @DisplayName("REENTRANT is the default, uses getLock, and is owned by the calling thread") + void reentrantByDefault() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + assertThat(operations.key("k").acquire().acquired()).isTrue(); + + verify(reentrant).tryLockAsync(0L, -1L, MILLISECONDS, Thread.currentThread().getId()); + verify(redisson, never()).getReadWriteLock(any(String.class)); + } + + @Test + @DisplayName("READ uses the read lock of getReadWriteLock") + void readUsesReadLock() { + when(readLock.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + assertThat(operations.key("k").type(LockType.READ).acquire().acquired()).isTrue(); + + verify(readLock).tryLockAsync(eq(0L), eq(-1L), eq(MILLISECONDS), anyLong()); + verify(redisson, never()).getLock(any(String.class)); + } + + @Test + @DisplayName("WRITE uses the write lock of getReadWriteLock") + void writeUsesWriteLock() { + when(writeLock.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + assertThat(operations.key("k").type(LockType.WRITE).acquire().acquired()).isTrue(); + + verify(writeLock).tryLockAsync(eq(0L), eq(-1L), eq(MILLISECONDS), anyLong()); + verify(redisson, never()).getLock(any(String.class)); + } + } + + @Nested + @DisplayName("durations passed to tryLockAsync") + class Durations { + + @Test + @DisplayName("passes lease -1 when no leaseTime is set") + void leaseMinusOneWithoutLeaseTime() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + operations.key("k").waitTime(Duration.ofSeconds(2)).acquire(); + + verify(reentrant).tryLockAsync(eq(2000L), eq(-1L), eq(MILLISECONDS), anyLong()); + } + + @Test + @DisplayName("passes a fixed leaseTime through in milliseconds") + void fixedLeaseInMillis() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + operations.key("k").leaseTime(Duration.ofMinutes(2)).acquire(); + + verify(reentrant).tryLockAsync(eq(0L), eq(120_000L), eq(MILLISECONDS), anyLong()); + } + + @Test + @DisplayName("passes waitTime in milliseconds") + void waitInMillis() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(false)); + + operations.key("k").waitTime(Duration.ofMillis(1500)).acquire(); + + verify(reentrant).tryLockAsync(eq(1500L), eq(-1L), eq(MILLISECONDS), anyLong()); + } + + @Test + @DisplayName("rejects a negative waitTime with IllegalArgumentException in the builder") + void rejectsNegativeWait() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").waitTime(Duration.ofMillis(-1))) + .withMessageContaining("waitTime"); + verify(redisson, never()).getLock(any(String.class)); + } + + @Test + @DisplayName("rejects a negative leaseTime with IllegalArgumentException in the builder") + void rejectsNegativeLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ofMillis(-1))) + .withMessageContaining("leaseTime"); + verify(redisson, never()).getLock(any(String.class)); + } + + @Test + @DisplayName("rejects a zero leaseTime, which Redisson would treat as renewal") + void rejectsZeroLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ZERO)) + .withMessage("leaseTime must be at least one millisecond, got PT0S"); + verify(redisson, never()).getLock(any(String.class)); + } + + @Test + @DisplayName("rejects a sub-millisecond leaseTime, which would round to zero") + void rejectsSubMillisecondLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ofNanos(500))) + .withMessageContaining("leaseTime must be at least one millisecond"); + verify(redisson, never()).getLock(any(String.class)); + } + } + + @Nested + @DisplayName("outcome") + class Outcomes { + + @Test + @DisplayName("records outcome ACQUIRED and returns an acquired handle") + void acquired() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + LockHandle handle = operations.key("k").acquire(); + + assertThat(handle.acquired()).isTrue(); + verify(metrics).recordAcquire(eq(Primitive.LOCK), eq(Outcome.ACQUIRED), any(Duration.class)); + } + + @Test + @DisplayName("records outcome SKIPPED and returns an unacquired handle without throwing") + void skipped() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(false)); + + LockHandle handle = operations.key("k").acquire(); + + assertThat(handle.acquired()).isFalse(); + verify(metrics).recordAcquire(eq(Primitive.LOCK), eq(Outcome.SKIPPED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted: cancels the pending attempt, restores the flag, records INTERRUPTED," + + " unacquired") + void interrupted() { + CompletableFuture pending = new CompletableFuture<>(); + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenAnswer(interruptingAnd(new CompletableFutureWrapper<>(pending))); + + LockHandle handle = operations.key("k").waitTime(Duration.ofSeconds(1)).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + // Cancelling is what makes Redisson release a lock the attempt still wins. + assertThat(pending).isCancelled(); + verify(metrics) + .recordAcquire(eq(Primitive.LOCK), eq(Outcome.INTERRUPTED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted as the attempt wins: keeps the lock and the flag, records ACQUIRED, so no" + + " lock is left without a handle") + void interruptedAsAttemptWins() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenAnswer(interruptingAnd(completesBeforeCancel(true))); + + LockHandle handle = operations.key("k").waitTime(Duration.ofSeconds(1)).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isTrue(); + verify(metrics).recordAcquire(eq(Primitive.LOCK), eq(Outcome.ACQUIRED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted before acquire is called: unacquired, flag kept, records INTERRUPTED, and" + + " nothing reaches Redisson") + void interruptedBeforeTheCall() { + Thread.currentThread().interrupt(); + + LockHandle handle = operations.key("k").acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + verify(metrics) + .recordAcquire(eq(Primitive.LOCK), eq(Outcome.INTERRUPTED), any(Duration.class)); + verify(reentrant, never()).tryLockAsync(anyLong(), anyLong(), any(), anyLong()); + } + + @Test + @DisplayName( + "on a Redisson executor thread: a waiting acquire throws before any call, try-once runs") + void redissonExecutorThread() throws InterruptedException { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + + Object waiting = + onThread( + "redisson-3-1", () -> operations.key("k").waitTime(Duration.ofSeconds(1)).acquire()); + + assertThat((Throwable) waiting) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot wait for a lock or permit on Redisson thread [redisson-3-1]"); + verifyNoInteractions(reentrant); + + Object once = onThread("redisson-3-1", () -> operations.key("k").acquire()); + + assertThat(((LockHandle) once).acquired()).isTrue(); + } + + @Test + @DisplayName("on the Redisson timer thread: even a try-once acquire throws before any call") + void redissonTimerThread() throws InterruptedException { + Object thrown = onThread("redisson-timer-4-1", () -> operations.key("k").acquire()); + + assertThat((Throwable) thrown) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot run on Redisson's timer thread [redisson-timer-4-1]"); + verifyNoInteractions(reentrant); + } + + @Test + @DisplayName( + "on a Redis Cluster client, a key with a '{' that forms no hash tag throws before any call") + void clusterKeyWithoutHashTag() { + Config config = new Config(); + config.useClusterServers(); + when(redisson.getConfig()).thenReturn(config); + + assertThatThrownBy(() -> operations.key("order:x{1").acquire()) + .isExactlyInstanceOf(LocksmithConfigurationException.class) + .hasMessageStartingWith( + "Key [test:lock:order:x{1] contains a '{' or '}' that forms no Redis Cluster hash" + + " tag"); + verify(redisson, never()).getLock(anyString()); + } + + @Test + @DisplayName( + "client shuts down while waiting: cancels the pending attempt, throws" + + " RedissonShutdownException") + void clientShutDownWhileWaiting() { + CompletableFuture pending = new CompletableFuture<>(); + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenReturn(new CompletableFutureWrapper<>(pending)); + when(redisson.isShuttingDown()).thenReturn(true); + + CompletableFuture attempt = + CompletableFuture.supplyAsync( + () -> operations.key("k").waitTime(Duration.ofSeconds(30)).acquire()); + + assertThatThrownBy(() -> attempt.get(5, SECONDS)) + .hasCauseInstanceOf(RedissonShutdownException.class); + // Cancelling is what makes Redisson release a lock the attempt still wins. + assertThat(pending).isCancelled(); + } + + @Test + @DisplayName("propagates a Redisson RuntimeException from tryLockAsync unchanged") + void redissonExceptionPropagates() { + RedisConnectionException failure = new RedisConnectionException("Redis down"); + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())) + .thenReturn(new CompletableFutureWrapper<>(failure)); + + assertThatThrownBy(() -> operations.key("k").acquire()).isSameAs(failure); + } + + @Test + @DisplayName("a metrics failure after the lock was taken propagates and unlocks once") + void metricsFailureReleasesLock() { + when(reentrant.tryLockAsync(anyLong(), anyLong(), any(), anyLong())).thenReturn(done(true)); + when(reentrant.unlockAsync(anyLong())) + .thenReturn(new CompletableFutureWrapper<>((Void) null)); + IllegalArgumentException failure = new IllegalArgumentException("meter clash"); + doThrow(failure) + .when(metrics) + .recordAcquire(eq(Primitive.LOCK), eq(Outcome.ACQUIRED), any(Duration.class)); + + assertThatThrownBy(() -> operations.key("k").acquire()).isSameAs(failure); + + verify(reentrant, times(1)).unlockAsync(Thread.currentThread().getId()); + } + } + + @Nested + @DisplayName("isLocked") + class IsLocked { + + @Test + @DisplayName("returns isLocked of the prefixed REENTRANT lock") + void reentrant() { + when(reentrant.isLocked()).thenReturn(true); + + assertThat(operations.isLocked("k", LockType.REENTRANT)).isTrue(); + } + + @Test + @DisplayName("returns isLocked of the read lock for READ") + void read() { + when(readLock.isLocked()).thenReturn(true); + + assertThat(operations.isLocked("k", LockType.READ)).isTrue(); + } + + @Test + @DisplayName("returns isLocked of the write lock for WRITE") + void write() { + when(writeLock.isLocked()).thenReturn(false); + + assertThat(operations.isLocked("k", LockType.WRITE)).isFalse(); + verify(writeLock).isLocked(); + } + + @Test + @DisplayName( + "on the Redisson timer thread throws before any call; on another Redisson thread it runs") + void redissonThreads() throws InterruptedException { + Object thrown = + onThread("redisson-timer-4-1", () -> operations.isLocked("k", LockType.REENTRANT)); + + assertThat((Throwable) thrown) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot run on Redisson's timer thread [redisson-timer-4-1]"); + verifyNoInteractions(reentrant); + + when(reentrant.isLocked()).thenReturn(true); + + assertThat(onThread("redisson-3-1", () -> operations.isLocked("k", LockType.REENTRANT))) + .isEqualTo(true); + } + } +} diff --git a/src/test/java/in/riido/locksmith/metrics/LockMetricsTest.java b/src/test/java/in/riido/locksmith/metrics/LockMetricsTest.java deleted file mode 100644 index dfcd330..0000000 --- a/src/test/java/in/riido/locksmith/metrics/LockMetricsTest.java +++ /dev/null @@ -1,191 +0,0 @@ -package in.riido.locksmith.metrics; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("LockMetrics Tests") -class LockMetricsTest { - - private MeterRegistry registry; - private LockMetrics lockMetrics; - - @BeforeEach - void setUp() { - registry = new SimpleMeterRegistry(); - lockMetrics = new LockMetrics(registry); - } - - @Nested - @DisplayName("Counter Tests") - class CounterTests { - - @Test - @DisplayName("Should register acquired counter") - void shouldRegisterAcquiredCounter() { - Counter counter = registry.find("locksmith.lock.acquired").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment acquired counter") - void shouldIncrementAcquiredCounter() { - lockMetrics.recordAcquired(); - lockMetrics.recordAcquired(); - - Counter counter = registry.find("locksmith.lock.acquired").counter(); - assertEquals(2, counter.count()); - } - - @Test - @DisplayName("Should register skipped counter with timeout reason") - void shouldRegisterSkippedCounterWithTimeoutReason() { - Counter counter = registry.find("locksmith.lock.skipped").tag("reason", "timeout").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should register skipped counter with immediate reason") - void shouldRegisterSkippedCounterWithImmediateReason() { - Counter counter = - registry.find("locksmith.lock.skipped").tag("reason", "immediate").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment skipped counter with correct reason - timeout") - void shouldIncrementSkippedCounterWithTimeoutReason() { - lockMetrics.recordSkipped(AcquisitionMode.WAIT_AND_SKIP); - - Counter counter = registry.find("locksmith.lock.skipped").tag("reason", "timeout").counter(); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should increment skipped counter with correct reason - immediate") - void shouldIncrementSkippedCounterWithImmediateReason() { - lockMetrics.recordSkipped(AcquisitionMode.SKIP_IMMEDIATELY); - - Counter counter = - registry.find("locksmith.lock.skipped").tag("reason", "immediate").counter(); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should register lease expired counter") - void shouldRegisterLeaseExpiredCounter() { - Counter counter = registry.find("locksmith.lock.lease.expired").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment lease expired counter") - void shouldIncrementLeaseExpiredCounter() { - lockMetrics.recordLeaseExpired(); - lockMetrics.recordLeaseExpired(); - lockMetrics.recordLeaseExpired(); - - Counter counter = registry.find("locksmith.lock.lease.expired").counter(); - assertEquals(3, counter.count()); - } - } - - @Nested - @DisplayName("Timer Tests") - class TimerTests { - - @Test - @DisplayName("Should register acquisition time timer") - void shouldRegisterAcquisitionTimeTimer() { - Timer timer = registry.find("locksmith.lock.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(0, timer.count()); - } - - @Test - @DisplayName("Should record acquisition time") - void shouldRecordAcquisitionTime() { - lockMetrics.recordAcquisitionTime(100); - lockMetrics.recordAcquisitionTime(200); - - Timer timer = registry.find("locksmith.lock.acquisition.time").timer(); - assertEquals(2, timer.count()); - assertTrue(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS) >= 300); - } - - @Test - @DisplayName("Should register held time timer") - void shouldRegisterHeldTimeTimer() { - Timer timer = registry.find("locksmith.lock.held.time").timer(); - assertNotNull(timer); - assertEquals(0, timer.count()); - } - - @Test - @DisplayName("Should record held time") - void shouldRecordHeldTime() { - lockMetrics.recordHeldTime(500); - lockMetrics.recordHeldTime(1000); - - Timer timer = registry.find("locksmith.lock.held.time").timer(); - assertEquals(2, timer.count()); - assertTrue(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS) >= 1500); - } - } - - @Nested - @DisplayName("Gauge Tests") - class GaugeTests { - - @Test - @DisplayName("Should register auto renew active gauge") - void shouldRegisterAutoRenewActiveGauge() { - Gauge gauge = registry.find("locksmith.lock.autorenew.active").gauge(); - assertNotNull(gauge); - assertEquals(0, gauge.value()); - } - - @Test - @DisplayName("Should increment auto renew active gauge") - void shouldIncrementAutoRenewActiveGauge() { - lockMetrics.incrementAutoRenewActive(); - lockMetrics.incrementAutoRenewActive(); - - Gauge gauge = registry.find("locksmith.lock.autorenew.active").gauge(); - assertEquals(2, gauge.value()); - } - - @Test - @DisplayName("Should decrement auto renew active gauge") - void shouldDecrementAutoRenewActiveGauge() { - lockMetrics.incrementAutoRenewActive(); - lockMetrics.incrementAutoRenewActive(); - lockMetrics.decrementAutoRenewActive(); - - Gauge gauge = registry.find("locksmith.lock.autorenew.active").gauge(); - assertEquals(1, gauge.value()); - } - - @Test - @DisplayName("Should allow negative auto renew active gauge") - void shouldAllowNegativeAutoRenewActiveGauge() { - lockMetrics.decrementAutoRenewActive(); - - Gauge gauge = registry.find("locksmith.lock.autorenew.active").gauge(); - assertEquals(-1, gauge.value()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/metrics/MicrometerLocksmithMetricsTest.java b/src/test/java/in/riido/locksmith/metrics/MicrometerLocksmithMetricsTest.java new file mode 100644 index 0000000..2825e6a --- /dev/null +++ b/src/test/java/in/riido/locksmith/metrics/MicrometerLocksmithMetricsTest.java @@ -0,0 +1,103 @@ +package in.riido.locksmith.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import in.riido.locksmith.metrics.LocksmithMetrics.Outcome; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayName("MicrometerLocksmithMetrics") +class MicrometerLocksmithMetricsTest { + + private SimpleMeterRegistry registry; + private MicrometerLocksmithMetrics metrics; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + metrics = new MicrometerLocksmithMetrics(registry); + } + + @Nested + @DisplayName("recordAcquire") + class RecordAcquire { + + @Test + @DisplayName("registers locksmith.acquire tagged primitive=lock, outcome=acquired") + void registersAcquireTimerWithTags() { + metrics.recordAcquire(Primitive.LOCK, Outcome.ACQUIRED, Duration.ofMillis(20)); + + Timer timer = + registry + .find("locksmith.acquire") + .tag("primitive", "lock") + .tag("outcome", "acquired") + .timer(); + assertThat(timer).isNotNull(); + assertThat(timer.getId().getTags()).hasSize(2); + assertThat(timer.count()).isEqualTo(1); + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isEqualTo(20); + } + + @ParameterizedTest(name = "outcome {0} is tagged with its tag value") + @EnumSource(Outcome.class) + @DisplayName("each outcome gets its own semaphore timer tagged with the enum's tag value") + void eachOutcomeTagged(Outcome outcome) { + metrics.recordAcquire(Primitive.SEMAPHORE, outcome, Duration.ZERO); + + assertThat( + registry + .find("locksmith.acquire") + .tag("primitive", "semaphore") + .tag("outcome", outcome.tagValue()) + .timer()) + .isNotNull(); + } + + @Test + @DisplayName("repeated records with the same tags reuse one timer") + void sameTagsReuseOneTimer() { + metrics.recordAcquire(Primitive.LOCK, Outcome.SKIPPED, Duration.ofMillis(1)); + metrics.recordAcquire(Primitive.LOCK, Outcome.SKIPPED, Duration.ofMillis(2)); + + assertThat(registry.find("locksmith.acquire").timers()).hasSize(1); + assertThat(registry.get("locksmith.acquire").timer().count()).isEqualTo(2); + } + } + + @Nested + @DisplayName("recordHeld") + class RecordHeld { + + @Test + @DisplayName("registers locksmith.held tagged only primitive=lock") + void registersHeldTimerWithPrimitiveTag() { + metrics.recordHeld(Primitive.LOCK, Duration.ofMillis(50)); + + Timer timer = registry.find("locksmith.held").tag("primitive", "lock").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.getId().getTags()).hasSize(1); + assertThat(timer.count()).isEqualTo(1); + assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isEqualTo(50); + } + + @Test + @DisplayName("lock and semaphore get separate held timers") + void separateTimersPerPrimitive() { + metrics.recordHeld(Primitive.LOCK, Duration.ofMillis(1)); + metrics.recordHeld(Primitive.SEMAPHORE, Duration.ofMillis(1)); + + assertThat(registry.find("locksmith.held").timers()).hasSize(2); + assertThat(registry.find("locksmith.held").tag("primitive", "semaphore").timer()).isNotNull(); + } + } +} diff --git a/src/test/java/in/riido/locksmith/metrics/RateLimitMetricsTest.java b/src/test/java/in/riido/locksmith/metrics/RateLimitMetricsTest.java deleted file mode 100644 index 964cbad..0000000 --- a/src/test/java/in/riido/locksmith/metrics/RateLimitMetricsTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package in.riido.locksmith.metrics; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("RateLimitMetrics Tests") -class RateLimitMetricsTest { - - private MeterRegistry meterRegistry; - private RateLimitMetrics metrics; - - @BeforeEach - void setUp() { - meterRegistry = new SimpleMeterRegistry(); - metrics = new RateLimitMetrics(meterRegistry); - } - - @Nested - @DisplayName("Acquired Counter Tests") - class AcquiredCounterTests { - - @Test - @DisplayName("Should record acquired counter") - void shouldRecordAcquiredCounter() { - metrics.recordAcquired(); - - Counter counter = meterRegistry.find("locksmith.rate.limit.acquired").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should record multiple acquisitions") - void shouldRecordMultipleAcquisitions() { - metrics.recordAcquired(); - metrics.recordAcquired(); - metrics.recordAcquired(); - - Counter counter = meterRegistry.find("locksmith.rate.limit.acquired").counter(); - assertEquals(3, counter.count()); - } - } - - @Nested - @DisplayName("Exceeded Counter Tests") - class ExceededCounterTests { - - @Test - @DisplayName("Should record exceeded counter with immediate reason") - void shouldRecordExceededCounterWithImmediateReason() { - metrics.recordExceeded(AcquisitionMode.SKIP_IMMEDIATELY); - - Counter counter = - meterRegistry.find("locksmith.rate.limit.exceeded").tag("reason", "immediate").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should record exceeded counter with timeout reason") - void shouldRecordExceededCounterWithTimeoutReason() { - metrics.recordExceeded(AcquisitionMode.WAIT_AND_SKIP); - - Counter counter = - meterRegistry.find("locksmith.rate.limit.exceeded").tag("reason", "timeout").counter(); - assertNotNull(counter); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should track different reasons separately") - void shouldTrackDifferentReasonsSeparately() { - metrics.recordExceeded(AcquisitionMode.SKIP_IMMEDIATELY); - metrics.recordExceeded(AcquisitionMode.SKIP_IMMEDIATELY); - metrics.recordExceeded(AcquisitionMode.WAIT_AND_SKIP); - - Counter immediateCounter = - meterRegistry.find("locksmith.rate.limit.exceeded").tag("reason", "immediate").counter(); - Counter timeoutCounter = - meterRegistry.find("locksmith.rate.limit.exceeded").tag("reason", "timeout").counter(); - - assertEquals(2, immediateCounter.count()); - assertEquals(1, timeoutCounter.count()); - } - } - - @Nested - @DisplayName("Acquisition Time Timer Tests") - class AcquisitionTimeTimerTests { - - @Test - @DisplayName("Should record acquisition time") - void shouldRecordAcquisitionTime() { - metrics.recordAcquisitionTime(150L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 150); - } - - @Test - @DisplayName("Should accumulate acquisition times") - void shouldAccumulateAcquisitionTimes() { - metrics.recordAcquisitionTime(100L); - metrics.recordAcquisitionTime(200L); - metrics.recordAcquisitionTime(300L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.acquisition.time").timer(); - assertEquals(3, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 600); - } - } - - @Nested - @DisplayName("Execution Time Timer Tests") - class ExecutionTimeTimerTests { - - @Test - @DisplayName("Should record execution time") - void shouldRecordExecutionTime() { - metrics.recordExecutionTime(250L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.execution.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 250); - } - - @Test - @DisplayName("Should accumulate execution times") - void shouldAccumulateExecutionTimes() { - metrics.recordExecutionTime(100L); - metrics.recordExecutionTime(200L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.execution.time").timer(); - assertEquals(2, timer.count()); - assertTrue(timer.totalTime(TimeUnit.MILLISECONDS) >= 300); - } - } - - @Nested - @DisplayName("Edge Case Tests") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle zero acquisition time") - void shouldHandleZeroAcquisitionTime() { - metrics.recordAcquisitionTime(0L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - } - - @Test - @DisplayName("Should handle zero execution time") - void shouldHandleZeroExecutionTime() { - metrics.recordExecutionTime(0L); - - Timer timer = meterRegistry.find("locksmith.rate.limit.execution.time").timer(); - assertNotNull(timer); - assertEquals(1, timer.count()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/metrics/SemaphoreMetricsTest.java b/src/test/java/in/riido/locksmith/metrics/SemaphoreMetricsTest.java deleted file mode 100644 index 7711cc7..0000000 --- a/src/test/java/in/riido/locksmith/metrics/SemaphoreMetricsTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package in.riido.locksmith.metrics; - -import static org.junit.jupiter.api.Assertions.*; - -import in.riido.locksmith.AcquisitionMode; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("SemaphoreMetrics Tests") -class SemaphoreMetricsTest { - - private MeterRegistry registry; - private SemaphoreMetrics semaphoreMetrics; - - @BeforeEach - void setUp() { - registry = new SimpleMeterRegistry(); - semaphoreMetrics = new SemaphoreMetrics(registry); - } - - @Nested - @DisplayName("Counter Tests") - class CounterTests { - - @Test - @DisplayName("Should register acquired counter") - void shouldRegisterAcquiredCounter() { - Counter counter = registry.find("locksmith.semaphore.acquired").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment acquired counter") - void shouldIncrementAcquiredCounter() { - semaphoreMetrics.recordAcquired(); - semaphoreMetrics.recordAcquired(); - - Counter counter = registry.find("locksmith.semaphore.acquired").counter(); - assertEquals(2, counter.count()); - } - - @Test - @DisplayName("Should register skipped counter with timeout reason") - void shouldRegisterSkippedCounterWithTimeoutReason() { - Counter counter = - registry.find("locksmith.semaphore.skipped").tag("reason", "timeout").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should register skipped counter with immediate reason") - void shouldRegisterSkippedCounterWithImmediateReason() { - Counter counter = - registry.find("locksmith.semaphore.skipped").tag("reason", "immediate").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment skipped counter with correct reason - timeout") - void shouldIncrementSkippedCounterWithTimeoutReason() { - semaphoreMetrics.recordSkipped(AcquisitionMode.WAIT_AND_SKIP); - - Counter counter = - registry.find("locksmith.semaphore.skipped").tag("reason", "timeout").counter(); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should increment skipped counter with correct reason - immediate") - void shouldIncrementSkippedCounterWithImmediateReason() { - semaphoreMetrics.recordSkipped(AcquisitionMode.SKIP_IMMEDIATELY); - - Counter counter = - registry.find("locksmith.semaphore.skipped").tag("reason", "immediate").counter(); - assertEquals(1, counter.count()); - } - - @Test - @DisplayName("Should register lease expired counter") - void shouldRegisterLeaseExpiredCounter() { - Counter counter = registry.find("locksmith.semaphore.lease.expired").counter(); - assertNotNull(counter); - assertEquals(0, counter.count()); - } - - @Test - @DisplayName("Should increment lease expired counter") - void shouldIncrementLeaseExpiredCounter() { - semaphoreMetrics.recordLeaseExpired(); - semaphoreMetrics.recordLeaseExpired(); - semaphoreMetrics.recordLeaseExpired(); - - Counter counter = registry.find("locksmith.semaphore.lease.expired").counter(); - assertEquals(3, counter.count()); - } - } - - @Nested - @DisplayName("Timer Tests") - class TimerTests { - - @Test - @DisplayName("Should register acquisition time timer") - void shouldRegisterAcquisitionTimeTimer() { - Timer timer = registry.find("locksmith.semaphore.acquisition.time").timer(); - assertNotNull(timer); - assertEquals(0, timer.count()); - } - - @Test - @DisplayName("Should record acquisition time") - void shouldRecordAcquisitionTime() { - semaphoreMetrics.recordAcquisitionTime(100); - semaphoreMetrics.recordAcquisitionTime(200); - - Timer timer = registry.find("locksmith.semaphore.acquisition.time").timer(); - assertEquals(2, timer.count()); - assertTrue(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS) >= 300); - } - - @Test - @DisplayName("Should register held time timer") - void shouldRegisterHeldTimeTimer() { - Timer timer = registry.find("locksmith.semaphore.held.time").timer(); - assertNotNull(timer); - assertEquals(0, timer.count()); - } - - @Test - @DisplayName("Should record held time") - void shouldRecordHeldTime() { - semaphoreMetrics.recordHeldTime(500); - semaphoreMetrics.recordHeldTime(1000); - - Timer timer = registry.find("locksmith.semaphore.held.time").timer(); - assertEquals(2, timer.count()); - assertTrue(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS) >= 1500); - } - } -} diff --git a/src/test/java/in/riido/locksmith/semaphore/PermitHandleTest.java b/src/test/java/in/riido/locksmith/semaphore/PermitHandleTest.java new file mode 100644 index 0000000..25a3b5c --- /dev/null +++ b/src/test/java/in/riido/locksmith/semaphore/PermitHandleTest.java @@ -0,0 +1,401 @@ +package in.riido.locksmith.semaphore; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisConnectionException; +import org.redisson.misc.CompletableFutureWrapper; +import org.slf4j.LoggerFactory; + +@DisplayName("PermitHandle") +class PermitHandleTest { + + private static final String FULL_KEY = "locksmith:semaphore:k"; + private static final String PERMIT_ID = "permit-1"; + + private RedissonClient redisson; + private RPermitExpirableSemaphore semaphore; + private LocksmithMetrics metrics; + private Logger logger; + private ListAppender appender; + + @BeforeEach + void setUp() { + redisson = mock(RedissonClient.class); + semaphore = mock(RPermitExpirableSemaphore.class); + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>((Void) null)); + metrics = mock(LocksmithMetrics.class); + logger = (Logger) LoggerFactory.getLogger(PermitHandle.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + Thread.interrupted(); + } + + private PermitHandle acquiredHandle() { + return new PermitHandle( + redisson, + semaphore, + PERMIT_ID, + FULL_KEY, + Duration.ofSeconds(1), + System.nanoTime(), + Duration.ZERO, + metrics); + } + + private void releaseFailsWith(Throwable failure) { + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>(failure)); + } + + private List warnings() { + return appender.list.stream().filter(e -> e.getLevel() == Level.WARN).toList(); + } + + @Test + @DisplayName("key() returns the full key and permitId() the Redisson permit id") + void accessors() { + PermitHandle handle = acquiredHandle(); + + assertThat(handle.key()).isEqualTo(FULL_KEY); + assertThat(handle.permitId()).isEqualTo(PERMIT_ID); + assertThat(handle.acquired()).isTrue(); + } + + @Nested + @DisplayName("close on an acquired handle") + class Acquired { + + @Test + @DisplayName("releases the permit id and records locksmith.held for primitive SEMAPHORE") + void releasesAndRecordsHeld() { + acquiredHandle().close(); + + verify(semaphore).releaseAsync(PERMIT_ID); + verify(semaphore, never()).release(any(String.class)); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + assertThat(warnings()).isEmpty(); + } + + @Test + @DisplayName("is idempotent: a second close does not release again") + void idempotent() { + PermitHandle handle = acquiredHandle(); + + handle.close(); + handle.close(); + + verify(semaphore).releaseAsync(PERMIT_ID); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"redisson-netty-1-1", "redisson-timer-1-1"}) + @DisplayName( + "on a Redisson I/O or timer thread it starts the release and returns without waiting") + void doesNotWaitOnRedissonIoOrTimerThread(String threadName) throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>(release)); + PermitHandle handle = acquiredHandle(); + AtomicBoolean returned = new AtomicBoolean(); + + Thread redisson = + new Thread( + () -> { + handle.close(); + returned.set(true); + }, + threadName); + redisson.start(); + redisson.join(SECONDS.toMillis(5)); + + assertThat(returned).isTrue(); + verify(metrics, never()).recordHeld(any(), any()); + release.complete(null); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + } + + @Test + @DisplayName("on a Redisson executor thread, such as a listener, it waits for the release") + void waitsOnRedissonExecutorThread() throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>(release)); + PermitHandle handle = acquiredHandle(); + AtomicBoolean returned = new AtomicBoolean(); + + Thread listener = + new Thread( + () -> { + handle.close(); + returned.set(true); + }, + "redisson-3-1"); + listener.start(); + listener.join(200); + + assertThat(returned).isFalse(); + release.complete(null); + listener.join(SECONDS.toMillis(5)); + assertThat(returned).isTrue(); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + } + + @Test + @DisplayName("on an interrupted thread it releases, logs no WARN, and keeps the flag") + void interruptedThread() { + PermitHandle handle = acquiredHandle(); + Thread.currentThread().interrupt(); + + handle.close(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + assertThat(warnings()).isEmpty(); + } + + @Test + @DisplayName("a recordHeld failure after release does not throw and logs one metrics WARN") + void metricsFailureAfterRelease() { + doThrow(new IllegalArgumentException("meter clash")) + .when(metrics) + .recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + PermitHandle handle = acquiredHandle(); + + assertThatNoException().isThrownBy(handle::close); + + verify(semaphore).releaseAsync(PERMIT_ID); + assertThat(warnings()).hasSize(1); + assertThat(warnings().get(0).getFormattedMessage()) + .isEqualTo("Permit [" + FULL_KEY + "] metrics recording failed: meter clash"); + } + } + + @Nested + @DisplayName("client shutting down") + class ClientShutdown { + + /** Closes on a new daemon thread, interrupted first; reports whether the flag survived. */ + private Thread closeInterrupted(PermitHandle handle, AtomicBoolean flagKept) { + Thread closer = + new Thread( + () -> { + Thread.currentThread().interrupt(); + handle.close(); + flagKept.set(Thread.currentThread().isInterrupted()); + }); + // A close() that never returns must not keep the test JVM alive. + closer.setDaemon(true); + closer.start(); + return closer; + } + + @Test + @DisplayName( + "a release that never finishes: close() stops waiting within about two seconds, flag kept") + void stopsWaitingOnceShuttingDown() throws Exception { + when(semaphore.releaseAsync(PERMIT_ID)) + .thenReturn(new CompletableFutureWrapper<>(new CompletableFuture())); + when(redisson.isShuttingDown()).thenReturn(true); + AtomicBoolean flagKept = new AtomicBoolean(); + + Thread closer = closeInterrupted(acquiredHandle(), flagKept); + closer.join(2500); + + // Before the fix, close() waited in join() for good, interrupted or not. + assertThat(closer.isAlive()).as("close() still waiting").isFalse(); + assertThat(flagKept).isTrue(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName( + "a client that is not shutting down: close() goes on waiting past a check, flag kept") + void waitsWhileClientRuns() throws Exception { + CompletableFuture release = new CompletableFuture<>(); + when(semaphore.releaseAsync(PERMIT_ID)).thenReturn(new CompletableFutureWrapper<>(release)); + AtomicBoolean flagKept = new AtomicBoolean(); + + Thread closer = closeInterrupted(acquiredHandle(), flagKept); + + verify(redisson, timeout(SECONDS.toMillis(5)).atLeastOnce()).isShuttingDown(); + assertThat(closer.isAlive()).as("close() still waiting").isTrue(); + release.complete(null); + closer.join(SECONDS.toMillis(5)); + assertThat(closer.isAlive()).as("close() still waiting").isFalse(); + assertThat(flagKept).isTrue(); + verify(metrics).recordHeld(eq(Primitive.SEMAPHORE), any(Duration.class)); + } + + @Test + @DisplayName("a release refused as Redisson shuts down logs one WARN line, without a trace") + void refusedReleaseLogsOneQuietLine() { + releaseFailsWith(new RedissonShutdownException("Redisson is shutdown")); + + assertThatNoException().isThrownBy(acquiredHandle()::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Permit [" + FULL_KEY + "] release was not confirmed after ") + .endsWith( + "ms (lease 1000ms) because the Redisson client is shutting down; the permit expires" + + " on its own"); + assertThat(warning.getThrowableProxy()).isNull(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName( + "a refused release wrapped twice, as on a Redis Cluster client, still logs the one quiet" + + " line") + void refusedReleaseWrappedTwiceLogsOneQuietLine() { + releaseFailsWith( + new CompletionException( + new CompletionException(new RedissonShutdownException("Redisson is shutdown")))); + + acquiredHandle().close(); + + assertThat(warnings()).hasSize(1); + assertThat(warnings().get(0).getFormattedMessage()) + .startsWith("Permit [" + FULL_KEY + "] release was not confirmed after "); + assertThat(warnings().get(0).getThrowableProxy()).isNull(); + } + } + + @Nested + @DisplayName("close on an unacquired handle") + class Unacquired { + + @Test + @DisplayName("reports acquired() false, permitId() null, and closes as a no-op") + void noOp() { + PermitHandle handle = + new PermitHandle( + redisson, null, null, FULL_KEY, Duration.ofSeconds(1), 0L, Duration.ZERO, metrics); + + assertThat(handle.acquired()).isFalse(); + assertThat(handle.permitId()).isNull(); + handle.close(); + + verifyNoInteractions(metrics); + assertThat(appender.list).isEmpty(); + } + } + + @Nested + @DisplayName("release failure") + class ReleaseFailure { + + @Test + @DisplayName( + "an expired or unknown permit held short of its lease logs the not-held WARN, which still" + + " names the lease as a possible cause, without a trace") + void notHeldBeforeLeaseRanOut() { + releaseFailsWith( + new IllegalArgumentException( + "Permit with id permit-1 has already been released or doesn't exist")); + // acquiredHandle() takes acquiredAtNanos as now, so held is far short of the 1s lease. + PermitHandle handle = acquiredHandle(); + + assertThatNoException().isThrownBy(handle::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Permit [" + FULL_KEY + "] was reported as not held at release, ") + .contains( + "ms after its acquire returned (lease 1000ms). Possible causes: the lease ran out," + + " counted from when the acquire was sent") + .doesNotContain("another instance may have run concurrently") + .endsWith("has already been released or doesn't exist"); + assertThat(warning.getThrowableProxy()).isNull(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName( + "an expired or unknown permit held its full lease logs the same not-held WARN, without a" + + " trace") + void notHeldAfterLeaseRanOut() { + releaseFailsWith( + new IllegalArgumentException( + "Permit with id permit-1 has already been released or doesn't exist")); + // acquiredAtNanos ten seconds ago with a one second lease makes held outrun the lease. + PermitHandle handle = + new PermitHandle( + redisson, + semaphore, + PERMIT_ID, + FULL_KEY, + Duration.ofSeconds(1), + System.nanoTime() - Duration.ofSeconds(10).toNanos(), + Duration.ZERO, + metrics); + + assertThatNoException().isThrownBy(handle::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Permit [" + FULL_KEY + "] was reported as not held at release, ") + .contains("ms after its acquire returned (lease 1000ms). Possible causes: ") + .endsWith("has already been released or doesn't exist"); + assertThat(warning.getThrowableProxy()).isNull(); + verify(metrics, never()).recordHeld(any(), any()); + } + + @Test + @DisplayName("swallows any other failure and logs a WARN with the exception") + void swallowsOtherFailure() { + releaseFailsWith(new RedisConnectionException("Redis down")); + PermitHandle handle = acquiredHandle(); + + assertThatNoException().isThrownBy(handle::close); + + assertThat(warnings()).hasSize(1); + ILoggingEvent warning = warnings().get(0); + assertThat(warning.getFormattedMessage()) + .startsWith("Permit [" + FULL_KEY + "] release failed after ") + .contains("(lease 1000ms)") + .contains("Redis down"); + assertThat(warning.getThrowableProxy().getClassName()) + .isEqualTo(RedisConnectionException.class.getName()); + } + } +} diff --git a/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsIntegrationTest.java b/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsIntegrationTest.java new file mode 100644 index 0000000..848afd1 --- /dev/null +++ b/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsIntegrationTest.java @@ -0,0 +1,509 @@ +package in.riido.locksmith.semaphore; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.redis.testcontainers.RedisContainer; +import in.riido.locksmith.DockerAvailableCondition; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.MicrometerLocksmithMetrics; +import in.riido.locksmith.metrics.NoOpLocksmithMetrics; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.redisson.Redisson; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.slf4j.LoggerFactory; +import org.testcontainers.utility.DockerImageName; + +@ExtendWith(DockerAvailableCondition.class) +@DisplayName("SemaphoreOperations against Redis") +class SemaphoreOperationsIntegrationTest { + + private static final LocksmithProperties PROPERTIES = new LocksmithProperties(null, null, null); + + private static RedisContainer redis; + private static RedissonClient client1; + private static RedissonClient client2; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + private String key; + + @BeforeAll + static void startRedis() { + redis = new RedisContainer(DockerImageName.parse("redis:7-alpine")); + redis.start(); + client1 = newClient(); + client2 = newClient(); + } + + @AfterAll + static void stopRedis() { + client1.shutdown(); + client2.shutdown(); + redis.stop(); + } + + @BeforeEach + void newKey() { + key = "it:" + UUID.randomUUID(); + } + + @AfterEach + void stopExecutor() { + executor.shutdownNow(); + } + + private static RedissonClient newClient() { + Config config = new Config(); + config + .useSingleServer() + .setAddress("redis://" + redis.getHost() + ":" + redis.getFirstMappedPort()); + return Redisson.create(config); + } + + private static SemaphoreOperations operations(RedissonClient client, LocksmithMetrics metrics) { + return new SemaphoreOperations(client, PROPERTIES, metrics); + } + + @Nested + @DisplayName("capacity") + class Capacity { + + @Test + @DisplayName("permits 2, four threads: at most two hold at once and all four eventually run") + void atMostTwoHoldAtOnce() throws Exception { + SemaphoreOperations semaphores = operations(client1, new NoOpLocksmithMetrics()); + AtomicInteger holding = new AtomicInteger(); + AtomicInteger highWater = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + results.add( + executor.submit( + () -> { + start.await(); + try (PermitHandle permit = + semaphores.key(key).permits(2).waitTime(Duration.ofSeconds(10)).acquire()) { + if (!permit.acquired()) { + return false; + } + highWater.accumulateAndGet(holding.incrementAndGet(), Math::max); + Thread.sleep(300); + holding.decrementAndGet(); + return true; + } + })); + } + + start.countDown(); + + for (Future result : results) { + assertThat(result.get(15, SECONDS)).isTrue(); + } + assertThat(highWater.get()).isEqualTo(2); + assertThat(semaphores.availablePermits(key)).isEqualTo(2); + } + } + + @Nested + @DisplayName("permit count") + class PermitCount { + + private Logger logger; + private ListAppender appender; + + @BeforeEach + void captureLog() { + logger = (Logger) LoggerFactory.getLogger(SemaphoreOperations.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void releaseLog() { + logger.detachAppender(appender); + } + + @Test + @DisplayName("a second SemaphoreOperations on another client raises 2 to 5, logging one INFO") + void secondInstanceRaisesCount() { + SemaphoreOperations first = operations(client1, new NoOpLocksmithMetrics()); + SemaphoreOperations second = operations(client2, new NoOpLocksmithMetrics()); + + try (PermitHandle permit = first.key(key).permits(2).acquire()) { + assertThat(permit.acquired()).isTrue(); + } + assertThat(first.availablePermits(key)).isEqualTo(2); + + try (PermitHandle permit = second.key(key).permits(5).acquire()) { + assertThat(permit.acquired()).isTrue(); + } + + assertThat(second.availablePermits(key)).isEqualTo(5); + assertThat(first.availablePermits(key)).isEqualTo(5); + List infos = + appender.list.stream().filter(e -> e.getLevel() == Level.INFO).toList(); + assertThat(infos).hasSize(1); + assertThat(infos.get(0).getFormattedMessage()) + .isEqualTo("Semaphore [locksmith:semaphore:" + key + "] permits changed from 2 to 5"); + } + + @Test + @DisplayName( + "sixteen threads on two instances racing a new key's first acquire: exactly five get in") + void racingFirstAcquireSetsCountOnce() throws Exception { + SemaphoreOperations first = operations(client1, new NoOpLocksmithMetrics()); + SemaphoreOperations second = operations(client2, new NoOpLocksmithMetrics()); + int racers = 16; + CyclicBarrier start = new CyclicBarrier(racers); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger acquired = new AtomicInteger(); + List> all = new ArrayList<>(); + try { + for (int i = 0; i < racers; i++) { + SemaphoreOperations operations = i % 2 == 0 ? first : second; + all.add( + executor.submit( + () -> { + start.await(5, SECONDS); + try (PermitHandle permit = operations.key(key).permits(5).acquire()) { + if (permit.acquired()) { + acquired.incrementAndGet(); + release.await(5, SECONDS); + } + } + return null; + })); + } + await() + .atMost(Duration.ofSeconds(5)) + .until(() -> acquired.get() == 5 && all.stream().filter(Future::isDone).count() == 11); + assertThat(first.availablePermits(key)).isZero(); + } finally { + release.countDown(); + } + for (Future future : all) { + future.get(10, SECONDS); + } + assertThat(acquired.get()).isEqualTo(5); + assertThat(client1.getPermitExpirableSemaphore("locksmith:semaphore:" + key).getPermits()) + .isEqualTo(5); + assertThat(first.availablePermits(key)).isEqualTo(5); + } + } + + @Nested + @DisplayName("lost Redis state") + class LostState { + + @Test + @DisplayName("after the key is deleted in Redis, the next acquire re-creates it with its count") + void recreatesDeletedSemaphore() { + SemaphoreOperations semaphores = operations(client1, new NoOpLocksmithMetrics()); + try (PermitHandle permit = semaphores.key(key).permits(2).acquire()) { + assertThat(permit.acquired()).isTrue(); + } + + client2.getPermitExpirableSemaphore("locksmith:semaphore:" + key).delete(); + + try (PermitHandle permit = semaphores.key(key).permits(2).acquire()) { + assertThat(permit.acquired()).isTrue(); + assertThat(semaphores.availablePermits(key)).isEqualTo(1); + } + } + + @Test + @DisplayName( + "after the set of held permits is lost while the semaphore is full, the next acquire" + + " restores the count") + void restoresCountAfterHeldPermitsLost() { + SemaphoreOperations semaphores = operations(client1, new NoOpLocksmithMetrics()); + PermitHandle a = semaphores.key(key).permits(2).acquire(); + PermitHandle b = semaphores.key(key).permits(2).acquire(); + assertThat(semaphores.availablePermits(key)).isZero(); + + client2.getKeys().delete("{locksmith:semaphore:" + key + "}:timeout"); + + try (PermitHandle permit = semaphores.key(key).permits(2).acquire()) { + assertThat(permit.acquired()).isTrue(); + } + a.close(); + b.close(); + } + } + + @Nested + @DisplayName("waiting") + class Waiting { + + @Test + @DisplayName( + "a waiter whose client shuts down during the wait throws RedissonShutdownException, not" + + " waiting forever") + void clientShutDownWhileWaiting() throws Exception { + try (PermitHandle held = + operations(client1, new NoOpLocksmithMetrics()).key(key).permits(1).acquire()) { + assertThat(held.acquired()).isTrue(); + RedissonClient client = newClient(); + SemaphoreOperations semaphores = operations(client, new NoOpLocksmithMetrics()); + Future waiter = + executor.submit( + () -> semaphores.key(key).permits(1).waitTime(Duration.ofSeconds(5)).acquire()); + Thread.sleep(300); + + client.shutdown(); + + // Redisson's shutdown never completes a pending wait; before the fix this waiter never + // returned, not even after its wait time. + assertThatThrownBy(() -> waiter.get(10, SECONDS)) + .hasCauseInstanceOf(RedissonShutdownException.class); + } + } + } + + @Nested + @DisplayName("interrupted caller") + class Interrupted { + + @Test + @DisplayName("interrupted before acquire: unacquired, flag kept, and no permit left taken") + void noPermitLeftTaken() { + // Redisson's blocking tryAcquire throws here yet still takes a permit, until its lease ends. + int permits = 50; + SemaphoreOperations semaphores = operations(client1, new NoOpLocksmithMetrics()); + semaphores.key(key).permits(permits).acquire().close(); + for (int i = 0; i < permits; i++) { + Thread.currentThread().interrupt(); + try { + PermitHandle handle = + semaphores.key(key).permits(permits).waitTime(Duration.ofSeconds(1)).acquire(); + assertThat(Thread.interrupted()).as("interrupt flag").isTrue(); + handle.close(); + } finally { + Thread.interrupted(); + } + } + + await() + .atMost(Duration.ofSeconds(5)) + .until(() -> semaphores.availablePermits(key) == permits); + } + + @Test + @DisplayName( + "a pre-interrupted try-once caller never blocks another instance's concurrent try-once") + void preInterruptedDoesNotBlockAnotherInstance() throws Exception { + // Before the fix, sending tryAcquireAsync while already interrupted still took the permit + // for a moment before the cancel released it again, so instance 2's try-once could lose the + // race for the single permit. + SemaphoreOperations semaphores1 = operations(client1, new NoOpLocksmithMetrics()); + SemaphoreOperations semaphores2 = operations(client2, new NoOpLocksmithMetrics()); + int trials = 200; + for (int i = 0; i < trials; i++) { + String trialKey = key + ":" + i; + // Warms up each instance's permit-count cache for this key, so the race below sends only + // the tryAcquireAsync call, the same way the lock's race sends only tryLockAsync. + semaphores1.key(trialKey).permits(1).acquire().close(); + semaphores2.key(trialKey).permits(1).acquire().close(); + CyclicBarrier barrier = new CyclicBarrier(2); + // A's own flag is set on the pooled thread that runs it, and cleared there afterwards, so + // it never leaks onto the executor thread for a later trial. + Future aFuture = + executor.submit( + () -> { + barrier.await(); + Thread.currentThread().interrupt(); + try { + return semaphores1.key(trialKey).permits(1).acquire(); + } finally { + Thread.interrupted(); + } + }); + Future bFuture = + executor.submit( + () -> { + barrier.await(); + return semaphores2.key(trialKey).permits(1).acquire(); + }); + + PermitHandle aHandle = aFuture.get(5, SECONDS); + PermitHandle bHandle = bFuture.get(5, SECONDS); + + assertThat(bHandle.acquired()).as("trial %d", i).isTrue(); + + aHandle.close(); + bHandle.close(); + } + } + } + + @Nested + @DisplayName("metrics") + class Metrics { + + @Test + @DisplayName( + "records locksmith.acquire (semaphore, acquired) and locksmith.held (semaphore) once each") + void acquireAndHeldRecorded() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + SemaphoreOperations semaphores = + operations(client1, new MicrometerLocksmithMetrics(registry)); + + try (PermitHandle permit = semaphores.key(key).permits(1).acquire()) { + assertThat(permit.acquired()).isTrue(); + } + + Timer acquire = + registry + .find("locksmith.acquire") + .tag("primitive", "semaphore") + .tag("outcome", "acquired") + .timer(); + Timer held = registry.find("locksmith.held").tag("primitive", "semaphore").timer(); + assertThat(acquire).isNotNull(); + assertThat(acquire.count()).isEqualTo(1); + assertThat(held).isNotNull(); + assertThat(held.count()).isEqualTo(1); + } + } + + @Nested + @Tag("slow") + @DisplayName("client shutdown during releases (slow)") + class ReleaseAtShutdown { + + @Test + @DisplayName( + "220 releases sent to a paused Redis: every close() returns once the client shuts down," + + " and no release WARN carries a stack trace") + void everyCloseReturns() throws Exception { + // More releases than the last 100 calls Redisson's shutdown settles: before the fix, 24 of + // 220 close() calls never returned, interrupted or not, even after Redis answered again. + int count = 220; + RedissonClient client = newClient(); + SemaphoreOperations semaphores = operations(client, new NoOpLocksmithMetrics()); + List handles = new ArrayList<>(); + for (int i = 0; i < count; i++) { + handles.add(semaphores.key(key).permits(count).acquire()); + } + assertThat(handles).allMatch(PermitHandle::acquired); + Logger logger = (Logger) LoggerFactory.getLogger(PermitHandle.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + CountDownLatch started = new CountDownLatch(count); + CountDownLatch returned = new CountDownLatch(count); + // Redis holds even CLIENT UNPAUSE until a pause of all clients runs out, so it is short. + assertThat(redis.execInContainer("redis-cli", "CLIENT", "PAUSE", "5000", "ALL").getStdout()) + .startsWith("OK"); + try { + for (PermitHandle handle : handles) { + Thread closer = + new Thread( + () -> { + started.countDown(); + handle.close(); + returned.countDown(); + }); + // A close() that never returns must not keep the test JVM alive. + closer.setDaemon(true); + closer.start(); + } + assertThat(started.await(5, SECONDS)).isTrue(); + Thread.sleep(200); + + client.shutdown(); + + assertThat(returned.await(5, SECONDS)).as("every close() returned").isTrue(); + assertThat(appender.list) + .filteredOn(e -> e.getLevel() == Level.WARN) + .isNotEmpty() + .allSatisfy( + warning -> { + assertThat(warning.getFormattedMessage()) + .contains("because the Redisson client is shutting down"); + assertThat(warning.getThrowableProxy()).isNull(); + }); + } finally { + logger.detachAppender(appender); + // Answers once the pause has run out, so the next test finds Redis serving. + redis.execInContainer("redis-cli", "PING"); + } + } + } + + @Nested + @Tag("slow") + @DisplayName("lease (slow)") + class Lease { + + private Logger logger; + private ListAppender appender; + + @BeforeEach + void captureLog() { + logger = (Logger) LoggerFactory.getLogger(PermitHandle.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void releaseLog() { + logger.detachAppender(appender); + } + + @Test + @DisplayName("an outrun 1s lease frees the permit; close() after 2s WARNs and does not throw") + void leaseOutrun() throws Exception { + SemaphoreOperations first = operations(client1, new NoOpLocksmithMetrics()); + SemaphoreOperations second = operations(client2, new NoOpLocksmithMetrics()); + PermitHandle permit = first.key(key).permits(1).leaseTime(Duration.ofSeconds(1)).acquire(); + assertThat(permit.acquired()).isTrue(); + + Thread.sleep(2000); + try (PermitHandle other = second.key(key).permits(1).acquire()) { + assertThat(other.acquired()).as("permit free after the lease ran out").isTrue(); + assertThatNoException().isThrownBy(permit::close); + } + + List warnings = + appender.list.stream().filter(e -> e.getLevel() == Level.WARN).toList(); + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0).getFormattedMessage()) + .startsWith( + "Permit [locksmith:semaphore:" + key + "] was reported as not held at release, ") + .contains("(lease 1000ms). Possible causes: the lease ran out"); + } + } +} diff --git a/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsTest.java b/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsTest.java new file mode 100644 index 0000000..db0e1d8 --- /dev/null +++ b/src/test/java/in/riido/locksmith/semaphore/SemaphoreOperationsTest.java @@ -0,0 +1,712 @@ +package in.riido.locksmith.semaphore; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import in.riido.locksmith.LocksmithConfigurationException; +import in.riido.locksmith.autoconfigure.LocksmithProperties; +import in.riido.locksmith.metrics.LocksmithMetrics; +import in.riido.locksmith.metrics.LocksmithMetrics.Outcome; +import in.riido.locksmith.metrics.LocksmithMetrics.Primitive; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.Answer; +import org.redisson.RedissonShutdownException; +import org.redisson.api.RFuture; +import org.redisson.api.RPermitExpirableSemaphore; +import org.redisson.api.RedissonClient; +import org.redisson.client.RedisConnectionException; +import org.redisson.config.Config; +import org.redisson.misc.CompletableFutureWrapper; +import org.slf4j.LoggerFactory; + +@DisplayName("SemaphoreOperations") +class SemaphoreOperationsTest { + + private static final String FULL_KEY = "test:semaphore:k"; + private static final Duration DEFAULT_LEASE = Duration.ofSeconds(90); + + private RedissonClient redisson; + private RPermitExpirableSemaphore semaphore; + private LocksmithMetrics metrics; + private SemaphoreOperations operations; + private Logger logger; + private ListAppender appender; + + @BeforeEach + void setUp() throws InterruptedException { + redisson = mock(RedissonClient.class); + semaphore = mock(RPermitExpirableSemaphore.class); + when(redisson.getPermitExpirableSemaphore(FULL_KEY)).thenReturn(semaphore); + when(semaphore.getPermitsAsync()).thenReturn(done(5)); + when(semaphore.setPermitsAsync(anyInt())).thenReturn(done(null)); + when(semaphore.releaseAsync(anyString())).thenReturn(done(null)); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit("id-1")); + metrics = mock(LocksmithMetrics.class); + operations = + new SemaphoreOperations( + redisson, + new LocksmithProperties( + null, "test:", new LocksmithProperties.Semaphore(DEFAULT_LEASE)), + metrics); + logger = (Logger) LoggerFactory.getLogger(SemaphoreOperations.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + Thread.interrupted(); + } + + private static RFuture> permit(String id) { + return new CompletableFutureWrapper<>(id == null ? List.of() : List.of(id)); + } + + private static RFuture done(T value) { + return new CompletableFutureWrapper<>(value); + } + + /** An attempt that Redisson completes just before the interrupted caller's cancel reaches it. */ + private static RFuture> completesBeforeCancel(List ids) { + CompletableFuture> attempt = new CompletableFuture<>(); + return new CompletableFutureWrapper<>(attempt) { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + attempt.complete(ids); + return super.cancel(mayInterruptIfRunning); + } + }; + } + + private List infos() { + return appender.list.stream().filter(e -> e.getLevel() == Level.INFO).toList(); + } + + /** Runs the call on a thread with the given name; returns its result, or what it threw. */ + private static Object onThread(String name, Supplier call) throws InterruptedException { + AtomicReference outcome = new AtomicReference<>(); + Thread thread = + new Thread( + () -> { + try { + outcome.set(call.get()); + } catch (RuntimeException e) { + outcome.set(e); + } + }, + name); + thread.start(); + thread.join(); + return outcome.get(); + } + + /** Interrupts the calling thread when the mocked Redisson call runs, then returns the result. */ + private static Answer interruptingAnd(T result) { + return invocation -> { + Thread.currentThread().interrupt(); + return result; + }; + } + + @Nested + @DisplayName("key layout") + class KeyLayout { + + @Test + @DisplayName("prefixes the key as semaphore: and reports it on the handle") + void prefixesKey() { + PermitHandle handle = operations.key("k").permits(5).acquire(); + + verify(redisson).getPermitExpirableSemaphore(FULL_KEY); + assertThat(handle.key()).isEqualTo(FULL_KEY); + } + } + + @Nested + @DisplayName("permits") + class Permits { + + @Test + @DisplayName("acquire() without permits throws LocksmithConfigurationException, no Redis call") + void missing() { + assertThatThrownBy(() -> operations.key("k").acquire()) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining(FULL_KEY) + .hasMessageContaining("got 0"); + verifyNoInteractions(redisson); + } + + @Test + @DisplayName("acquire() with permits 0 throws LocksmithConfigurationException, no Redis call") + void zero() { + assertThatThrownBy(() -> operations.key("k").permits(0).acquire()) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining(FULL_KEY) + .hasMessageContaining("got 0"); + verifyNoInteractions(redisson); + } + + @Test + @DisplayName("acquire() with negative permits throws LocksmithConfigurationException") + void negative() { + assertThatThrownBy(() -> operations.key("k").permits(-3).acquire()) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("got -3"); + verifyNoInteractions(redisson); + } + } + + @Nested + @DisplayName("durations passed to tryAcquireAsync") + class Durations { + + @Test + @DisplayName("defaults: wait 0 and the lease from locksmith.semaphore.lease-time, in ms") + void defaults() throws InterruptedException { + operations.key("k").permits(5).acquire(); + + verify(semaphore).tryAcquireAsync(1, 0L, 90_000L, MILLISECONDS); + } + + @Test + @DisplayName("passes waitTime and leaseTime in milliseconds") + void explicit() throws InterruptedException { + operations + .key("k") + .permits(5) + .waitTime(Duration.ofMillis(1500)) + .leaseTime(Duration.ofSeconds(30)) + .acquire(); + + verify(semaphore).tryAcquireAsync(1, 1500L, 30_000L, MILLISECONDS); + } + + @Test + @DisplayName("rejects a negative waitTime with IllegalArgumentException in the builder") + void rejectsNegativeWait() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").waitTime(Duration.ofMillis(-1))) + .withMessageContaining("waitTime"); + } + + @Test + @DisplayName("rejects a zero leaseTime with IllegalArgumentException in the builder") + void rejectsZeroLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ZERO)) + .withMessage("leaseTime must be at least one millisecond, got PT0S"); + } + + @Test + @DisplayName("rejects a negative leaseTime with IllegalArgumentException in the builder") + void rejectsNegativeLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ofSeconds(-1))) + .withMessageContaining("leaseTime"); + } + + @Test + @DisplayName("rejects a sub-millisecond leaseTime, which would round to zero") + void rejectsSubMillisecondLease() { + assertThatIllegalArgumentException() + .isThrownBy(() -> operations.key("k").leaseTime(Duration.ofNanos(500))) + .withMessageContaining("leaseTime must be at least one millisecond"); + } + } + + @Nested + @DisplayName("outcome") + class Outcomes { + + @Test + @DisplayName("records outcome ACQUIRED and returns a handle with the permit id") + void acquired() { + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(handle.acquired()).isTrue(); + assertThat(handle.permitId()).isEqualTo("id-1"); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.ACQUIRED), any(Duration.class)); + } + + @Test + @DisplayName("a null permit id records outcome SKIPPED and returns an unacquired handle") + void skipped() throws InterruptedException { + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit(null)); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(handle.acquired()).isFalse(); + assertThat(handle.permitId()).isNull(); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.SKIPPED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted: cancels the pending attempt, restores the flag, records INTERRUPTED," + + " unacquired") + void interrupted() { + CompletableFuture> pending = new CompletableFuture<>(); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenAnswer(interruptingAnd(new CompletableFutureWrapper<>(pending))); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + // Cancelling is what makes Redisson release a permit the attempt still wins. + assertThat(pending).isCancelled(); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.INTERRUPTED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted as the attempt wins: keeps the permit and the flag, records ACQUIRED, so no" + + " permit is left without a handle") + void interruptedAsAttemptWins() { + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenAnswer(interruptingAnd(completesBeforeCancel(List.of("id-1")))); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.permitId()).isEqualTo("id-1"); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.ACQUIRED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted as an empty attempt completes: the lost-key check stops too, INTERRUPTED," + + " no exception") + void interruptedAsEmptyAttemptCompletes() { + CompletableFuture lostKeyCheck = new CompletableFuture<>(); + when(semaphore.getPermitsAsync()) + .thenReturn(done(5), new CompletableFutureWrapper<>(lostKeyCheck)); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenAnswer(interruptingAnd(completesBeforeCancel(List.of()))); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + assertThat(lostKeyCheck).isCancelled(); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.INTERRUPTED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted while the count is set: unacquired, flag kept, INTERRUPTED, no exception") + void interruptedWhileCountIsSet() { + CompletableFuture pending = new CompletableFuture<>(); + when(semaphore.getPermitsAsync()) + .thenAnswer(interruptingAnd(new CompletableFutureWrapper<>(pending))); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + assertThat(pending).isCancelled(); + verify(semaphore, never()).tryAcquireAsync(anyInt(), anyLong(), anyLong(), any()); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.INTERRUPTED), any(Duration.class)); + } + + @Test + @DisplayName( + "interrupted before acquire is called: unacquired, flag kept, records INTERRUPTED, and" + + " nothing reaches Redisson") + void interruptedBeforeTheCall() { + Thread.currentThread().interrupt(); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(handle.acquired()).isFalse(); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.INTERRUPTED), any(Duration.class)); + verify(semaphore, never()).getPermitsAsync(); + verify(semaphore, never()).tryAcquireAsync(anyInt(), anyLong(), anyLong(), any()); + verify(semaphore, never()).setPermitsAsync(anyInt()); + } + + @Test + @DisplayName( + "on a Redisson executor thread: a waiting acquire throws before any call, try-once runs") + void redissonExecutorThread() throws InterruptedException { + Object waiting = + onThread( + "redisson-3-1", + () -> operations.key("k").permits(5).waitTime(Duration.ofSeconds(1)).acquire()); + + assertThat((Throwable) waiting) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot wait for a lock or permit on Redisson thread [redisson-3-1]"); + verifyNoInteractions(semaphore); + + Object once = onThread("redisson-3-1", () -> operations.key("k").permits(5).acquire()); + + assertThat(((PermitHandle) once).acquired()).isTrue(); + } + + @Test + @DisplayName("on the Redisson timer thread: even a try-once acquire throws before any call") + void redissonTimerThread() throws InterruptedException { + Object thrown = + onThread("redisson-timer-4-1", () -> operations.key("k").permits(5).acquire()); + + assertThat((Throwable) thrown) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot run on Redisson's timer thread [redisson-timer-4-1]"); + verifyNoInteractions(semaphore); + } + + @Test + @DisplayName( + "on a Redis Cluster client, a key with a '{' that forms no hash tag throws before any call") + void clusterKeyWithoutHashTag() { + Config config = new Config(); + config.useClusterServers(); + when(redisson.getConfig()).thenReturn(config); + + assertThatThrownBy(() -> operations.key("order:x{1").permits(5).acquire()) + .isExactlyInstanceOf(LocksmithConfigurationException.class) + .hasMessageStartingWith( + "Key [test:semaphore:order:x{1] contains a '{' or '}' that forms no Redis Cluster" + + " hash tag"); + verify(redisson, never()).getPermitExpirableSemaphore(anyString()); + } + + @Test + @DisplayName( + "client shuts down while waiting: cancels the pending attempt, throws" + + " RedissonShutdownException") + void clientShutDownWhileWaiting() { + CompletableFuture> pending = new CompletableFuture<>(); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(new CompletableFutureWrapper<>(pending)); + when(redisson.isShuttingDown()).thenReturn(true); + + CompletableFuture attempt = + CompletableFuture.supplyAsync( + () -> operations.key("k").permits(5).waitTime(Duration.ofSeconds(30)).acquire()); + + assertThatThrownBy(() -> attempt.get(5, SECONDS)) + .hasCauseInstanceOf(RedissonShutdownException.class); + // Cancelling is what makes Redisson release a permit the attempt still wins. + assertThat(pending).isCancelled(); + } + + @Test + @DisplayName("propagates a Redisson RuntimeException from tryAcquireAsync unchanged") + void redissonExceptionPropagates() throws InterruptedException { + RedisConnectionException failure = new RedisConnectionException("Redis down"); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(new CompletableFutureWrapper<>(failure)); + + assertThatThrownBy(() -> operations.key("k").permits(5).acquire()).isSameAs(failure); + } + + @Test + @DisplayName("a metrics failure after the permit was taken propagates and releases it once") + void metricsFailureReleasesPermit() { + IllegalArgumentException failure = new IllegalArgumentException("meter clash"); + doThrow(failure) + .when(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.ACQUIRED), any(Duration.class)); + + assertThatThrownBy(() -> operations.key("k").permits(5).acquire()).isSameAs(failure); + + verify(semaphore, times(1)).releaseAsync("id-1"); + } + } + + @Nested + @DisplayName("ensurePermits") + class EnsurePermits { + + @Test + @DisplayName("create: getPermits 0 calls setPermits(n), which creates it atomically, no INFO") + void create() { + when(semaphore.getPermitsAsync()).thenReturn(done(0)); + + operations.key("k").permits(3).acquire(); + + verify(semaphore).setPermitsAsync(3); + assertThat(infos()).isEmpty(); + } + + @Test + @DisplayName("change: getPermits 2 with permits 5 calls setPermits(5) and logs one INFO") + void change() { + when(semaphore.getPermitsAsync()).thenReturn(done(2)); + + operations.key("k").permits(5).acquire(); + + verify(semaphore).setPermitsAsync(5); + assertThat(infos()).hasSize(1); + assertThat(infos().get(0).getFormattedMessage()) + .isEqualTo("Semaphore [" + FULL_KEY + "] permits changed from 2 to 5"); + } + + @Test + @DisplayName("unchanged: getPermits 5 with permits 5 calls neither set method, no INFO") + void unchanged() { + operations.key("k").permits(5).acquire(); + + verify(semaphore, never()).setPermitsAsync(anyInt()); + assertThat(infos()).isEmpty(); + } + + @Test + @DisplayName("consults Redis once per key per value: a repeat is free, a new value asks again") + void oncePerKeyPerValue() { + operations.key("k").permits(5).acquire(); + operations.key("k").permits(5).acquire(); + + verify(semaphore, times(1)).getPermitsAsync(); + + operations.key("k").permits(7).acquire(); + + verify(semaphore, times(2)).getPermitsAsync(); + verify(semaphore).setPermitsAsync(7); + } + + @Test + @DisplayName("a cached count is read without waiting on a count change in progress") + void cachedCountDoesNotWaitOnUpdate() throws Exception { + operations.key("k").permits(5).acquire(); + CountDownLatch inRedis = new CountDownLatch(1); + CompletableFuture count = new CompletableFuture<>(); + when(semaphore.getPermitsAsync()) + .thenAnswer( + invocation -> { + inRedis.countDown(); + return new CompletableFutureWrapper<>(count); + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future change = + executor.submit(() -> operations.key("k").permits(7).acquire()); + assertThat(inRedis.await(1, SECONDS)).isTrue(); + + Future repeat = + executor.submit(() -> operations.key("k").permits(5).acquire()); + + assertThat(repeat.get(500, MILLISECONDS).acquired()).isTrue(); + count.complete(5); + assertThat(change.get(1, SECONDS).acquired()).isTrue(); + } finally { + count.complete(5); + executor.shutdownNow(); + } + } + + @Test + @DisplayName("a Redis call for one key does not block a new key in the same map bin") + void otherKeyInSameBinDoesNotWait() throws Exception { + String keyA = "a"; + String keyB = keyInSameBin(keyA); + RPermitExpirableSemaphore semaphoreA = mock(RPermitExpirableSemaphore.class); + RPermitExpirableSemaphore semaphoreB = mock(RPermitExpirableSemaphore.class); + when(redisson.getPermitExpirableSemaphore("test:semaphore:" + keyA)).thenReturn(semaphoreA); + when(redisson.getPermitExpirableSemaphore("test:semaphore:" + keyB)).thenReturn(semaphoreB); + CountDownLatch inRedis = new CountDownLatch(1); + CompletableFuture countA = new CompletableFuture<>(); + when(semaphoreA.getPermitsAsync()) + .thenAnswer( + invocation -> { + inRedis.countDown(); + return new CompletableFutureWrapper<>(countA); + }); + when(semaphoreA.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit("id-a")); + when(semaphoreB.getPermitsAsync()).thenReturn(done(5)); + when(semaphoreB.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit("id-b")); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = + executor.submit(() -> operations.key(keyA).permits(5).acquire()); + assertThat(inRedis.await(1, SECONDS)).isTrue(); + + Future second = + executor.submit(() -> operations.key(keyB).permits(5).acquire()); + + assertThat(second.get(500, MILLISECONDS).permitId()).isEqualTo("id-b"); + countA.complete(5); + assertThat(first.get(1, SECONDS).permitId()).isEqualTo("id-a"); + } finally { + countA.complete(5); + executor.shutdownNow(); + } + } + + /** + * Returns a key whose full key lands in the same bin as {@code key}'s in a ConcurrentHashMap of + * the default 16 bins, the size of the still empty cache. The bin index is the spread hash of + * ConcurrentHashMap masked to the table size. + */ + private String keyInSameBin(String key) { + int bin = bin("test:semaphore:" + key); + for (int i = 0; ; i++) { + String candidate = "b" + i; + if (bin("test:semaphore:" + candidate) == bin) { + return candidate; + } + } + } + + private int bin(String fullKey) { + int h = fullKey.hashCode(); + return (h ^ (h >>> 16)) & 15; + } + } + + @Nested + @DisplayName("lost Redis state") + class LostState { + + @Test + @DisplayName("null permit id and getPermits 0: sets the count again and retries once") + void reinitialisesAndRetries() throws InterruptedException { + when(semaphore.getPermitsAsync()).thenReturn(done(0)); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit(null), permit("id-2")); + + PermitHandle handle = operations.key("k").permits(5).acquire(); + + assertThat(handle.acquired()).isTrue(); + assertThat(handle.permitId()).isEqualTo("id-2"); + // once on the first acquire of the key, once after the loss + verify(semaphore, times(2)).setPermitsAsync(5); + verify(semaphore, times(2)).tryAcquireAsync(1, 0L, 90_000L, MILLISECONDS); + assertThat(infos()) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly( + "Semaphore [" + FULL_KEY + "] had no permits in Redis; count set to 5 again"); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.ACQUIRED), any(Duration.class)); + } + + @Test + @DisplayName("the retry waits only the part of the wait time that is left") + void retryWaitsOnlyTimeLeft() throws InterruptedException { + when(semaphore.getPermitsAsync()).thenReturn(done(0)); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenAnswer( + invocation -> { + Thread.sleep(50); + return permit(null); + }) + .thenReturn(permit("id-2")); + + PermitHandle handle = + operations.key("k").permits(5).waitTime(Duration.ofMillis(1000)).acquire(); + + assertThat(handle.acquired()).isTrue(); + ArgumentCaptor waits = ArgumentCaptor.forClass(Long.class); + verify(semaphore, times(2)) + .tryAcquireAsync(eq(1), waits.capture(), eq(90_000L), eq(MILLISECONDS)); + assertThat(waits.getAllValues().get(0)).isEqualTo(1000L); + assertThat(waits.getAllValues().get(1)).isBetween(0L, 950L); + } + + @Test + @DisplayName("null permit id and getPermits 2: the semaphore is full, no retry, SKIPPED") + void fullSemaphoreIsNotReinitialised() throws InterruptedException { + when(semaphore.getPermitsAsync()).thenReturn(done(2)); + when(semaphore.tryAcquireAsync(anyInt(), anyLong(), anyLong(), any())) + .thenReturn(permit(null)); + + PermitHandle handle = operations.key("k").permits(2).acquire(); + + assertThat(handle.acquired()).isFalse(); + verify(semaphore, never()).setPermitsAsync(anyInt()); + verify(semaphore, times(1)).tryAcquireAsync(anyInt(), anyLong(), anyLong(), any()); + verify(metrics) + .recordAcquire(eq(Primitive.SEMAPHORE), eq(Outcome.SKIPPED), any(Duration.class)); + } + } + + @Nested + @DisplayName("availablePermits") + class AvailablePermits { + + @Test + @DisplayName("returns availablePermits of the prefixed semaphore") + void passThrough() { + when(semaphore.availablePermits()).thenReturn(4); + + assertThat(operations.availablePermits("k")).isEqualTo(4); + verify(redisson).getPermitExpirableSemaphore(FULL_KEY); + } + + @Test + @DisplayName("never below zero, where Redisson reports a lowered count still held as negative") + void neverNegative() { + when(semaphore.availablePermits()).thenReturn(-2); + + assertThat(operations.availablePermits("k")).isZero(); + } + + @Test + @DisplayName( + "on the Redisson timer thread throws before any call; on another Redisson thread it runs") + void redissonThreads() throws InterruptedException { + Object thrown = onThread("redisson-timer-4-1", () -> operations.availablePermits("k")); + + assertThat((Throwable) thrown) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot run on Redisson's timer thread [redisson-timer-4-1]"); + verifyNoInteractions(semaphore); + + when(semaphore.availablePermits()).thenReturn(4); + + assertThat(onThread("redisson-3-1", () -> operations.availablePermits("k"))).isEqualTo(4); + } + } +} diff --git a/src/test/java/in/riido/locksmith/support/KeyTemplateTest.java b/src/test/java/in/riido/locksmith/support/KeyTemplateTest.java new file mode 100644 index 0000000..31bb0fc --- /dev/null +++ b/src/test/java/in/riido/locksmith/support/KeyTemplateTest.java @@ -0,0 +1,260 @@ +package in.riido.locksmith.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import in.riido.locksmith.LocksmithConfigurationException; +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ParseException; + +@DisplayName("KeyTemplate") +class KeyTemplateTest { + + static class Order { + private final String id; + + Order(String id) { + this.id = id; + } + + public String getId() { + return id; + } + } + + static class Fixture { + void place(String userId, Order order) {} + + void tag(List tags) {} + } + + private static final Method PLACE = method(); + private static final String PLACE_NAME = Fixture.class.getName() + ".place"; + + private static Method method() { + try { + return Fixture.class.getDeclaredMethod("place", String.class, Order.class); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + private static String evaluate(String template, Object... args) { + return KeyTemplate.evaluate(KeyTemplate.parse(template), PLACE, args); + } + + @Nested + @DisplayName("evaluate") + class Evaluate { + + @Test + @DisplayName("literal-only template returns the text unchanged") + void literalOnly() { + assertThat(evaluate("scheduler:cleanup", "u1", null)).isEqualTo("scheduler:cleanup"); + } + + @Test + @DisplayName("mixed text and island concatenates the text and the variable") + void mixedTextAndIsland() { + assertThat(evaluate("user:#{#userId}", "u1", null)).isEqualTo("user:u1"); + } + + @Test + @DisplayName("island-only template returns the expression value") + void islandOnly() { + assertThat(evaluate("#{'user-' + #userId}", "u1", null)).isEqualTo("user-u1"); + } + + @Test + @DisplayName("#p0 and #a1 resolve to arguments by index") + void indexedVariables() { + assertThat(evaluate("#{#p0}:#{#a1.id}", "u1", new Order("o7"))).isEqualTo("u1:o7"); + } + + @Test + @DisplayName("property path #order.id resolves through the getter") + void propertyPath() { + assertThat(evaluate("order:#{#order.id}", "u1", new Order("o7"))).isEqualTo("order:o7"); + } + + @Test + @DisplayName("null result is rejected naming the method and the template") + void nullResultRejected() { + assertThatThrownBy(() -> evaluate("#{#userId}", null, null)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("place") + .hasMessageContaining("[#{#userId}]"); + } + + @Test + @DisplayName("blank result is rejected naming the method and the template") + void blankResultRejected() { + assertThatThrownBy(() -> evaluate("#{#userId}", " ", null)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("place") + .hasMessageContaining("[#{#userId}]"); + } + + @Test + @DisplayName("null island in a mixed template is rejected naming the part") + void nullPartRejected() { + assertThatThrownBy(() -> evaluate("order:#{#userId}", null, null)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage( + "Key template [order:#{#userId}] on " + + PLACE_NAME + + ": part #{#userId} resolved to null"); + } + + @Test + @DisplayName("empty island in a mixed template is rejected as blank") + void emptyPartRejected() { + assertThatThrownBy(() -> evaluate("order:#{#userId}", "", null)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage( + "Key template [order:#{#userId}] on " + + PLACE_NAME + + ": part #{#userId} resolved to a blank value"); + } + + @Test + @DisplayName("blank island in a mixed template is rejected as blank") + void blankPartRejected() { + assertThatThrownBy(() -> evaluate("order:#{#userId}", " ", null)) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage( + "Key template [order:#{#userId}] on " + + PLACE_NAME + + ": part #{#userId} resolved to a blank value"); + } + + @Test + @DisplayName("null second island is rejected naming that island") + void nullSecondPartRejected() { + assertThatThrownBy(() -> evaluate("#{#userId}:#{#order.id}", "u1", new Order(null))) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessage( + "Key template [#{#userId}:#{#order.id}] on " + + PLACE_NAME + + ": part #{#order.id} resolved to null"); + } + + @Test + @DisplayName("non-null islands render exactly as Spring's composite expression does") + void rendersLikeComposite() { + Expression expression = KeyTemplate.parse("order:#{#userId}-#{#order.id}:#{#p0.length()}"); + Object[] args = {"u1", new Order("o7")}; + String spring = + expression.getValue( + new MethodBasedEvaluationContext( + null, PLACE, args, DefaultParameterNameDiscoverer.getSharedInstance()), + String.class); + + assertThat(KeyTemplate.evaluate(expression, PLACE, args)) + .isEqualTo(spring) + .isEqualTo("order:u1-o7:2"); + } + } + + @Nested + @DisplayName("parse") + class Parse { + + @Test + @DisplayName("invalid SpEL throws Spring's ParseException") + void invalidSpelThrowsParseException() { + assertThatThrownBy(() -> KeyTemplate.parse("user:#{#userId +}")) + .isInstanceOf(ParseException.class); + } + } + + @Nested + @DisplayName("validateVariables") + class ValidateVariables { + + private void validate(String template) { + KeyTemplate.validateVariables(KeyTemplate.parse(template), PLACE); + } + + @Test + @DisplayName("literal-only template has no variables and passes") + void literalOnlyPasses() { + assertThatCode(() -> validate("scheduler:cleanup")).doesNotThrowAnyException(); + } + + @Test + @DisplayName("parameter names, property paths, #pN and #aN in range pass") + void knownVariablesPass() { + Expression expression = + KeyTemplate.parse("#{#userId}:#{#order.id}:#{#p0}:#{#a1}:#{'x' + #p1.id}"); + + assertThatCode(() -> KeyTemplate.validateVariables(expression, PLACE)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("missing parameter name fails with class, method, variable and -parameters hint") + void missingParameterNameFails() { + assertThatThrownBy(() -> validate("user:#{#customerId}")) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("[user:#{#customerId}]") + .hasMessageContaining(Fixture.class.getName()) + .hasMessageContaining("place") + .hasMessageContaining("#customerId") + .hasMessageContaining("compile with -parameters or use #p0"); + } + + @Test + @DisplayName("#p2 on a two-parameter method fails as out of range") + void indexOutOfRangeFails() { + assertThatThrownBy(() -> validate("#{#p2}")) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("#p2"); + } + + @Test + @DisplayName("#p01 is not an index variable and fails") + void leadingZeroIndexFails() { + assertThatThrownBy(() -> validate("#{#p01}")) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("#p01"); + } + + @Test + @DisplayName("#this inside a selection over a list parameter passes") + void thisInSelectionPasses() throws NoSuchMethodException { + Method tag = Fixture.class.getDeclaredMethod("tag", List.class); + + assertThatCode( + () -> + KeyTemplate.validateVariables(KeyTemplate.parse("#{#p0.^[#this != null]}"), tag)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a bean reference fails at startup instead of on every call") + void beanReferenceFails() { + assertThatThrownBy(() -> validate("x:#{@idService.next()}")) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("[x:#{@idService.next()}]") + .hasMessageContaining(Fixture.class.getName()) + .hasMessageContaining("uses bean reference @idService"); + } + + @Test + @DisplayName("#root fails as an unknown variable") + void rootFails() { + assertThatThrownBy(() -> validate("#{#root}")) + .isInstanceOf(LocksmithConfigurationException.class) + .hasMessageContaining("#root"); + } + } +} diff --git a/src/test/java/in/riido/locksmith/support/RedissonFuturesTest.java b/src/test/java/in/riido/locksmith/support/RedissonFuturesTest.java new file mode 100644 index 0000000..efffd73 --- /dev/null +++ b/src/test/java/in/riido/locksmith/support/RedissonFuturesTest.java @@ -0,0 +1,168 @@ +package in.riido.locksmith.support; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import in.riido.locksmith.LocksmithConfigurationException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; + +@DisplayName("RedissonFutures") +class RedissonFuturesTest { + + /** Runs the call on a thread with the given name; returns its result, or what it threw. */ + private static Object onThread(String name, Supplier call) throws InterruptedException { + AtomicReference outcome = new AtomicReference<>(); + Thread thread = + new Thread( + () -> { + try { + outcome.set(call.get()); + } catch (RuntimeException e) { + outcome.set(e); + } + }, + name); + thread.start(); + thread.join(); + return outcome.get(); + } + + private static Object guard(String threadName, boolean waits) throws InterruptedException { + return onThread( + threadName, + () -> { + RedissonFutures.requireNotRedissonThread(waits); + return "allowed"; + }); + } + + @Nested + @DisplayName("requireNotRedissonThread") + class RequireNotRedissonThread { + + @ParameterizedTest(name = "waits={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("an I/O thread is refused with Redisson's own message, waiting or not") + void ioThread(boolean waits) throws InterruptedException { + assertThat((Throwable) guard("redisson-netty-2-1", waits)) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessage("Sync methods can't be invoked from async/rx/reactive listeners"); + } + + @ParameterizedTest(name = "waits={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("the timer thread is refused, waiting or not") + void timerThread(boolean waits) throws InterruptedException { + assertThat((Throwable) guard("redisson-timer-3-1", waits)) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Locksmith cannot run on Redisson's timer thread"); + } + + @Test + @DisplayName("an executor thread is refused only for an acquire that waits") + void executorThread() throws InterruptedException { + assertThat(guard("redisson-4-1", false)).isEqualTo("allowed"); + assertThat((Throwable) guard("redisson-4-1", true)) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Locksmith cannot wait for a lock or permit on Redisson thread [redisson-4-1]"); + } + + @ParameterizedTest(name = "waits={0}") + @ValueSource(booleans = {false, true}) + @DisplayName("any other thread is allowed") + void otherThread(boolean waits) throws InterruptedException { + assertThat(guard("http-nio-8080-exec-1", waits)).isEqualTo("allowed"); + } + } + + @Nested + @DisplayName("onRedissonIoOrTimerThread") + class OnRedissonIoOrTimerThread { + + @Test + @DisplayName("true on an I/O or the timer thread, false on an executor or any other thread") + void classifies() throws InterruptedException { + Supplier check = RedissonFutures::onRedissonIoOrTimerThread; + assertThat(onThread("redisson-netty-2-1", check)).isEqualTo(true); + assertThat(onThread("redisson-timer-3-1", check)).isEqualTo(true); + assertThat(onThread("redisson-4-1", check)).isEqualTo(false); + assertThat(onThread("main", check)).isEqualTo(false); + } + } + + @Nested + @DisplayName("requireClusterSafeKey") + class RequireClusterSafeKey { + + private RedissonClient client(boolean cluster) { + Config config = new Config(); + if (cluster) { + config.useClusterServers(); + } else { + config.useSingleServer(); + } + RedissonClient redisson = mock(RedissonClient.class); + when(redisson.getConfig()).thenReturn(config); + return redisson; + } + + @ParameterizedTest(name = "{0}") + @ValueSource( + strings = { + "p:order:x{1", + "p:order:x{}1", + "p:order:x{", + "p:{x", + "p:a{}b}", + "p:order:42}", + "p:}", + "p:a}b" + }) + @DisplayName("on Cluster, a '{' that forms no hash tag, or a '}' without '{', is refused") + void refusedOnCluster(String key) { + assertThatThrownBy(() -> RedissonFutures.requireClusterSafeKey(client(true), key)) + .isExactlyInstanceOf(LocksmithConfigurationException.class) + .hasMessageStartingWith( + "Key [" + key + "] contains a '{' or '}' that forms no Redis Cluster hash tag"); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"p:order:{x}1", "p:order:{42}", "p:a}{b}", "p:order:42"}) + @DisplayName("on Cluster, a key with a proper hash tag, or without braces, is allowed") + void allowedOnCluster(String key) { + assertThatNoException() + .isThrownBy(() -> RedissonFutures.requireClusterSafeKey(client(true), key)); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"p:order:x{1", "p:order:42}"}) + @DisplayName("outside Cluster, a brace that forms no hash tag is allowed") + void allowedOutsideCluster(String key) { + assertThatNoException() + .isThrownBy(() -> RedissonFutures.requireClusterSafeKey(client(false), key)); + } + + @Test + @DisplayName("a key without braces never reads the client's configuration") + void noBraceSkipsConfig() { + RedissonClient redisson = mock(RedissonClient.class); + + RedissonFutures.requireClusterSafeKey(redisson, "p:order:42"); + + verifyNoInteractions(redisson); + } + } +} diff --git a/src/test/java/in/riido/locksmith/support/ReturnDefaultsTest.java b/src/test/java/in/riido/locksmith/support/ReturnDefaultsTest.java new file mode 100644 index 0000000..10cbbea --- /dev/null +++ b/src/test/java/in/riido/locksmith/support/ReturnDefaultsTest.java @@ -0,0 +1,98 @@ +package in.riido.locksmith.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("ReturnDefaults") +class ReturnDefaultsTest { + + @Nested + @DisplayName("forType") + class ForType { + + @Test + @DisplayName("void returns null") + void voidReturnsNull() { + assertThat(ReturnDefaults.forType(void.class)).isNull(); + } + + @Test + @DisplayName("Optional returns Optional.empty()") + void optionalReturnsEmpty() { + assertThat(ReturnDefaults.forType(Optional.class)).isEqualTo(Optional.empty()); + } + + @Test + @DisplayName("OptionalInt, OptionalLong and OptionalDouble return their empty value") + void primitiveOptionalsReturnEmpty() { + assertThat(ReturnDefaults.forType(OptionalInt.class)).isEqualTo(OptionalInt.empty()); + assertThat(ReturnDefaults.forType(OptionalLong.class)).isEqualTo(OptionalLong.empty()); + assertThat(ReturnDefaults.forType(OptionalDouble.class)).isEqualTo(OptionalDouble.empty()); + } + + @Test + @DisplayName("boolean and Boolean return false") + void booleansReturnFalse() { + assertThat(ReturnDefaults.forType(boolean.class)).isEqualTo(false); + assertThat(ReturnDefaults.forType(Boolean.class)).isEqualTo(false); + } + + static Stream numericTypes() { + return Stream.of( + Arguments.of(byte.class, (byte) 0), + Arguments.of(Byte.class, (byte) 0), + Arguments.of(short.class, (short) 0), + Arguments.of(Short.class, (short) 0), + Arguments.of(int.class, 0), + Arguments.of(Integer.class, 0), + Arguments.of(long.class, 0L), + Arguments.of(Long.class, 0L), + Arguments.of(float.class, 0F), + Arguments.of(Float.class, 0F), + Arguments.of(double.class, 0D), + Arguments.of(Double.class, 0D), + Arguments.of(char.class, '\0'), + Arguments.of(Character.class, '\0')); + } + + @ParameterizedTest(name = "{0} returns zero of that type") + @MethodSource("numericTypes") + @DisplayName("primitives and boxes return zero of that type") + void primitivesReturnZeroOfType(Class type, Object zero) { + assertThat(ReturnDefaults.forType(type)).isEqualTo(zero).isInstanceOf(zero.getClass()); + } + + @Test + @DisplayName("CompletionStage and CompletableFuture return a new future completed with null") + void stagesReturnCompletedNullFuture() { + Object stage = ReturnDefaults.forType(CompletionStage.class); + Object future = ReturnDefaults.forType(CompletableFuture.class); + + assertThat(stage).isInstanceOf(CompletableFuture.class).isNotSameAs(future); + assertThat((CompletableFuture) stage).isCompletedWithValue(null); + assertThat((CompletableFuture) future).isCompletedWithValue(null); + } + + @Test + @DisplayName("other reference types return null") + void otherReferenceTypesReturnNull() { + assertThat(ReturnDefaults.forType(String.class)).isNull(); + assertThat(ReturnDefaults.forType(List.class)).isNull(); + assertThat(ReturnDefaults.forType(Object.class)).isNull(); + } + } +} diff --git a/src/test/java/in/riido/locksmith/template/LocksmithLockTemplateTest.java b/src/test/java/in/riido/locksmith/template/LocksmithLockTemplateTest.java deleted file mode 100644 index a0458a6..0000000 --- a/src/test/java/in/riido/locksmith/template/LocksmithLockTemplateTest.java +++ /dev/null @@ -1,453 +0,0 @@ -package in.riido.locksmith.template; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.LockType; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.template.handle.LockHandle; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.redisson.api.RLock; -import org.redisson.api.RReadWriteLock; -import org.redisson.api.RedissonClient; - -@DisplayName("LocksmithLockTemplate Tests") -class LocksmithLockTemplateTest { - - private RedissonClient redissonClient; - private LocksmithLockTemplate template; - private RLock lock; - - @BeforeEach - void setUp() { - redissonClient = mock(RedissonClient.class); - LocksmithProperties properties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(10), Duration.ofSeconds(60), "lock:", false, false), - null, - null); - template = new LocksmithLockTemplate(redissonClient, properties); - lock = mock(RLock.class); - } - - @Nested - @DisplayName("Builder tryLock Tests") - class BuilderTryLockTests { - - @Test - @DisplayName("Should acquire lock immediately with default parameters") - void shouldAcquireLockImmediately() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = template.withKey("my-key").tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(redissonClient).getLock("lock:my-key"); - verify(lock).tryLock(0, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should return not-acquired handle when lock not acquired") - void shouldReturnNotAcquiredHandle() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - try (LockHandle handle = template.withKey("my-key").tryLock()) { - assertFalse(handle.isAcquired()); - } - - verify(lock, never()).unlock(); - } - - @Test - @DisplayName("Should use custom wait time via builder") - void shouldUseCustomWaitTime() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(5000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = - template.withKey("my-key").waitTime(Duration.ofSeconds(5)).tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(lock).tryLock(5000, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom lease time via builder") - void shouldUseCustomLeaseTime() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(5000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = - template - .withKey("my-key") - .waitTime(Duration.ofSeconds(5)) - .leaseTime(Duration.ofSeconds(30)) - .tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(lock).tryLock(5000, 30000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use autoRenew via builder") - void shouldUseAutoRenew() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = template.withKey("my-key").autoRenew().tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should return not-acquired handle when interrupted") - void shouldReturnNotAcquiredWhenInterrupted() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenThrow(new InterruptedException()); - - try (LockHandle handle = template.withKey("my-key").tryLock()) { - assertFalse(handle.isAcquired()); - } - - assertTrue(Thread.currentThread().isInterrupted()); - Thread.interrupted(); // Clear interrupt status - } - - @Test - @DisplayName("Should auto-release lock on handle close") - void shouldAutoReleaseLockOnClose() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - LockHandle handle = template.withKey("my-key").tryLock(); - assertTrue(handle.isAcquired()); - handle.close(); - - verify(lock).unlock(); - } - } - - @Nested - @DisplayName("Lock Type Tests via Builder") - class LockTypeTests { - - private RReadWriteLock readWriteLock; - private RLock readLock; - private RLock writeLock; - - @BeforeEach - void setUpReadWriteLock() { - readWriteLock = mock(RReadWriteLock.class); - readLock = mock(RLock.class); - writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:my-key")).thenReturn(readWriteLock); - when(readWriteLock.readLock()).thenReturn(readLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - } - - @Test - @DisplayName("Should use read lock for READ type via builder") - void shouldUseReadLockForReadType() throws InterruptedException { - when(readLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = template.withKey("my-key").lockType(LockType.READ).tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(readWriteLock).readLock(); - verify(readWriteLock, never()).writeLock(); - } - - @Test - @DisplayName("Should use write lock for WRITE type via builder") - void shouldUseWriteLockForWriteType() throws InterruptedException { - when(writeLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = template.withKey("my-key").lockType(LockType.WRITE).tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(readWriteLock).writeLock(); - verify(readWriteLock, never()).readLock(); - } - - @Test - @DisplayName("Should use reentrant lock for REENTRANT type via builder") - void shouldUseReentrantLockForReentrantType() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - try (LockHandle handle = template.withKey("my-key").lockType(LockType.REENTRANT).tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(redissonClient).getLock("lock:my-key"); - verify(redissonClient, never()).getReadWriteLock(anyString()); - } - } - - @Nested - @DisplayName("unlock Tests") - class UnlockTests { - - @Test - @DisplayName("Should unlock reentrant lock via standalone method") - void shouldUnlockReentrantLock() { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - - template.unlock("my-key"); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should unlock read lock via standalone method with type") - void shouldUnlockReadLock() { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock readLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:my-key")).thenReturn(readWriteLock); - when(readWriteLock.readLock()).thenReturn(readLock); - - template.unlock("my-key", LockType.READ); - - verify(readLock).unlock(); - } - - @Test - @DisplayName("Should unlock write lock via standalone method with type") - void shouldUnlockWriteLock() { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:my-key")).thenReturn(readWriteLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - - template.unlock("my-key", LockType.WRITE); - - verify(writeLock).unlock(); - } - - @Test - @DisplayName("Should handle IllegalMonitorStateException gracefully") - void shouldHandleIllegalMonitorStateException() { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - doThrow(new IllegalMonitorStateException("Not held")).when(lock).unlock(); - - assertDoesNotThrow(() -> template.unlock("my-key")); - } - } - - @Nested - @DisplayName("isLocked Tests") - class IsLockedTests { - - @Test - @DisplayName("Should return true when lock is held") - void shouldReturnTrueWhenLockIsHeld() { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.isLocked()).thenReturn(true); - - assertTrue(template.isLocked("my-key")); - } - - @Test - @DisplayName("Should return false when lock is not held") - void shouldReturnFalseWhenLockIsNotHeld() { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.isLocked()).thenReturn(false); - - assertFalse(template.isLocked("my-key")); - } - - @Test - @DisplayName("Should check correct lock type via standalone method") - void shouldCheckCorrectLockType() { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:my-key")).thenReturn(readWriteLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - when(writeLock.isLocked()).thenReturn(true); - - assertTrue(template.isLocked("my-key", LockType.WRITE)); - verify(readWriteLock).writeLock(); - } - } - - @Nested - @DisplayName("Builder execute Tests") - class BuilderExecuteTests { - - @Test - @DisplayName("Should execute callback when lock acquired") - void shouldExecuteCallbackWhenLockAcquired() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - String result = template.withKey("my-key").execute(() -> "result"); - - assertEquals("result", result); - verify(lock).unlock(); - } - - @Test - @DisplayName("Should return null when lock not acquired") - void shouldReturnNullWhenLockNotAcquired() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(false); - - String result = template.withKey("my-key").execute(() -> "result"); - - assertNull(result); - verify(lock, never()).unlock(); - } - - @Test - @DisplayName("Should release lock even when callback throws exception") - void shouldReleaseLockWhenCallbackThrows() throws InterruptedException { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - assertThrows( - RuntimeException.class, - () -> - template - .withKey("my-key") - .execute( - () -> { - throw new RuntimeException("Test error"); - })); - - verify(lock).unlock(); - } - - @Test - @DisplayName("Should return null when interrupted") - void shouldReturnNullWhenInterrupted() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenThrow(new InterruptedException()); - - String result = template.withKey("my-key").execute(() -> "result"); - - assertNull(result); - assertTrue(Thread.currentThread().isInterrupted()); - Thread.interrupted(); // Clear interrupt status - } - - @Test - @DisplayName("Should use custom wait time in execute via builder") - void shouldUseCustomWaitTimeInExecute() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(5000, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - template.withKey("my-key").waitTime(Duration.ofSeconds(5)).execute(() -> "result"); - - verify(lock).tryLock(5000, 600000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom lease time in execute via builder") - void shouldUseCustomLeaseTimeInExecute() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true); - - template.withKey("my-key").leaseTime(Duration.ofSeconds(30)).execute(() -> "result"); - - verify(lock).tryLock(0, 30000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use lock type in execute via builder") - void shouldUseLockTypeInExecute() throws Exception { - RReadWriteLock readWriteLock = mock(RReadWriteLock.class); - RLock writeLock = mock(RLock.class); - when(redissonClient.getReadWriteLock("lock:my-key")).thenReturn(readWriteLock); - when(readWriteLock.writeLock()).thenReturn(writeLock); - when(writeLock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - - template.withKey("my-key").lockType(LockType.WRITE).execute(() -> "result"); - - verify(readWriteLock).writeLock(); - verify(writeLock).unlock(); - } - - @Test - @DisplayName("Should use autoRenew in execute via builder") - void shouldUseAutoRenewInExecute() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true); - - template.withKey("my-key").autoRenew().execute(() -> "result"); - - verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should handle IllegalMonitorStateException during unlock") - void shouldHandleIllegalMonitorStateExceptionInExecute() throws Exception { - when(redissonClient.getLock("lock:my-key")).thenReturn(lock); - when(lock.tryLock(0, 600000, TimeUnit.MILLISECONDS)).thenReturn(true); - doThrow(new IllegalMonitorStateException("Expired")).when(lock).unlock(); - - String result = template.withKey("my-key").execute(() -> "result"); - - assertEquals("result", result); - } - } - - @Nested - @DisplayName("Key Prefix Tests") - class KeyPrefixTests { - - @Test - @DisplayName("Should apply key prefix") - void shouldApplyKeyPrefix() throws InterruptedException { - when(redissonClient.getLock("lock:custom-key")).thenReturn(lock); - when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true); - - try (LockHandle handle = template.withKey("custom-key").tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(redissonClient).getLock("lock:custom-key"); - } - - @Test - @DisplayName("Should apply custom key prefix") - void shouldApplyCustomKeyPrefix() throws InterruptedException { - LocksmithProperties customProperties = - new LocksmithProperties( - new LocksmithProperties.LockProperties( - true, Duration.ofMinutes(10), Duration.ofSeconds(60), "myapp:", false, false), - null, - null); - LocksmithLockTemplate customTemplate = - new LocksmithLockTemplate(redissonClient, customProperties); - - when(redissonClient.getLock("myapp:custom-key")).thenReturn(lock); - when(lock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true); - - try (LockHandle handle = customTemplate.withKey("custom-key").tryLock()) { - assertTrue(handle.isAcquired()); - } - - verify(redissonClient).getLock("myapp:custom-key"); - } - } -} diff --git a/src/test/java/in/riido/locksmith/template/LocksmithRateLimitTemplateTest.java b/src/test/java/in/riido/locksmith/template/LocksmithRateLimitTemplateTest.java deleted file mode 100644 index 7e3f2b1..0000000 --- a/src/test/java/in/riido/locksmith/template/LocksmithRateLimitTemplateTest.java +++ /dev/null @@ -1,287 +0,0 @@ -package in.riido.locksmith.template; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.AcquisitionMode; -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.RateLimitConfigurationException; -import in.riido.locksmith.metrics.RateLimitMetrics; -import java.time.Duration; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.redisson.api.RBucket; -import org.redisson.api.RRateLimiter; -import org.redisson.api.RedissonClient; - -@ExtendWith(MockitoExtension.class) -@DisplayName("LocksmithRateLimitTemplate Tests") -class LocksmithRateLimitTemplateTest { - - @Mock private RedissonClient redissonClient; - @Mock private RRateLimiter rateLimiter; - @Mock private RateLimitMetrics metrics; - - @SuppressWarnings("rawtypes") - @Mock - private RBucket metaBucket; - - private LocksmithRateLimitTemplate template; - private LocksmithProperties properties; - - @BeforeEach - void setUp() { - properties = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit:", false, false)); - lenient().when(redissonClient.getBucket(anyString())).thenReturn(metaBucket); - template = new LocksmithRateLimitTemplate(redissonClient, properties); - } - - @Nested - @DisplayName("Builder TryAcquire Tests") - class BuilderTryAcquireTests { - - @Test - @DisplayName("Should acquire rate limit successfully") - void shouldAcquireRateLimitSuccessfully() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - boolean acquired = template.withKey("test-key").tryAcquire(); - - assertTrue(acquired); - verify(rateLimiter).trySetRate(any(), eq(10L), eq(Duration.ofSeconds(1))); - verify(rateLimiter).tryAcquire(); - } - - @Test - @DisplayName("Should return false when rate limit exceeded") - void shouldReturnFalseWhenRateLimitExceeded() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(false); - - boolean acquired = template.withKey("test-key").tryAcquire(); - - assertFalse(acquired); - } - - @Test - @DisplayName("Should use key prefix from properties") - void shouldUseKeyPrefixFromProperties() { - when(redissonClient.getRateLimiter("ratelimit:my-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - template.withKey("my-key").tryAcquire(); - - verify(redissonClient, atLeastOnce()).getRateLimiter("ratelimit:my-key"); - } - - @Test - @DisplayName("Should configure permits via builder") - void shouldConfigurePermitsViaBuilder() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - template.withKey("test-key").permits(20).tryAcquire(); - - verify(rateLimiter).trySetRate(any(), eq(20L), any()); - } - - @Test - @DisplayName("Should configure interval via builder") - void shouldConfigureIntervalViaBuilder() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - template.withKey("test-key").interval(Duration.ofMinutes(1)).tryAcquire(); - - verify(rateLimiter).trySetRate(any(), anyLong(), eq(Duration.ofMinutes(1))); - } - - @Test - @DisplayName("Should configure wait time via builder") - void shouldConfigureWaitTimeViaBuilder() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire(eq(1L), eq(Duration.ofSeconds(5)))).thenReturn(true); - - template.withKey("test-key").waitTime(Duration.ofSeconds(5)).tryAcquire(); - - verify(rateLimiter).tryAcquire(eq(1L), eq(Duration.ofSeconds(5))); - } - - @Test - @DisplayName("Should chain multiple configurations") - void shouldChainMultipleConfigurations() { - when(redissonClient.getRateLimiter("ratelimit:chained-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire(eq(1L), eq(Duration.ofSeconds(10)))).thenReturn(true); - - boolean acquired = - template - .withKey("chained-key") - .permits(50) - .interval(Duration.ofMinutes(1)) - .waitTime(Duration.ofSeconds(10)) - .tryAcquire(); - - assertTrue(acquired); - verify(rateLimiter).trySetRate(any(), eq(50L), eq(Duration.ofMinutes(1))); - verify(rateLimiter).tryAcquire(eq(1L), eq(Duration.ofSeconds(10))); - } - } - - @Nested - @DisplayName("Builder Execute Tests") - class BuilderExecuteTests { - - @Test - @DisplayName("Should execute callback when rate limit acquired") - void shouldExecuteCallbackWhenRateLimitAcquired() throws Exception { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - String result = template.withKey("test-key").execute(() -> "success"); - - assertEquals("success", result); - } - - @Test - @DisplayName("Should return null when rate limit not acquired") - void shouldReturnNullWhenRateLimitNotAcquired() throws Exception { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(false); - - String result = template.withKey("test-key").execute(() -> "should-not-execute"); - - assertNull(result); - } - - @Test - @DisplayName("Should propagate exception from callback") - void shouldPropagateExceptionFromCallback() { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - assertThrows( - RuntimeException.class, - () -> - template - .withKey("test-key") - .execute( - () -> { - throw new RuntimeException("Test error"); - })); - } - - @Test - @DisplayName("Should execute callback via builder with custom config") - void shouldExecuteCallbackViaBuilder() throws Exception { - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - AtomicInteger counter = new AtomicInteger(0); - String result = - template - .withKey("test-key") - .permits(10) - .interval(Duration.ofSeconds(1)) - .execute( - () -> { - counter.incrementAndGet(); - return "executed"; - }); - - assertEquals("executed", result); - assertEquals(1, counter.get()); - } - } - - @Nested - @DisplayName("Metrics Tests") - class MetricsTests { - - @Test - @DisplayName("Should record metrics when enabled") - void shouldRecordMetricsWhenEnabled() throws Exception { - properties = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit:", false, true)); - template = new LocksmithRateLimitTemplate(redissonClient, properties, metrics); - - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(true); - - template.withKey("test-key").execute(() -> "result"); - - verify(metrics).recordAcquired(); - verify(metrics).recordAcquisitionTime(anyLong()); - verify(metrics).recordExecutionTime(anyLong()); - } - - @Test - @DisplayName("Should record exceeded metric when rate limit not acquired") - void shouldRecordExceededMetricWhenNotAcquired() { - properties = - new LocksmithProperties( - null, - null, - new LocksmithProperties.RateLimitProperties( - true, Duration.ofMinutes(1), "ratelimit:", false, true)); - template = new LocksmithRateLimitTemplate(redissonClient, properties, metrics); - - when(redissonClient.getRateLimiter("ratelimit:test-key")).thenReturn(rateLimiter); - when(rateLimiter.trySetRate(any(), anyLong(), any())).thenReturn(true); - when(rateLimiter.tryAcquire()).thenReturn(false); - - template.withKey("test-key").tryAcquire(); - - verify(metrics).recordExceeded(AcquisitionMode.SKIP_IMMEDIATELY); - } - } - - @Nested - @DisplayName("Builder Validation Tests") - class BuilderValidationTests { - - @Test - @DisplayName("Should throw exception for zero permits via builder") - void shouldThrowExceptionForZeroPermitsViaBuilder() { - assertThrows( - RateLimitConfigurationException.class, - () -> template.withKey("test-key").permits(0).tryAcquire()); - } - - @Test - @DisplayName("Should throw exception for negative permits via builder") - void shouldThrowExceptionForNegativePermitsViaBuilder() { - assertThrows( - RateLimitConfigurationException.class, - () -> template.withKey("test-key").permits(-1).tryAcquire()); - } - } -} diff --git a/src/test/java/in/riido/locksmith/template/LocksmithSemaphoreTemplateTest.java b/src/test/java/in/riido/locksmith/template/LocksmithSemaphoreTemplateTest.java deleted file mode 100644 index 9807106..0000000 --- a/src/test/java/in/riido/locksmith/template/LocksmithSemaphoreTemplateTest.java +++ /dev/null @@ -1,429 +0,0 @@ -package in.riido.locksmith.template; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -import in.riido.locksmith.autoconfigure.LocksmithProperties; -import in.riido.locksmith.exception.SemaphoreConfigurationException; -import in.riido.locksmith.template.handle.PermitHandle; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.redisson.api.RBucket; -import org.redisson.api.RPermitExpirableSemaphore; -import org.redisson.api.RedissonClient; - -@DisplayName("LocksmithSemaphoreTemplate Tests") -class LocksmithSemaphoreTemplateTest { - - private RedissonClient redissonClient; - private LocksmithSemaphoreTemplate template; - private RPermitExpirableSemaphore semaphore; - private RBucket metaBucket; - - @BeforeEach - @SuppressWarnings("unchecked") - void setUp() { - redissonClient = mock(RedissonClient.class); - LocksmithProperties properties = - new LocksmithProperties( - null, - new LocksmithProperties.SemaphoreProperties( - true, Duration.ofMinutes(5), Duration.ofSeconds(60), "semaphore:", false, false), - null); - template = new LocksmithSemaphoreTemplate(redissonClient, properties); - semaphore = mock(RPermitExpirableSemaphore.class); - metaBucket = mock(RBucket.class); - } - - private void setupSemaphore(String key) { - when(redissonClient.getPermitExpirableSemaphore("semaphore:" + key)).thenReturn(semaphore); - when(redissonClient.getBucket("semaphore:" + key + ":meta")).thenReturn(metaBucket); - when(metaBucket.get()).thenReturn(null); - when(semaphore.trySetPermits(anyInt())).thenReturn(true); - } - - @Nested - @DisplayName("Builder tryAcquire Tests") - class BuilderTryAcquireTests { - - @Test - @DisplayName("Should acquire permit immediately with default parameters") - void shouldAcquirePermitImmediately() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire()) { - assertTrue(handle.isAcquired()); - assertEquals("permit-123", handle.permitId()); - } - - verify(semaphore).trySetPermits(5); - verify(metaBucket).set(5); - } - - @Test - @DisplayName("Should return not-acquired handle when permit not acquired") - void shouldReturnNotAcquiredHandle() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(null); - - try (PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire()) { - assertFalse(handle.isAcquired()); - assertNull(handle.permitId()); - } - } - - @Test - @DisplayName("Should use custom wait time via builder") - void shouldUseCustomWaitTime() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(5000, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = - template.withKey("my-key").permits(5).waitTime(Duration.ofSeconds(5)).tryAcquire()) { - assertTrue(handle.isAcquired()); - } - - verify(semaphore).tryAcquire(5000, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom lease time via builder") - void shouldUseCustomLeaseTime() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 60000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = - template.withKey("my-key").permits(5).leaseTime(Duration.ofMinutes(1)).tryAcquire()) { - assertTrue(handle.isAcquired()); - } - - verify(semaphore).tryAcquire(0, 60000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom wait and lease time via builder") - void shouldUseCustomWaitAndLeaseTime() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(10000, 120000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = - template - .withKey("my-key") - .permits(5) - .waitTime(Duration.ofSeconds(10)) - .leaseTime(Duration.ofMinutes(2)) - .tryAcquire()) { - assertTrue(handle.isAcquired()); - } - - verify(semaphore).tryAcquire(10000, 120000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should return not-acquired handle when interrupted") - void shouldReturnNotAcquiredWhenInterrupted() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenThrow(new InterruptedException()); - - try (PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire()) { - assertFalse(handle.isAcquired()); - } - - assertTrue(Thread.currentThread().isInterrupted()); - Thread.interrupted(); // Clear interrupt status - } - - @Test - @DisplayName("Should auto-release permit on handle close") - void shouldAutoReleasePermitOnClose() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire(); - assertTrue(handle.isAcquired()); - handle.close(); - - verify(semaphore).release("permit-123"); - } - } - - @Nested - @DisplayName("Permit Validation Tests") - class PermitValidationTests { - - @Test - @DisplayName("Should throw exception when permits not set") - void shouldThrowExceptionWhenPermitsNotSet() { - assertThrows( - SemaphoreConfigurationException.class, () -> template.withKey("my-key").tryAcquire()); - } - - @Test - @DisplayName("Should throw exception for non-positive permits") - void shouldThrowExceptionForNonPositivePermits() { - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("my-key").permits(0).tryAcquire()); - - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("my-key").permits(-1).tryAcquire()); - } - - @Test - @DisplayName("Should throw exception for non-positive permits in execute") - void shouldThrowExceptionForNonPositivePermitsInExecute() { - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("my-key").permits(0).execute(() -> "result")); - - assertThrows( - SemaphoreConfigurationException.class, - () -> template.withKey("my-key").permits(-1).execute(() -> "result")); - } - } - - @Nested - @DisplayName("releasePermit Tests") - class ReleasePermitTests { - - @Test - @DisplayName("Should release permit") - void shouldReleasePermit() { - when(redissonClient.getPermitExpirableSemaphore("semaphore:my-key")).thenReturn(semaphore); - - template.releasePermit("my-key", "permit-123"); - - verify(semaphore).release("permit-123"); - } - - @Test - @DisplayName("Should handle IllegalArgumentException gracefully") - void shouldHandleIllegalArgumentException() { - when(redissonClient.getPermitExpirableSemaphore("semaphore:my-key")).thenReturn(semaphore); - doThrow(new IllegalArgumentException("Permit expired")).when(semaphore).release("permit-123"); - - assertDoesNotThrow(() -> template.releasePermit("my-key", "permit-123")); - } - - @Test - @DisplayName("Should handle other exceptions gracefully") - void shouldHandleOtherExceptionsGracefully() { - when(redissonClient.getPermitExpirableSemaphore("semaphore:my-key")).thenReturn(semaphore); - doThrow(new RuntimeException("Redis error")).when(semaphore).release("permit-123"); - - assertDoesNotThrow(() -> template.releasePermit("my-key", "permit-123")); - } - } - - @Nested - @DisplayName("Builder execute Tests") - class BuilderExecuteTests { - - @Test - @DisplayName("Should execute callback when permit acquired") - void shouldExecuteCallbackWhenPermitAcquired() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - String result = template.withKey("my-key").permits(5).execute(() -> "result"); - - assertEquals("result", result); - verify(semaphore).release("permit-123"); - } - - @Test - @DisplayName("Should return null when permit not acquired") - void shouldReturnNullWhenPermitNotAcquired() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn(null); - - String result = template.withKey("my-key").permits(5).execute(() -> "result"); - - assertNull(result); - verify(semaphore, never()).release(anyString()); - } - - @Test - @DisplayName("Should release permit even when callback throws exception") - void shouldReleasePermitWhenCallbackThrows() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - assertThrows( - RuntimeException.class, - () -> - template - .withKey("my-key") - .permits(5) - .execute( - () -> { - throw new RuntimeException("Test error"); - })); - - verify(semaphore).release("permit-123"); - } - - @Test - @DisplayName("Should use custom wait time in execute via builder") - void shouldUseCustomWaitTimeInExecute() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(5000, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - template.withKey("my-key").permits(5).waitTime(Duration.ofSeconds(5)).execute(() -> "result"); - - verify(semaphore).tryAcquire(5000, 300000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should use custom lease time in execute via builder") - void shouldUseCustomLeaseTimeInExecute() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 60000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - template - .withKey("my-key") - .permits(5) - .leaseTime(Duration.ofMinutes(1)) - .execute(() -> "result"); - - verify(semaphore).tryAcquire(0, 60000, TimeUnit.MILLISECONDS); - } - - @Test - @DisplayName("Should return null when interrupted") - void shouldReturnNullWhenInterrupted() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenThrow(new InterruptedException()); - - String result = template.withKey("my-key").permits(5).execute(() -> "result"); - - assertNull(result); - assertTrue(Thread.currentThread().isInterrupted()); - Thread.interrupted(); // Clear interrupt status - } - - @Test - @DisplayName("Should handle IllegalArgumentException during release in execute") - void shouldHandleIllegalArgumentExceptionInExecute() throws Exception { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - doThrow(new IllegalArgumentException("Expired")).when(semaphore).release("permit-123"); - - String result = template.withKey("my-key").permits(5).execute(() -> "result"); - - assertEquals("result", result); - } - } - - @Nested - @DisplayName("Semaphore Initialization Tests") - class SemaphoreInitializationTests { - - @Test - @DisplayName("Should initialize semaphore with permits") - void shouldInitializeSemaphoreWithPermits() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire()) { - assertTrue(handle.isAcquired()); - } - - verify(semaphore).trySetPermits(5); - verify(metaBucket).set(5); - } - - @Test - @DisplayName("Should not reinitialize semaphore on second call") - void shouldNotReinitializeSemaphoreOnSecondCall() throws InterruptedException { - setupSemaphore("my-key"); - when(semaphore.tryAcquire(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - template.withKey("my-key").permits(5).tryAcquire().close(); - template.withKey("my-key").permits(5).tryAcquire().close(); - - verify(semaphore, times(1)).trySetPermits(5); - } - - @Test - @DisplayName("Should use existing semaphore permits") - void shouldUseExistingSemaphorePermits() throws InterruptedException { - when(redissonClient.getPermitExpirableSemaphore("semaphore:my-key")).thenReturn(semaphore); - when(redissonClient.getBucket("semaphore:my-key:meta")).thenReturn(metaBucket); - when(metaBucket.get()).thenReturn(10); // Existing value - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - template.withKey("my-key").permits(5).tryAcquire().close(); - - verify(semaphore, never()).trySetPermits(anyInt()); - } - - @Test - @DisplayName("Should warn when permit count mismatch with existing semaphore") - void shouldWarnWhenPermitCountMismatch() throws InterruptedException { - when(redissonClient.getPermitExpirableSemaphore("semaphore:my-key")).thenReturn(semaphore); - when(redissonClient.getBucket("semaphore:my-key:meta")).thenReturn(metaBucket); - when(metaBucket.get()).thenReturn(10); // Different from requested 5 - when(semaphore.tryAcquire(0, 300000, TimeUnit.MILLISECONDS)).thenReturn("permit-123"); - - try (PermitHandle handle = template.withKey("my-key").permits(5).tryAcquire()) { - assertTrue(handle.isAcquired()); - } - - verify(semaphore, never()).trySetPermits(anyInt()); - } - } - - @Nested - @DisplayName("Key Prefix Tests") - class KeyPrefixTests { - - @Test - @DisplayName("Should apply key prefix") - void shouldApplyKeyPrefix() throws InterruptedException { - setupSemaphore("custom-key"); - when(semaphore.tryAcquire(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - template.withKey("custom-key").permits(5).tryAcquire().close(); - - verify(redissonClient, atLeastOnce()).getPermitExpirableSemaphore("semaphore:custom-key"); - } - - @Test - @DisplayName("Should apply custom key prefix") - void shouldApplyCustomKeyPrefix() throws InterruptedException { - LocksmithProperties customProperties = - new LocksmithProperties( - null, - new LocksmithProperties.SemaphoreProperties( - true, Duration.ofMinutes(5), Duration.ofSeconds(60), "myapp:", false, false), - null); - LocksmithSemaphoreTemplate customTemplate = - new LocksmithSemaphoreTemplate(redissonClient, customProperties); - - when(redissonClient.getPermitExpirableSemaphore("myapp:custom-key")).thenReturn(semaphore); - when(redissonClient.getBucket("myapp:custom-key:meta")).thenReturn(metaBucket); - when(metaBucket.get()).thenReturn(null); - when(semaphore.trySetPermits(5)).thenReturn(true); - when(semaphore.tryAcquire(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))) - .thenReturn("permit-123"); - - customTemplate.withKey("custom-key").permits(5).tryAcquire().close(); - - verify(redissonClient, atLeastOnce()).getPermitExpirableSemaphore("myapp:custom-key"); - } - } -}