Skip to content

Commit 85cac96

Browse files
committed
Nested database context scope.
1 parent 79af1a9 commit 85cac96

15 files changed

+82
-75
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ The default JSON settings structure is as follows:
6565

6666
# DatabaseContextScope
6767

68-
The `DatabaseContextService` contains a collection of the `DatabaseContext` instances created by `IDatabaseContextFactory`. However, these will not be flowed across async contexts.
68+
The `DatabaseContextService` contains a collection of the `DatabaseContext` instances created by `IDatabaseContextFactory`. However, since the `DatabaseContextService` is a singleton the same collection will be used in all thread contexts. This includes not only the same execution context, but also "peered" execution context running in parallel.
6969

70-
To enable async context flow wrap the initial database context creation in a `using` statement:
70+
To enable an individual execution/thread context-specific collection which also enables async context flow wrap the initial database context creation in a new `DatabaseContextScope()`:
7171

7272
``` c#
7373
using (new DatabaseContextScope())

Shuttle.Core.Data.Tests/AsyncFixture.cs

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
using System.Collections.Generic;
33
using System.Threading;
44
using System.Threading.Tasks;
5+
using System.Transactions;
56
using NUnit.Framework;
67

78
namespace Shuttle.Core.Data.Tests;
89

910
public class AsyncFixture : MappingFixture
1011
{
11-
private readonly Query _rowsQuery = new(@"
12+
private readonly Query _rowsQuery = new Query(@"
1213
select
1314
Id,
1415
Name,
@@ -53,26 +54,61 @@ public void Should_be_able_to_use_the_different_database_context_for_separate_th
5354
}
5455

5556
[Test]
56-
public void Should_be_able_to_use_the_same_connection_name_for_separate_tasks_and_have_them_block_async()
57+
public void Should_not_be_able_to_create_duplicate_database_contexts_async()
5758
{
58-
var tasks = new List<Task>();
59+
using (new DatabaseContextScope())
60+
using (DatabaseContextFactory.Create())
61+
{
62+
Assert.That(()=> DatabaseContextFactory.Create(), Throws.InvalidOperationException);
63+
}
64+
}
65+
66+
[Test]
67+
public async Task Should_be_able_to_create_nested_database_context_scopes_async()
68+
{
69+
await using (DatabaseContextFactory.Create())
70+
{
71+
await DatabaseGateway.ExecuteAsync(new Query("DROP TABLE IF EXISTS dbo.Nested"));
72+
await DatabaseGateway.ExecuteAsync(new Query("CREATE TABLE dbo.Nested (Id int)"));
73+
}
5974

6075
using (new DatabaseContextScope())
76+
await using (DatabaseContextFactory.Create())
6177
{
62-
for (var i = 0; i < 10; i++)
78+
var count = await DatabaseGateway.GetScalarAsync<int>(new Query("select count(*) from dbo.Nested"));
79+
80+
Assert.That(count, Is.Zero);
81+
82+
await DatabaseGateway.ExecuteAsync(new Query("INSERT INTO dbo.Nested (Id) VALUES (1)"));
83+
84+
count = await DatabaseGateway.GetScalarAsync<int>(new Query("select count(*) from dbo.Nested"));
85+
86+
Assert.That(count, Is.EqualTo(1));
87+
88+
using (new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled))
89+
using (new DatabaseContextScope())
90+
await using (DatabaseContextFactory.Create())
6391
{
64-
tasks.Add(Task.Run(() =>
65-
{
66-
using (DatabaseContextFactory.Create())
67-
{
68-
Console.WriteLine($"{DateTime.Now:O}");
69-
DatabaseGateway.GetRowsAsync(_rowsQuery);
70-
}
71-
}));
92+
await DatabaseGateway.ExecuteAsync(new Query("INSERT INTO dbo.Nested (Id) VALUES (2)"));
93+
94+
count = await DatabaseGateway.GetScalarAsync<int>(new Query("select count(*) from dbo.Nested"));
95+
96+
Assert.That(count, Is.EqualTo(2));
7297
}
98+
99+
count = await DatabaseGateway.GetScalarAsync<int>(new Query("select count(*) from dbo.Nested"));
100+
101+
Assert.That(count, Is.EqualTo(1));
73102
}
74103

75-
Task.WaitAll(tasks.ToArray());
104+
await using (DatabaseContextFactory.Create())
105+
{
106+
var count = await DatabaseGateway.GetScalarAsync<int>(new Query("select count(*) from dbo.Nested"));
107+
108+
Assert.That(count, Is.EqualTo(1));
109+
110+
await DatabaseGateway.ExecuteAsync(new Query("DROP TABLE IF EXISTS dbo.Nested"));
111+
}
76112
}
77113

78114
[Test]

Shuttle.Core.Data.Tests/DatabaseContextFactoryFixture.cs

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,6 @@ public void Should_be_able_to_create_a_database_context()
1818
}
1919
}
2020

21-
[Test]
22-
public void Should_be_able_to_time_out_when_creating_another_context_with_the_same_name_as_an_existing_context_tha_does_not_complete()
23-
{
24-
var factory = DatabaseContextFactory;
25-
26-
using (factory.Create(DefaultConnectionStringName))
27-
{
28-
Assert.That(() => factory.Create(DefaultConnectionStringName, TimeSpan.FromMilliseconds(20)), Throws.TypeOf<TimeoutException>());
29-
}
30-
}
3121

3222
[Test]
3323
public void Should_be_able_to_check_connection_availability()
@@ -37,8 +27,8 @@ public void Should_be_able_to_check_connection_availability()
3727
Assert.That(databaseContextFactory.Object.IsAvailable(new CancellationToken(), 0, 0), Is.True);
3828
Assert.That(databaseContextFactory.Object.IsAvailable("name", new CancellationToken(), 0, 0), Is.True);
3929

40-
databaseContextFactory.Setup(m => m.Create(null)).Throws(new Exception());
41-
databaseContextFactory.Setup(m => m.Create(It.IsAny<string>(), null)).Throws(new Exception());
30+
databaseContextFactory.Setup(m => m.Create()).Throws(new Exception());
31+
databaseContextFactory.Setup(m => m.Create(It.IsAny<string>())).Throws(new Exception());
4232

4333
Assert.That(databaseContextFactory.Object.IsAvailable(new CancellationToken(), 0, 0), Is.False);
4434
Assert.That(databaseContextFactory.Object.IsAvailable("name", new CancellationToken(), 0, 0), Is.False);

Shuttle.Core.Data.Tests/DatabaseContextFixture.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Data.Common;
3-
using System.Threading;
43
using System.Threading.Tasks;
54
using Microsoft.Data.SqlClient;
65
using Moq;
@@ -17,7 +16,7 @@ public void Should_not_be_able_to_create_an_invalid_connection()
1716
{
1817
Assert.Throws<ArgumentException>(() =>
1918
{
20-
using (new DatabaseContext("context", "Microsoft.Data.SqlClient", new SqlConnection("```"), new Mock<IDbCommandFactory>().Object, DatabaseContextService, new SemaphoreSlim(1,1)))
19+
using (new DatabaseContext("context", "Microsoft.Data.SqlClient", new SqlConnection("```"), new Mock<IDbCommandFactory>().Object, DatabaseContextService))
2120
{
2221
}
2322
});
@@ -28,7 +27,7 @@ public void Should_not_be_able_to_create_a_non_existent_connection()
2827
{
2928
Assert.Throws<SqlException>(() =>
3029
{
31-
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", new SqlConnection("data source=.;initial catalog=idontexist;integrated security=sspi"), new Mock<IDbCommandFactory>().Object, DatabaseContextService, new SemaphoreSlim(0, 1)))
30+
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", new SqlConnection("data source=.;initial catalog=idontexist;integrated security=sspi"), new Mock<IDbCommandFactory>().Object, DatabaseContextService))
3231
{
3332
databaseContext.CreateCommand(new Query("select 1"));
3433
}
@@ -49,7 +48,7 @@ public async Task Should_be_able_to_begin_and_commit_a_transaction_async()
4948

5049
private async Task Should_be_able_to_begin_and_commit_a_transaction_async(bool sync)
5150
{
52-
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService, new SemaphoreSlim(0, 1)))
51+
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService))
5352
{
5453
if (sync)
5554
{
@@ -78,7 +77,7 @@ public async Task Should_be_able_to_begin_and_rollback_a_transaction_async()
7877

7978
private async Task Should_be_able_to_begin_and_rollback_a_transaction_async(bool sync)
8079
{
81-
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, new DatabaseContextService(), new SemaphoreSlim(0, 1)))
80+
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, new DatabaseContextService()))
8281
{
8382
if (sync)
8483
{
@@ -105,7 +104,7 @@ public async Task Should_be_able_to_call_commit_without_a_transaction_async()
105104

106105
private async Task Should_be_able_to_call_commit_without_a_transaction_async(bool sync)
107106
{
108-
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService, new SemaphoreSlim(0, 1)))
107+
await using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService))
109108
{
110109
if (sync)
111110
{
@@ -121,7 +120,7 @@ private async Task Should_be_able_to_call_commit_without_a_transaction_async(boo
121120
[Test]
122121
public void Should_be_able_to_call_dispose_more_than_once()
123122
{
124-
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService, new SemaphoreSlim(0, 1)))
123+
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", DbConnectionFactory.Create(DefaultProviderName, DefaultConnectionString), new Mock<IDbCommandFactory>().Object, DatabaseContextService))
125124
{
126125
databaseContext.Dispose();
127126
databaseContext.Dispose();
@@ -138,7 +137,7 @@ public void Should_be_able_to_create_a_command()
138137

139138
dbCommandFactory.Setup(m => m.Create(dbConnection, query.Object)).Returns(dbCommand.Object);
140139

141-
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", dbConnection, dbCommandFactory.Object, DatabaseContextService, new SemaphoreSlim(0, 1)))
140+
using (var databaseContext = new DatabaseContext("context", "Microsoft.Data.SqlClient", dbConnection, dbCommandFactory.Object, DatabaseContextService))
142141
{
143142
databaseContext.CreateCommand(query.Object);
144143
}

Shuttle.Core.Data.Tests/DatabaseContextServiceFixture.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Data.Common;
2-
using System.Threading;
32
using Moq;
43
using NUnit.Framework;
54

@@ -12,11 +11,11 @@ public void Should_be_able_to_use_different_contexts()
1211
{
1312
var service = new DatabaseContextService();
1413

15-
var context1 = new DatabaseContext("mock-1", "provider-name", new Mock<DbConnection>().Object, new Mock<IDbCommandFactory>().Object, service, new SemaphoreSlim(1, 1));
14+
var context1 = new DatabaseContext("mock-1", "provider-name", new Mock<DbConnection>().Object, new Mock<IDbCommandFactory>().Object, service);
1615

1716
Assert.That(service.Active.Name, Is.EqualTo(context1.Name));
1817

19-
var context2 = new DatabaseContext("mock-2", "provider-name", new Mock<DbConnection>().Object, new Mock<IDbCommandFactory>().Object, service, new SemaphoreSlim(1, 1));
18+
var context2 = new DatabaseContext("mock-2", "provider-name", new Mock<DbConnection>().Object, new Mock<IDbCommandFactory>().Object, service);
2019

2120
Assert.That(service.Active.Name, Is.EqualTo(context2.Name));
2221

Shuttle.Core.Data/.package/package.nuspec

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<package>
44
<metadata>
55
<id>Shuttle.Core.Data</id>
6-
<version>16.0.2</version>
6+
<version>17.0.0</version>
77
<authors>Eben Roux</authors>
88
<owners>Eben Roux</owners>
99
<license type="expression">BSD-3-Clause</license>
@@ -21,8 +21,7 @@
2121
<dependency id="Microsoft.Extensions.Configuration.Binder" version="7.0.3" />
2222
<dependency id="Microsoft.Extensions.DependencyInjection" version="7.0.0" />
2323
<dependency id="Microsoft.Extensions.Options" version="7.0.1" />
24-
<dependency id="Shuttle.Core.Contract" version="11.1.0" />
25-
<dependency id="Shuttle.Core.Threading" version="13.0.0" />
24+
<dependency id="Shuttle.Core.Contract" version="11.1.1" />
2625
</dependencies>
2726
</metadata>
2827
<files>

Shuttle.Core.Data/Configuration/DatabaseContextFactoryOptions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ namespace Shuttle.Core.Data
55
public class DatabaseContextFactoryOptions
66
{
77
public string DefaultConnectionStringName { get; set; }
8-
public TimeSpan DefaultCreateTimeout { get; set; } = TimeSpan.FromSeconds(30);
98
}
109
}

Shuttle.Core.Data/DatabaseContext.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,19 @@ namespace Shuttle.Core.Data
99
{
1010
public class DatabaseContext : IDatabaseContext
1111
{
12-
private readonly SemaphoreSlim _lock;
1312
private readonly IDatabaseContextService _databaseContextService;
1413
private readonly IDbCommandFactory _dbCommandFactory;
1514
private readonly IDbConnection _dbConnection;
1615
private bool _disposed;
1716

18-
public DatabaseContext(string name, string providerName, IDbConnection dbConnection, IDbCommandFactory dbCommandFactory, IDatabaseContextService databaseContextService, SemaphoreSlim semaphoreSlim)
17+
public DatabaseContext(string name, string providerName, IDbConnection dbConnection, IDbCommandFactory dbCommandFactory, IDatabaseContextService databaseContextService)
1918
{
2019
Name = Guard.AgainstNullOrEmptyString(name, nameof(name));
2120
ProviderName = Guard.AgainstNullOrEmptyString(providerName, "providerName");
2221

2322
_dbCommandFactory = Guard.AgainstNull(dbCommandFactory, nameof(dbCommandFactory));
2423
_databaseContextService = Guard.AgainstNull(databaseContextService, nameof(databaseContextService));
2524
_dbConnection = Guard.AgainstNull(dbConnection, nameof(dbConnection));
26-
_lock = Guard.AgainstNull(semaphoreSlim, nameof(semaphoreSlim));
2725

2826
_databaseContextService.Add(this);
2927
}
@@ -105,7 +103,6 @@ public void Dispose()
105103
}
106104
finally
107105
{
108-
_lock.Release();
109106
_disposed = true;
110107
}
111108

Shuttle.Core.Data/DatabaseContextFactory.cs

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ public class DatabaseContextFactory : IDatabaseContextFactory
1616

1717
private readonly IDbConnectionFactory _dbConnectionFactory;
1818
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
19-
private readonly Dictionary<string, SemaphoreSlim> _semaphores = new Dictionary<string, SemaphoreSlim>();
2019

2120
public DatabaseContextFactory(IOptionsMonitor<ConnectionStringOptions> connectionStringOptions, IOptions<DataAccessOptions> dataAccessOptions, IDbConnectionFactory dbConnectionFactory, IDbCommandFactory dbCommandFactory, IDatabaseContextService databaseContextService)
2221
{
@@ -32,22 +31,12 @@ public DatabaseContextFactory(IOptionsMonitor<ConnectionStringOptions> connectio
3231

3332
public event EventHandler<DatabaseContextEventArgs> DatabaseContextCreated;
3433

35-
public IDatabaseContext Create(string connectionStringName, TimeSpan? timeout = null)
34+
public IDatabaseContext Create(string connectionStringName)
3635
{
3736
Guard.AgainstNullOrEmptyString(connectionStringName, nameof(connectionStringName));
3837

3938
_lock.Wait();
4039

41-
if (!_semaphores.ContainsKey(connectionStringName))
42-
{
43-
_semaphores.Add(connectionStringName, new SemaphoreSlim(1, 1));
44-
}
45-
46-
if (!_semaphores[connectionStringName].Wait(timeout ?? _dataAccessOptions.DatabaseContextFactory.DefaultCreateTimeout))
47-
{
48-
throw new TimeoutException(string.Format(Resources.DatabaseContextFactoryTimeoutException, connectionStringName, timeout ?? _dataAccessOptions.DatabaseContextFactory.DefaultCreateTimeout));
49-
}
50-
5140
try
5241
{
5342
var connectionStringOptions = _connectionStringOptions.Get(connectionStringName);
@@ -62,7 +51,7 @@ public IDatabaseContext Create(string connectionStringName, TimeSpan? timeout =
6251
throw new InvalidOperationException(string.Format(Resources.DuplicateDatabaseContextException, connectionStringName));
6352
}
6453

65-
var databaseContext = new DatabaseContext(connectionStringName, connectionStringOptions.ProviderName, (DbConnection)_dbConnectionFactory.Create(connectionStringOptions.ProviderName, connectionStringOptions.ConnectionString), _dbCommandFactory, _databaseContextService, _semaphores[connectionStringName]);
54+
var databaseContext = new DatabaseContext(connectionStringName, connectionStringOptions.ProviderName, (DbConnection)_dbConnectionFactory.Create(connectionStringOptions.ProviderName, connectionStringOptions.ConnectionString), _dbCommandFactory, _databaseContextService);
6655

6756
DatabaseContextCreated?.Invoke(this, new DatabaseContextEventArgs(databaseContext));
6857

@@ -74,14 +63,14 @@ public IDatabaseContext Create(string connectionStringName, TimeSpan? timeout =
7463
}
7564
}
7665

77-
public IDatabaseContext Create(TimeSpan? timeout = null)
66+
public IDatabaseContext Create()
7867
{
7968
if (string.IsNullOrEmpty(_dataAccessOptions.DatabaseContextFactory.DefaultConnectionStringName))
8069
{
8170
throw new InvalidOperationException(Resources.DatabaseContextFactoryOptionsException);
8271
}
8372

84-
return Create(_dataAccessOptions.DatabaseContextFactory.DefaultConnectionStringName, timeout);
73+
return Create(_dataAccessOptions.DatabaseContextFactory.DefaultConnectionStringName);
8574
}
8675
}
8776
}
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Threading;
34

45
namespace Shuttle.Core.Data
56
{
67
public class DatabaseContextScope : IDisposable
78
{
9+
private static readonly AsyncLocal<Stack<DatabaseContextCollection>> DatabaseContextCollectionStack = new AsyncLocal<Stack<DatabaseContextCollection>>();
810
private static readonly AsyncLocal<DatabaseContextCollection> AmbientData = new AsyncLocal<DatabaseContextCollection>();
911

1012
public DatabaseContextScope()
1113
{
12-
if (AmbientData.Value != null)
14+
if (DatabaseContextCollectionStack.Value == null)
1315
{
14-
throw new InvalidOperationException(Resources.AmbientScopeException);
16+
DatabaseContextCollectionStack.Value = new Stack<DatabaseContextCollection>();
1517
}
1618

19+
DatabaseContextCollectionStack.Value.Push(AmbientData.Value);
20+
1721
AmbientData.Value = new DatabaseContextCollection();
1822
}
1923

2024
public static DatabaseContextCollection Current => AmbientData.Value;
2125

2226
public void Dispose()
2327
{
24-
AmbientData.Value = null;
28+
AmbientData.Value = DatabaseContextCollectionStack.Value.Pop();
2529
}
2630
}
2731
}

Shuttle.Core.Data/Extensions/DatabaseContextFactoryExtensions.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
using System;
2-
using System.Data;
3-
using System.Data.Common;
42
using System.Threading;
53
using Shuttle.Core.Contract;
64

75
namespace Shuttle.Core.Data
86
{
97
public static class DatabaseContextFactoryExtensions
108
{
11-
public static bool IsAvailable(this IDatabaseContextFactory databaseContextFactory,
12-
CancellationToken cancellationToken, int retries = 4, int secondsBetweenRetries = 15)
9+
public static bool IsAvailable(this IDatabaseContextFactory databaseContextFactory, CancellationToken cancellationToken, int retries = 4, int secondsBetweenRetries = 15)
1310
{
1411
Guard.AgainstNull(databaseContextFactory, nameof(databaseContextFactory));
1512

0 commit comments

Comments
 (0)