Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Cache invalidation with multi-tenant resource heirarchy #20

Closed
benfoster opened this Issue · 5 comments

2 participants

@benfoster

Unfortunately cache invalidation is not working for any of our resources which I believe is simply down to our URI templates.

Our API is multi-tenant with each tenant being identified by the Authorization header.

Each tenant can have a number of sites which can be located at GET /sites.

Non-GET operations always occur on a site level resource i.e. POST /sites/{siteId}/pages.

Sub-resources are grouped logically. For example:

  • sites/{siteId}/portfolio - Used to manage the order of projects in the portfolio
  • sites/{siteId}/portfolio/projects - Portfolio projects
  • sites/{siteId}/portfolio/projects/{projectId} - A specific project
  • sites/{siteId}/portfolio/projects/{projectId}/media - Manage media for a specific project

Due to the nature of the application and how the API data is used, in almost all cases, cache invalidation should occur at the top level site resource.

So a POST to sites/{siteId}/pages should invalidate (including any querystrings):

  • sites/{siteId}/pages
  • sites/{siteId}/pages/{pageId}

In the same way a POST to sites/{siteId}/projects/{projectId}/media should invalidate everything from sites/{siteId}/portfolio/*. For example, if we change the cover image for a project we need to ensure the project list resource cache is invalidated so that it picks up the new image.

I've added a Help page to our api detailing our resources. Any pointers would be appreciated.

@aliostad
Owner

This is already possible and I had implemented this in some of the previous examples. CacheCow was built with this very requirement in mind.

As I explained in my recent blog post we are planning for better resource organisation and the fundamental problem is actually ASP.NET routing itself.

I have created a project called Resourx which will deal

However, in the meantime this code should do the trick for you. What happens here (assuming your IDs are numeric) is to set the route pattern for /api/foo/123/bar/456 to /api/foo/123/bar/* - also for collections such as /api/foo/123/bar to also set to /api/foo/123/bar/*. And in LinkedRoutePatternProvider also we return the same pattern only if it is a POST.

            // assuming IDs are integer
            cachingHandler.CacheKeyGenerator = (url, headers) =>
                                                   {
                    string routePattern = url;
                    if (Char.IsNumber(url.Last())) // if it is an item
                    {
                        var lastIndexOfSlash = routePattern.LastIndexOf('/');
                        routePattern = routePattern.Substring(0, lastIndexOfSlash) + "/*";
                    }
                    else // it is a collection. otherwise it would be /foo/bar/123
                    {
                        if (url.Last() != '/')
                            routePattern = url + "/";

                        routePattern += "*";
                    }
                    return new CacheKey(url, headers.SelectMany(h => h.Value), routePattern);
                };

            cachingHandler.LinkedRoutePatternProvider = (url, method) =>
                {
                    if(method!=HttpMethod.Post) // not interested if not POST
                        return new string[0];

                    string routePattern = url;
                    if (url.Last() != '/')
                        routePattern += "/";
                    routePattern += "*";
                    return new []{routePattern};
                };
@benfoster

Thanks for this! Did you say you have got an example project with this? Had a look in the source but could only see the test projects.

I'd like to write a decent set of tests before we actually add the caching layer into our API as it's pretty crucial that we get it right.

@aliostad
Owner

It is very easy to test.

I need to dig it up as probably is out of date. But the code above should work. The problem is ASP.NET routing which even ASP.NET team is set out to do something about it.

@benfoster

So the goal is to have the CacheKeyGenerator and LinkedRoutePatternProvider return the same route pattern for a sub resource?

This way a GET to /sites/1/pages will generate a route pattern of /sites/1/pages/* and a POST to /sites/1/pages/10 will generate a linked pattern of /sites/1/pages/* thus invalidating the cache.

Have I understood that correctly?

If so, I had to make a few changes to your implementation to limit the level of resources. I've not yet hooked this up to the CachingHandler but I think this will work:

public static class CachingHandlerHelper
{
    private static string[] InvalidateCacheMethods = new[] { "POST", "PUT", "PATCH", "DELETE" };

    public static CacheKey GenerateCacheKey(string url, IEnumerable<KeyValuePair<string, IEnumerable<string>>> headers)
    {
        Ensure.Argument.NotNullOrEmpty(url, "url");

        var routePattern = CreateRoutePattern(url);
        return new CacheKey(url, headers.SelectMany(h => h.Value), routePattern);
    }

    public static IEnumerable<string> GetLinkedRoutes(string url, HttpMethod method)
    {
        Ensure.Argument.NotNullOrEmpty(url, "url");
        Ensure.Argument.NotNull(method, "method");

        if (!InvalidateCacheMethods.Any(m => m == method.Method))
        {
            // No need to invalidate
            return new string[0]; 
        }

        // Otherwise, create the route pattern for the url
        return new [] { CreateRoutePattern(url) };
    }

    private static string CreateRoutePattern(string url)
    {
        string routePattern = url.Trim('/');

        /* sites/1 => sites/*
         * sites/1/pages => sites/1/pages/*
         * sites/1/pages/10 => sites/1/pages/*
         * sites/1/portfolio/projects => sites/1/portfolio/*
         * sites/1/portfolio/projects/10 => sites/1/portfolio/*
         */

        var isItemPath = Char.IsNumber(url.Last()); // e.g. /sites/1/pages/10

        if (isItemPath)
        {
            // remove the id
            var lastIndexOfSlash = routePattern.LastIndexOf('/');
            routePattern = routePattern.Substring(0, lastIndexOfSlash);
        }

        // ensure that resources are at most 3 levels deep e.g. /sites/1/portfolio/projects/* => /sites/1/portfolio/*
        routePattern = LimitPath(routePattern, limit: 3);

        // change the path to a wildcard path
        routePattern = string.Concat("/", routePattern, "/*");

        return routePattern;
    }

    private static string LimitPath(string path, int limit)
    {
        var fixedPath = path;
        var segments = fixedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

        if (segments.Length > limit)
        {
            fixedPath = string.Join("/", segments.Take(limit));
        }

        return fixedPath;
    }
}

[TestFixture]
public class CachingHandlerHelperTests
{       
    [Test]
    public void Collection_path()
    {           
        GenerateCacheKey("/sites").ShouldEqual("/sites/*");
    }

    [Test]
    public void Item_path()
    {
        GenerateCacheKey("/sites/10").ShouldEqual("/sites/*");
    }

    [Test]
    public void Site_resource_collection_path()
    {
        GenerateCacheKey("/sites/1/portfolio").ShouldEqual("/sites/1/portfolio/*");
    }

    [Test]
    public void Site_resource_item_path()
    {
        GenerateCacheKey("/sites/1/pages/10").ShouldEqual("/sites/1/pages/*");
    }

    [Test]
    public void Site_sub_resource_collection_path()
    {
        GenerateCacheKey("/sites/1/portfolio/projects").ShouldEqual("/sites/1/portfolio/*");
    }

    [Test]
    public void Site_sub_resource_item_path()
    {
        GenerateCacheKey("/sites/1/portfolio/projects/10").ShouldEqual("/sites/1/portfolio/*");
    }

    [Test]
    public void Site_sub_sub_resource_collection_path()
    {
        GenerateCacheKey("/sites/1/portfolio/projects/10/media").ShouldEqual("/sites/1/portfolio/*");
    }

    [Test]
    public void Site_sub_sub_resource_item_path()
    {
        GenerateCacheKey("/sites/1/portfolio/projects/10/media/5").ShouldEqual("/sites/1/portfolio/*");
    }

    private static string GenerateCacheKey(string path)
    {
        var key = CachingHandlerHelper.GenerateCacheKey(path, new List<KeyValuePair<string, IEnumerable<string>>>());
        return key.RoutePattern;
    }
}
@aliostad
Owner

Yes, you can make the rule expand to other methods as well.

At the end of the day, resource organisation is responsibility of the application as such CacheCow cannot make any assumption about it.

We are hoping to propose a solution in Project Resourx. I know that ASP.NET team also are thinking of improving routing.

@aliostad aliostad closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Something went wrong with that request. Please try again.