Views: 833
Number of votes: 4
Average rating:

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.

  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

Vincent
( By Vincent, 9/24/2020 12:30:41 AM)

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

Shahram Shahinzadeh
( By Shahram Shahinzadeh, 9/24/2020 9:45:06 AM)

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

Daniel Ovaska
( By Daniel Ovaska, 9/24/2020 10:48:48 AM)

Kill it! :)

 

Daniel Ovaska
( By Daniel Ovaska, 9/24/2020 10:56:13 AM)

Giuliano Dore
( By Giuliano Dore, 9/28/2020 9:53:31 AM)

Really good !

Please login to comment.