Daniel Ovaska
Sep 23, 2020
  5253
(5 votes)

Improved url caching in Episerver

If you try to get a url from the UrlResolver Episerver CMS will try to cache it for you which is great! It works well until you change the Url segment on the page. Then it fails to update that cached url and you might end up with 404s for the links to that page and its children for a couple of minutes until the cache clears. I've tried it in both versions 11.12 and 11.19 and the bug is easily reproducable in an Alloy site.
Note: This bug was later fixed in 11.20 which makes this work around unneccessary.

  1. Just change url segement (Name in URL) field on alloy track page to alloy-track-2 or similar. Publish. 
  2. Go to start page and refresh it (CTRL F5). Try clicking on alloy track in top navigation
  3. 404

Restarting the site will of course solve it. Waiting a couple of minutes will too. If you don't like any of these options or waiting for a bug fix that takes care of this issue you can use my little workaround below. It's also a nice example of how you can use standard .NET object oriented programming to extend and tweak Episerver behaviour. Find the interface you are interested in, tweak it, register it in ioc, use it.

  1. New url cache handler
    Lets make a new url cache! It will be fun! The one Episerver uses under the hood uses the IContentUrlCache interface, so let's make a new one that supports clearing the cached urls when badly needed e.g. when someone decides to change that "Name in URL" field. I'm adding a RemoveAll() method and a new master key that is common for all urls to make it easy to clear them all.
using EPiServer;
using EPiServer.Core;
using EPiServer.Framework;
using EPiServer.Framework.Cache;
using EPiServer.Framework.Web;
using EPiServer.Globalization;
using EPiServer.Logging.Compatibility;
using EPiServer.Web;
using EPiServer.Web.Internal;
using EPiServer.Web.Routing;
using EPiServer.Web.Routing.Internal;
using EPiServer.Web.Routing.Segments;
using EPiServer.Web.Routing.Segments.Internal;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.Linq;
using System.Web;
using System.Web.Routing;

namespace DanielOvaska
{
    
    public class ImprovedContentUrlCache : IContentUrlCache
    {
        private const string UrlPrefix = "ep:url:";
        private const string DependecyPrefix = "ep:url:d:";
        private readonly IObjectInstanceCache _cache;
        private readonly AncestorReferencesLoader _ancestorLoader;
        private readonly TimeSpan _cacheExpirationTime;

        public ImprovedContentUrlCache(
          IObjectInstanceCache cache,
          AncestorReferencesLoader ancestorLoader,
          RoutingOptions contentOptions)
        {
            this._cache = cache;
            this._ancestorLoader = ancestorLoader;
            this._cacheExpirationTime = contentOptions.UrlCacheExpirationTime;
            if (this._cacheExpirationTime <= TimeSpan.Zero)
                throw new ArgumentException("The cache expiration time should be greater than zero");
        }

        public string Get(ContentUrlCacheContext context)
        {
            var url = this._cache.Get(this.GetCacheKey(context)) as string;
            return url;
        }

        public void Remove(ContentUrlCacheContext context)
        {
            this._cache.Remove(this.GetDependencyKey(context.ContentLink));
        }
        public void RemoveAll()
        {
            this._cache.Insert(_masterKeyForAllUrls, "cleared at " + DateTime.Now.ToLongTimeString(), null);
        }
        public void Insert(string url, ContentUrlCacheContext context)
        {
            ContentReference contentLink = context.ContentLink;
            IEnumerable<ContentReference> ancestors = this._ancestorLoader.GetAncestors(contentLink, AncestorLoaderRule.ContentAssetAware);
            TimeSpan cacheExpirationTime = this._cacheExpirationTime;
            this._cache.Insert(this.GetCacheKey(context), (object)url, new CacheEvictionPolicy(cacheExpirationTime, CacheTimeoutType.Sliding, Enumerable.Empty<string>(), this.CreateDependencyKeys(contentLink, ancestors)));
        }
        private string _masterKeyForAllUrls = "ImprovedContentUrlCache";
        internal IEnumerable<string> CreateDependencyKeys(
          ContentReference contentLink,
          IEnumerable<ContentReference> ancestors)
        {
            yield return _masterKeyForAllUrls;
            yield return this.GetDependencyKey(contentLink);
            foreach (ContentReference ancestor in ancestors)
                yield return this.GetDependencyKey(ancestor);
        }

        internal string GetCacheKey(ContentUrlCacheContext context)
        {
            return "ep:url:" + context.GetHashCode().ToString();
        }

        internal string GetDependencyKey(ContentReference contentLink)
        {
            return "ep:url:d:" + contentLink.ToReferenceWithoutVersion().GetHashCode().ToString();
        }
    }
}

2. Dependency injection of new class.
Now we must tell Episerver to use our improved url cache handler instead. You can do that easily by a few lines when configuring the IoC container:

 [InitializableModule]
    public class DependencyResolverInitialization : IConfigurableModule
    {
        public void ConfigureContainer(ServiceConfigurationContext context)
        {
            //Implementations for custom interfaces can be registered here.

            context.ConfigurationComplete += (o, e) =>
            {
                //Register custom implementations that should be used in favour of the default implementations
                context.Services.AddSingleton<IContentUrlCache,ImprovedContentUrlCache>();
...

3. Reacting to published event
Let's hook into the published event to clear url cache if and only if any editor has been tampering with url segments to avoid getting those pesky 404s. I'm going to clear them all to avoid messing with tricky cache dependencies since it also affects their children...and language versions etc. Just clear it. Changing urls on an existing page should be a rare event so it really shouldn't have any real performance issues. Let's add some code so that cache will only be cleared if the url segment has been changed. We don't want to kill the cache every time an editor publishes a typo fix to a random page. That would be bad for performance.

[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class ChangeEventInitialization : IInitializableModule
    {
        private ILogger _log = LogManager.GetLogger(typeof(ChangeEventInitialization));
        public void Initialize(InitializationEngine context)
        {
            var events = ServiceLocator.Current.GetInstance<IContentEvents>();
            events.PublishedContent += Events_PublishedContent;
        }
        private void Events_PublishedContent(object sender, EPiServer.ContentEventArgs e)
        {
            _log.Information($"Published content fired for content {e.ContentLink.ID}");
            var urlCache = ServiceLocator.Current.GetInstance<IContentUrlCache>();
            var page = e.Content as PageData;
            if(page!=null)
            {
                var contentVersionRepository = ServiceLocator.Current.GetInstance<IContentVersionRepository>();
                var versions = contentVersionRepository.List(e.ContentLink);
                if(versions.Count()>1)
                {
                    var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
                    var previousPage = contentRepository.Get<PageData>(versions.ToArray()[1].ContentLink);
                    if(previousPage.URLSegment!=page.URLSegment)
                    {
                        var improvedUrlCache = urlCache as ImprovedContentUrlCache;
                        if (improvedUrlCache != null)
                        {
                            _log.Information($"Removing cached urls due to content update");
                            improvedUrlCache.RemoveAll();
                        }
                    }
                }
            }
           
        }
        public void Uninitialize(InitializationEngine context)
        {
            var events = ServiceLocator.Current.GetInstance<IContentEvents>();
            events.PublishedContent -= Events_PublishedContent;
        }
    }

Hopefully that helps someone until Episerver fixes that cache invalidation of urls themselves. Then feel free to remove this little work around! Also remember that changing urls on a page is usually a bad idea due to SEO and incoming links anyway but that's another discussion. 

Happy coding!

Sep 23, 2020

Comments

Vincent
Vincent Sep 24, 2020 12:30 AM

Daniel, much appreciated for sharing. This is very useful to know.  

Shahram Shahinzadeh
Shahram Shahinzadeh Sep 24, 2020 09:45 AM

Thank you Daniel! Yes it seems it is a bug and we are going to investigate it a.s.a.p. 

Daniel Ovaska
Daniel Ovaska Sep 24, 2020 10:48 AM

Kill it! :)

 

Daniel Ovaska
Daniel Ovaska Sep 24, 2020 10:56 AM

Giuliano Dore
Giuliano Dore Sep 28, 2020 09:53 AM

Really good !

Matt Smith
Matt Smith Jan 29, 2021 11:30 AM

I noticed your suggestion uses deprecated code.

This issue was resolved by Episerver in bugs CMS-16555 and CMS-16839. Update Episerver.CMS.Core to 11.20.0+ in your projects.

Please login to comment.
Latest blogs
Copy Optimizely SaaS CMS Settings to ENV Format Via Bookmarklet

Do you work with multiple Optimizely SaaS CMS instances? Use a bookmarklet to automatically copy them to your clipboard, ready to paste into your e...

Daniel Isaacs | Dec 22, 2024 | Syndicated blog

Increase timeout for long running SQL queries using SQL addon

Learn how to increase the timeout for long running SQL queries using the SQL addon.

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Overriding the help text for the Name property in Optimizely CMS

I recently received a question about how to override the Help text for the built-in Name property in Optimizely CMS, so I decided to document my...

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Resize Images on the Fly with Optimizely DXP's New CDN Feature

With the latest release, you can now resize images on demand using the Content Delivery Network (CDN). This means no more storing multiple versions...

Satata Satez | Dec 19, 2024

Simplify Optimizely CMS Configuration with JSON Schema

Optimizely CMS is a powerful and versatile platform for content management, offering extensive configuration options that allow developers to...

Hieu Nguyen | Dec 19, 2024

Useful Optimizely CMS Web Components

A list of useful Optimizely CMS components that can be used in add-ons.

Bartosz Sekula | Dec 18, 2024 | Syndicated blog