Skip to content

LoadingCache with expireAfterAccess(...) effectively ignores invalidateAll() if cache reload is in progress #251

@jkarshin

Description

@jkarshin

This may be related to #193, in that a cache load takes precedence over a cache invalidate operation in some situations.

Consider the following scenario involving a LoadingCache:

  1. LoadingCache is initially empty
  2. Thread-1 performs a cache read for some key
  3. LoadingCache invokes lambda to load the value for the key (on Thread-1)
  4. While the lambda is running, Thread-2 updates the underlying value for the key (perhaps in a DB)
  5. While the lambda is running, Thread-2 calls invalidateAll() on the cache
  6. After the call to invalidateAll() completes, Thread-2 performs a cache read for the same key in step 2.

For the initial read (on Thread-1), I would expect the result to be indeterminate; since there's a race between the lambda's execution and the write performed by Thread-2. However, I think it is reasonable to expect that after the call to invalidateAll(), the cache read performed by Thread-2 should result in a cache re-load, which would read the newly written value.

For a LoadingCache without expireAfterAccess(...)/expireAfterWrite(...), this appears to be the case. The invalidateAll() call is blocked until the initial cache load completes, and the second cache read causes a reload.

However, if expireAfterAccess(...)/expireAfterWrite(...) is called when creating the LoadingCache, invalidateAll() is NOT blocked. The cache read on Thread-2 is still blocked by the cache load on Thread-1 (since they are accessing the same key), however, the cache is never actually invalidated. Subsequent cache reads yield the stale value from the initial load.

Code to reproduce (Java version: 1.8.0_162, Caffeine version: 2.6.2):

    private static final String KEY = "KEY";

    @Test
    void testConcurrentReadWrite() throws Exception {
        // Use an atomic int to simulate a DB
        AtomicInteger dbValue = new AtomicInteger(0);

        // Create a cache that will retrieve a value from the DB and sleep
        LoadingCache<String, Integer> cache = Caffeine.newBuilder()
                .expireAfterAccess(Duration.ofMinutes(9001))
                .build(key -> {
                    int toReturn = dbValue.get();
                    log("Performing cache reload, db value: " + toReturn);
                    sleep(1000);
                    log("Returning from cache reload...");
                    return toReturn;
                });

        /*
         * Thread #1 will perform a cache read. Since the cache is empty, this
         * should trigger a cache load which will retrieve '0'.
         */
        Thread t1 = new Thread(() -> {
            log("Performing cache read...");
            int result = cache.get(KEY);
            log("Cache read result: " + result);
        });

        /*
         * Thread #2 will perform a write while Thread #1 is reloading the cache.
         * Thread #1's read will be unaffected by this write.
         * After the write completes, we will perform a cache read.
         */
        Thread t2 = new Thread(() -> {
            // Sleep to ensure the cache reload is in progress when we call invalidate
            sleep(500);

            // "Write" to the db and invalidate the cache.
            dbValue.set(1);
            log("Wrote to DB. Invalidating cache...");
            cache.invalidateAll();
            log("Finished invalidating cache. Performing cache read...");
            int result = cache.get(KEY);
            log("Cache read result: " + result);
        });

        // Launch threads and wait
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

    // **********************************
    // *  Helpers
    // **********************************

    private void log(final String s) {
        System.out.println(LocalTime.now() + ", " + Thread.currentThread()
                .getName() + ": " + s);
    }

    private void sleep(final long millis) {
        try {
            Thread.sleep(millis);
        }
        catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

Executing the above test yields the following output:

15:57:17.479, Thread-1: Performing cache read...
15:57:17.482, Thread-1: Performing cache reload, db value: 0
15:57:17.972, Thread-2: Wrote to DB. Invalidating cache...
15:57:17.973, Thread-2: Finished invalidating cache. Performing cache read...
15:57:18.483, Thread-1: Returning from cache reload...
15:57:18.486, Thread-1: Cache read result: 0
15:57:18.488, Thread-2: Cache read result: 0

Notice how the cache invalidation completes immediately and the second cache read returns the stale cached result.

Executing the same test with the expireAfterAccess(...) removed yields the following output:

15:58:59.706, Thread-1: Performing cache read...
15:58:59.711, Thread-1: Performing cache reload, db value: 0
15:59:00.197, Thread-2: Wrote to DB. Invalidating cache...
15:59:00.714, Thread-1: Returning from cache reload...
15:59:00.714, Thread-1: Cache read result: 0
15:59:00.714, Thread-2: Finished invalidating cache. Performing cache read...
15:59:00.714, Thread-2: Performing cache reload, db value: 1
15:59:01.715, Thread-2: Returning from cache reload...
15:59:01.715, Thread-2: Cache read result: 1

Note that this time, the invalidateAll() operation is blocked until the initial cache load completes. Additionally, the second cache read results in a cache reload, which returns the updated value.

In fairness, the javadocs for invalidateAll(...) do state that "The behavior of this operation is undefined for an entry that is being loaded and is otherwise not present." That said, it feels extreme that calling invalidateAll(...) will be ignored without any indication, if a load happens to be in-progress.

Additionally for what it's worth, if the invalidateAll(...) call is replaced with invalidate(KEY), the invalidation is blocked and the cache is invalidated as I would expect (even if .expireAfterAccess(...) is present).

Just wondering if this is all expected, and if so, is there a way to do an invalidateAll() operation and be guaranteed that the next cache read will result in a cache re-load?

Thanks in advance!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions