Generating CacheKeys

Just wanted to share something I’m using for creating cache key’s.

so typically I use this kind of thing:

var cacheKey = UrnId.Create<MyDTO>(req.PropA);

trouble is, if the name has FieldSeperator char in it, it will throw an exception so you end up calling .Replace() to clean them up.

…then there are times when I have a more complex cache key that is basically every public property like

var cacheKey = UrnId.Create<MyDTO>($$"{req.PropA}_{req.PropB}_{req.PropC}");

the code begins to not be so nice so I’ve been using this library to help me generate value based hashcodes for the cache keys with the following extension methods

public static string GetValueHashCode<T>(this T instance)
{
    return UrnId.Create<T>(FieldwiseHasher.Hash(instance));
}

public static string GetValueHashCode<T, TValue>(this T instance, Expression<Func<T, TValue>> selector)
{
    var invoke = selector.Compile().Invoke(instance);
    return UrnId.Create<T>(FieldwiseHasher.Hash(invoke));
}

and used like

public object Any(MyDTO req)
{
  // hashes all public props of type (include base type)
  var cacheKey = req.GetValueHashCode();

  // hashes single prop value
  var cacheKey = req.GetValueHashCode(x => x.PropA);

  // hashes anon prop values
  var cacheKey = req.GetValueHashCode(x => new { x.PropA, x.PropB });

  return RequestContext.ToOptimizedResultUsingCache(
             Cache, 
             cacheKey, 
             ...
}

There are some caveats for the hashcode generation but for my needs, it seems to work fine.

I should probably let you know about the [CacheResponse] feature in the next release, basically instead of using ToOptimizedResult() you can just use a CacheResponse Filter Attribute, e.g:

[CacheResponse(Duration = 60)] //60 sec
public object Any(MyDto req)
{
    return response;
}

And like ToOptimizedResult() it will cache the most optimal response in the cache, e.g. a compressed/deflated JSON response by default in the registered ICacheClient but can be made to cache in the local In memory cache with:

[CacheResponse(Duration = 60, LocalCache = true)]
public object Any(MyDto req)
{
    return response;
}

One improvement over ToOptimizedResult() is the ability to specify a custom MaxAge which tells the client that it can use its local cached version up to specified MaxAge without revalidating with the server, e.g:

[CacheResponse(Duration = 60, MaxAge = 30)]
public object Any(MyDto req)
{
    return response;
}

Which tells browsers (and other cache-aware clients) that they can use their locally cached versions for the same subsequent requests sent for up to 30 seconds.

2 Likes

Thanks, good to know.

Can you customise how the cache key is generated or does it always use the whole dto?
Is the existing method being obsoleted?
Will the existing methods have the same ability (informing client of maxage)?

A weakness in the .net cache attribute (or attributes in general) is that you end up using string literals to express things that needn’t or shouldn’t be strings (i.e. varyByParam and property names) as they don’t support expressions/funcs/delegates so normally try and avoid this kind of thing. Not as refactor friendly and more prone to runtime errors.

It uses the /pathinfo + ?querystring as the base cache key for the Request. You can’t customize the CacheKey in an Attribute since attributes only allow constant expressions which prohibits usage of things like lambda’s. The best we could do on the attribute in future is have a customizable key pattern with place holders, e.g:

//OperationName is special placeholder for DTO TypeName, whilst Id/Name from DTO
[CacheResponse(KeyPattern = "myprefix:{OperationName}:{Id}/{Name}")]

Although I’ll wait to see if there’s demand for this and which additional placeholders people want.

The [CacheResponse] feature works by registering a populated CacheInfo in Request.Items[Keywords.CacheInfo] which gets processed by the new HttpCacheFeature plugin. So then CacheKey can then be customized by modifying the CacheInfo POCO in the Service (or subsequent Global Request Filters) by modifying the CacheInfo POCO, e.g:

[CacheResponse(Duration = 60)]
public object Any(MyDto req)
{
    var cacheInfo = (CacheInfo)req.GetItem(Keywords.CacheInfo);
    cacheInfo.KeyBase = ...; //custom cache key
    ...

    return response;
}

The cacheInfo.KeyBase is used along with the cacheInfo.KeyModifiers to form the unique CacheKey used to store the Response. The KeyModifiers suffix is used to provide a unique key based on the unique response outputs, e.g. Content Type, SessionId (if VaryByUser), Role (if VaryByRoles), or whether it was a JSONP request, etc.

No, it will still be the most flexible since it allows programmatic control of creating the cache key and cached response. But since [CacheResponse] is declarative, easier to use and doesn’t require rewriting your Service response I’d expect it to be the more popular option going forward.

ToOptimizedResultUsingCache has also been improved where it now responds with a 304 NotModified for clients which already have the latest version of the response cached locally - improving response times, CPU + bandwidth.

By Default Optimized Results have a max-age=0 so clients consider the response that they have is expired so it will force clients to contact the server to determine if they have the latest cache or not. You can also customize the Cache-Control for all Optimized Results with:

this.GetPlugin<HttpCacheFeature>().CacheControlForOptimizedResults = "max-age=60";

Which will let clients use their local cache for up to 60 seconds before contacting the server to see if their cached version is still valid or not. There’s no finer-grain option for ToOptimizedResults so if you need it you would have to set the LastModified and Cache-Control HTTP Response headers in the Service yourself.

3 Likes

I could be missing something, but if the cache key is modified in the Service wouldn’t that just affect the key used to write to the cache? As in [ResponseAttribute] the key is generated and used to lookup the cached item in Execute in one-step before it would have a chance to progress to include the Service modifications to KeyBase and KeyModifiers

Yeah good point, I’ve extracted the resolve cache logic out into a reusable IRequest.HandleValidCache(cacheInfo) ext method out into this commit so if you are modifying the cache key you can test if the cacheKey exists after modifying the cache key with:

[CacheResponse(Duration = 60)]
public object Any(MyDto req)
{
    var cacheInfo = (CacheInfo)req.GetItem(Keywords.CacheInfo);
    cacheInfo.KeyBase = ...; //custom cache key
    if (Request.HandleValidCache(cacheInfo))
        return null;
    ...

    return response;
}

This change is available from v4.0.55 that’s now available on MyGet.

1 Like

Just checking out the CacheResponse and I noticed a couple of things.

The LastModified http header is not being returned if I set a global, only when I set it against a method

Plugins.Add(new HttpCacheFeature { CacheControlForOptimizedResults = "max-age=30" });

doesn’t return last modified but the following does, it this expected?

[CacheResponse(Duration = 60, MaxAge = 30)]
public object Any(MyDto dto) { return MyDto(); }

Also The last modified / Cache-Control max-age: 30 header is only being added to the non cached response. I don’t have a session enabled or varybyuser etc set so would expect this to be kept and returned with cached responses, otherwise only the first hit for each non-cached response can use last modified header to avoid reissuing the request to the server. Am I misinterpreting this?

[EDIT]

Should also add that I’ve tried changing the apphost config to

GetPlugin<HttpCacheFeature>().CacheControlForOptimizedResults = "max-age=30";
GetPlugin<HttpCacheFeature>().DefaultMaxAge = TimeSpan.FromSeconds(30);

but neither seems to add headers to method with [CacheResponse(Duration=60)]

The CacheControlForOptimizedResults configuration is only for specifying Cache Control header for existing Services that return ToOptimizedResults, e.g:

public object Any(MyDto dto)
{
     return Request.ToOptimizedResultUsingCache(Cache, cacheKey, () => new MyDto());
}

The [CacheResponse] attribute is separate and doesn’t have anything to do the ToOptimizedResult above.

[CacheResponse(Duration = 60, MaxAge = 30)]
public object Any(MyDto dto) { return MyDto(); }

This says cache the output for 60 seconds and return a MaxAge of 30 seconds to the client.

Also The last modified / Cache-Control max-age: 30 header is only being added to the non cached response.

Can you show the HTTP Headers, do you mean it doesn’t return cache headers for 304 NotModified responses?

yes I noticed that, helps when you read the thing properly! :blush:

Also I’m only seeing 200 responses (even when it’s not hitting my service breakpoint) coming back but I’m now beginning to suspect this is IIS Express related.

Ok I see what you mean now, the headers weren’t being added when the response was served from the cache. It should be resolved now with this commit. Also I’ve renamed Request.HasValidCache(cacheInfo) to Request.HandleValidCache(cacheInfo) since it handles writing the cache to the response, i.e. not just returning whether it has a valid cache or not.

This change is available from the latest v4.0.55 that’s now on MyGet.

Still not seeing header added when I configure the default max age

public override void Configure(Container container)
{
   // config stuff omitted
   GetPlugin<HttpCacheFeature>().DefaultMaxAge = TimeSpan.FromSeconds(30);
}

public class MyService : Service
{
    [CacheResponse(Duration = 60)]
    public object Any(MyRequest request)
    {
         // response omitted
    }
}

the method is only hit once per 60 seconds, but the Cache-Control header is never seen in any response

You need to be explicit that you want client HTTP Caching enabled, the DefaultMaxAge is for when you return a custom HTTP Result with either the ETag or LastModified properties populated, i.e:

return new HttpResult(dto) {
    ETag = ...
    LastModified = ...
}

Since it’s clear you want client caching we add the DefaultMaxAge for you.

But the [CacheResponse(Duration = 60)] just says you want the server to cache the response for 60 secs, we’d need some indication that you want to emit client caching headers as well.

1 Like

ah! that makes sense. Ta