Saga Persister

Component: Sql Persistence
NuGet Package NServiceBus.Persistence.Sql (2.1)
Target NServiceBus Version: 6.x

Saga Definition

A saga can be implemented as follows:

public class OrderSaga :
    SqlSaga<OrderSaga.SagaData>,
    IAmStartedByMessages<StartOrder>
{
    static ILog log = LogManager.GetLogger<OrderSaga>();

    protected override void ConfigureMapping(IMessagePropertyMapper mapper)
    {
        mapper.ConfigureMapping<StartOrder>(_ => _.OrderId);
    }

    protected override string CorrelationPropertyName => nameof(SagaData.OrderId);

    public Task Handle(StartOrder message, IMessageHandlerContext context)
    {
        log.Info($"Received StartOrder message {Data.OrderId}.");
        MarkAsComplete();
        return Task.CompletedTask;
    }

    public class SagaData :
        ContainSagaData
    {
        public Guid OrderId { get; set; }
    }
}

Note that there are some differences to how a standard NServiceBus saga is implemented.

SqlSaga Base Class

All sagas need to inherit from SqlSaga<T>. This is a custom base class that has a less verbose mapping API.

IL detection of correlation property

The divergence from the standard standard NServiceBus saga API is required since the SQL Persistence need to be able to determine certain meta data about a saga at compile time.

In a standard Saga, the Correlation Id is configured in the ConfigureHowToFindSaga method. On the surface it would seem to be possible to infer the correlation property from that method.

Take this code:

protected override void ConfigureHowToFindSaga(SagaPropertyMapper<SagaData> mapper)
{
    mapper.ConfigureMapping<StartOrder>(message => message.OrderId)
        .ToSaga(data => data.OrderId);
}

At build time the IL of the target assembly is interrogated to generate the SQL installation scripts. The IL will contain enough information to determine that ToSaga is called on the property SagaData.OrderId. However there are several reasons that an explicit property definition was chosen for defining the Correlation Id over inferring it from the ConfigureHowToFindSaga.

Discovering Sagas

At the IL level it is not possible to discover the base hierarchy of a type given the IL for that type alone. So, in IL, to detect if a given type inherits from Saga<T> the full hierarchy of the type needs to be interrogated. This includes loading and interrogating references assemblies, where any types hierarchy extends into those assemblies. This adds significant complexity and performance overheads to the build time operation of generating SQL installation scripts.

Inferring edge cases

While inferring the Correlation Id from the IL of ConfigureHowToFindSaga is possible, there are many edge cases that make this approach problematic. Some of these include:

  • It is possible to map a message to a complex expression. This greatly increases the complexity of accurately determining the Correlation Id due to the higher complexity of the resultant IL.
  • The implementation of ConfigureHowToFindSaga means it is evaluated at run time. So it supports branching logic, performing mapping in helper methods, and mapping in various combinations of base classes and child classes. Use of any of these would prevent determining the Correlation Id from the IL.
  • Mapping performed in another assembly. If the mapping is performed in a helper method or base class, and that implementation exists in another assembly, then this would negatively effect build times due to the necessity of loading and parsing the IL for those assemblies.

Table Structure

Table Name

The name used for a saga table consist of two parts.

  • The prefix of the table name is the Table Prefix defined at the endpoint level.
  • The suffix of the table name is either the saga Type.Name or, if defined, the Table Suffix defined at the saga level.
class MySaga:SqlSaga<MySaga.SagaData>
{
    protected override string TableSuffix => "TheCustomTableName";
Using Delimited Identifiers in the TableSuffix is currently not supported.

Columns

Id

The value of IContainSagaData.Id. Primary Key.

Metadata

Json serialized dictionary containing all NServiceBus managed information about the Saga.

Data

Json serialized saga data

PersistenceVersion

The Assembly version of the SQL Persister.

SagaTypeVersion

The Assembly version of the Assembly where the Saga exists.

Correlation Ids

There is between 0 and 2 correlation id columns named Correlation_[PROPERTYNAME]. The type will correspond to the .NET type of the mapped property on the saga data.

For each Correlation Id there will be a corresponding index named Index_Correlation_[PROPERTYNAME].

Correlation Ids

Saga Message Correlation is implemented by promoting the correlation property to the level of a column on the saga table. So when a saga data is persisted the correlation property is copied from the instance and duplicated in a column named by convention (Correlation_[PROPERTYNAME]) on the table.

No Correlation Id

When implementing a Custom Saga Finder it is possible to have a message that does not map to a correlation id and instead interrogate the Json serialized data stored in the database.

public class SagaWithNoMessageMapping :
    SqlSaga<SagaWithNoMessageMapping.SagaData>
{
    protected override void ConfigureMapping(IMessagePropertyMapper mapper)
    {
    }

    protected override string CorrelationPropertyName => null;

    public class SagaData :
        ContainSagaData
    {
    }
}

Single Correlation Id

In most cases there will be a single correlation Id per Saga Type.

public class SagaWithCorrelation :
    SqlSaga<SagaWithCorrelation.SagaData>,
    IAmStartedByMessages<StartSagaMessage>
{
    protected override void ConfigureMapping(IMessagePropertyMapper mapper)
    {
        mapper.ConfigureMapping<StartSagaMessage>(_ => _.CorrelationProperty);
    }

    protected override string CorrelationPropertyName => nameof(SagaData.CorrelationProperty);

    public Task Handle(StartSagaMessage message, IMessageHandlerContext context)
    {
        return Task.CompletedTask;
    }

    public class SagaData :
        ContainSagaData
    {
        public Guid CorrelationProperty { get; set; }
    }
}

Correlation and Transitional Ids

During the migration from one correlation id to another correlation id there may be two correlation is that coexist. See also Transitioning Correlation ids Sample.

public class SagaWithCorrelationAndTransitional :
    SqlSaga<SagaWithCorrelationAndTransitional.SagaData>,
    IAmStartedByMessages<StartSagaMessage>
{
    protected override void ConfigureMapping(IMessagePropertyMapper mapper)
    {
        mapper.ConfigureMapping<StartSagaMessage>(_ => _.CorrelationProperty);
    }

    protected override string CorrelationPropertyName => nameof(SagaData.CorrelationProperty);

    protected override string TransitionalCorrelationPropertyName => nameof(SagaData.TransitionalCorrelationProperty);

    public Task Handle(StartSagaMessage message, IMessageHandlerContext context)
    {
        return Task.CompletedTask;
    }

    public class SagaData :
        ContainSagaData
    {
        public Guid CorrelationProperty { get; set; }
        public Guid TransitionalCorrelationProperty { get; set; }
    }
}

Correlation Types

Each correlation property type has an equivalent SQL data type.

Microsoft SQL Server

CorrelationPropertyTypeSql Type
Stringnvarchar(200)
DateTimedatetime
DateTimeOffsetdatetimeoffset
Intbigint
Guiduniqueidentifier

MySQL

CorrelationPropertyTypeSql Type
Stringvarchar(200) character set utf8mb4
DateTimedatetime
Intbigint(20)
Guidvarchar(38) character set ascii

Oracle

CorrelationPropertyTypeSql Type
StringNVARCHAR2(200)
DateTimeTIMESTAMP
IntNUMBER(19)
GuidVARCHAR2(38)

The following .NET types are interpreted as CorrelationPropertyType.Int:

Json.net Settings

Custom Settings

Customizes the instance of JsonSerializerSettings used for serialization.

var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.Auto,
    Converters =
    {
        new IsoDateTimeConverter
        {
            DateTimeStyles = DateTimeStyles.RoundtripKind
        }
    }
};
var persistence = endpointConfiguration.UsePersistence<SqlPersistence>();
var sagaSettings = persistence.SagaSettings();
sagaSettings.JsonSettings(settings);

Version / Type specific deserialization settings

The Type and Saga Assembly version are persisted. It is possible to explicitly control the deserialization of sagas based on Version and/or Type. This allows the serialization approach to be evolved forward while avoiding migrations.

var currentSettings = new JsonSerializerSettings
{
    DateFormatHandling = DateFormatHandling.IsoDateFormat
};
var settingForVersion1 = new JsonSerializerSettings
{
    DateFormatHandling = DateFormatHandling.MicrosoftDateFormat
};
var persistence = endpointConfiguration.UsePersistence<SqlPersistence, StorageType.Sagas>();
var sagaSettings = persistence.SagaSettings();
sagaSettings.JsonSettings(currentSettings);
sagaSettings.JsonSettingsForVersion(
    builder: (type, version) =>
    {
        if (version < new Version(2, 0))
        {
            return settingForVersion1;
        }
        // default to what is defined by persistence.JsonSettings()
        return null;
    });

Custom Reader

Customize the creation of the JsonReader.

var persistence = endpointConfiguration.UsePersistence<SqlPersistence>();
var sagaSettings = persistence.SagaSettings();
sagaSettings.ReaderCreator(
    readerCreator: textReader =>
    {
        return new JsonTextReader(textReader);
    });

Custom Writer

Customize the creation of the JsonWriter.

var persistence = endpointConfiguration.UsePersistence<SqlPersistence>();
var sagaSettings = persistence.SagaSettings();
sagaSettings.WriterCreator(
    writerCreator: builder =>
    {
        var writer = new StringWriter(builder);
        return new JsonTextWriter(writer)
        {
            Formatting = Formatting.None
        };
    });

Saga Finder

The SQL Persistence exposes an API to enable creating Saga Finders.

Usage

The API is exposed as an extension method on SynchronizedStorageSession and can be called as follows:

Microsoft SQL Server

On Microsoft SQL Server, the saga finder feature requires the JSON_VALUE function that is only available starting with SQL Server 2016.
class SqlServerSagaFinder :
    IFindSagas<MySagaData>.Using<MyMessage>
{
    public Task<MySagaData> FindBy(MyMessage message, SynchronizedStorageSession session, ReadOnlyContextBag context)
    {
        return session.GetSagaData<MySagaData>(
            context: context,
            whereClause: "JSON_VALUE(Data,'$.PropertyPathInJson') = @propertyValue",
            appendParameters: (builder, append) =>
            {
                var parameter = builder();
                parameter.ParameterName = "propertyValue";
                parameter.Value = message.PropertyValue;
                append(parameter);
            });
    }
}

MySql

class MySqlSagaFinder :
    IFindSagas<MySagaData>.Using<MyMessage>
{
    public Task<MySagaData> FindBy(MyMessage message, SynchronizedStorageSession session, ReadOnlyContextBag context)
    {
        return session.GetSagaData<MySagaData>(
            context: context,
            whereClause: "JSON_EXTRACT(Data,'$.PropertyPathInJson') = @propertyValue",
            appendParameters: (builder, append) =>
            {
                var parameter = builder();
                parameter.ParameterName = "propertyValue";
                parameter.Value = message.PropertyValue;
                append(parameter);
            });
    }
}

Parameters

context

Used to ensure the concurrency metadata is stored in the current session.

whereClause

This text will be appended to a standard Saga select statement:

select
    Id,
    SagaTypeVersion,
    Concurrency,
    Metadata,
    Data
from EndpointName_SagaName

appendParameters

appendParameters allows DbParameters to be appended to the underlying DbCommand that will perform the query.

builder: calls through to DbCommand.CreateParameter to allow construction on a DbParameter.

append: calls through to DbParameterCollection.Add to add the parameter to the underlying DbCommand.

IContainSagaData Construction

Converting the returned information into an IContainSagaData will then be performed by the SQL Persister.

See also SQL Persistence Saga Finder Sample.

Samples

Related Articles


Last modified