Replacing Redis by Cosmos DB

I've been using Redis for a while now and it just works, but I find particularly painful when I want to see what's cached without writing a custom tool for that. Another reason that drove me to try this solution was the fact that I'm paying too much for a Redis instance in Azure. So I thought I would give it a try and see how well Comos DB would perform in this situation.

The replacing process for me was actually quite simple since I had, to start with, an interface to deal with the cache called ICacheHelper and two implementation, one using Redis and one using MemoryCache. The interface itself was really simple:

public interface ICacheHelper
{
    Task Set<T>(string key, T obj, TimeSpan span);
    Task<T> TryGet<T>(string key);
    Task Remove(string key);
}

Now, all I needed to do is to create an implementation using Cosmos DB and update the DI configuration to use it.

To start with, I'll need to add the Cosmos DB package. There are multiple ways to do it, I prefer using the command line by running dotnet add package Microsoft.Azure.DocumentDB.Core.

I know I know, it says DocumentDB in the package. Microsoft will change it eventually.

And here's how my implementation is going to look like. Just so it's simpler to understand, I'm going to break down the code in multiple parts and explain the process. For the constructor, all I'm passing is a custom object that contains all the settings I need to connect to a Cosmos DB instance. I'm also setting some defaults for the JsonSerializer, although this is not mandatory at all.

public CosmosCacheHelper(CosmosDbConfig config)
{
    _config = config;
    _jsonSerializer = new JsonSerializerSettings
    {
        NullValueHandling = NullValueHandling.Ignore,
        MissingMemberHandling = MissingMemberHandling.Ignore,
        ContractResolver = new CamelCasePropertyNamesContractResolver()
    };
    this._client = new DocumentClient(
        new Uri(_config.Endpoint), 
        _config.Key, 
        _jsonSerializer);
}

I've created a custom class to store the data I need in the cache. The Id and Data properties are pretty obvious, but what the heck is Ttl? TTL is Time to Live which is a property that will tell Cosmos DB to auto delete a document after a certain number of seconds.

private class CacheDocument<T>
{
    public string Id { get; set; }
    public long Ttl { get; set; }
    public T Data { get; set; }
}

The Set method will receive a key and the object itself and also a time to live as a TimeSpan. When I create the document, I'm coverting the TimeSpan into total seconds and setting the Ttl property.

public async Task Set<T>(string key, T item, TimeSpan span)
{
    var entry = new CacheDocument<T> {
        Data = item, 
        Id = key, 
        Ttl = Convert.ToInt64(span.TotalSeconds)};
    
    await _client.UpsertDocumentAsync(
        UriFactory.CreateDocumentCollectionUri(
            _config.DatabaseId, _config.CollectionId), entry,
        new RequestOptions {JsonSerializerSettings = _jsonSerializer});
}

Time to retrieve some data from the cache. If you know a bit of Cosmos DB, you might notice that I'm setting the PartitionKey the same as the key itself, which is not the most optimal way of using the PartitionKey, but that will do for now.

public async Task<T> TryGet<T>(string key)
{
    try
    {
        Document document =
          _client.ReadDocumentAsync(
              UriFactory.CreateDocumentUri(_config.DatabaseId,
                _config.CollectionId, key),
                new RequestOptions {
                PartitionKey = new PartitionKey(key)
            }).GetAwaiter().GetResult();
        var entry = (CacheDocument<T>) (dynamic) document;
        return entry.Data;
    }
    catch (DocumentClientException e)
    {
        if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
        { return default(T); }
        throw;
    }
}

Finally the Remove method which is not going to be used often, but here it is.

public async Task Remove(string key)
{
    await _client.DeleteDocumentAsync(
        UriFactory.CreateDocumentUri(
            _config.DatabaseId, _config.CollectionId, key), 
            new RequestOptions{PartitionKey = new PartitionKey(key)});
}

When it comes to the DI configuration, that's how mine is looking like, but for you it will really depend on what DI tool you're using.

builder.Register(a => new CosmosCacheHelper(new CosmosDbConfig
{
    Endpoint = GetEnvironmentVariable("CosmosCache_EndPoint"),
    Key = GetEnvironmentVariable("CosmosCache_Key"),
    DatabaseId = GetEnvironmentVariable("CosmosCache_DatabaseId"),
    CollectionId = GetEnvironmentVariable("CosmosCache_CollectionId")
})).AsImplementedInterfaces();

And here's an example of how you can use it.

var key = $"MyCachedCat{cat.Id}";
if ((await _cacheHelper.TryGet<Cat>(key)) != null)
{
    cached = new Cat { Id = cat.Id };
    await _cacheHelper.Set(key, cached, new TimeSpan(0, 1, 0, 0));
}

And just before I forget, in your Cosmos DB container, make sure you enable time to live, otherwise this solution won't work properly.

I'll evaluate this solution and update the post with some dollar amounts after a few weeks.

Hope it helps.

Cheers