Allowing sublayouts to control their own HTML cache keys

I spoke to a fellow MVP recently, Kern Herskind Nightingale and the topic of my blog came up, along with the fact that I hadn’t (at the time) updated for a while.  The reason was my previous post about overriding rendering types. Originally, I had intended to use an example that would allow developers to add an interface to their sublayout classes and specify custom logic around building their cache keys.

The code was straight-forward, it all made sense, but it didn’t work as I intended.  After a bit of investigation I realised that the problem was staring me in the face and my approach was somewhat flawed.  I didn’t want to scrap the post because I think that overriding rendering types is a useful thing to know about, but at the same time I obviously couldn’t publish it as it stood.  So, I separated out the original post from this example.

Kern’s advice was that, sometimes, people can still learn from things that went wrong.  So hopefully if you’ve stumbled across this post, perhaps because you’re trying the same thing, this will still prove in some way educational.

As I mentioned previously, the goal was to allow developers to set up custom caching logic on their sublayouts.  You’ll be aware of the current VaryBy… options, and usually they’re sufficient.  Sometimes, though, you just need a bit more.  So, I thought that giving individual sublayouts the ability to modify their own cache keys would be pretty useful.

The code I came up with is below. It doesn’t work – don’t pop it into your solutions.  That said I think that the reason it doesn’t work still provides an interesting insight into how sublayouts work and may help people who fall into the same trap as I did.  The initial version of the code consists of four things:

  1. A marker interface
    public interface IRequireACustomCacheKey
    {
        /// <summary>
        /// Modifies the cache key generated by <see cref="Sitecore.Web.UI.WebControl"/>
        /// based on some custom logic.
        /// </summary>
        /// <param name="originalCacheKey">The original cache key.</param>
        /// <returns>A cache key to be used for the Sitecore HTML cache, or an empty string to disable caching.</returns>
        string GetCacheKey(string originalCacheKey);
    }
  2. A modified version of the Sublayout control
    public class ExtendedSublayout : Sitecore.Web.UI.WebControls.Sublayout
    {
        public override string GetCacheKey()
        {
            var cacheKey = base.GetCacheKey();
            if (!Cacheable || SkipCaching())
            {
                return cacheKey;
            }
    
            var control = GetUserControl() as IRequireACustomCacheKey;
            if (control != null)
            {
                cacheKey = control.GetCacheKey(cacheKey);
            }
    
            return cacheKey;
        }
    }
  3. A modified version of the SublayoutRenderingType class
    public class SublayoutRenderingType : Sitecore.Web.UI.SublayoutRenderingType
    {
        public override Control GetControl(NameValueCollection parameters, bool assert)
        {
            var sublayout = new ExtendedSublayout();
            foreach (string name in parameters.Keys)
            {
                string value = parameters[name];
                ReflectionUtil.SetProperty(sublayout, name, value);
            }
            return sublayout;
        }
    }
  4. A test sublayout
    public partial class CustomCacheControl : System.Web.UI.UserControl, IRequireACustomCacheKey
    {
        public string GetCacheKey(string originalCacheKey)
        {
            // Only generate a different cache key each 20s for testing
            var key = originalCacheKey + "_#custom:" + DateTime.Now.ToString("yyyy-MM-ddTHH-mm") + (DateTime.Now.Second/20);
    
            System.Diagnostics.Debug.WriteLine("Key: " + key);
    
            return key;
        }
    }
  5. An include file
    <?xml version="1.0" encoding="utf-8" ?>
    <configuration xmlns:p="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
      <sitecore>
        <renderingControls>
          <control template="sublayout" set:type="MyAssembly.RenderingTypes.SublayoutRenderingType, MyAssembly" />
        </renderingControls>
      </sitecore>
    </configuration>

If you try the above code, you’ll see some rather strange behaviour.  The current version pops up the first time that a new cache key is generated, but subsequent requests always fall back to the version that was initially generated.

It took me longer than it should have to spot the problem, mainly because there are actually two problems.

  1. Control instantiation: one of the basic mechanisms of the Sitecore HTML caching functionality is that, if a cached version exists, Sitecore does not bother instantiating the actual control.  As you might expect, the GetUserControl method will instantiate the control if one hasn’t already been created.  This is the downside to delegating the cache key generation to the actual sublayout: you cannot generate the cache key without instantiating the sublayout.  Worse still, as we’re using an interface the control has to be instantiated even to check whether it has custom logic.
  2. Inconsistent cache key generation: it turns out that Sitecore invokes the GetCacheKey method twice at different stages: once during CreateChildControls and once during Render.  When called in the the CreateChildControls method, GetUserControl always returns an instance of the sublayout; meaning you get the correct cache key value.  Later, during the Render method, GetUserControl would sometimes return a different control, resulting in an incorrect cache key value.

At first, I thought that my approach was fundamentally broken because of the need to instantiate the sublayout control.  Interestingly, this turns out not to be the case, as GetUserControl doesn’t add the control to the page, and so none of the page life-cycle events get fired.  There is obviously some extra work involved, but since most user controls I’ve seen are driven by the page events, the overhead isn’t quite so bad.

Once I realised that, I looked further into why I was getting different cache keys returned by the two calls.  It turns out that the Sitecore Sublayout class’s CreateChildControls method will do one of two things: create and add the sublayout as a child control, or create a literal containing the cached HTML as a child control.  I’m not quite sure why this is done, as the Render method fetches the cached HTML again and writes that out, rather than using the literal, perhaps it serves a greater purpose, or perhaps it’s a left-over.  The interesting thing in this case is in CreateChildControls: when the literal is created, it overwrites the instance of the sublayout that was created to call the cache key logic.  Consequently, on Render, the result of GetUserControl is no longer an IRequireACustomCacheKey, and so the custom logic is never used.  This results in standard caching being applied, hence always seeing the initial version.

There isn’t a massive amount that can be done for the first problem.  The best we can do is mitigate it by restoring the original sublayout rendering type and tying this one to a custom rendering item template.  It’s still a bit messy, but it will at least reduce the overhead so that it only affects the controls you want it to.  At this point, checking for the interface is somewhat redundant: you might want to do something like throwing an error if the control doesn’t have it and if you’re on a dev instance.

The second problem can be worked around simply by making sure that result of the first call to GetCacheKey is stored once it has been generated.  Future calls for that sublayout’s cache key can then simply return that value as needed.  This is pretty safe as, personally, I wouldn’t expect the cache key to change significantly on a sublayout within the context of a single request.

One last thing that I deliberately haven’t yet touched on at all so far is memory.  This is one thing you need to keep in mind.  Generating new cache keys just means that currently cached versions no longer match the old cache key: the old versions don’t get expired from the cache.  This could be desirable if your cache key varies in such a manner that old keys may still be re-visited.  On the other hand, if your cache key varies based on something like time (as in this example), new versions will just keep getting added to the cache as time goes on.  By default, HTML cache items stay in the cache until it is cleared, so if you are going to use this method, you should definitely consider either writing some form of selective cache expiry, or clearing the HTML cache on a somewhat regular basis to prevent this from becoming a memory leak.

As always, I haven’t thoroughly tested this and so if you decide to use it, then you should ensure it works correctly and has no adverse effects on performance or any other parts of your application.