2

I'm running SQL Server 2014 locally for a database that will be deployed to an Azure SQL V12 database.

I have a table that stores values of extensible properties for a business-entity object, in this case the three tables look like this:

CREATE TABLE Widgets (
    WidgetId bigint IDENTITY(1,1),
    ...
)

CREATE TABLE WidgetProperties (
    PropertyId int IDENTITY(1,1),
    Name       nvarchar(50)
    Type       int -- 0 = int, 1 = string, 2 = date, etc
)

CREATE TABLE WidgetPropertyValues (
    WidgetId   bigint,
    PropertyId int,
    Revision   int,
    DateTime   datetimeoffset(7),
    Value      varbinary(255)

    CONSTRAINT [PK_WidgetPropertyValues] PRIMARY KEY CLUSTERED (
        [WidgetId] ASC,
        [PropertyIdId] ASC,
        [Revision] ASC
    )
)

ALTER TABLE dbo.WidgetPropertyValues WITH CHECK ADD CONSTRAINT FK_WidgetPropertyValues_WidgetProperties FOREIGN KEY( PropertyId )
REFERENCES dbo.WidgetProperties ( PropertyId )

ALTER TABLE dbo.WidgetPropertyValues WITH CHECK ADD  CONSTRAINT FK_WidgetPropertyValues_Widgets FOREIGN KEY( WidgetId )
REFERENCES dbo.Widgets ( WidgetId )

So you see how WidgetId, PropertyId, Revision is a composite key and the table stores the entire history of Values (the current values are obtained by getting the rows with the biggest Revision number for each WidgetId + PropertyId.

I want to know how I can set-up the Revision column to increment by 1 for each WidgetId + PropertyId. I want data like this:

WidgetId, PropertyId, Revision, DateTime, Value
------------------------------------------------
       1           1         1               123
       1           1         2               456
       1           1         3               789
       1           2         1               012

IDENTITY wouldn't work because it's global to the table and the same applies with SEQUENCE objects.

Update I can think of a possible solution using an INSTEAD OF INSERT trigger:

CREATE TRIGGER WidgetPropertyValueInsertTrigger ON WidgetPropertyValues
    INSTEAD OF INSERT
AS
BEGIN
    DECLARE @maxRevision int
    SELECT @maxRevision = ISNULL( MAX( Revision ), 0 ) FROM WidgetPropertyValues WHERE WidgetId = INSERTED.WidgetId AND PropertyId = INSERTED.PropertyId

    INSERT INTO WidgetPropertyValues VALUES (
        INSERTED.WidgetId,
        INSERTED.PropertyId,
        @maxRevision + 1,
        INSERTED.DateTime,
        INSERTED.Value,
    )
END

(For the uninitiated, INSTEAD OF INSERT triggers run instead of any INSERT operation on the table, compared to a normal INSERT-trigger which runs before or after an INSERT operation)

I think this would be concurrency-safe because all INSERT operations have an implicit transaction, and any associated triggers are executed in the same transaction context, which should mean it's safe. Unless anyone can claim otherwise?

Kamil Gosciminski
  • 16,547
  • 8
  • 49
  • 72
Dai
  • 141,631
  • 28
  • 261
  • 374
  • If you had `IDENTITY` you could use `row_number()` window function to enumerate revisions based on their insert order. – Kamil Gosciminski Jan 06 '16 at 01:23
  • Or use a sequence to do the @ConsiderMe suggestion without adding a field to the table itself – Jorge Campos Jan 06 '16 at 01:24
  • @ConsiderMe Business requirements mean the `Revision` number does need to be accurate and contiguous so we can tell when a row has been deleted. – Dai Jan 06 '16 at 01:24
  • @ConsiderMe and if I used `IDENTITY` then the table's composite key contains a surrogate key, which isn't ideal. – Dai Jan 06 '16 at 01:25
  • 1
    @Dai you need some kind of a column to be able to assign correct revision numbers if you already have the data in your table. Storing such information in a multi-user environment would cause problems because of concurrency. There are some workarounds for this, though, which involve storing the relevant part on which revision is being applied in another table and acquiring a row-level lock. – Kamil Gosciminski Jan 06 '16 at 01:29
  • @ConsiderMe What about a `DEFAULT` constraint that calls a function that gets the maximum `Revision` number for a given `WidgetId+PropertyId`? – Dai Jan 06 '16 at 01:33
  • You may find some clarification in @ErwinBrandstetter answer to this question http://stackoverflow.com/questions/34571125/custom-serial-autoincrement-per-group-of-values/34571410#34571410 - be sure to read the comments – Kamil Gosciminski Jan 06 '16 at 01:38
  • Hey @Dai you want something like this: http://sqlfiddle.com/#!6/69e25/5 on the fly? I mean on the insert statement? – Jorge Campos Jan 06 '16 at 01:44
  • @ConsiderMe I've amended my question with a suggestion regarding trigger usage. – Dai Jan 06 '16 at 01:50
  • 2
    @ConsiderMe Actually, this is moot. I've decided to go with noncontiguous `Revision` values and use `IDENTITY` instead. – Dai Jan 06 '16 at 01:53
  • 1
    @Dai as far as the trigger solution is concerned, there is a time gap between selecting and inserting - this is why it could break. – Kamil Gosciminski Jan 06 '16 at 01:59

1 Answers1

0

You code has a race condition - a concurrent transaction might select and insert the same Revision between your SELECT and your INSERT. That could cause occasional (primary) key violations in concurrent environment (forcing you to retry the entire transaction).

Instead of retrying the whole transaction, a better strategy is to retry only the INSERT. Simply put your code in a loop, and if key violation (and only key violation) happens, increment the Revision and try again.

Something like this (writing from my head):

DECLARE @maxRevision int = (
    SELECT
        @maxRevision = ISNULL(MAX(Revision), 0)
    FROM
        WidgetPropertyValues
    WHERE
        WidgetId = INSERTED.WidgetId
        AND PropertyId = INSERTED.PropertyId
);

WHILE 0 = 0 BEGIN

    SET @maxRevision = @maxRevision + 1;

    BEGIN TRY

        INSERT INTO WidgetPropertyValues
        VALUES (
            INSERTED.WidgetId,
            INSERTED.PropertyId,
            @maxRevision,
            INSERTED.DateTime,
            INSERTED.Value,
        );

        BREAK;

    END TRY
    BEGIN CATCH

        -- The error was different from key violation,
        -- in which case we just pass it back to caller.
        IF ERROR_NUMBER() <> 2627
            THROW;

        -- Otherwise, this was a key violation, and we can let the loop 
        -- enter the next iteration (to retry with the incremented value).

    END CATCH

END
Branko Dimitrijevic
  • 50,809
  • 10
  • 93
  • 167