Microsoft Bot Framework - Use Redis to store conversation state

Bots created using Microsoft Bot Framework are by default stateless. The conversation state and it’s associated context is stored by Bot State Service in cloud. The state service stores information in 3 distinct bags keyed by their associated ids -

Property Key Description
User User Id Remembering context for a user on a channel
Conversation Conversation Id Remembering context all users in a conversation
Private Conversation Conversation Id + User Id Remembering context for a user in a conversation

Bot State Service documentation provides more detail explanation to them. Moreover all these property bags are scoped by the Bot id and Channel id, essentially making them unique.
The Dialog Stack and Dialog Data are both stored in Private Conversation bag.

If we want to store these data in our database, Bot Framework provides two extension points to do that -

In this post, we will implement IBotDataStore to store the context in Redis. However making a REST service by implementing IBotState should be similar. The source code is available here.

Redis Store

public interface IBotDataStore<T>
{
    Task<bool> FlushAsync(BotDataKey key, CancellationToken cancellationToken);
    Task<T> LoadAsync(BotDataKey key, BotStoreType botStoreType, CancellationToken cancellationToken);
    Task SaveAsync(BotDataKey key, BotStoreType botStoreType, T data, CancellationToken cancellationToken);
}

IBotDataStore is a simple interface with only three methods to implement, all of them being self explanatory. We will start with SaveAsync. We will use StackExchange.Redis for C# client.

public async Task SaveAsync(BotDataKey key, BotStoreType botStoreType, BotData data, CancellationToken cancellationToken)
{
    Connect();

    var redisKey = GetKey(key, botStoreType);
    var serializedData = Serialize(data.Data);

    var database = _connection.GetDatabase(_options.Database);
    var tran = database.CreateTransaction();
    if (data.ETag != "*")
        tran.AddCondition(Condition.HashEqual(redisKey, ETAG_KEY, data.ETag));
    tran.HashSetAsync(redisKey, new HashEntry[]
    {
        new HashEntry(ETAG_KEY, DateTime.UtcNow.Ticks.ToString()),
        new HashEntry(DATA_KEY, serializedData)
    });

    bool committed = await tran.ExecuteAsync();

    if (!committed)
        throw new ConcurrencyException("Inconsistent SaveAsync based on ETag!");
}

The critical part here is to maintain optimistic concurrency control for storing the data. This situation arises most commonly when the bot is in process of handling a user message and the user sends another message. The second message may end up in different bot instance which would start processing with older bot state. We will maintain optimistic concurrency using ETag property of BotData. ETag property will be set to DateTime.UtcNow.Ticks. If it differs while saving the new state, we will throw an exception. To achieve this we will use Transactions in Redis. Note that transactions in Redis works differently than your typical RDBMS. It gets more complicated due to how connection multiplexing is performed by StackExchange Redis client. An excellent explanation of this is given here. In our code, we add a “Condition” for the transaction to be successful. If the condition fails, the entire transaction is void and committed will be false.

The LoadAsync method is pretty simple. We return null if there is no value for a particular key (first time scenario), otherwise we return BotData.

public async Task<BotData> LoadAsync(BotDataKey key, BotStoreType botStoreType, CancellationToken cancellationToken)
{
    Connect();

    var database = _connection.GetDatabase(_options.Database);
    var redisKey = GetKey(key, botStoreType);
    var result = await database.HashGetAllAsync(redisKey);
    if (result == null || result.Count() == 0)
    {
        return null;
    }

    var botData = new BotData();
    botData.ETag = result.Where(t => t.Name.Equals(ETAG_KEY)).FirstOrDefault().Value;
    botData.Data = Deserialize((byte[])result.Where(t => t.Name.Equals(DATA_KEY)).FirstOrDefault().Value);
    return botData;
}

Integrating with Bot

To start using Redis Store instead of Bot State Service, we just need to override the existing Autofac registration of IBotDataStore with new one. The Bot Builder SDK uses CachingBotDataStore implementation for IBotDataStore which is just a decorator for ConnectorStore. We will replace ConnectorStore with RedisStore. Just call the RegisterBotDependencies in Application_Start() in Global.asax.cs and it will work.

private void RegisterBotDependencies()
{
    var builder = new ContainerBuilder();

    RedisStoreOptions redisOptions = new RedisStoreOptions()
    {
        Configuration = "localhost"
    };

    builder.Register(c => new RedisStore(redisOptions))
       .As<RedisStore>()
       .SingleInstance();

    builder.Register(c => new CachingBotDataStore(c.Resolve<RedisStore>(),
                                                  CachingBotDataStoreConsistencyPolicy.ETagBasedConsistency))
        .As<IBotDataStore<BotData>>()
        .AsSelf()
        .InstancePerLifetimeScope();

    builder.Update(Conversation.Container);

    DependencyResolver.SetResolver(new AutofacDependencyResolver(Conversation.Container));
}

Wrapping Up

As mentioned before, the other way to achieve the same is by creating a REST Api and implementing IBotState. However in essence, the way we do this would remain same. Microsoft Bot Builder is highly flexible when it comes to extending it with custom logics. Again the source code is at my github repo. I have also published it as a nuget package.

I hope you liked it. Post a comment if you have any question.

comments powered by Disqus