Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Cache invalidation in CQRS application

We practice CQRS architecture in our application, i.e. we have a number of classes implementing ICommand and there are handlers for each command: ICommandHandler<ICommand>. Same way goes for data retrieval - we have IQUery<TResult> with IQueryHandler<IQuery, TResult>. Pretty common these days.

Some queries are used very often (for multiple drop downs on pages) and it makes sense to cache the result of their execution. So we have a decorator around IQueryHandler that caches some query executions.
Queries implement interface ICachedQuery and decorator caches the results. Like this:

public interface ICachedQuery {
    String CacheKey { get; }
    int CacheDurationMinutes { get; }
}

public class CachedQueryHandlerDecorator<TQuery, TResult> 
    : IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    private IQueryHandler<TQuery, TResult> decorated;
    private readonly ICacheProvider cacheProvider;

    public CachedQueryHandlerDecorator(IQueryHandler<TQuery, TResult> decorated, 
        ICacheProvider cacheProvider) {
        this.decorated = decorated;
        this.cacheProvider = cacheProvider;
    }

    public TResult Handle(TQuery query) {
        var cachedQuery = query as ICachedQuery;
        if (cachedQuery == null)
            return decorated.Handle(query);

        var cachedResult = (TResult)cacheProvider.Get(cachedQuery.CacheKey);

        if (cachedResult == null)
        {
            cachedResult = decorated.Handle(query);
            cacheProvider.Set(cachedQuery.CacheKey, cachedResult, 
                cachedQuery.CacheDurationMinutes);
        }

        return cachedResult;
    }
}

There was a debate whether we should have an interface on queries or an attribute. Interface is currently used because you can programmatically change the cache key depending on what is being cached. I.e. you can add entities' id into cache key (i.e. have keys like "person_55", "person_56", etc.).

The issue is of course with cache invalidation (naming and cache invalidation, eh?). Problem with that is that queries do not match one-to-one with commands or entities. And execution of a single command (i.e modification of a person record) should render invalid multiple cache records: person record and drop down with persons' names.

At the moment I have a several candidates for the solution:

  1. Have all the cache keys recorded somehow in entity class, mark the entity as ICacheRelated and return all these keys as part of this interface. And when EntityFramework is updating/creating the record, get these cache keys and invalidate them. (Hacky!)
  2. Commands should be invalidating all the caches. Or rather have ICacheInvalidatingCommand that should return list of cache keys that should be invalidated. And have a decorator on ICommandHandler that will invalidate the cache when the command is executed.
  3. Don't invalidate the caches, just set short cache lifetimes (how short?)
  4. Magic beans.

I don't like any of the options (maybe apart from number 4). But I think that option 2 is one I'll give a go. Problem with this, cache key generation becomes messy, I'll need to have a common place between commands and queries that know how to generate keys. Another issue would that it'll be too easy to add another cached query and miss the invalidating part on commands (or not all commands that should invalidate will invalidate).

Any better suggestions?

like image 852
trailmax Avatar asked Oct 13 '14 18:10

trailmax


People also ask

What is meant by cache invalidation?

Cache invalidation refers to process during which web cache proxies declare cached content as invalid, meaning it will not longer be served as the most current piece of content when it is requested. Several invalidation methods are possible, including purging, refreshing and banning.

When should cache be invalidated?

Cache invalidation is a process where the computer system declares the cache entries as invalid and removes or replaces them. The basic objective of using cache invalidation is that when the client requests the affected content, the latest version is returned.

Why do we need cache invalidation?

By definition, a cache doesn't hold the source of truth of your data (e.g., a database). Cache invalidation describes the process of actively invalidating stale cache entries when data in the source of truth mutates.


2 Answers

I'm wondering whether you should really do caching here at all, since SQL server is pretty good in caching results, so you should see queries that return a fixed list of drop down values to be really fast.

Of course, when you do caching, it depends on the data what the cache duration should be. It depends on how the system is used. For instance, if new values are added by an administrator, it's easy to explain that it takes a few minutes before other users will see his changes.

If, on the other hand, a normal user is expected to add values, while working with a screen that has such list, things might be different. But in that case, it might even be good to make the experience for the user more fluent, by presenting him with the drop down or giving him the option to add a new value right there. That new value is than processed in the same transaction and everything will be fine.

If you want to do cache invalidation however, I would say you need to let your commands publish domain events. This way other independent parts of the system can react to this operation and can do (among other things) the cache invalidation.

For instance:

public class AddCityCommandHandler : ICommandHandler<AddCityCommand>
{
    private readonly IRepository<City> cityRepository;
    private readonly IGuidProvider guidProvider;
    private readonly IDomainEventPublisher eventPublisher;

    public AddCountryCommandHandler(IRepository<City> cityRepository,
        IGuidProvider guidProvider, IDomainEventPublisher eventPublisher) { ... }

    public void Handle(AddCityCommand command)
    {
        City city = cityRepository.Create();

        city.Id = this.guidProvider.NewGuid();
        city.CountryId = command.CountryId;

        this.eventPublisher.Publish(new CityAdded(city.Id));
    }
}

Here you publish the CityAdded event which might look like this:

public class CityAdded : IDomainEvent
{
    public readonly Guid CityId;

    public CityAdded (Guid cityId) {
        if (cityId == Guid.Empty) throw new ArgumentException();
        this.CityId = cityId;
    }
}

Now you can have zero or more subscribers for this event:

public class InvalidateGetCitiesByCountryQueryCache : IEventHandler<CityAdded>
{
    private readonly IQueryCache queryCache;
    private readonly IRepository<City> cityRepository;

    public InvalidateGetCitiesByCountryQueryCache(...) { ... }

    public void Handle(CityAdded e)
    {
        Guid countryId = this.cityRepository.GetById(e.CityId).CountryId;

        this.queryCache.Invalidate(new GetCitiesByCountryQuery(countryId));
    }
}

Here we have special event handler that handles the CityAdded domain event just to invalide the cache for the GetCitiesByCountryQuery. The IQueryCache here is an abstraction specially crafted for caching and invalidating query results. The InvalidateGetCitiesByCountryQueryCache explicitly creates the query who's results should be invalided. This Invalidate method can than make use of the ICachedQuery interface to determine its key and invalide the results (if any).

Instead of using the ICachedQuery to determine the key however, I just serialize the whole query to JSON and use that as key. This way each query with unique parameters will automatically get its own key and cache, and you don't have to implement this on the query itself. This is a very safe mechanism. However, in case your cache should survive AppDomain recycles, you need to make sure that you get exactly the same key across app restarts (which means the ordering of the serialized properties must be guaranteed).

One thing you must keep in mind though is that this mechanism is especially suited in case of eventual consistency. To take the previous example, when do you want to invalidate the cache? Before you added the city or after? If you invalidate the cache just before, it's possible that the cache is repopulated before you do the commit. That would suck of course. On the other hand, if you do it just after, it's possible that someone still observes the old value directly after. Especially when your events are queued and processed in the background.

But what you can do is execute the queued events directly after you did the commit. You can use a command handler decorator for that:

public class EventProcessorCommandHandlerDecorator<T> : ICommandHandler<T>
{
    private readonly EventPublisherImpl eventPublisher;
    private readonly IEventProcessor eventProcessor;
    private readonly ICommandHandler<T> decoratee;

    public void Handle(T command)
    {
        this.decotatee.Handle(command);

        foreach (IDomainEvent e in this.eventPublisher.GetQueuedEvents())
        {
            this.eventProcessor.Process(e);
        }
    }
}

Here the decorator depends directly on the event publisher implementation to allow calling the GetQueuedEvents() method that would be unavailable from the IDomainEventPublisher interface. And we iterate all events and pass those events on to the IEventProcessor mediator (which just works as the IQueryProcessor does).

Do note a few things about this implementation though. It's NOT transactional. If you need to be sure that all your events get processed, you need to store them in a transactional queue and process them from there. For cache invalidation however, it doesn't seem like a big problem to me.

This design might seem like overkill just for caching, but once you started publishing domain events, you'll start to see many use cases for them that will make working with your system considerably simpler.

like image 53
Steven Avatar answered Oct 07 '22 13:10

Steven


Are you using a separate read and write model? If so, perhaps your "projection" classes (the ones that handle events from the write model and do CRUD on the read model) could invalidate the appropriate cache entries at the same time.

like image 27
pnschofield Avatar answered Oct 07 '22 12:10

pnschofield