From fa3fc37ae3d4a0eb91240801f033f693b4dabb85 Mon Sep 17 00:00:00 2001 From: "kanghyun.yang" Date: Thu, 17 Sep 2026 22:51:22 +0900 Subject: [PATCH] NO-ISSUE Add tests for ReqShield configuration and handling of update locks --- .../config/ReqShieldConfiguration.kt | 29 ++- .../kotlin/coroutine/KeyLocalLockTest.kt | 4 +- .../kotlin/coroutine/ReqShieldTest.kt | 21 +++ .../config/ReqShieldConfigurationTest.kt | 53 ++++++ .../cse/reqshield/reactor/ReqShield.kt | 15 +- .../reactor/config/ReqShieldConfiguration.kt | 29 ++- ...ReqShieldSyncThrowingClientFunctionTest.kt | 166 ++++++++++++++++++ .../cse/reqshield/reactor/ReqShieldTest.kt | 30 ++++ .../config/ReqShieldConfigurationTest.kt | 54 ++++++ .../coroutine/aspect/ReqShieldAspect.kt | 10 +- .../coroutine/config/LibAutoConfiguration.kt | 2 +- .../ReqShieldAspectJdkProxyIntegrationTest.kt | 72 ++++++++ .../coroutine/aspect/ReqShieldAspectTest.kt | 4 +- .../config/AutoConfigurationResourcesTest.kt | 63 +++++++ .../spring/webflux/aspect/ReqShieldAspect.kt | 10 +- .../webflux/config/LibAutoConfiguration.kt | 2 +- .../ReqShieldAspectJdkProxyIntegrationTest.kt | 71 ++++++++ .../webflux/aspect/ReqShieldAspectTest.kt | 5 +- .../config/AutoConfigurationResourcesTest.kt | 63 +++++++ .../spring/aspect/ReqShieldAspect.kt | 10 +- .../ReqShieldAspectJdkProxyIntegrationTest.kt | 75 ++++++++ .../test/kotlin/aspect/ReqShieldAspectTest.kt | 4 +- .../config/AutoConfigurationResourcesTest.kt | 64 +++++++ .../config/ReqShieldConfiguration.kt | 29 ++- .../cse/reqshield/KeyLocalLockTest.kt | 55 +++++- .../linecorp/cse/reqshield/ReqShieldTest.kt | 27 +++ .../config/ReqShieldConfigurationTest.kt | 31 ++++ .../example/DefaultKeyGeneratorTest.kt | 91 ++++++++++ 28 files changed, 1049 insertions(+), 40 deletions(-) create mode 100644 core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfigurationTest.kt create mode 100644 core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldSyncThrowingClientFunctionTest.kt create mode 100644 core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfigurationTest.kt create mode 100644 core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt create mode 100644 core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/AutoConfigurationResourcesTest.kt create mode 100644 core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt create mode 100644 core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/AutoConfigurationResourcesTest.kt create mode 100644 core-spring/src/test/kotlin/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt create mode 100644 core-spring/src/test/kotlin/config/AutoConfigurationResourcesTest.kt create mode 100644 req-shield-spring-boot3-webflux-kotlin-coroutine-example/src/test/kotlin/com/linecorp/cse/reqshield/spring3/webflux/kotlin/coroutine/example/DefaultKeyGeneratorTest.kt diff --git a/core-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfiguration.kt b/core-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfiguration.kt index b310775..cdcde30 100644 --- a/core-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfiguration.kt +++ b/core-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfiguration.kt @@ -52,12 +52,7 @@ data class ReqShieldConfiguration( val isLocalLock: Boolean = true, val lockTimeoutMillis: Long = DEFAULT_LOCK_TIMEOUT_MILLIS, val decisionForUpdate: Int = DEFAULT_DECISION_FOR_UPDATE, - val keyLock: KeyLock = - if (isLocalLock) { - KeyLocalLock(lockTimeoutMillis) - } else { - KeyGlobalLock(globalLockFunction!!, globalUnLockFunction!!, lockTimeoutMillis) - }, + val keyLock: KeyLock = defaultKeyLock(isLocalLock, globalLockFunction, globalUnLockFunction, lockTimeoutMillis), val maxAttemptGetCache: Int = MAX_ATTEMPT_GET_CACHE, val reqShieldWorkMode: ReqShieldWorkMode = ReqShieldWorkMode.CREATE_AND_UPDATE_CACHE, /** @@ -100,6 +95,28 @@ data class ReqShieldConfiguration( } } +/** + * Builds the [KeyLock] used when the caller does not pass one. + * + * A default parameter expression is evaluated before the init block, so the global lock functions + * must be validated here as well to report a missing one as an [IllegalArgumentException]. + */ +private fun defaultKeyLock( + isLocalLock: Boolean, + globalLockFunction: (suspend (String, String, Long) -> Boolean)?, + globalUnLockFunction: (suspend (String, String) -> Boolean)?, + lockTimeoutMillis: Long, +): KeyLock = + if (isLocalLock) { + KeyLocalLock(lockTimeoutMillis) + } else { + KeyGlobalLock( + requireNotNull(globalLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message }, + requireNotNull(globalUnLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message }, + lockTimeoutMillis, + ) + } + enum class ReqShieldWorkMode { CREATE_AND_UPDATE_CACHE, ONLY_CREATE_CACHE, diff --git a/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/KeyLocalLockTest.kt b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/KeyLocalLockTest.kt index 6c3d70c..dcef50b 100644 --- a/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/KeyLocalLockTest.kt +++ b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/KeyLocalLockTest.kt @@ -119,7 +119,7 @@ class KeyLocalLockTest : BaseKeyLockTest { override fun testConcurrencyWithOneKey() = runBlocking { val keyLock = KeyLocalLock(lockTimeoutMillis) - val key = "myKey" + val key = "myKey-concurrency-one" val lockType = LockType.CREATE val lockAcquiredCount = AtomicInteger(0) val tasksCompletedCount = AtomicInteger(0) @@ -199,7 +199,7 @@ class KeyLocalLockTest : BaseKeyLockTest { override fun testLockExpiration() = runBlocking { val keyLock = KeyLocalLock(lockTimeoutMillis) - val key = "myKey" + val key = "myKey-lock-expiration" val lockType = LockType.CREATE assertNotNull(keyLock.tryLock(key, lockType)) diff --git a/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/ReqShieldTest.kt b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/ReqShieldTest.kt index 0428a83..e84b61a 100644 --- a/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/ReqShieldTest.kt +++ b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/ReqShieldTest.kt @@ -615,6 +615,27 @@ class ReqShieldTest : BaseReqShieldTest { coVerify { callable() } } + @Test + fun testSetMethodCacheExistsAndTheUpdateTargetButUpdateLockNotAcquired() = + runTest { + val timeToLiveMillis: Long = 1000 + val isolatedKey = "update-lock-not-acquired-${java.util.UUID.randomUUID()}" + val reqShieldData = updateTargetData(oldValue, timeToLiveMillis) + + coEvery { cacheGetter.invoke(isolatedKey) } returns reqShieldData + coEvery { keyLock.tryLock(isolatedKey, LockType.UPDATE) } returns null + + val result = reqShield.getAndSetReqShieldData(isolatedKey, callable, timeToLiveMillis) + // Flush any (wrongly) queued refresh work on the background scope before asserting absence. + awaitBackgroundWrites() + + assertSame(reqShieldData, result) + coVerify { keyLock.tryLock(isolatedKey, LockType.UPDATE) } + coVerify(inverse = true) { keyLock.unLock(isolatedKey, LockType.UPDATE, any()) } + coVerify(inverse = true) { callable() } + coVerify(inverse = true) { cacheSetter.invoke(isolatedKey, any(), any()) } + } + @Test override fun testSetMethodCacheExistsAndTheUpdateTargetOnlyCreateCache() = runTest { diff --git a/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfigurationTest.kt b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfigurationTest.kt new file mode 100644 index 0000000..05ac803 --- /dev/null +++ b/core-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/kotlin/coroutine/config/ReqShieldConfigurationTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.kotlin.coroutine.config + +import com.linecorp.cse.reqshield.support.exception.code.ErrorCode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ReqShieldConfigurationTest { + @Test + fun testGlobalLockWithoutLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> true }, + getCacheFunction = { null }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message, exception.message) + } + + @Test + fun testGlobalLockWithoutUnLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> true }, + getCacheFunction = { null }, + globalLockFunction = { _, _, _ -> true }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message, exception.message) + } +} diff --git a/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/ReqShield.kt b/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/ReqShield.kt index bb13e79..42ca3e8 100644 --- a/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/ReqShield.kt +++ b/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/ReqShield.kt @@ -286,7 +286,9 @@ class ReqShield( getFunction: (String) -> Mono?>, key: String, ): Mono?> = - getFunction(key) + // Deferred so a client function that throws synchronously fails as an onError signal. + Mono + .defer { getFunction(key) } .onErrorMap { e -> ClientException(ErrorCode.GET_CACHE_ERROR, cause = e) } private fun executeSetCacheFunction( @@ -296,7 +298,10 @@ class ReqShield( lockType: LockType, token: String?, ): Mono = - setFunction(key, value, value.timeToLiveMillis) + // Deferred so a client function that throws synchronously fails as an onError signal, + // which keeps the lock release in doFinally reachable. + Mono + .defer { setFunction(key, value, value.timeToLiveMillis) } .onErrorMap { e -> ClientException(ErrorCode.SET_CACHE_ERROR, cause = e) } .doFinally { // Only the holder of a token took a lock, so only it may release one. @@ -328,8 +333,10 @@ class ReqShield( lockType: LockType?, token: String?, ): Mono = - callable - .call() + // Deferred so a supplier that throws synchronously fails as an onError signal, + // which keeps the lock release below reachable. + Mono + .defer { callable.call() } .doOnError { _ -> // Only the holder of a token took a lock, so only it may release one. if (lockType != null && token != null) { diff --git a/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfiguration.kt b/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfiguration.kt index 332a3b4..7804ac9 100644 --- a/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfiguration.kt +++ b/core-reactor/src/main/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfiguration.kt @@ -51,12 +51,7 @@ data class ReqShieldConfiguration( val lockTimeoutMillis: Long = DEFAULT_LOCK_TIMEOUT_MILLIS, val scheduler: Scheduler = Schedulers.boundedElastic(), val decisionForUpdate: Int = DEFAULT_DECISION_FOR_UPDATE, - val keyLock: KeyLock = - if (isLocalLock) { - KeyLocalLock(lockTimeoutMillis) - } else { - KeyGlobalLock(globalLockFunction!!, globalUnLockFunction!!, lockTimeoutMillis) - }, + val keyLock: KeyLock = defaultKeyLock(isLocalLock, globalLockFunction, globalUnLockFunction, lockTimeoutMillis), val maxAttemptGetCache: Int = MAX_ATTEMPT_GET_CACHE, val reqShieldWorkMode: ReqShieldWorkMode = ReqShieldWorkMode.CREATE_AND_UPDATE_CACHE, ) { @@ -72,6 +67,28 @@ data class ReqShieldConfiguration( } } +/** + * Builds the [KeyLock] used when the caller does not pass one. + * + * A default parameter expression is evaluated before the init block, so the global lock functions + * must be validated here as well to report a missing one as an [IllegalArgumentException]. + */ +private fun defaultKeyLock( + isLocalLock: Boolean, + globalLockFunction: ((String, String, Long) -> Mono)?, + globalUnLockFunction: ((String, String) -> Mono)?, + lockTimeoutMillis: Long, +): KeyLock = + if (isLocalLock) { + KeyLocalLock(lockTimeoutMillis) + } else { + KeyGlobalLock( + requireNotNull(globalLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message }, + requireNotNull(globalUnLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message }, + lockTimeoutMillis, + ) + } + enum class ReqShieldWorkMode { CREATE_AND_UPDATE_CACHE, ONLY_CREATE_CACHE, diff --git a/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldSyncThrowingClientFunctionTest.kt b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldSyncThrowingClientFunctionTest.kt new file mode 100644 index 0000000..1c99e47 --- /dev/null +++ b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldSyncThrowingClientFunctionTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.reactor + +import com.linecorp.cse.reqshield.reactor.config.ReqShieldConfiguration +import com.linecorp.cse.reqshield.reactor.config.ReqShieldWorkMode +import com.linecorp.cse.reqshield.support.exception.ClientException +import com.linecorp.cse.reqshield.support.exception.code.ErrorCode +import com.linecorp.cse.reqshield.support.model.Product +import com.linecorp.cse.reqshield.support.model.ReqShieldData +import com.linecorp.cse.reqshield.support.utils.nowToEpochTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import reactor.test.StepVerifier +import java.util.UUID +import java.util.concurrent.Callable +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertNotNull + +/** + * Client functions may fail before they ever return a publisher (e.g. a `require` at the top of the + * annotated method). Such a failure must become an onError signal so that the cleanup already wired + * into the chain - lock release and error mapping - actually runs. + */ +class ReqShieldSyncThrowingClientFunctionTest { + private val value = Product("testValue", "testValue") + private val cachedValue = Product("oldTestValue", "oldTestValue") + private val timeToLiveMillis = 10000L + + // Long enough that a leaked lock cannot be released by its own expiration during the test. + private val lockTimeoutMillis = 60000L + + private val throwingSupplier = + Callable> { throw IllegalStateException("supplier failed before returning a Mono") } + + /** Unique per test: KeyLocalLock keeps its lock map in a companion object shared by the whole JVM. */ + private fun isolatedKey(name: String) = "$name-${UUID.randomUUID()}" + + /** Cached entry that has passed the decisionForUpdate threshold (90% of its TTL). */ + private fun updateTargetReqShieldData(): ReqShieldData = + ReqShieldData( + cachedValue, + ReqShieldData.Status.NEW, + nowToEpochTime() - (timeToLiveMillis * 0.9).toLong(), + timeToLiveMillis, + ) + + @Test + fun `should release the update lock when the supplier throws synchronously`() { + val key = isolatedKey("sync-throwing-supplier-update") + val keyLock = KeyLocalLock(lockTimeoutMillis) + val cached = updateTargetReqShieldData() + val reqShield = + ReqShield( + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> Mono.just(true) }, + getCacheFunction = { Mono.just(cached) }, + keyLock = keyLock, + scheduler = Schedulers.immediate(), + ), + ) + + StepVerifier + .create(reqShield.getAndSetReqShieldData(key, throwingSupplier, timeToLiveMillis)) + .expectNext(cached) + .verifyComplete() + + assertNotNull( + keyLock.tryLock(key, LockType.UPDATE).block(), + "the failed background refresh must have released its update lock", + ) + } + + @Test + fun `should still serve the cached value in only create cache mode when the supplier throws synchronously`() { + val key = isolatedKey("sync-throwing-supplier-only-create") + val cached = updateTargetReqShieldData() + val reqShield = + ReqShield( + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> Mono.just(true) }, + getCacheFunction = { Mono.just(cached) }, + keyLock = KeyLocalLock(lockTimeoutMillis), + scheduler = Schedulers.immediate(), + reqShieldWorkMode = ReqShieldWorkMode.ONLY_CREATE_CACHE, + ), + ) + + // The refresh is fire-and-forget: its failure must not reach the caller of a cache hit. + StepVerifier + .create(reqShield.getAndSetReqShieldData(key, throwingSupplier, timeToLiveMillis)) + .expectNext(cached) + .verifyComplete() + } + + @Test + fun `should map a synchronously throwing get cache function to a get cache client exception`() { + val key = isolatedKey("sync-throwing-get-cache") + val getCacheInvocations = AtomicInteger(0) + val reqShield = + ReqShield( + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> Mono.just(true) }, + getCacheFunction = { + getCacheInvocations.incrementAndGet() + throw IllegalStateException("cache read failed before returning a Mono") + }, + keyLock = KeyLocalLock(lockTimeoutMillis), + scheduler = Schedulers.immediate(), + ), + ) + + val result = reqShield.getAndSetReqShieldData(key, Callable { Mono.just(value) }, timeToLiveMillis) + + assertEquals(0, getCacheInvocations.get(), "the returned Mono must not read the cache before subscription") + + StepVerifier + .create(result) + .expectErrorMatches { it is ClientException && it.errorCode == ErrorCode.GET_CACHE_ERROR } + .verify() + + assertEquals(1, getCacheInvocations.get()) + } + + @Test + fun `should return the supplier value and release the create lock when the set cache function throws synchronously`() { + val key = isolatedKey("sync-throwing-set-cache") + val keyLock = KeyLocalLock(lockTimeoutMillis) + val reqShield = + ReqShield( + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> throw IllegalStateException("cache write failed before returning a Mono") }, + getCacheFunction = { Mono.empty() }, + keyLock = keyLock, + scheduler = Schedulers.immediate(), + ), + ) + + // The cache write is fire-and-forget, so its failure must not fail the request either. + StepVerifier + .create(reqShield.getAndSetReqShieldData(key, Callable { Mono.just(value) }, timeToLiveMillis)) + .assertNext { assertEquals(value, it.value) } + .verifyComplete() + + assertNotNull( + keyLock.tryLock(key, LockType.CREATE).block(), + "the failed cache write must have released its create lock", + ) + } +} diff --git a/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldTest.kt b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldTest.kt index a49cb03..68240f4 100644 --- a/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldTest.kt +++ b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/ReqShieldTest.kt @@ -628,6 +628,36 @@ class ReqShieldTest : BaseReqShieldTest { verify { callable.call() } } + @Test + fun testSetMethodCacheExistsAndTheUpdateTargetButUpdateLockNotAcquired() { + timeToLiveMillis = 1000 + val reqShieldData = updateTargetReqShieldData(oldValue) + // Schedulers.immediate() makes the fire-and-forget refresh run synchronously (if it were + // triggered at all), so a zero-invocation assertion right after the call is conclusive. + val shield = + ReqShield( + ReqShieldConfiguration(cacheSetter, cacheGetter, keyLock = keyLock, scheduler = Schedulers.immediate()), + ) + + every { cacheGetter.invoke(key) } returns Mono.just(reqShieldData) + every { keyLock.tryLock(key, LockType.UPDATE) } returns Mono.empty() + + val result = shield.getAndSetReqShieldData(key, callable, timeToLiveMillis) + + StepVerifier + .create(result) + .expectNextMatches { + assertEquals(reqShieldData, it) + true + }.expectComplete() + .verify() + + verify { keyLock.tryLock(key, LockType.UPDATE) } + verify(inverse = true) { keyLock.unLock(key, LockType.UPDATE, any()) } + verify(inverse = true) { callable.call() } + verify(inverse = true) { cacheSetter.invoke(key, any(), any()) } + } + @Test override fun testSetMethodCacheExistsAndTheUpdateTargetOnlyCreateCache() { timeToLiveMillis = 1000 diff --git a/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfigurationTest.kt b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfigurationTest.kt new file mode 100644 index 0000000..e14fb11 --- /dev/null +++ b/core-reactor/src/test/kotlin/com/linecorp/cse/reqshield/reactor/config/ReqShieldConfigurationTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.reactor.config + +import com.linecorp.cse.reqshield.support.exception.code.ErrorCode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import reactor.core.publisher.Mono + +class ReqShieldConfigurationTest { + @Test + fun testGlobalLockWithoutLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> Mono.just(true) }, + getCacheFunction = { Mono.empty() }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message, exception.message) + } + + @Test + fun testGlobalLockWithoutUnLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> Mono.just(true) }, + getCacheFunction = { Mono.empty() }, + globalLockFunction = { _, _, _ -> Mono.just(true) }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message, exception.message) + } +} diff --git a/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspect.kt b/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspect.kt index b95909d..4fe44f1 100644 --- a/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspect.kt +++ b/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspect.kt @@ -28,6 +28,7 @@ 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.springframework.aop.support.AopUtils import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.BeanFactoryAware import org.springframework.beans.factory.annotation.Qualifier @@ -118,7 +119,12 @@ open class ReqShieldAspect( } } - internal open fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = (joinPoint.signature as MethodSignature).method + /** + * A JDK dynamic proxy reports the interface method, which carries none of the annotations, so the + * implementation method is resolved from the target class instead. + */ + internal open fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = + AopUtils.getMostSpecificMethod((joinPoint.signature as MethodSignature).method, joinPoint.target?.javaClass) internal fun getCacheableAnnotation(joinPoint: ProceedingJoinPoint): ReqShieldCacheable = AnnotationUtils.getAnnotation(getTargetMethod(joinPoint), ReqShieldCacheable::class.java) @@ -175,7 +181,7 @@ open class ReqShieldAspect( expression.getValue(context, String::class.java) } else { val keyGenerator = getOrCreateKeyGenerator(annotationCacheKeyGenerator) - keyGenerator.generate(joinPoint.target, method, args).toString() + keyGenerator.generate(joinPoint.target, method, *args).toString() } require(!key.isNullOrBlank()) { diff --git a/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/LibAutoConfiguration.kt b/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/LibAutoConfiguration.kt index 7d7a78b..9f9a4ee 100644 --- a/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/LibAutoConfiguration.kt +++ b/core-spring-webflux-kotlin-coroutine/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/LibAutoConfiguration.kt @@ -31,7 +31,7 @@ import org.springframework.context.annotation.Import import kotlin.coroutines.CoroutineContext @Configuration -@EnableAspectJAutoProxy(proxyTargetClass = true) +@EnableAspectJAutoProxy @Import(ReqShieldAspect::class) open class LibAutoConfiguration { /** diff --git a/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt new file mode 100644 index 0000000..3d44a28 --- /dev/null +++ b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.aspect + +import com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.annotation.ReqShieldCacheable +import com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.cache.AsyncCache +import com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.config.LibAutoConfiguration +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.lang.reflect.Proxy + +private const val JDK_PROXY_CACHE_NAME = "jdkProxy" + +/** + * A bean that implements an interface is proxied by a JDK dynamic proxy, because + * [LibAutoConfiguration] leaves the proxying strategy to the application. The join point then + * reports the interface method, which carries none of the annotations, so the aspect has to + * resolve the implementation method itself. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [LibAutoConfiguration::class, ReqShieldAspectJdkProxyIntegrationTest.TestConfig::class]) +class ReqShieldAspectJdkProxyIntegrationTest { + @Autowired + private lateinit var service: ProductNameService + + @Test + fun annotatedSuspendMethodShouldBeResolvedBehindAJdkDynamicProxy() = + runBlocking { + assertTrue(Proxy.isProxyClass(service.javaClass), "expected a JDK dynamic proxy but was ${service.javaClass}") + assertEquals("product-1", service.findName("1")) + } + + @Configuration + open class TestConfig { + @Bean + open fun asyncCache(): AsyncCache = InMemoryAsyncCache() + + @Bean + open fun productNameService(): ProductNameService = ProductNameServiceImpl() + } + + interface ProductNameService { + suspend fun findName(id: String): String + } + + class ProductNameServiceImpl : ProductNameService { + @ReqShieldCacheable(cacheName = JDK_PROXY_CACHE_NAME, key = "#id", timeToLiveMillis = 10_000) + override suspend fun findName(id: String): String = "product-$id" + } +} diff --git a/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectTest.kt b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectTest.kt index a36f58f..452a4fe 100644 --- a/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectTest.kt +++ b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/aspect/ReqShieldAspectTest.kt @@ -48,7 +48,6 @@ import org.junit.jupiter.api.Test import org.slf4j.LoggerFactory import org.springframework.beans.factory.BeanFactory import org.springframework.cache.interceptor.KeyGenerator -import org.springframework.cache.interceptor.SimpleKeyGenerator import java.lang.reflect.Method import kotlin.coroutines.Continuation import kotlin.coroutines.EmptyCoroutineContext @@ -189,7 +188,8 @@ class ReqShieldAspectTest : BaseReqShieldModuleSupportTest { every { reqShieldAspect.getTargetMethod(joinPoint) } returns methodOf(TestBean::cacheableWithDefaultKeyGenerator.name) assertEquals( - "$cacheName::${SimpleKeyGenerator.generateKey(arrayOf(argument))}", + // SimpleKeyGenerator returns a single non-array argument as the key, so the key is the argument itself + "$cacheName::{x=paramX, y=paramY}", reqShieldAspect.getCacheableCacheKey(joinPoint), ) } diff --git a/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/AutoConfigurationResourcesTest.kt b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/AutoConfigurationResourcesTest.kt new file mode 100644 index 0000000..1fe09d0 --- /dev/null +++ b/core-spring-webflux-kotlin-coroutine/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/kotlin/coroutine/config/AutoConfigurationResourcesTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.config + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Guards the two auto-configuration registration files shipped in `src/main/resources` against a + * silent typo: nothing else in the build loads them, so a broken class name would only surface at + * runtime in a consuming application. + */ +class AutoConfigurationResourcesTest { + private val expectedClassName = LibAutoConfiguration::class.java.name + + @Test + fun `spring factories names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring.factories") + + assertTrue( + names.any { it.contains(expectedClassName) }, + "spring.factories should reference $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + @Test + fun `Boot 3 AutoConfiguration imports names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports") + + assertTrue( + names.any { it == expectedClassName }, + "AutoConfiguration.imports should list $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + private fun readClasspathResourceLines(resourcePath: String): List { + val resource = + checkNotNull(javaClass.classLoader.getResource(resourcePath)) { + "Resource not found on classpath: $resourcePath" + } + return resource.openStream().bufferedReader().readLines().map { it.trim() } + } + + private fun assertClassLoadable(className: String) { + Class.forName(className) + } +} diff --git a/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspect.kt b/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspect.kt index 53549e6..f6448a1 100644 --- a/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspect.kt +++ b/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspect.kt @@ -27,6 +27,7 @@ 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.springframework.aop.support.AopUtils import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.BeanFactoryAware import org.springframework.beans.factory.annotation.Qualifier @@ -150,7 +151,12 @@ open class ReqShieldAspect( return ReqShield(reqShieldConfiguration) } - internal open fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = (joinPoint.signature as MethodSignature).method + /** + * A JDK dynamic proxy reports the interface method, which carries none of the annotations, so the + * implementation method is resolved from the target class instead. + */ + internal open fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = + AopUtils.getMostSpecificMethod((joinPoint.signature as MethodSignature).method, joinPoint.target?.javaClass) internal fun getCacheableAnnotation(joinPoint: ProceedingJoinPoint): ReqShieldCacheable = AnnotationUtils.getAnnotation(getTargetMethod(joinPoint), ReqShieldCacheable::class.java) @@ -201,7 +207,7 @@ open class ReqShieldAspect( getOrParseExpression(annotationCacheKey).getValue(context, String::class.java) } else { val keyGenerator = getOrCreateKeyGenerator(annotationCacheKeyGenerator) - keyGenerator.generate(joinPoint.target, method, joinPoint.args).toString() + keyGenerator.generate(joinPoint.target, method, *joinPoint.args).toString() } require(!key.isNullOrBlank()) { diff --git a/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/LibAutoConfiguration.kt b/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/LibAutoConfiguration.kt index b9af186..c43bbe9 100644 --- a/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/LibAutoConfiguration.kt +++ b/core-spring-webflux/src/main/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/LibAutoConfiguration.kt @@ -25,7 +25,7 @@ import reactor.core.scheduler.Scheduler import reactor.core.scheduler.Schedulers @Configuration -@EnableAspectJAutoProxy(proxyTargetClass = true) +@EnableAspectJAutoProxy @Import(ReqShieldAspect::class) open class LibAutoConfiguration { /** diff --git a/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt new file mode 100644 index 0000000..8ddc8c8 --- /dev/null +++ b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.spring.webflux.aspect + +import com.linecorp.cse.reqshield.spring.webflux.annotation.ReqShieldCacheable +import com.linecorp.cse.reqshield.spring.webflux.cache.AsyncCache +import com.linecorp.cse.reqshield.spring.webflux.config.LibAutoConfiguration +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import reactor.core.publisher.Mono +import java.lang.reflect.Proxy + +private const val JDK_PROXY_CACHE_NAME = "jdkProxy" + +/** + * A bean that implements an interface is proxied by a JDK dynamic proxy, because + * [LibAutoConfiguration] leaves the proxying strategy to the application. The join point then + * reports the interface method, which carries none of the annotations, so the aspect has to + * resolve the implementation method itself. + */ +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [LibAutoConfiguration::class, ReqShieldAspectJdkProxyIntegrationTest.TestConfig::class]) +class ReqShieldAspectJdkProxyIntegrationTest { + @Autowired + private lateinit var service: ProductNameService + + @Test + fun annotatedMethodShouldBeResolvedBehindAJdkDynamicProxy() { + assertTrue(Proxy.isProxyClass(service.javaClass), "expected a JDK dynamic proxy but was ${service.javaClass}") + assertEquals("product-1", service.findName("1").block()) + } + + @Configuration + open class TestConfig { + @Bean + open fun asyncCache(): AsyncCache = InMemoryAsyncCache() + + @Bean + open fun productNameService(): ProductNameService = ProductNameServiceImpl() + } + + interface ProductNameService { + fun findName(id: String): Mono + } + + class ProductNameServiceImpl : ProductNameService { + @ReqShieldCacheable(cacheName = JDK_PROXY_CACHE_NAME, key = "#id", timeToLiveMillis = 10_000) + override fun findName(id: String): Mono = Mono.just("product-$id") + } +} diff --git a/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectTest.kt b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectTest.kt index d89a3df..f6103e1 100644 --- a/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectTest.kt +++ b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/aspect/ReqShieldAspectTest.kt @@ -33,7 +33,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.BeanFactory import org.springframework.cache.interceptor.KeyGenerator -import org.springframework.cache.interceptor.SimpleKeyGenerator import org.springframework.util.ReflectionUtils import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -55,7 +54,9 @@ class ReqShieldAspectTest : BaseReqShieldModuleSupportTest { private val cacheKeyGenerator = "customGenerator" private val spelEvaluatedKey = "$cacheName::paramXparamY" private val keyGeneratorKey = "$cacheName::KeyGeneratedByGenerator" - private val defaultGeneratedKey = "$cacheName::${SimpleKeyGenerator.generateKey(arrayOf(argument))}" + + // SimpleKeyGenerator returns a single non-array argument as the key, so the key is the argument itself + private val defaultGeneratedKey = "$cacheName::{x=paramX, y=paramY}" private val beanFactory = mockk() diff --git a/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/AutoConfigurationResourcesTest.kt b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/AutoConfigurationResourcesTest.kt new file mode 100644 index 0000000..8027bae --- /dev/null +++ b/core-spring-webflux/src/test/kotlin/com/linecorp/cse/reqshield/spring/webflux/config/AutoConfigurationResourcesTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.spring.webflux.config + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Guards the two auto-configuration registration files shipped in `src/main/resources` against a + * silent typo: nothing else in the build loads them, so a broken class name would only surface at + * runtime in a consuming application. + */ +class AutoConfigurationResourcesTest { + private val expectedClassName = LibAutoConfiguration::class.java.name + + @Test + fun `spring factories names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring.factories") + + assertTrue( + names.any { it.contains(expectedClassName) }, + "spring.factories should reference $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + @Test + fun `Boot 3 AutoConfiguration imports names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports") + + assertTrue( + names.any { it == expectedClassName }, + "AutoConfiguration.imports should list $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + private fun readClasspathResourceLines(resourcePath: String): List { + val resource = + checkNotNull(javaClass.classLoader.getResource(resourcePath)) { + "Resource not found on classpath: $resourcePath" + } + return resource.openStream().bufferedReader().readLines().map { it.trim() } + } + + private fun assertClassLoadable(className: String) { + Class.forName(className) + } +} diff --git a/core-spring/src/main/kotlin/com/linecorp/cse/reqshield/spring/aspect/ReqShieldAspect.kt b/core-spring/src/main/kotlin/com/linecorp/cse/reqshield/spring/aspect/ReqShieldAspect.kt index 0a7868c..15ac9a9 100644 --- a/core-spring/src/main/kotlin/com/linecorp/cse/reqshield/spring/aspect/ReqShieldAspect.kt +++ b/core-spring/src/main/kotlin/com/linecorp/cse/reqshield/spring/aspect/ReqShieldAspect.kt @@ -26,6 +26,7 @@ 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.springframework.aop.support.AopUtils import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.BeanFactoryAware import org.springframework.beans.factory.annotation.Qualifier @@ -129,7 +130,12 @@ class ReqShieldAspect( return ReqShield(reqShieldConfiguration) } - internal fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = (joinPoint.signature as MethodSignature).method + /** + * A JDK dynamic proxy reports the interface method, which carries none of the annotations, so the + * implementation method is resolved from the target class instead. + */ + internal fun getTargetMethod(joinPoint: ProceedingJoinPoint): Method = + AopUtils.getMostSpecificMethod((joinPoint.signature as MethodSignature).method, joinPoint.target?.javaClass) internal fun getCacheableAnnotation(joinPoint: ProceedingJoinPoint): ReqShieldCacheable = AnnotationUtils.getAnnotation(getTargetMethod(joinPoint), ReqShieldCacheable::class.java) @@ -180,7 +186,7 @@ class ReqShieldAspect( getOrParseExpression(annotationCacheKey).getValue(context, String::class.java) } else { val keyGenerator = getOrCreateKeyGenerator(annotationCacheKeyGenerator) - keyGenerator.generate(joinPoint.target, method, joinPoint.args).toString() + keyGenerator.generate(joinPoint.target, method, *joinPoint.args).toString() } require(!key.isNullOrBlank()) { diff --git a/core-spring/src/test/kotlin/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt b/core-spring/src/test/kotlin/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt new file mode 100644 index 0000000..221ab43 --- /dev/null +++ b/core-spring/src/test/kotlin/aspect/ReqShieldAspectJdkProxyIntegrationTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package aspect + +import com.linecorp.cse.reqshield.spring.annotation.ReqShieldCacheable +import com.linecorp.cse.reqshield.spring.config.LibAutoConfiguration +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.lang.reflect.Proxy + +/** + * A bean that implements an interface is proxied by a JDK dynamic proxy under the default + * [LibAutoConfiguration] settings. The join point then reports the interface method, which carries + * none of the annotations, so the aspect has to resolve the implementation method itself. + */ +class ReqShieldAspectJdkProxyIntegrationTest { + private lateinit var context: AnnotationConfigApplicationContext + + @BeforeEach + fun setUp() { + context = AnnotationConfigApplicationContext(LibAutoConfiguration::class.java, TestConfig::class.java) + } + + @AfterEach + fun tearDown() { + context.close() + } + + @Test + fun annotatedMethodShouldBeResolvedBehindAJdkDynamicProxy() { + val service = context.getBean(ProductNameService::class.java) + + assertTrue(Proxy.isProxyClass(service.javaClass), "expected a JDK dynamic proxy but was ${service.javaClass}") + assertEquals("product-1", service.findName("1")) + } + + @Configuration + open class TestConfig { + @Bean + open fun reqShieldCache(): ReqShieldAspectIntegrationTest.InMemoryReqShieldCache = + ReqShieldAspectIntegrationTest.InMemoryReqShieldCache() + + @Bean + open fun productNameService(): ProductNameService = ProductNameServiceImpl() + } + + interface ProductNameService { + fun findName(id: String): String + } + + class ProductNameServiceImpl : ProductNameService { + @ReqShieldCacheable(cacheName = "jdkProxy", key = "#id", timeToLiveMillis = 10_000) + override fun findName(id: String): String = "product-$id" + } +} diff --git a/core-spring/src/test/kotlin/aspect/ReqShieldAspectTest.kt b/core-spring/src/test/kotlin/aspect/ReqShieldAspectTest.kt index 07cdb05..2fc2ad3 100644 --- a/core-spring/src/test/kotlin/aspect/ReqShieldAspectTest.kt +++ b/core-spring/src/test/kotlin/aspect/ReqShieldAspectTest.kt @@ -42,7 +42,6 @@ import org.junit.jupiter.api.Test import org.slf4j.LoggerFactory import org.springframework.beans.factory.BeanFactory import org.springframework.cache.interceptor.KeyGenerator -import org.springframework.cache.interceptor.SimpleKeyGenerator import org.springframework.util.ReflectionUtils import java.lang.reflect.Method import java.time.Duration @@ -299,7 +298,8 @@ class ReqShieldAspectTest : BaseReqShieldModuleSupportTest { // when, then assertEquals( - "$cacheName::${SimpleKeyGenerator.generateKey(arrayOf(argument))}", + // SimpleKeyGenerator returns a single non-array argument as the key, so the key is the argument itself + "$cacheName::{x=paramX, y=paramY}", reqShieldAspect.getCacheableCacheKey(joinPoint), ) } diff --git a/core-spring/src/test/kotlin/config/AutoConfigurationResourcesTest.kt b/core-spring/src/test/kotlin/config/AutoConfigurationResourcesTest.kt new file mode 100644 index 0000000..2205e8d --- /dev/null +++ b/core-spring/src/test/kotlin/config/AutoConfigurationResourcesTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package config + +import com.linecorp.cse.reqshield.spring.config.LibAutoConfiguration +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Guards the two auto-configuration registration files shipped in `src/main/resources` against a + * silent typo: nothing else in the build loads them, so a broken class name would only surface at + * runtime in a consuming application. + */ +class AutoConfigurationResourcesTest { + private val expectedClassName = LibAutoConfiguration::class.java.name + + @Test + fun `spring factories names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring.factories") + + assertTrue( + names.any { it.contains(expectedClassName) }, + "spring.factories should reference $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + @Test + fun `Boot 3 AutoConfiguration imports names the module's LibAutoConfiguration`() { + val names = readClasspathResourceLines("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports") + + assertTrue( + names.any { it == expectedClassName }, + "AutoConfiguration.imports should list $expectedClassName, but was: $names", + ) + assertClassLoadable(expectedClassName) + } + + private fun readClasspathResourceLines(resourcePath: String): List { + val resource = + checkNotNull(javaClass.classLoader.getResource(resourcePath)) { + "Resource not found on classpath: $resourcePath" + } + return resource.openStream().bufferedReader().readLines().map { it.trim() } + } + + private fun assertClassLoadable(className: String) { + Class.forName(className) + } +} diff --git a/core/src/main/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfiguration.kt b/core/src/main/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfiguration.kt index 4198368..fb4b9e6 100644 --- a/core/src/main/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfiguration.kt +++ b/core/src/main/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfiguration.kt @@ -51,12 +51,7 @@ data class ReqShieldConfiguration( */ val executor: ScheduledExecutorService = sharedExecutor, val decisionForUpdate: Int = DEFAULT_DECISION_FOR_UPDATE, - val keyLock: KeyLock = - if (isLocalLock) { - KeyLocalLock(lockTimeoutMillis) - } else { - KeyGlobalLock(globalLockFunction!!, globalUnLockFunction!!, lockTimeoutMillis) - }, + val keyLock: KeyLock = defaultKeyLock(isLocalLock, globalLockFunction, globalUnLockFunction, lockTimeoutMillis), val maxAttemptGetCache: Int = MAX_ATTEMPT_GET_CACHE, val reqShieldWorkMode: ReqShieldWorkMode = ReqShieldWorkMode.CREATE_AND_UPDATE_CACHE, ) { @@ -90,6 +85,28 @@ data class ReqShieldConfiguration( } } +/** + * Builds the [KeyLock] used when the caller does not pass one. + * + * A default parameter expression is evaluated before the init block, so the global lock functions + * must be validated here as well to report a missing one as an [IllegalArgumentException]. + */ +private fun defaultKeyLock( + isLocalLock: Boolean, + globalLockFunction: ((String, String, Long) -> Boolean)?, + globalUnLockFunction: ((String, String) -> Boolean)?, + lockTimeoutMillis: Long, +): KeyLock = + if (isLocalLock) { + KeyLocalLock(lockTimeoutMillis) + } else { + KeyGlobalLock( + requireNotNull(globalLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message }, + requireNotNull(globalUnLockFunction) { ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message }, + lockTimeoutMillis, + ) + } + enum class ReqShieldWorkMode { CREATE_AND_UPDATE_CACHE, ONLY_CREATE_CACHE, diff --git a/core/src/test/kotlin/com/linecorp/cse/reqshield/KeyLocalLockTest.kt b/core/src/test/kotlin/com/linecorp/cse/reqshield/KeyLocalLockTest.kt index c9ece1c..68eacf9 100644 --- a/core/src/test/kotlin/com/linecorp/cse/reqshield/KeyLocalLockTest.kt +++ b/core/src/test/kotlin/com/linecorp/cse/reqshield/KeyLocalLockTest.kt @@ -18,6 +18,7 @@ package com.linecorp.cse.reqshield import com.linecorp.cse.reqshield.support.BaseKeyLockTest import com.linecorp.cse.reqshield.support.BaseReqShieldTest.Companion.AWAIT_TIMEOUT +import com.linecorp.cse.reqshield.support.constant.ConfigValues.LOCK_MONITOR_INTERVAL_MILLIS import org.awaitility.Awaitility.await import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -37,7 +38,7 @@ class KeyLocalLockTest : BaseKeyLockTest { override fun testConcurrencyWithOneKey() { val keyLock = KeyLocalLock(lockTimeoutMillis) val executorService = Executors.newFixedThreadPool(20) - val key = "myKey" + val key = "myKey-concurrency-one" val lockType = LockType.CREATE val lockAcquiredCount = AtomicInteger(0) val tasksCompletedCount = AtomicInteger(0) @@ -115,7 +116,7 @@ class KeyLocalLockTest : BaseKeyLockTest { @Test override fun testLockExpiration() { val keyLock = KeyLocalLock(lockTimeoutMillis) - val key = "myKey" + val key = "myKey-lock-expiration" val lockType = LockType.CREATE assertNotNull(keyLock.tryLock(key, lockType)) @@ -619,5 +620,55 @@ class KeyLocalLockTest : BaseKeyLockTest { keyLock.shutdown() } + /** + * The expired-lock monitor's only externally observable effect is that its entry disappears + * from the (otherwise private) global lockMap without anyone calling tryLock/unLock for that + * key. tryLock() itself force-releases an expired lock inline, so a test that only calls + * tryLock/unLock again cannot tell the monitor's removal apart from that inline force-release. + * Reflection into the companion's private lockMap is deliberate: it is the only way to prove + * the monitor itself, rather than tryLock's inline fallback, is what removes the entry. + */ + @Suppress("UNCHECKED_CAST") + private fun readLockMap(): Map { + // The companion's private val is compiled as a static field on the outer class, not on + // the Companion nested class. + val field = KeyLocalLock::class.java.getDeclaredField("lockMap") + field.isAccessible = true + return field.get(null) as Map + } + + @Test + fun `expired-lock monitor removes entries from the shared lock map without any tryLock or unLock call`() { + val lockTimeout = 300L + val keyLock = KeyLocalLock(lockTimeout) + val keys = List(5) { "monitor-removal-test-$it-${java.util.UUID.randomUUID()}" } + + // Given: several locks acquired and left held (never unlocked by this test). + keys.forEach { key -> assertNotNull(keyLock.tryLock(key, LockType.CREATE)) } + assertTrue(keys.all { readLockMap().containsKey(buildLockKeyForTest(it, LockType.CREATE)) }) + + // When: waiting past expiry + the monitor's sweep interval, with nobody calling + // tryLock/unLock for these keys in the meantime. + await() + .atMost(Duration.ofMillis(lockTimeout + LOCK_MONITOR_INTERVAL_MILLIS * 2 + 1000L)) + .untilAsserted { + val map = readLockMap() + keys.forEach { key -> + assertFalse( + map.containsKey(buildLockKeyForTest(key, LockType.CREATE)), + "Monitor should have removed the expired entry for $key", + ) + } + } + + keyLock.shutdown() + } + + /** Mirrors KeyLocalLock's private buildLockKey so the test can look up the same map key. */ + private fun buildLockKeyForTest( + key: String, + lockType: LockType, + ) = "${com.linecorp.cse.reqshield.support.constant.ConfigValues.LOCK_KEY_PREFIX}${key}_${lockType.name}" + private fun doWork() = Thread.sleep(1000) } diff --git a/core/src/test/kotlin/com/linecorp/cse/reqshield/ReqShieldTest.kt b/core/src/test/kotlin/com/linecorp/cse/reqshield/ReqShieldTest.kt index beef766..e3eebe4 100644 --- a/core/src/test/kotlin/com/linecorp/cse/reqshield/ReqShieldTest.kt +++ b/core/src/test/kotlin/com/linecorp/cse/reqshield/ReqShieldTest.kt @@ -578,6 +578,33 @@ class ReqShieldTest : BaseReqShieldTest { } } + @Test + fun testSetMethodCacheExistsAndTheUpdateTargetButUpdateLockNotAcquired() { + timeToLiveMillis = 1000 + val isolatedKey = "update-lock-not-acquired-${java.util.UUID.randomUUID()}" + val reqShieldData = updateTargetData(oldValue) + val cacheExecutor = Executors.newSingleThreadScheduledExecutor() + val shield = ReqShield(ReqShieldConfiguration(cacheSetter, cacheGetter, keyLock = keyLock, executor = cacheExecutor)) + + every { cacheGetter.invoke(isolatedKey) } returns reqShieldData + every { keyLock.tryLock(isolatedKey, LockType.UPDATE) } returns null + + try { + val result = shield.getAndSetReqShieldData(isolatedKey, callable, timeToLiveMillis) + // Flush the executor so any (wrongly) queued refresh work would have run by now. + cacheExecutor.submit {}.get(2, TimeUnit.SECONDS) + + assertSame(reqShieldData, result) + verify { keyLock.tryLock(isolatedKey, LockType.UPDATE) } + verify(inverse = true) { keyLock.unLock(isolatedKey, LockType.UPDATE, any()) } + verify(inverse = true) { callable.call() } + verify(inverse = true) { cacheSetter.invoke(isolatedKey, any(), any()) } + } finally { + cacheExecutor.shutdownNow() + assertTrue(cacheExecutor.awaitTermination(2, TimeUnit.SECONDS)) + } + } + @Test override fun testSetMethodCacheExistsAndTheUpdateTargetAndCallableReturnNull() { timeToLiveMillis = 1000 diff --git a/core/src/test/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfigurationTest.kt b/core/src/test/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfigurationTest.kt index 74a566e..5d7d0e0 100644 --- a/core/src/test/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfigurationTest.kt +++ b/core/src/test/kotlin/com/linecorp/cse/reqshield/config/ReqShieldConfigurationTest.kt @@ -16,12 +16,43 @@ package com.linecorp.cse.reqshield.config +import com.linecorp.cse.reqshield.support.exception.code.ErrorCode import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.util.concurrent.ThreadPoolExecutor class ReqShieldConfigurationTest { + @Test + fun testGlobalLockWithoutLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> true }, + getCacheFunction = { null }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_LOCK_FUNCTION.message, exception.message) + } + + @Test + fun testGlobalLockWithoutUnLockFunctionAndWithoutExplicitKeyLock() { + val exception = + assertThrows { + ReqShieldConfiguration( + setCacheFunction = { _, _, _ -> true }, + getCacheFunction = { null }, + globalLockFunction = { _, _, _ -> true }, + isLocalLock = false, + ) + } + + assertEquals(ErrorCode.DOES_NOT_EXIST_GLOBAL_UNLOCK_FUNCTION.message, exception.message) + } + @Test fun testDefaultThreadPoolSizeIsOptimal() { val config = diff --git a/req-shield-spring-boot3-webflux-kotlin-coroutine-example/src/test/kotlin/com/linecorp/cse/reqshield/spring3/webflux/kotlin/coroutine/example/DefaultKeyGeneratorTest.kt b/req-shield-spring-boot3-webflux-kotlin-coroutine-example/src/test/kotlin/com/linecorp/cse/reqshield/spring3/webflux/kotlin/coroutine/example/DefaultKeyGeneratorTest.kt new file mode 100644 index 0000000..0dd9d7b --- /dev/null +++ b/req-shield-spring-boot3-webflux-kotlin-coroutine-example/src/test/kotlin/com/linecorp/cse/reqshield/spring3/webflux/kotlin/coroutine/example/DefaultKeyGeneratorTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.cse.reqshield.spring3.webflux.kotlin.coroutine.example + +import com.linecorp.cse.reqshield.spring.webflux.kotlin.coroutine.annotation.ReqShieldCacheable +import com.linecorp.cse.reqshield.spring3.webflux.kotlin.coroutine.example.dto.Product +import com.linecorp.cse.reqshield.support.redis.AbstractRedisTest +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +/** + * Runs on Spring 6.1, which strips the trailing `Continuation` of a suspend function itself before + * generating a key. Only here can a default-generated key that ignores the method arguments be + * observed, so this is where the collapse of unrelated arguments onto one cache key is guarded. + */ +@SpringBootTest( + classes = [SpringWebfluxCoroutineApplication::class, DefaultKeyGeneratorTestConfiguration::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +) +@ExtendWith(SpringExtension::class) +class DefaultKeyGeneratorTest : AbstractRedisTest() { + @Autowired + private lateinit var defaultKeyGeneratorService: DefaultKeyGeneratorService + + @Test + fun differentArgumentsShouldNotShareTheDefaultGeneratedKey() = + runBlocking { + val firstId = UUID.randomUUID().toString() + val secondId = UUID.randomUUID().toString() + + // Both requests run at the same time: sharing one key would collapse the second one onto + // the first and hand it the product the first request cached. + val (first, second) = + listOf( + async { defaultKeyGeneratorService.getProduct(firstId) }, + async { defaultKeyGeneratorService.getProduct(secondId) }, + ).awaitAll() + + assertEquals(firstId, first.productId) + assertEquals(secondId, second.productId) + assertEquals(2, defaultKeyGeneratorService.callCount()) + } +} + +@TestConfiguration(proxyBeanMethods = false) +class DefaultKeyGeneratorTestConfiguration { + @Bean + fun defaultKeyGeneratorService(): DefaultKeyGeneratorService = DefaultKeyGeneratorService() +} + +/** Neither `key` nor `keyGenerator` is set, so the key comes from Spring's `SimpleKeyGenerator`. */ +open class DefaultKeyGeneratorService { + private val counter = AtomicInteger(0) + + open fun callCount(): Int = counter.get() + + @ReqShieldCacheable(cacheName = "defaultKeyGenerator", timeToLiveMillis = 60 * 1000) + open suspend fun getProduct(productId: String): Product { + // slow enough that the second request reaches the lock before the first one finishes + delay(300) + counter.incrementAndGet() + + return Product(productId, "product_$productId") + } +}