Skip to content

quarkus-cache with cache.getAsync() does not respect expire-after-write or expire-after-access #50513

@BiLuoHen

Description

@BiLuoHen

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: 10

Test 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?

  1. run test code
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions