Visit the github wiki for more information.
A lib that regulates the cache-based requests an application receives in terms of request-collapsing.
- Java 17 or later if you are a user.
- Kotlin 1.9 or later if you are a user.
- (If link this lib with Spring) Spring Boot 3.5 (Spring Framework 6.2) or later if you are a user.
- (If you use the Reactor modules) Project Reactor 3.7 or later; (if you use the coroutine modules) kotlinx.coroutines 1.8 or later.
Choose a module based on your platform (there is a detailed explanation in the wiki):
implementation("com.linecorp.cse.reqshield:core:{version}")
implementation("com.linecorp.cse.reqshield:core-reactor:{version}")
implementation("com.linecorp.cse.reqshield:core-kotlin-coroutine:{version}")
implementation("com.linecorp.cse.reqshield:core-spring:{version}")
implementation("com.linecorp.cse.reqshield:core-spring-webflux:{version}")
implementation("com.linecorp.cse.reqshield:core-spring-webflux-kotlin-coroutine:{version}")
Release versions are available from Maven Central. Add Maven Central and use a release version such as 1.0.0:
repositories {
mavenCentral()
}
dependencies {
implementation("com.linecorp.cse.reqshield:core:1.0.0")
}Dev versions are published as Maven snapshots and require the Maven Central Snapshot repository. Use a snapshot version
such as 1.0.0-SNAPSHOT:
repositories {
mavenCentral()
maven {
name = "MavenCentralSnapshots"
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
mavenContent {
snapshotsOnly()
}
}
}
dependencies {
implementation("com.linecorp.cse.reqshield:core:1.0.0-SNAPSHOT")
}Snapshot versions can be updated without changing the version string. If Gradle continues to use a cached snapshot,
refresh dependencies with ./gradlew build --refresh-dependencies.
- Redis-backed integration tests using Testcontainers always run as part of module test tasks.
- Requirements:
- A working local Docker daemon with network access to pull
redis:6.2.7-alpineon first run. - Sufficient permissions to start containers from tests.
- A working local Docker daemon with network access to pull
- If you need to temporarily bypass Redis ITs locally (e.g., no Docker), run specific unit-test-only tasks or exclude the example modules when invoking Gradle.
- Reactor 3.8 declares
Mono's type parameter non-null with JSpecify annotations, and the Kotlin compiler reports violations as errors by default. The Reactor-based API still uses nullable type arguments such asMono<ReqShieldData<T>?>(AsyncCache.get,ReqShieldConfiguration.getCacheFunction), so Kotlin code that implements or calls it fails to compile on Reactor 3.8. - Until the API changes, compile such Kotlin code with
-Xjspecify-annotations=warn, asreq-shield-spring-boot4-webflux-exampledoes. Java callers and the coroutine modules are not affected.
@ReqShieldCacheable(nullHandling = ...)controls hownullvalues are emitted in WebFlux:EMIT_EMPTY(default): mapnulltoMono.empty().ERROR: throw anIllegalStateExceptionif anullvalue is produced.
- The Spring adapters store every entry under
"{cacheName}::{key}"(the same convention as Spring'sRedisCacheManager).@ReqShieldCacheEvictapplies the same rule, so an evict with the samecacheNameand the same key source always targets the entry written by@ReqShieldCacheable. - Lock keys are derived from that namespaced key and prefixed with
reqshield:lock:, so two caches that happen to use the same raw key never share a lock or an entry. - If you call
ReqShielddirectly (core / core-reactor / core-kotlin-coroutine), the key you pass is used as is. keycomes from one of three sources:- Method arguments (neither
keynorkeyGeneratoris set), in the layout of Spring'sSimpleKeyGenerator: no argument givesSimpleKey [], one argument gives itstoString(), several giveSimpleKey [a, b]. - A SpEL
key. A text is used as is. An array, a collection or a map (for examplekey = "{#tenant, #id}") is written by content, as[tenant, id]. Any other value is converted by Spring (an enum by its name), or is itstoString()when Spring has no converter for it (a data class, for example). - A
keyGeneratorbean. ThetoString()of its result is used as is, so a custom generator has to keep different arguments apart itself. In the coroutine adapter the generator also receives theContinuation.
- Method arguments (neither
- Where a value is written by content or inside
SimpleKey [...], its text is escaped so that it cannot run into the next value:,[]{}=and the backslash get a backslash in front, and an empty text is written as\e. A single argument escapes only the backslash.nullis always written as\0. For example,("a, b", "c")givesSimpleKey [a\, b, c], and a data class argument next to another argument givesSimpleKey [t, Filter(a\=1\, b\=2)]. - Use the same key source on the
@ReqShieldCacheablemethod and the@ReqShieldCacheEvictmethod of an entry, because the sources do not write every value the same way. For one text argumentid, the method-argument key andkey = "#id"differ in two cases. The method-argument key writes a backslash twice (tenant\alicebecomestenant\\alice), and SpEL keeps the text as is. The method-argument key writesnullas\0, and a SpEL key that isnullfails the call. If the read uses the method arguments and the evict useskey = "#id", the evict misses the entry of anidthat holds a backslash, and the read keeps returning the old value. Set the samekeyon both methods:@ReqShieldCacheable(cacheName = "product", key = "#id") fun getProduct(id: String): Product @ReqShieldCacheEvict(cacheName = "product", key = "#id") fun removeProduct(id: String)
- Two argument lists of a method then get different keys, as long as the
toString()of each value tells its own values apart. A class whosetoString()joins free text does not:Pair("a, b", "c")andPair("a", "b, c")are both(a, b, c). Give such a method akey. The same holds for a parameter typedAnythat takes the text[a, b]in one call and a list in another. - A key object whose class does not override
toString()fails the call with anIllegalArgumentException, because its text would be only the class name and the hash code. This applies to a method argument on the default path, to the result of a SpELkey, and to the result of akeyGeneratorbean. The key is built before the method is called, so the annotated method does not run - neither a@ReqShieldCacheablemethod nor a@ReqShieldCacheEvictone. Only the class of the value itself is checked: an object that prints such an object inside its owntoString()(aPairof one) passes, and its entry is then never hit. - A collection or a map that contains itself is rejected the same way.
- When
isLocalLock = false, your cache bean must also implement the module'sGlobalLockSupportinterface (com.linecorp.cse.reqshield.spring.cache.GlobalLockSupport,...spring.webflux.cache.GlobalLockSupport,...spring.webflux.kotlin.coroutine.cache.GlobalLockSupport). If it does not, the first call to the annotated method fails with anIllegalArgumentExceptioninstead of silently running without request collapsing. - Every lock acquisition carries an ownership token. Only the holder that acquired the lock can release it, so a slow holder whose lock already expired can no longer release the lock of the next holder.
- Recommended Redis implementation:
- Lock:
SET {lockKey} {token} NX PX {ttlMillis}(atomic; neverSETNXfollowed by a separatePEXPIRE) - Unlock: compare-and-delete in a Lua script, e.g.
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
- Lock:
- The example modules contain working implementations for
RedisTemplate,ReactiveRedisTemplateand the coroutine extensions.
-
The local lock (
isLocalLock = true, the default) keeps one entry per(cache key, lock type)it is currently locking, in a map shared by the whole JVM. A cache key that is both created and refreshed therefore uses up to two entries. -
An entry is dropped as soon as the lock is released; an entry whose holder never released it is dropped by the cleanup monitor once
lockTimeoutMillishas passed, which runs on a 1 s interval and so can lag by up to that much. The map's size therefore tracks how many keys are being locked at the same time, not how many distinct keys the application sees. A service with 50 ms of supplier latency at 10k rps holds roughly 500 locks at once. -
The map is uncapped by default. To bound it, set
req-shield.lock.max-entries;0(the default) means uncapped. Size it against concurrent lock ownership, not key cardinality.# Spring modules: read from the Environment, so application.yml works req-shield: lock: max-entries: 2000
# core / core-reactor / core-kotlin-coroutine used directly: system property java -Dreq-shield.lock.max-entries=2000 -jar app.jarThe Spring modules read the same key from the
Environment, so a-Doverride still outranks the yml entry there, andREQ_SHIELD_LOCK_MAX_ENTRIESworks as an environment variable. -
Once the map is full, a request for a new key is handed a permit that no map entry backs. It then runs exactly as if it had taken the lock - it calls the supplier and writes the cache - so what the cap costs is request collapsing for that key, and nothing else: no added latency, and the cache still gets populated. Keys whose entry is already in the map are not subject to the cap at all.
-
The cap is a soft one. The size check is an estimate and is not atomic with the insertion it guards, so concurrent callers can push the map slightly past the configured number.
-
The first refusal and every 1000th after it are logged at WARN.
-
A value that cannot be read as a non-negative number is logged at WARN and ignored, leaving the current cap unchanged. This is deliberate in both directions: a class initializer that throws would poison the library for the whole JVM, and an application context should not fail to start over a tuning knob typo. Setting the cap programmatically (
LocalLockLimit.maxEntries = -1) still fails fast, because there the stack trace is actionable.
@ReqShieldCacheEvictevicts after the annotated method completes successfully (Spring's@CacheEvictdefault). If the method throws, nothing is evicted; if the eviction itself fails, that failure propagates to the caller.- In WebFlux the eviction also runs when the method completes empty (for example a
Mono<Void>handler). - Known limitation: a value that is computed before an eviction can be written after it. A request returns its data first and writes the cache asynchronously, and a background refresh can be in flight, so an entry evicted in that window comes back with the old value until its TTL ends. The eviction and the write can run on different nodes, so the library cannot order them. If stale data after an update is not acceptable, use a short TTL or put a version of the data in the cache key.
- Once an entry is older than
decisionForUpdatepercent of its TTL, a read returns it and starts a refresh in the background, unless another request already holds the update lock of that key. The refresh calls the supplier on its own and can still be running after the caller has its data. core-reactorand the WebFlux adapter subscribe the refresh with the caller's ReactorContext, so a supplier that reads it (for exampleReactiveSecurityContextHolder) works in a refresh as it does in a request. A reactive transaction of the caller is part of thatContexttoo; a supplier that must not run in it should not be refreshed from inside the transaction.- In
core-reactorand the WebFlux adapter, the cache functions and the lock functions of a global lock see the caller's ReactorContexttoo, on a cache miss and in a refresh. This includes the cache write and the lock release, which run in the background after the caller has its data. Functions that choose the cache or the lock store from theContext(one store for each tenant, for example) therefore write and release in the store that the read and the lock used. core-kotlin-coroutineand the coroutine adapter launch the refresh in the configured scope with the caller's coroutine context elements, except itsJob, dispatcher and exception handler: the refresh is not cancelled with the request and does not inherit its dispatcher. For any other element the scope's own one takes precedence. A transaction of the caller that lives in its coroutine context (the ReactorContextof a reactive transaction) is handed over too; a supplier that must not run in it should not be refreshed from inside the transaction.coreand the blocking Spring adapter run the refresh on the executor thread.ThreadLocalstate of the caller (SecurityContextHolder,RequestContextHolder, MDC, a transaction) is not available there. Pass what the method needs as arguments, or supply areqShieldExecutorthat propagates the context your application needs.
- A request that loses the lock polls the cache every 50 ms, up to
maxAttemptGetCachetimes (default 60). - A cache read failure while polling is logged and counted as a failed attempt. Three consecutive failures are treated as a cache outage and the request falls back to calling the supplier itself right away.
- When the attempts are exhausted the request calls the supplier once. A supplier failure is propagated as a
ClientException(SUPPLIER_ERROR)with the original exception ascause; it is never turned into a cachednull. - In
core-kotlin-coroutine, aCancellationExceptionthrown by the supplier or by a cache get/set function while the calling coroutine is still active (awithTimeoutinside the supplier, for example) is a failure of that function. It is reported asClientExceptionlike any other failure. Only the cancellation of the calling coroutine itself propagates unwrapped. A failing lock function is not wrapped: on a cache miss its exception reaches the caller as thrown (aCancellationExceptionincluded), on a refresh it only skips that refresh, and a failed unlock is logged.
coreaccepts anyjava.util.concurrent.Executorfor its background cache writes; onlyexecuteis called and the library never shuts a caller-supplied pool down. The default is a shared daemon pool. The Spring adapter runs the writes on a daemon pool of its own that is shut down with the context, and registers noExecutorbean, so Spring Boot keeps itsapplicationTaskExecutor. To use your own pool, define anExecutorbean namedreqShieldExecutor; its lifecycle stays yours, and like anyExecutorbean it makes Spring Boot skipapplicationTaskExecutor.core-reactoraccepts aScheduler(defaultboundedElastic). The Spring WebFlux adapter uses the sharedboundedElastictoo and never disposes it; to use your own, define aSchedulerbean namedreqShieldScheduler.core-kotlin-coroutineaccepts aCoroutineScopefor background cache writes (default: a shared supervisor scope onDispatchers.IO). The coroutine Spring adapter runs them on a supervisor scope of its own onDispatchers.IOand cancels it on context shutdown; to use your own, define aCoroutineScopebean namedreqShieldCoroutineScope.- The Spring adapters register none of these beans themselves, so an application bean of the same name replaces the default instead of clashing with it, and its lifecycle stays with the application. A bean of one of these names that is not of the expected type fails the application startup instead of being ignored.
@ReqShieldCacheable/@ReqShieldCacheEvictfromcore-spring-webfluxrequire methods that returnMono. The aspect hands back a lazyMono, and other return types are not rejected up front:- On a
void(Unit) method Spring discards thatMono, so the method body never runs and nothing is evicted, without any error. ReturnMono<Void>from eviction methods instead. - Any other return type fails with a
ClassCastExceptionat the call site.
- On a
- Use
core-springfor blocking methods andcore-spring-webflux-kotlin-coroutineforsuspendfunctions.
@ReqShieldCacheable/@ReqShieldCacheEvictfromcore-spring-webflux-kotlin-coroutinerequiresuspendfunctions. Annotating a regular function fails fast with anIllegalArgumentException; usecore-springorcore-spring-webfluxfor blocking orMono-returning methods.
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Please see CONTRIBUTING.md for contributing to Req-Shield.
Apache License 2.0
- File an issue in the issue tracker to report a bug or suggest an idea.