0

I would like to use optimistic concurrency with TransactionScope. Here's the code I come up so far:

var options = new TransactionOptions {IsolationLevel = IsolationLevel.ReadCommitted};
using (var scope = new TransactionScope(TransactionScopeOption.Required, options))
{
    using (var connection = new SqlConnection(_connectionString))
    {
        // ... execute some sql code here

        // bump up version
        var version = connection.ExecuteScalar<DateTime>(@"
            DECLARE @version datetime2 = SYSUTCDATETIME();
            UPDATE [Something].[Test]
                SET [Version] = @version
                WHERE Id = @Id
            SELECT @version
        ", new {Id = id});

        // ... execute more sql code here

        // check if version has not changed since bump up
        // NOTE: version is global for the whole application, not per row basis
        var newVersion = connection.ExecuteScalar<DateTime>("SELECT MAX([Version]) FROM [Something].[Test]");
        if (newVersion == version) scope.Complete(); // looks fine, mark as completed
    }
} // what about changes between scope.Complete() and this line?

Unfortunately this code has one serious issue. Between version check and transaction commit there might be some change in the database. It's a standard time of check to time of use bug. The only way I can see to resolve it is to execute version check and transaction commit as a single command.

Is it possible to execute some SQL code along with transaction commit using TransactionScope? If no then what other solution could be used?

EDIT1: Version needs to be per application, not per row.

EDIT2: I could use serializable isolation level, but it is not an option due to performance issues this would cause.

  • Seems to me like the easiest solution would be to move all this sql code to a stored procedure and put it in a transaction. Honestly moving sql out of your application is a good idea anyway to create a layered architecture and decouple the code from the data implementation. – Sean Lange Feb 13 '17 at 15:22
  • @SeanLange This change will need to be made on already existing system and moving whole logic to stored procedures is not an option. – Damian Kamiński Feb 13 '17 at 16:06

1 Answers1

3

Unfortunately this code has one serious issue. Between version check and transaction commit there might be some change in the database.

This is simply not true. The default constructor of TransactionSope, as in the code you posted, uses the Serializable isolation level. While this is arguably a problem, it does have the side effect of preventing any modification to any row you queried. It is pessimistic concurrency control.

You are right that you should use optimistic concurrency control, though. You need to use a TransactionScope constructor that accepts TransactionOptions and pass in the option to use a more decent isolation level, eg. read committed. As for the row version, use a simple int that you increment with each write in the app.

UPDATE [Something].[Test]
 SET ..., [Version] = @new_version
 OUTPUT Inserted.Id
 WHERE Id = @Id AND [Version] = @old_version;

@old_version is the version you found on the record when you query it. @new_version is @old_version+1. If the row was modified after you read it then the WHERE will not find it and your result will be an empty set, so you know you have to read, refresh and try again (a conflict occurred). This is a well known optimistic control scheme.

Note though that optimistic concurrency control makes more sense where the read and the write span two different transactions (eg. read in T1, display to user form, then write in T2). When the read and the write occur in the same transaction then you better leave it to the engine. I would simply use snapshot isolation level which solves the problem out-of-the-box.

Community
  • 1
  • 1
Remus Rusanu
  • 288,378
  • 40
  • 442
  • 569
  • To be honest, in the real code I've changed isolation level to ReadCommitted. Didn't include it in this sample though. Will edit in the moment. – Damian Kamiński Feb 13 '17 at 15:54
  • Regarding your solution. Looks like I didn't describe my issue well. I need to have a global counter, not per row basis. I cannot use serializable, because there will be a lot of reads and blocking a couple of tables on each write is not an option. Will change the question description in a moment. – Damian Kamiński Feb 13 '17 at 15:56
  • `I need to have a global counter, not per row basis`: maintaining such a counter under concurrency is, basically, *impossible*. What's wrong with counter per row, why can't you use it? – Remus Rusanu Feb 13 '17 at 16:00
  • Basically other system would like to be able to cache significant amount of data from our database and they would like to get only changes. The idea is to have a global counter (datetime2 instead of any other to not keep current counter value anywhere). Counter per row is not an option here. – Damian Kamiński Feb 13 '17 at 16:03
  • [Change Tracking](https://msdn.microsoft.com/en-us/library/bb933875.aspx), [Change Data Capture](https://technet.microsoft.com/en-us/library/bb522489(v=sql.105).aspx). Using a global watermark for changes is fundamentally flawed: it cannot detect deleted rows. – Remus Rusanu Feb 13 '17 at 16:07
  • `it cannot detect deleted rows` True, fortunately our business logic prevents that anyway, so it's not an issue. – Damian Kamiński Feb 13 '17 at 16:12
  • So then your real question is how to generate a global per application incremented counter? Use a [sequence](https://msdn.microsoft.com/en-us/library/ff878370.aspx). – Remus Rusanu Feb 13 '17 at 16:17
  • Sequence would not help me either, because I also need to ensure that transaction committed as last one always have newest version. I believe this is not ensured by a sequence. I think I've found out a solution though. Instead using datetime2 I can use an unique int. This will allow me to try to commit a new, bumped up version, and if that version does already exist then retry (with incremented version) until finally committed without errors. I know, this is far from perfect (global counter is always bad), but it is the simplest solution I can come up with. CDC would be over-engineered. – Damian Kamiński Feb 13 '17 at 16:42
  • Anyway, I thought that there's some clever trick to attach some SQL code to `TransactionScope` commit. If I would know there isn't I definitely would describe whole question better. Many thanks for help, cheers! – Damian Kamiński Feb 13 '17 at 16:46