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

Null values are not cached #127

Open
morki opened this issue Jun 7, 2020 · 13 comments
Open

Null values are not cached #127

morki opened this issue Jun 7, 2020 · 13 comments
Labels
type: enhancement New feature or request

Comments

@morki
Copy link

morki commented Jun 7, 2020

Is it intended that null value is not cached?
I think it has to be configurable, because null is valid value for me, or am I missing something?

Example:

@Cacheable("test")
fun getCompanyForUser(id: Long): String? {
   // implementation
}

Not every user has to have company, but when the method returns null, it's not cached, but called every time. I didn't even find this behaviour documented.

I am using io.micronaut.cache:micronaut-cache-caffeine:2.0.0.M2.

@f2014440
Copy link

f2014440 commented Jun 26, 2020

I'm also facing the same issue in our use case We want to cache 404s from backends and we use @Cacheable and micronauut httpclient. In micronaut HttpClientIntroductionAdvice whenever the status code is 404 it returns null and in cacheInterceptor null value is ignored and it's not cached. So how can we cache 404s? Would appreciate any help. This is how we use @Cacheable and httpClient:

@Client("item")                                                                                                                                                                                                                                                                                                                                                                                   
@Surrogate
@Cacheable(keyGenerator = MemcachedCacheKeyGenerator::class)
interface ItemClient {
  @Get("\${backends.item.path}")
  @Cacheable(cacheNames = ["item"], parameters = ["tcin"])
  fun getItem(tcin: String): CompletableFuture<SourceItem?>
}

@morki
Copy link
Author

morki commented Jun 26, 2020

@graemerocher I am thinking about adding special token to represent the null value in cache. Can you please help me with these questions?

  • What the token should look like? Some string like __MICRONAUT_NULL__ or some singleton object?

  • What about backward compatibility? Should it be configurable to save nulls with default to not like now?

  • What about other cache providers? Can I create PR only for Caffeine? For example EhCacheSyncCache is not nullable in signature:

    @Override
    public void put(@Nonnull Object key, @Nonnull Object value) {
        ArgumentUtils.requireNonNull("key", key);
        ArgumentUtils.requireNonNull("value", value);
        nativeCache.put(key, value);
    }

@graemerocher
Copy link
Contributor

Lots of difficult questions that I am not sure the answer to, it is certainly something that should be able to be disabled. Some caches serialize the value so the object would need to be some type that is serializable that you can then check the value of. A string like __MICRONAUT_NULL__ is an option, but not the most efficient thing given how many characters that is.

I think the behaviour should continue as it right now and only if the user sets say for example micronaut.cache.store-null-as=null then Micronaut would store the string value "null" and check whether for "null" when retrieving the object.

@morki
Copy link
Author

morki commented Jun 26, 2020

@graemerocher thank you for your feedback, it sounds nice. What do you think about implementing this behaviour for now only for Caffeine?

@morki
Copy link
Author

morki commented Jun 26, 2020

And another question is it seems like it would require using Optional of null value, is it ok for you?

@graemerocher
Copy link
Contributor

graemerocher commented Jun 26, 2020

I'm not sure what you mean, you can probably return Optional.empty() if the value is equal to the string to check

@morki
Copy link
Author

morki commented Jun 26, 2020

I am talking here about this part in CacheInterceptor, Optional.empty() seems to be treated as cache miss:

                for (String cacheName : cacheNames) {
                    SyncCache syncCache = cacheManager.getCache(cacheName);
                    try {
                        Optional optional = syncCache.get(key, returnArgument);
                        if (optional.isPresent()) {
                            if (LOG.isDebugEnabled()) {
                                LOG.debug("Value found in cache [" + cacheName + "] for invocation: " + context);
                            }
                            cacheHit = true;
                            wrapper.value = optional.get();
                            break;
                        }
                    } catch (RuntimeException e) {
                        if (errorHandler.handleLoadError(syncCache, key, e)) {
                            throw e;
                        }
                    }
                }
                if (!cacheHit) {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Value not found in cache for invocation: " + context);
                    }
                    doProceed(context, wrapper);
                    syncPut(cacheNames, key, wrapper.value);
                }

But I am new to Java so maybe I misinterpret it.

@alvarosanchez alvarosanchez added the type: enhancement New feature or request label Jul 27, 2020
@zsiegel
Copy link

zsiegel commented Oct 2, 2020

I would be in favor of just making this a configurable option.

I stumbled on this issue as I was trying to find out if null values were cached or not. In my case I was expecting the behavior to not cache null by default - although I certainly understand the use cases presented above.

Would a simple config property for each cache do the trick?

@graemerocher
Copy link
Contributor

Could be viable yes

@mattwelke
Copy link

mattwelke commented Sep 13, 2022

I think I stumbled into this today while completing the official tutorial on caching. It appears that when an empty list of strings is returned from the method marked @Cacheable, the caching framework decides not to cache the empty list. I found this unintuitive.

Details:

I was completing the tutorial https://guides.micronaut.io/latest/micronaut-cache-maven-java.html. When I started up the app, the first URL I tried was http://localhost:8080/january. It took about three seconds. I expected the next request to that same URL to take much less time (because the previous result would have been cached). It also took about three seconds though. I couldn't figure out what was going on until I re-read the tutorial to find that the tutorial wanted me to try the URL http://localhost:8080/november, and caching did work for that URL.

The only difference between November and the other months in the tutorial is that November has two headlines saved and the others don't. So this response doesn't involve using cached method return values:

{
  "month": "JANUARY"
}

But this one does:

{
  "month": "NOVEMBER",
  "headlines": [
    "Micronaut Graduates to Trial Level in Thoughtworks technology radar Vol.1",
    "Micronaut AOP: Awesome flexibility without the complexity"
  ]
}

I found this unintuitive because to me, "the month of January has no headlines right now" sounds like a valid scenario for the business logic. I would want that cached. To recalculate this might involve performing work like a database query. It'd be nice to be able to skip that by using caching.

A workaround I could think of would be to wrap my data in a type to represent the "no headlines" scenario, but this seems like redundant work because Java already has a type capable of conveying this information (an empty list of strings).

@mattwelke
Copy link

I tested my workaround idea. For this application, it doesn't work because the cacheable method is returning null. I now realize my issue is exactly what's described here, where the problem is how it reacts to null being returned.

Details:

I wrapped my list of headlines in a new type:

public record Headlines(int count, List<String> headlines) {}

And I updated News:

@Introspected 
public record News(Month month, Headlines headlines) {}

And I made the needed changes to the service class to use this type instead. I still get the behaviour I described above, and now I realize why. The code in the cacheable method is this:

try {
    TimeUnit.SECONDS.sleep(3); 
    return headlines.get(month);
} catch (InterruptedException e) {
    return null;
}

The catch block isn't relevant. But what is relevant is how it tries to get the headlines for the month by checking the map defined in the class:

Map<Month, Headlines> headlines = new HashMap<Month, Headlines>() {{
    put(Month.NOVEMBER, new Headlines(2, Arrays.asList("Micronaut Graduates to Trial Level in Thoughtworks technology radar Vol.1",
            "Micronaut AOP: Awesome flexibility without the complexity")));
    put(Month.OCTOBER, new Headlines(1, Collections.singletonList("Micronaut AOP: Awesome flexibility without the complexity")));
}};

And Map.get returns null if it doesn't find a match.

@mattwelke
Copy link

mattwelke commented Sep 13, 2022

Another workaround I just tried was to use Optional so that I was returning Optional.empty() in cases where there was no match. This didn't help either.

Details:

I changed the return type of the cacheable method from List<String> to Optional<List<String>>. I also used a check with Map.containsKey to ensure that I could catch scenarios where there are no headlines for the month and never return null when this happens, instead returning Optional.empty().

This didn't work either. It still takes about three seconds every time for http://localhost:8080/january.

@Introspected 
public record News(Month month, Optional<List<String>> headlines) {}
@Cacheable
public Optional<List<String>> headlines(Month month) {
    try {        
        TimeUnit.SECONDS.sleep(3);
        if (!headlines.containsKey(month)) {
            return Optional.empty();
        }
        return Optional.of(headlines.get(month));
    } catch (InterruptedException e) {
        return Optional.empty();
    }
}

So it seems like Micronaut's caching framework deals with Optional.empty() the same way it deals with null. It doesn't cache it and it will execute the cacheable method every time.

@mattwelke
Copy link

mattwelke commented Sep 13, 2022

My third attempt at a workaround works. If I make sure the cacheable method checks for when there are no headlines (by using Map.containsKey), I can catch that scenario and return an empty list instead of the result of Map.get, and it works fine.

Details:

In NewsService.java, I changed:

@Cacheable
public List<String> headlines(Month month) {
    try {
        TimeUnit.SECONDS.sleep(3);
        return headlines.get(month);
    } catch (InterruptedException e) {
        return null;
    }
}

to:

@Cacheable
public List<String> headlines(Month month) {
    try {
        TimeUnit.SECONDS.sleep(3);
        if (!headlines.containsKey(month)) {
            return Collections.emptyList();
        }
        return headlines.get(month);
    } catch (InterruptedException e) {
        return null;
    }
}

Then all month URLs take about three seconds the first request and a few milliseconds for each subsequent request, including http://localhost:8080/january.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

6 participants