Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to serve the old (expired) value in case of error? #699

Closed
mickaeltr opened this issue Apr 14, 2022 · 8 comments
Closed

How to serve the old (expired) value in case of error? #699

mickaeltr opened this issue Apr 14, 2022 · 8 comments

Comments

@mickaeltr
Copy link

Hello, I am working on a project using Caffeine (2.9) with Spring Boot.

I'd like to implement a behaviour similar to the stale-if-error Cache-Control HTTP header:

The stale-if-error HTTP Cache-Control extension allows a cache to return a stale response when an error -- e.g., a 500 Internal Server Error, a network segment, or DNS failure -- is encountered, rather than returning a "hard" error. This improves availability.

The question was already raised on StackOverflow and got an answer that I could adapt to Caffeine. However it seems to me that it's not exactly behaving accordingly. As far as I could see, after the cache expiration, the old value is always returned on the first hit (then the refreshed value on the second hit).

Do you think what I am trying to achieve is possible or would it be a new feature? Thanks


Here is what I've done:

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    private static final Duration CACHE_DURATION = Duration.ofHours(1);

    @Bean
    @Override
    public CacheManager cacheManager() {
        var cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(
            Caffeine.newBuilder().refreshAfterWrite(CACHE_DURATION)
        );
        cacheManager.setCacheLoader(key -> {
            var cacheKey = (CacheKey) key;
            return cacheKey.method.invoke(cacheKey.target, cacheKey.params);
        });
        return cacheManager;
    }

    @Bean
    @Override
    public KeyGenerator keyGenerator() {
        return CacheKey::new;
    }

    private static class CacheKey extends SimpleKey {
        private final Object target;
        private final Method method;
        private final Object[] params;

        private CacheKey(Object target, Method method, Object... params) {
            super(params);
            this.target = target;
            this.method = method;
            this.params = params;
        }
    }
}
@ben-manes
Copy link
Owner

The guava style of returning if the refresh completed on the triggering thread was recently implemented in #688. Can you try master (snapshot or jitpack) to see if that fits your needs? The caller would need to return a completed future by overriding asyncReload.

@mickaeltr
Copy link
Author

Thanks @ben-manes for your responsiveness. I've just tried with com.github.ben-manes:caffeine:d9f132cefe9a0833686354bd9986832e4f7d6beb and it seems like the behavior is still:

  1. on first hit, loads the value and returns it
  2. keeps returning the cached value
  3. on first hit after the expiration, returns the stale value and reloads asynchronously
  4. on second hit, returns the new value

@ben-manes
Copy link
Owner

Thanks @mickaeltr. Can you write an isolated unit test for me to debug with? That would avoid any gafs on my side by testing something different than what you're doing.

@ben-manes
Copy link
Owner

  1. on first hit after the expiration, returns the stale value and reloads asynchronously

If it reloads asynchronously then it is fire-and-forget. Guava has the same except by default it runs on the calling thread,

  public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
    checkNotNull(key);
    checkNotNull(oldValue);
    return Futures.immediateFuture(load(key));
  }

In Caffeine we have Caffeine.executor which defaults to ForkJoinPool.commonPool() so the operation is asynchronous by default.

If you want the same behavior as Guava's then you would override asyncReload to return a completed future,

default CompletableFuture<? extends V> asyncReload(
K key, V oldValue, Executor executor) throws Exception {
requireNonNull(key);
requireNonNull(executor);
return CompletableFuture.supplyAsync(() -> {
try {
return reload(key, oldValue);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new CompletionException(e);
}
}, executor);
}

@mickaeltr
Copy link
Author

I created reproduction tests:

I'll try later to override the asyncReload as you suggested. Thanks again.

@ben-manes
Copy link
Owner

It looks like jitpack did not replace the Spring dependency so (in my IDE) it resolves to v2. I switched to Sonatype's repository for 3.1.0-SNAPSHOT. That passes the test_stale_if_error and fails test_stale_while_revalidate as desired.

patch.txt

@mickaeltr
Copy link
Author

Thanks a lot! 🙌

I confirm it should work starting from version 3.1.0, with the following configuration:

import java.lang.reflect.Method;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.cache.interceptor.SimpleKey;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;

import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    private static final Duration CACHE_DURATION = Duration.ofSeconds(1);

    @Bean
    @Override
    public CacheManager cacheManager() {
        var cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder().refreshAfterWrite(CACHE_DURATION));
        cacheManager.setCacheLoader(new CacheLoader<>() {
            @Override
            public Object load(@NonNull Object key) throws Exception {
                var cacheKey = (CacheKey) key;
                return cacheKey.method.invoke(cacheKey.target, cacheKey.params);
            }

            @Override
            @NonNull
            public CompletableFuture<Object> asyncReload(
                @NonNull Object key,
                @NonNull Object oldValue,
                @NonNull Executor executor
            ) {
                try {
                    return CompletableFuture.completedFuture(load(key));
                } catch (Exception e) {
                    return CompletableFuture.failedFuture(e);
                }
            }
        });
        return cacheManager;
    }

    @Bean
    @Override
    public KeyGenerator keyGenerator() {
        return CacheKey::new;
    }

    private static class CacheKey extends SimpleKey {
        private final Object target;
        private final Method method;
        private final Object[] params;

        private CacheKey(Object target, Method method, Object... params) {
            super(params);
            this.target = target;
            this.method = method;
            this.params = params;
        }
    }
}

@ben-manes
Copy link
Owner

Released in 3.1.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants