diff --git a/source/Nevermore.IntegrationTests/Advanced/CustomKeyAllocatorFixture.cs b/source/Nevermore.IntegrationTests/Advanced/CustomKeyAllocatorFixture.cs index d48c63c8..13c36e04 100644 --- a/source/Nevermore.IntegrationTests/Advanced/CustomKeyAllocatorFixture.cs +++ b/source/Nevermore.IntegrationTests/Advanced/CustomKeyAllocatorFixture.cs @@ -105,12 +105,12 @@ public void Reset() allocations.Clear(); } - public int NextId(string tableName) + public long NextId(string tableName) { return allocations.AddOrUpdate(tableName, (_) => 100, (_, prev) => prev + 100); } - public ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken) + public ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken) { return ValueTask.FromResult(NextId(tableName)); } diff --git a/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs b/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs index ee983f60..7df599bb 100644 --- a/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs +++ b/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs @@ -112,7 +112,7 @@ public void ShouldAllocateForDifferentCollections() AssertNext(allocator, "Todos", 4); AssertNext(allocator, "Todos", 5); } - + [Test] public async Task NextIdAsync_ShouldAllocateForDifferentCollections() { @@ -128,6 +128,57 @@ public async Task NextIdAsync_ShouldAllocateForDifferentCollections() await AssertNextAsync(allocator, "Todos", 4); await AssertNextAsync(allocator, "Todos", 5); } + + [Test] + public void CanAllocateLongKeys() + { + var allocator = new KeyAllocator(Store, 10); + + var collectionName = Guid.NewGuid().ToString("N"); // collection name doesn't matter, use a GUID to avoid colliding with other tests + + using (var tx = Store.BeginWriteTransaction()) + { + tx.ExecuteNonQuery("INSERT INTO [KeyAllocation] ([CollectionName], [Allocated]) VALUES (@collectionName, @allocated)", + new CommandParameterValues + { + { "collectionName", collectionName }, + { "allocated", int.MaxValue - 1 } + }); + tx.Commit(); + } + + // the allocator will try and allocate the next block of 10, from int.max-1 to int.max+9. + // the fact that it doesn't throw an exception is proof enough, but let's check the return values + // to be sure + AssertNext(allocator, collectionName, (long)int.MaxValue); + AssertNext(allocator, collectionName, (long)int.MaxValue + 1); + } + + [Test] + public async Task NextIdAsync_CanAllocateLongKeys() + { + var allocator = new KeyAllocator(Store, 10); + + var collectionName = Guid.NewGuid().ToString("N"); // collection name doesn't matter, use a GUID to avoid colliding with other tests + + using (var tx = await Store.BeginWriteTransactionAsync()) + { + await tx.ExecuteNonQueryAsync("INSERT INTO [KeyAllocation] ([CollectionName], [Allocated]) VALUES (@collectionName, @allocated)", + new CommandParameterValues + { + { "collectionName", collectionName }, + { "allocated", int.MaxValue - 1 } + }); + await tx.CommitAsync(); + } + + // the allocator will try and allocate the next block of 10, from int.max-1 to int.max+9. + // the fact that it doesn't throw an exception is proof enough, but let's check the return values + // to be sure + await AssertNextAsync(allocator, collectionName, (long)int.MaxValue); + await AssertNextAsync(allocator, collectionName, (long)int.MaxValue + 1); + } + [Test] public void ShouldAllocateInParallel() @@ -249,12 +300,12 @@ public async Task AllocateIdAsync_ShouldAllocateInParallel() customerIdsAfter.Should().BeEquivalentTo(expectedProjectIds); } - static void AssertNext(KeyAllocator allocator, string collection, int expected) + static void AssertNext(KeyAllocator allocator, string collection, long expected) { allocator.NextId(collection).Should().Be(expected); } - static async Task AssertNextAsync(KeyAllocator allocator, string collection, int expected) + static async Task AssertNextAsync(KeyAllocator allocator, string collection, long expected) { (await allocator.NextIdAsync(collection, CancellationToken.None)).Should().Be(expected); } diff --git a/source/Nevermore/Mapping/IKeyAllocator.cs b/source/Nevermore/Mapping/IKeyAllocator.cs index f62ff1f3..9b1504fe 100644 --- a/source/Nevermore/Mapping/IKeyAllocator.cs +++ b/source/Nevermore/Mapping/IKeyAllocator.cs @@ -6,7 +6,7 @@ namespace Nevermore.Mapping public interface IKeyAllocator { void Reset(); - int NextId(string tableName); - ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken); + long NextId(string tableName); + ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/source/Nevermore/Mapping/KeyAllocator.cs b/source/Nevermore/Mapping/KeyAllocator.cs index 3506d291..5322c42f 100644 --- a/source/Nevermore/Mapping/KeyAllocator.cs +++ b/source/Nevermore/Mapping/KeyAllocator.cs @@ -21,21 +21,16 @@ public KeyAllocator(IRelationalStore store, int blockSize) } public void Reset() - { - allocations.Clear(); - } + => allocations.Clear(); - public int NextId(string tableName) - { - var allocation = allocations.GetOrAdd(tableName, _ => new Allocation(store, tableName, blockSize)); - return allocation.Next(); - } + Allocation GetAllocation(string tableName) + => allocations.GetOrAdd(tableName, t => new Allocation(store, t, blockSize)); - public async ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken) - { - var allocation = allocations.GetOrAdd(tableName, _ => new Allocation(store, tableName, blockSize)); - return await allocation.NextAsync(cancellationToken).ConfigureAwait(false); - } + public long NextId(string tableName) + => GetAllocation(tableName).Next(); + + public ValueTask NextIdAsync(string tableName, CancellationToken cancellationToken) + => GetAllocation(tableName).NextAsync(cancellationToken); class Allocation { @@ -43,9 +38,9 @@ class Allocation readonly string collectionName; readonly int blockSize; readonly SemaphoreSlim sync = new(1, 1); - int blockStart; - int blockNext; - int blockFinish; + long blockStart; + long blockNext; + long blockFinish; public Allocation(IRelationalStore store, string collectionName, int blockSize) { @@ -54,78 +49,68 @@ public Allocation(IRelationalStore store, string collectionName, int blockSize) this.blockSize = blockSize; } - public async ValueTask NextAsync(CancellationToken cancellationToken) + public async ValueTask NextAsync(CancellationToken cancellationToken) { - using (await sync.LockAsync(cancellationToken)) + using var releaseLock = await sync.LockAsync(cancellationToken); + + async Task GetNextMaxValue(CancellationToken ct) { - async Task GetNextMaxValue(CancellationToken ct) + using var transaction = await store.BeginWriteTransactionAsync(IsolationLevel.Serializable, name: $"{nameof(KeyAllocator)}.{nameof(Allocation)}.{nameof(GetNextMaxValue)}", cancellationToken: ct).ConfigureAwait(false); + var parameters = new CommandParameterValues { - using var transaction = await store.BeginWriteTransactionAsync(IsolationLevel.Serializable, name: $"{nameof(KeyAllocator)}.{nameof(Allocation)}.{nameof(GetNextMaxValue)}", cancellationToken: ct).ConfigureAwait(false); - var parameters = new CommandParameterValues - { - { "collectionName", collectionName }, - { "blockSize", blockSize } - }; - parameters.CommandType = CommandType.StoredProcedure; - - var result = await transaction.ExecuteScalarAsync("GetNextKeyBlock", parameters, cancellationToken: ct).ConfigureAwait(false); - await transaction.CommitAsync(ct).ConfigureAwait(false); - return result; - } - - async Task ExtendAllocation(CancellationToken ct) + { "collectionName", collectionName }, + { "blockSize", blockSize } + }; + parameters.CommandType = CommandType.StoredProcedure; + + var result = await transaction.ExecuteScalarAsync("GetNextKeyBlock", parameters, cancellationToken: ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + // Older versions of the GetNextKeyBlock stored proc and KeyAllocation table might be using 32-bit ID's + // The type-check here lets us remain compatible with that while supporting 64-bit ID's as well + return result is int i ? i : (long)result; + } + + if (blockNext == blockFinish) + { + await GetRetryPolicy().ExecuteActionAsync(async ct => { var max = await GetNextMaxValue(ct).ConfigureAwait(false); SetRange(max); - } - - if (blockNext == blockFinish) - { - await GetRetryPolicy().ExecuteActionAsync(ExtendAllocation, cancellationToken).ConfigureAwait(false); - } - - var result = blockNext; - blockNext++; - - return result; + }, cancellationToken).ConfigureAwait(false); } + + return blockNext++; } - public int Next() + public long Next() { - using (sync.Lock()) + using var releaseLock = sync.Lock(); + + long GetNextMaxValue() { - int GetNextMaxValue() + using var transaction = store.BeginWriteTransaction(IsolationLevel.Serializable, name: $"{nameof(KeyAllocator)}.{nameof(Allocation)}.{nameof(GetNextMaxValue)}"); + var parameters = new CommandParameterValues { - using var transaction = store.BeginWriteTransaction(IsolationLevel.Serializable, name: $"{nameof(KeyAllocator)}.{nameof(Allocation)}.{nameof(GetNextMaxValue)}"); - var parameters = new CommandParameterValues - { - {"collectionName", collectionName}, - {"blockSize", blockSize} - }; - parameters.CommandType = CommandType.StoredProcedure; - - var result = transaction.ExecuteScalar("GetNextKeyBlock", parameters); - transaction.Commit(); - return result; - } - - void ExtendAllocation() + { "collectionName", collectionName }, + { "blockSize", blockSize } + }; + parameters.CommandType = CommandType.StoredProcedure; + + var result = transaction.ExecuteScalar("GetNextKeyBlock", parameters); + transaction.Commit(); + return result is int i ? i : (long)result; // 32/64-bit compatibility, see NextAsync() for explanation + } + + if (blockNext == blockFinish) + { + GetRetryPolicy().ExecuteAction(() => { var max = GetNextMaxValue(); SetRange(max); - } - - if (blockNext == blockFinish) - { - GetRetryPolicy().ExecuteAction(ExtendAllocation); - } - - var result = blockNext; - blockNext++; - - return result; + }); } + + return blockNext++; } RetryPolicy GetRetryPolicy() @@ -138,7 +123,7 @@ RetryPolicy GetRetryPolicy() /// /// Must only ever be executed while protected by the mutex! /// - void SetRange(int max) + void SetRange(long max) { var first = (max - blockSize) + 1; blockStart = first; @@ -146,10 +131,7 @@ void SetRange(int max) blockFinish = max + 1; } - public override string ToString() - { - return $"{blockStart} to {blockNext} (next: {blockFinish})"; - } + public override string ToString() => $"{blockStart} to {blockNext} (next: {blockFinish})"; } } } \ No newline at end of file diff --git a/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs b/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs index 9098810e..47e592b4 100644 --- a/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs +++ b/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs @@ -9,9 +9,9 @@ namespace Nevermore.Mapping { public sealed class StringPrimaryKeyHandler : AsyncPrimaryKeyHandler { - readonly Func<(string idPrefix, int key), string> format; + readonly Func<(string idPrefix, long key), string> format; - public StringPrimaryKeyHandler(string? idPrefix = null, Func<(string idPrefix, int key), string>? format = null) + public StringPrimaryKeyHandler(string? idPrefix = null, Func<(string idPrefix, long key), string>? format = null) { IdPrefix = idPrefix; this.format = format ?? (x => $"{x.idPrefix}-{x.key}"); diff --git a/source/Nevermore/UpgradeScripts/Script0001-KeyAllocation.sql b/source/Nevermore/UpgradeScripts/Script0001-KeyAllocation.sql index eae04fb8..b61c367f 100644 --- a/source/Nevermore/UpgradeScripts/Script0001-KeyAllocation.sql +++ b/source/Nevermore/UpgradeScripts/Script0001-KeyAllocation.sql @@ -2,7 +2,7 @@ CREATE TABLE dbo.KeyAllocation ( CollectionName nvarchar(50) constraint PK_KeyAllocation_CollectionName primary key, - Allocated int not null + Allocated bigint not null ) GO @@ -18,7 +18,7 @@ CREATE PROCEDURE dbo.GetNextKeyBlock AS BEGIN SET NOCOUNT ON - DECLARE @result int + DECLARE @result bigint UPDATE KeyAllocation SET @result = Allocated = (Allocated + @blockSize) @@ -27,7 +27,7 @@ BEGIN if (@@ROWCOUNT = 0) begin INSERT INTO KeyAllocation (CollectionName, Allocated) values (@collectionName, @blockSize) - SELECT @blockSize + SELECT CAST(@blockSize as bigint) -- type must be the same as @result end SELECT @result