UrlResolver caching incorrect market URLs

Vote:
 

Hi all,

We're working on a CMS/Commerce upgrade piece of work and have noticed a bug with UrlResolver pulling the wrong URL when the user switches market. This has been introduced in the upgrade and is observable on the integration environment where this code has been deployed, compared to the pre-production environment where it has not. The CMS and Commerce versions we have upgraded to are:

CMS: 11.20.11

Commerce: 13.32.0

We have a custom language and market segment class inheriting from SegmentBase to insert an initial segment of {domain}/{language}-{market}/{path}, e.g www.domain.com/en-gb/ or www.domain.com/fr-fr/.

This issue only occurs when the user switches to a market with the same language, so user switches from www.domain.com/en-gb/ to www.domain.com/en-ie/ for example, all links generated using UrlResolver (or Url.ContentUrl()) will point to www.domain.com/en-gb/. This would suggest that there is cache invalidation around the current language.

Anything I've found around this, points to a URL caching issue that was resolved by Optimizely in bugs CMS-16555 and CMS-16839. Fixed in CMS version 11.20.0+. From what I can tell those issues are regarding cache invalidation when the content item URL has changed, which isn't relevent to this scenario. 

I've found this post (https://world.optimizely.com/blogs/Daniel-Ovaska/Dates/2020/9/improved-url-caching-in-episerver/), which might be an approach to invalidate the cache but I wanted to check if this is an issue anyone else has experienced? Possibly, if there is a solution without needing to implement a custom IContentUrlCache.

Thanks,

Niall

#280281
May 13, 2022 10:35
Vote:
 

I went for the IContentUrlCache approach. My scenario is that one product can belong to two different root (segment) categories and based on current segment context (selected by user), the product should have a unique URL.

I think you might get problems with invalidating cache. If there are multiple markets for one language, they will invalidate each other, leading to very few cache hits.

My implementation for IContentUrlCache was quite simple actually. I created an interceptor that looks like this (more information at the end about registering the interceptor):

public class CustomContentUrlCache : IContentUrlCache
{
    private readonly IContentUrlCache _contentUrlCache;
    private readonly ISiteSegmentContext _siteSegmentContext;

    public CustomContentUrlCache(
        IContentUrlCache contentUrlCache,
        ISiteSegmentContext siteSegmentContext)
    {
        _contentUrlCache = contentUrlCache;
        _siteSegmentContext = siteSegmentContext;
    }

    public string Get(ContentUrlCacheContext context)
    {
        var newContext = new ContentUrlCacheContextWrapper(context, _siteSegmentContext.GetCurrentSegment());

        return _contentUrlCache.Get(newContext);
    }

    public void Remove(ContentUrlCacheContext context) => _contentUrlCache.Remove(context);

    public void Insert(string url, ContentUrlCacheContext context)
    {
        var newContext = new ContentUrlCacheContextWrapper(context, _siteSegmentContext.GetCurrentSegment());

        _contentUrlCache.Insert(url, newContext);
    }
}

This is the ContentUrlCacheContextWrapper implementation:

public class ContentUrlCacheContextWrapper : ContentUrlCacheContext
{
    public ContentReference CurrentSegment { get; }

    public ContentUrlCacheContextWrapper(ContentUrlCacheContext context, ContentReference currentSegment)
        : base(context.ContentLink, context.Language, context.Host, context.VirtualPathArguments)
    {
        CurrentSegment = currentSegment;
    }
    
    public override int GetHashCode()
    {
        var hashCodeCombiner = new HashCode();
        hashCodeCombiner.Add(base.GetHashCode());
        hashCodeCombiner.Add(CurrentSegment);
        return hashCodeCombiner.ToHashCode();
    }
}

The HashCode class used above comes from the Nuget package Microsoft.Bcl.HashCode.

If you haven't registered an interceptor before, here is sample code for it:

[ModuleDependency(typeof(ServiceContainerInitialization))]
[InitializableModule]
public class InterceptorsInitialization : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        AddInterceptors(context.Services);
    }

    private void AddInterceptors(IServiceConfigurationProvider services)
    {
        services.Intercept<IContentUrlCache>((locator, contentUrlCache) =>
        {
            var routingOptions = locator.GetInstance<RoutingOptions>();

            return routingOptions.UrlCacheEnabled()
                ? new CustomContentUrlCache(contentUrlCache, locator.GetInstance<ISiteSegmentContext>())
                : contentUrlCache;
        });
    }

    public void Initialize(InitializationEngine context)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}
#280367
Edited, May 15, 2022 12:00
Niall McCabe - May 16, 2022 10:34
Thanks Mattias, that's an interesting approach.
Niall McCabe - May 16, 2022 12:06
Hi Mattias, I can't find any reference to ISiteSegmentContext in the documentation on world, or in my project's Episerver assemblies. Is this a custom interface? How is this implemented?
Mattias Olsson - May 16, 2022 12:19
Yes, it's a custom interface. I was thinking you could use your own logic there, like ICurrentMarket interface or some other of your own services to resolve current market for the user.
Niall McCabe - May 16, 2022 12:21
Ah, sorry, I didn't quite grasp that. That makes perfect sense. Thanks for the pointer Mattias
Mattias Olsson - May 17, 2022 7:37
No problem. The currentSegment parameter in my sample code could be any type of object. It will be used when creating the hashcode for the URL cache.

Hope you will get this working.
* You are NOT allowed to include any hyperlinks in the post because your account hasn't associated to your company. User profile should be updated.