loading...

Time-Based Caching of a Sitecore Rendering

kmac23va profile image Kenneth McAndrew ・6 min read

The Problem

I ran into a scenario with a client reskinning their site, and the biggest change was converting the header from a simple click-and-go to a full megamenu. In the megamenu, each section brought in a chunk of the landing page functionality, from a search query to a multi-level drilldown. This means making a lot of data calls to assemble the viewmodel, and then spitting this out. Given all that, it certainly makes sense to cache.

Since Sitecore gives us a nice, easy way to cache a rendering (which is an HTML cache), it makes sense to take advantage of that. The catch to remember is that when a publish happens, the cache is invalidated. Which means that the header will get decached frequently if there's a lot of publishing going on. Not the best scenario, even if you build a good back-end data model!

The Research

Digging through Google for help on this, I came across https://sitecoremaster.com/caching/customizing-html-caching-with-sitecore-with-mvc/ that would supplant the Sitecore default timeout (which is 00:00, IE forever). And this did work...I set it up, plugged in 5 minutes for the timeout, and the cache did invalidate every 5 minutes. Except if I published, it also still invalidated.

I asked around the good ol' Sitecore Slack to see if there was a way to exclude a rendering from being decached on publish, and out of the box there's no way to do it. I tried digging into the methods that clear the cache, nothing good there. Then I looked into the caching methods themselves, the actual getter and setter, and I found the secret sauce.

The Solution

Secret Sauce!

Inside the getter (RenderFromCache) and the setter (AddRecordedHtmlToCache), by default it uses the current site context to save the cache. But these methods are all virtual, and thus all changeable by a developer. So then I had an idea...what if, instead of saving the cache to the current site, what if I saved it to another site...one that wasn't subject to decaching on publish? So let's get started down this yellow brick road!

Step 1: Create a site definition

You don't need a full-on site with items and such for this, just a simple site definition. In fact, you can inherit your existing site definition if you want, something like this:

<site name="cachingSite" inherits="website" hostName="cachingSite" cacheHtml="true" htmlCacheSize="300MB" allowDebug="false" enablePreview="false" enableWebEdit="false" enableDebugger="false" preventHtmlCacheClear="true" patch:after="site[@name='website']" />

The key elements here are the cacheHtml setting being true, and the preventHtmlCacheClear setting also being true. (Note the preventHtmlCacheClear is new for 9.3+; before this, you had to explicitly state the site needed to be cache-cleared on publish. Since we don't want that at all, this covers both scenarios.) I set the hostName value just to give it a name that will never be activated, so there's not a "real" site here.

Step 2: Settings

I maintain three settings for this:

  • TimeoutRenderingParam - the ID of the cache timeout field
  • TimeoutRenderingKey - the name of the key to add to the cache
  • CachingSite - The site name for the caching site (from above)

This allows the code to be flexible, so that any rendering that uses the Timeout field will use the caching site automatically.

using Sitecore.Data;

namespace Foundation.Caching {
    public struct Settings {
        public static ID TimeoutRenderingParam => new ID("{38A43E35-17D1-4D45-B399-9EA66C94B861}");
        public static string TimeoutRenderingKey => "timeout";
        public static string CachingSite => "cachingSite";
    }
}

Step 3: GenerateCacheKey

This overrides the default Sitecore implementation to add in a cache key value if the timeout field is filled in.

using Sitecore.Data.Items;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Mvc.Presentation;

namespace Foundation.Caching.Pipelines.Renderings {
    public class GenerateCacheKey : Sitecore.Mvc.Pipelines.Response.RenderRendering.GenerateCacheKey {
        public GenerateCacheKey(RendererCache rendererCache) : base(rendererCache) {
        }

        protected override string GenerateKey(Rendering rendering, RenderRenderingArgs args) {
            string cacheKey = base.GenerateKey(rendering, args);
            Item renderingItem = rendering.RenderingItem.InnerItem;

            //If the timeout rendering parameter exists and has a value, add a token to the cache key that it's present
            if (!string.IsNullOrEmpty(renderingItem[Settings.TimeoutRenderingParam])) {
                cacheKey += $"_#{Settings.TimeoutRenderingKey}:1";
            }

            return cacheKey;
        }
    }
}

Step 4: AddRecordedHtmlToCache

This is the "setter" code. If the timeout cache key is present, the code will start to use our caching site to store the HTML, instead of the context site.

using System;
using Sitecore.Caching;
using Sitecore.Diagnostics;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Sites;

namespace Foundation.Caching.Pipelines.Renderings {
    public class AddRecordedHtmlToCache : Sitecore.Mvc.Pipelines.Response.RenderRendering.AddRecordedHtmlToCache {
        public override void Process(RenderRenderingArgs args) {
            Assert.ArgumentNotNull(args, nameof(args));
            if (!args.Rendered) { return; }
            string cacheKey = args.CacheKey;
            if (!args.Cacheable || string.IsNullOrEmpty(cacheKey)) { return; }
            UpdateCache(cacheKey, args);
        }

        protected override void AddHtmlToCache(string cacheKey, string html, RenderRenderingArgs args) {
            //Check if the Timeout key is in the cache key; if not, try the base/Sitecore version
            if (!cacheKey.Contains(Settings.TimeoutRenderingKey)) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the Timeout value is present and a valid TimeSpan; if not, try the base/Sitecore version
            bool timeoutValue = TimeSpan.TryParse(args.Rendering.RenderingItem.InnerItem[Settings.TimeoutRenderingParam], out _);

            if (!timeoutValue) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the caching site exists; if not, try the base/Sitecore version
            SiteContext cachingSite = SiteContext.GetSite(Settings.CachingSite);

            if (cachingSite == null) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //Check if the HTML cache is available in the caching site; if not, try the base/Sitecore version
            HtmlCache htmlCache = CacheManager.GetHtmlCache(cachingSite);

            if (htmlCache == null) {
                base.AddHtmlToCache(cacheKey, html, args);
                return;
            }

            //If everything is good, set the timeout based on the rendering parameter and put the HTML in the caching site
            TimeSpan timeout = GetTimeout(args);
            htmlCache.SetHtml(cacheKey, html, timeout);
        }

        protected override TimeSpan GetTimeout(RenderRenderingArgs args) => TimeSpan.TryParse(args.Rendering.RenderingItem.InnerItem[Settings.TimeoutRenderingParam], out TimeSpan result) ? result : args.Rendering.Caching.Timeout;
    }
}

Step 5: RenderFromCache

This is the "getter" code. If the timeout cache key is present, the code will check our caching site for the HTML.

using System.IO;
using Sitecore.Abstractions;
using Sitecore.Caching;
using Sitecore.Mvc.Pipelines.Response.RenderRendering;
using Sitecore.Mvc.Presentation;
using Sitecore.Sites;

namespace Foundation.Caching.Pipelines.Renderings {
    public class RenderFromCache : Sitecore.Mvc.Pipelines.Response.RenderRendering.RenderFromCache {
        public RenderFromCache(RendererCache rendererCache, BaseClient baseClient) : base(rendererCache, baseClient) {
        }

        protected override bool Render(string cacheKey, TextWriter writer, RenderRenderingArgs args) {
            //Check if the Timeout key is in the cache key; if not, try the base/Sitecore version
            if (!cacheKey.Contains(Settings.TimeoutRenderingKey)) {
                return base.Render(cacheKey, writer, args);
            }

            //Check if the caching site exists; if not, try the base/Sitecore version
            SiteContext cachingSite = SiteContext.GetSite(Settings.CachingSite);

            if (cachingSite == null) {
                return base.Render(cacheKey, writer, args);
            }

            //Check if an HTML cache exists for the caching site and that the HTML is present; if not, try the base/Sitecore version
            HtmlCache htmlCache = CacheManager.GetHtmlCache(cachingSite);
            string html = htmlCache?.GetHtml(cacheKey);

            if (html == null) {
                return base.Render(cacheKey, writer, args);
            }

            //If everything is good, write the HTML from the cache
            writer.Write(html);

            return true;
        }
    }
}

Step 6: Configuring the Pipelines

Finally, you need to wire all of this up with a patch file, overriding the appropriate built-in pipelines.

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:env="http://www.sitecore.net/xmlconfig/env/">
  <sitecore>
      <pipelines>
            <mvc.renderRendering>
                <processor type="Foundation.Caching.Pipelines.Renderings.RenderFromCache, Foundation.Caching" resolve="true" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.RenderFromCache, Sitecore.Mvc']" />
                <processor type="Foundation.Caching.Pipelines.Renderings.AddRecordedHtmlToCache, Foundation.Caching" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.AddRecordedHtmlToCache, Sitecore.Mvc']" />
                <processor type="Foundation.Caching.Pipelines.Renderings.GenerateCacheKey, Foundation.Caching" resolve="true" patch:instead="processor[@type='Sitecore.Mvc.Pipelines.Response.RenderRendering.GenerateCacheKey, Sitecore.Mvc']" />
            </mvc.renderRendering>
      </pipelines>
  </sitecore>
</configuration>

The Code!

I've created a Github project with the code. There is a Sitecore package that has the new field, which you can then add in via whatever serialization method you prefer. Please see the readme file for more information.

GitHub logo kmac23va / Foundation.Caching

A Sitecore Helix module to expand the built-in caching for timeout expiration

Foundation.Caching

This module is designed to expand Sitecore's caching capability to add a timeout parameter. By inserting a value, the rendering will only expire its cache at the end of the specified time, not on a content publish.

Installation

Add the module code to your solution (either importing the files or as a Helix module). Run the included Sitecore package to add the timeout field; you can then incorporate the field into your source control using your preferred tool (TDS, Unicorn, CLI).

Notes

  • This module is configured with the Sitecore 10 Sitecore.Mvc reference. You can change this as appropriate for your solution.
  • The caching site definition is preset with the preventHtmlCacheClear attribute; if you use this module with a 9.2 or lower site, you do not have to do anything extra, as the site will not be included in the publish:end cache clearing.

More Information

See this blog post for…

Posted on by:

kmac23va profile

Kenneth McAndrew

@kmac23va

Over 20 years in web development, from HTML (remember image maps and frames?) to classic ASP to ASP.NET to .NET CMSes.

Discussion

pic
Editor guide