Skip to content

Commit 9e6804c

Browse files
committed
[SQL Server] The database must be locked during migrations and tear down.
1 parent fcfa582 commit 9e6804c

File tree

4 files changed

+190
-19
lines changed

4 files changed

+190
-19
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace Thinktecture.EntityFrameworkCore.Testing;
2+
3+
/// <summary>
4+
/// Options used for locking the database during migrations and tear down.
5+
/// </summary>
6+
public class SqlServerLockTableOptions
7+
{
8+
/// <summary>
9+
/// Indication whether the feature is enabled or not.
10+
/// </summary>
11+
public bool IsEnabled { get; }
12+
13+
/// <summary>
14+
/// The name of the table.
15+
/// Default: '__IntegrationTestIsolation'
16+
/// </summary>
17+
public string Name { get; set; }
18+
19+
/// <summary>
20+
/// The schema of the table.
21+
/// </summary>
22+
public string? Schema { get; set; }
23+
24+
/// <summary>
25+
/// Number of retries for creation of the table.
26+
/// Default: 10
27+
/// </summary>
28+
public int MaxNumberOfLockRetries { get; set; }
29+
30+
/// <summary>
31+
/// Min. delay between retries to create the table.
32+
/// Default: 50ms
33+
/// </summary>
34+
public TimeSpan MinRetryDelay { get; set; }
35+
36+
/// <summary>
37+
/// Max. delay between retries to create the table.
38+
/// Default: 300ms
39+
/// </summary>
40+
public TimeSpan MaxRetryDelay { get; set; }
41+
42+
/// <summary>
43+
/// Initializes new instance of <see cref="SqlServerLockTableOptions"/>.
44+
/// </summary>
45+
/// <param name="isEnabled">Indication whether the feature is enabled or not.</param>
46+
public SqlServerLockTableOptions(bool isEnabled)
47+
{
48+
IsEnabled = isEnabled;
49+
Name = "__IntegrationTestIsolation";
50+
MaxNumberOfLockRetries = 10;
51+
MinRetryDelay = TimeSpan.FromMilliseconds(50);
52+
MaxRetryDelay = TimeSpan.FromMilliseconds(200);
53+
}
54+
}

src/Thinktecture.EntityFrameworkCore.SqlServer.Testing/EntityFrameworkCore/Testing/SqlServerTestDbContextProvider.cs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,21 @@ public class SqlServerTestDbContextProvider<T> : SqlServerTestDbContextProvider,
4343
private readonly Func<DbContextOptions<T>, IDbDefaultSchema, T?>? _contextFactory;
4444
private readonly TestingLoggingOptions _testingLoggingOptions;
4545

46+
private readonly bool _lockTableEnabled;
47+
private readonly string _lockTableName;
48+
private readonly string? _lockTableSchema;
49+
private readonly int _maxNumberOfLockRetries;
50+
private readonly TimeSpan _minRetryDelay;
51+
private readonly TimeSpan _maxRetryDelay;
52+
private readonly Random _random;
53+
4654
private Func<DbContextOptions<T>, IDbDefaultSchema, T>? _defaultContextFactory;
4755
private T? _arrangeDbContext;
4856
private T? _actDbContext;
4957
private T? _assertDbContext;
5058
private IDbContextTransaction? _tx;
5159
private bool _isAtLeastOneContextCreated;
5260
private readonly IsolationLevel _sharedTablesIsolationLevel;
53-
private readonly IsolationLevel _migrationAndCleanupIsolationLevel;
5461
private bool _isDisposed;
5562

5663
/// <inheritdoc />
@@ -89,7 +96,6 @@ protected internal SqlServerTestDbContextProvider(SqlServerTestDbContextProvider
8996
_instanceWideLock = new object();
9097
Schema = options.Schema ?? throw new ArgumentException($"The '{nameof(options.Schema)}' cannot be null.", nameof(options));
9198
_sharedTablesIsolationLevel = ValidateIsolationLevel(options.SharedTablesIsolationLevel);
92-
_migrationAndCleanupIsolationLevel = options.MigrationAndCleanupIsolationLevel ?? IsolationLevel.Serializable;
9399
_isolationOptions = options.IsolationOptions;
94100
_masterConnection = options.MasterConnection ?? throw new ArgumentException($"The '{nameof(options.MasterConnection)}' cannot be null.", nameof(options));
95101
_masterDbContextOptions = options.MasterDbContextOptions ?? throw new ArgumentException($"The '{nameof(options.MasterDbContextOptions)}' cannot be null.", nameof(options));
@@ -99,6 +105,15 @@ protected internal SqlServerTestDbContextProvider(SqlServerTestDbContextProvider
99105
_contextInitializations = options.ContextInitializations ?? throw new ArgumentException($"The '{nameof(options.ContextInitializations)}' cannot be null.", nameof(options));
100106
ExecutedCommands = options.ExecutedCommands;
101107
_contextFactory = options.ContextFactory;
108+
109+
_random = new Random();
110+
111+
_lockTableEnabled = options.LockTable.IsEnabled;
112+
_lockTableName = options.LockTable.Name;
113+
_lockTableSchema = options.LockTable.Schema;
114+
_maxNumberOfLockRetries = options.LockTable.MaxNumberOfLockRetries;
115+
_minRetryDelay = options.LockTable.MinRetryDelay;
116+
_maxRetryDelay = options.LockTable.MaxRetryDelay;
102117
}
103118

104119
private static IsolationLevel ValidateIsolationLevel(IsolationLevel? isolationLevel)
@@ -231,7 +246,57 @@ protected virtual IDbContextTransaction BeginTransaction(T ctx)
231246
{
232247
ArgumentNullException.ThrowIfNull(ctx);
233248

234-
return ctx.Database.BeginTransaction(_migrationAndCleanupIsolationLevel);
249+
if (!_lockTableEnabled)
250+
return ctx.Database.BeginTransaction(IsolationLevel.Serializable);
251+
252+
var sqlGenerationHelper = ctx.GetService<ISqlGenerationHelper>();
253+
var lockTableName = sqlGenerationHelper.DelimitIdentifier(_lockTableName, _lockTableSchema);
254+
255+
CreateTestIsolationTable(ctx, lockTableName);
256+
257+
var tx = ctx.Database.BeginTransaction(IsolationLevel.Serializable);
258+
259+
try
260+
{
261+
LockDatabase(ctx, lockTableName);
262+
}
263+
catch (Exception)
264+
{
265+
tx.Dispose();
266+
throw;
267+
}
268+
269+
return tx;
270+
}
271+
272+
private void CreateTestIsolationTable(T ctx, string lockTableName)
273+
{
274+
var createTableSql = $@"
275+
IF(OBJECT_ID('{lockTableName}') IS NULL)
276+
CREATE TABLE {lockTableName}(Id INT NOT NULL)";
277+
278+
for (var i = 0;; i++)
279+
{
280+
try
281+
{
282+
ctx.Database.ExecuteSqlRaw(createTableSql);
283+
return;
284+
}
285+
catch (Exception)
286+
{
287+
if (i > _maxNumberOfLockRetries)
288+
throw;
289+
290+
var delay = new TimeSpan(_random.NextInt64(_minRetryDelay.Ticks, _maxRetryDelay.Ticks));
291+
Task.Delay(delay).GetAwaiter().GetResult();
292+
}
293+
}
294+
}
295+
296+
private static void LockDatabase(T ctx, string lockTableName)
297+
{
298+
var selectSql = $"SELECT * FROM {lockTableName} WITH (HOLDLOCK, UPDLOCK)";
299+
ctx.Database.ExecuteSqlRaw(selectSql);
235300
}
236301

237302
/// <summary>
@@ -353,7 +418,21 @@ private async Task DisposeContextsAndRollbackMigrationsAsync(CancellationToken c
353418
// Create a new ctx as a last resort to rollback migrations and clean up the database
354419
await using var ctx = _actDbContext ?? _arrangeDbContext ?? _assertDbContext ?? CreateDbContext(_masterDbContextOptions, new DbDefaultSchema(Schema));
355420

356-
await _isolationOptions.CleanupAsync(ctx, Schema, cancellationToken);
421+
IDbContextTransaction? migrationTx = null;
422+
423+
if (ctx.Database.CurrentTransaction is null)
424+
migrationTx = BeginMigrationAndCleanupTransaction(ctx);
425+
426+
try
427+
{
428+
await _isolationOptions.CleanupAsync(ctx, Schema, cancellationToken);
429+
430+
await (migrationTx?.CommitAsync(cancellationToken) ?? Task.CompletedTask);
431+
}
432+
finally
433+
{
434+
await (migrationTx?.DisposeAsync() ?? ValueTask.CompletedTask);
435+
}
357436
}
358437

359438
await (_arrangeDbContext?.DisposeAsync() ?? ValueTask.CompletedTask);

src/Thinktecture.EntityFrameworkCore.SqlServer.Testing/EntityFrameworkCore/Testing/SqlServerTestDbContextProviderBuilder.cs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class SqlServerTestDbContextProviderBuilder<T> : TestDbContextProviderBui
2727
private bool _useThinktectureSqlServerMigrationsSqlGenerator = true;
2828
private string? _sharedTablesSchema;
2929
private IsolationLevel? _sharedTablesIsolationLevel;
30-
private IsolationLevel? _migrationAndCleanupIsolationLevel;
30+
private SqlServerLockTableOptions? _lockTable;
3131
private Func<DbContextOptions<T>, IDbDefaultSchema, T?>? _contextFactory;
3232
private Func<SqlServerTestDbContextProviderOptions<T>, SqlServerTestDbContextProvider<T>?>? _providerFactory;
3333

@@ -57,27 +57,58 @@ public SqlServerTestDbContextProviderBuilder(string connectionString, ITestIsola
5757
}
5858

5959
/// <summary>
60-
/// Specifies the isolation level to use with shared tables.
61-
/// Default is <see cref="IsolationLevel.ReadCommitted"/>.
60+
/// Disables locking of the database during migrations and tear down.
6261
/// </summary>
63-
/// <param name="sharedTablesIsolationLevel">Isolation level to use.</param>
6462
/// <returns>Current builder for chaining</returns>
65-
public SqlServerTestDbContextProviderBuilder<T> UseSharedTablesIsolationLevel(IsolationLevel sharedTablesIsolationLevel)
63+
public SqlServerTestDbContextProviderBuilder<T> DisableLockingDuringDDL()
6664
{
67-
_sharedTablesIsolationLevel = sharedTablesIsolationLevel;
65+
_lockTable = new SqlServerLockTableOptions(false);
66+
67+
return this;
68+
}
69+
70+
/// <summary>
71+
/// Enables locking of the database during migrations and tear down.
72+
/// This feature is enabled by default.
73+
/// </summary>
74+
/// <param name="tableName">The name of the lock table.</param>
75+
/// <param name="schema">The schema of the lock table.</param>
76+
/// <param name="maxNumberOfLockRetries">Number of retries for creation of the lock table.</param>
77+
/// <param name="minRetryDelay">Minimum delay between retries.</param>
78+
/// <param name="maxRetryDelay">Maximum delay between retries.</param>
79+
/// <returns>Current builder for chaining</returns>
80+
public SqlServerTestDbContextProviderBuilder<T> UseLockingDuringDDL(
81+
string tableName,
82+
string? schema,
83+
int maxNumberOfLockRetries = 10,
84+
TimeSpan? minRetryDelay = null,
85+
TimeSpan? maxRetryDelay = null)
86+
{
87+
_lockTable = new SqlServerLockTableOptions(true)
88+
{
89+
Name = tableName,
90+
Schema = schema,
91+
MaxNumberOfLockRetries = maxNumberOfLockRetries
92+
};
93+
94+
if (minRetryDelay.HasValue)
95+
_lockTable.MinRetryDelay = minRetryDelay.Value;
96+
97+
if (maxRetryDelay.HasValue)
98+
_lockTable.MaxRetryDelay = maxRetryDelay.Value;
6899

69100
return this;
70101
}
71102

72103
/// <summary>
73-
/// Specifies the isolation level to use when migrating and cleaning up the database.
74-
/// Default is <see cref="IsolationLevel.Serializable"/>.
104+
/// Specifies the isolation level to use with shared tables.
105+
/// Default is <see cref="IsolationLevel.ReadCommitted"/>.
75106
/// </summary>
76-
/// <param name="migrationAndCleanupIsolationLevel">Isolation level to use.</param>
107+
/// <param name="sharedTablesIsolationLevel">Isolation level to use.</param>
77108
/// <returns>Current builder for chaining</returns>
78-
public SqlServerTestDbContextProviderBuilder<T> UseMigrationAndCleanupIsolationLevel(IsolationLevel migrationAndCleanupIsolationLevel)
109+
public SqlServerTestDbContextProviderBuilder<T> UseSharedTablesIsolationLevel(IsolationLevel sharedTablesIsolationLevel)
79110
{
80-
_migrationAndCleanupIsolationLevel = migrationAndCleanupIsolationLevel;
111+
_sharedTablesIsolationLevel = sharedTablesIsolationLevel;
81112

82113
return this;
83114
}
@@ -372,7 +403,7 @@ public SqlServerTestDbContextProvider<T> Build()
372403
ContextFactory = _contextFactory,
373404
ExecutedCommands = state.CommandCapturingInterceptor?.Commands,
374405
SharedTablesIsolationLevel = _sharedTablesIsolationLevel,
375-
MigrationAndCleanupIsolationLevel = _migrationAndCleanupIsolationLevel
406+
LockTable = _lockTable
376407
};
377408

378409
return _providerFactory?.Invoke(options) ?? new SqlServerTestDbContextProvider<T>(options);

src/Thinktecture.EntityFrameworkCore.SqlServer.Testing/EntityFrameworkCore/Testing/SqlServerTestDbContextProviderOptions.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Data;
22
using System.Data.Common;
3+
using System.Diagnostics.CodeAnalysis;
34
using Thinktecture.Logging;
45

56
namespace Thinktecture.EntityFrameworkCore.Testing;
@@ -49,11 +50,17 @@ public ITestIsolationOptions IsolationOptions
4950
/// </summary>
5051
public IsolationLevel? SharedTablesIsolationLevel { get; set; }
5152

53+
private SqlServerLockTableOptions? _lockTable;
54+
5255
/// <summary>
53-
/// Isolation level to use when migrating and cleaning up the database.
54-
/// Default is <see cref="IsolationLevel.Serializable"/>.
56+
/// Options used for locking the database during migrations and tear down.
5557
/// </summary>
56-
public IsolationLevel? MigrationAndCleanupIsolationLevel { get; set; }
58+
[AllowNull]
59+
public SqlServerLockTableOptions LockTable
60+
{
61+
get => _lockTable ??= new SqlServerLockTableOptions(true);
62+
set => _lockTable = value;
63+
}
5764

5865
/// <summary>
5966
/// Initializes new instance of <see cref="SqlServerTestDbContextProviderOptions{T}"/>.

0 commit comments

Comments
 (0)