-
Notifications
You must be signed in to change notification settings - Fork 3k
Description
Describe the bug
When using io.quarkus.cache.Cache#getAsync(...) with a Caffeine cache configured with expire-after-write=1S, cached entries are not evicted or reloaded after expiration.
After several rapid calls or a thread/context switch, the cached value becomes permanently stuck, even after minutes.
This behavior contradicts the expected TTL semantics of Caffeine.
Configuration Example
quarkus:
cache:
caffeine:
TEST_DATA:
expire-after-write: 1S
maximum-size: 10Test Code(Java)
import io.quarkus.cache.Cache;
import io.quarkus.cache.CacheName;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalTime;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Path("test")
@Produces(MediaType.APPLICATION_JSON)
public class TestApi {
@CacheName("TEST_DATA")
Cache cache;
@GET
@Path("a")
public Uni<List<String>> test() {
// This method MOCKS an upstream jOOQ reactive query by emitting items asynchronously
// on a different thread, after a small delay. With a short expire-after-write (e.g. 1s)
// and a cache implementation that uses asMap().computeIfAbsent(...), this reliably
// reproduces the "entry never expires" issue: after TTL passes, a subsequent call still hits.
System.out.println(LocalTime.now() + " cache test: " + Thread.currentThread().getName());
return cache.getAsync("all", key -> {
System.out.println(LocalTime.now() + " cache miss: " + Thread.currentThread().getName());
// Mocked jOOQ reactive Publisher:
// Key: Uses a separate thread and delayed emission to simulate an async upstream
return Multi.createFrom().<String>emitter(em -> {
ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();
ses.schedule(() -> {
try {
em.emit("all");
em.emit("test");
em.complete();
} finally {
ses.shutdown();
}
}, 100, TimeUnit.MILLISECONDS); // simulate async completion after 100ms
}).collect().asList(); // Collect to Uni<List<String>> as many jOOQ reactive queries would be consumed
});
}
}curl the api many times until the thread chang to unother one
Observed Logs (excerpt)
22:27:36.166011200 cache test: vert.x-eventloop-thread-0
22:27:36.167011500 cache miss: vert.x-eventloop-thread-0 ✅ first load
22:27:38.101905000 cache test: vert.x-eventloop-thread-0
22:27:38.101905000 cache miss: vert.x-eventloop-thread-0 ✅ TTL seems to work once
22:27:39.895996400 cache test: vert.x-eventloop-thread-1 (THREAD SWITCH: vert.x-eventloop-thread-0 -> 1)
22:27:42.408341000 cache test: vert.x-eventloop-thread-1
22:27:43.108227600 cache test: vert.x-eventloop-thread-1
22:27:44.124845000 cache test: vert.x-eventloop-thread-1
22:27:48.872919500 cache test: vert.x-eventloop-thread-1
22:27:49.572715500 cache test: vert.x-eventloop-thread-1
22:27:50.177643300 cache test: vert.x-eventloop-thread-1
22:27:51.280042500 cache test: vert.x-eventloop-thread-1
22:27:58.126269000 cache test: vert.x-eventloop-thread-1 ❌ No more cache miss, even after many seconds
After thread/context changes, the key stays forever in the cache and TTL is never applied again.
Expected behavior
After expire-after-write duration passes, the next call to cache.getAsync(...) should reload the value
Cached entries should not survive indefinitely
TTL behavior should be deterministic and not depend on thread/context
Actual behavior
TTL is only observed occasionally (when still on the same context)
After thread/context switch, the cache item never expires
Even minutes later, no reload occurs unless the key is manually invalidated
How to Reproduce?
- run test code
- after thread changed: No more cache miss, even after many seconds
Output of uname -a or ver
win 11
Output of java -version
21
Quarkus version or git rev
3.28.3
Build tool (ie. output of mvnw --version or gradlew --version)
maven
Probability root cause
cache.asMap().xx(), should use cache.get(), don't use cache.asMap() function