Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,36 @@ public sealed class SqlServerContainerFixture : IAsyncDisposable, IAsyncInitiali

private MsSqlContainer? _container;

// Pool Blocking Period=NeverBlock: by default SqlClient caches a failed open for 5 s to 1 min and rethrows it for
// every open on the same pool. All test databases are created through this one connection string, so a single
// failed connect would otherwise fail every fixture that initializes in that window.
public string ConnectionString =>
(
_container ?? throw new InvalidOperationException("The SQL Server container has not been started.")
).GetConnectionString() + ";MultipleActiveResultSets=True;";
).GetConnectionString() + ";MultipleActiveResultSets=True;Pool Blocking Period=NeverBlock;";

// The tail of the container output, to tell a crashed SQL Server (microsoft/mssql-docker#974) from a network issue.
internal async Task<string> GetDiagnosticsAsync()
{
if (_container is null)
{
return "The SQL Server container has not been started.";
}

try
{
var (stdout, stderr) = await _container.GetLogsAsync().ConfigureAwait(false);
var tail = string.Join(
Environment.NewLine,
$"{stdout}{stderr}".Split('\n', StringSplitOptions.RemoveEmptyEntries).TakeLast(40)
);
return $"SQL Server container {_container.Id} output (last 40 lines):{Environment.NewLine}{tail}";
}
catch (Exception ex)
{
return $"Reading the SQL Server container output failed: {ex.Message}";
}
}

public ValueTask DisposeAsync() => _container?.DisposeAsync() ?? ValueTask.CompletedTask;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,54 +21,89 @@ public sealed class SqlServerDatabaseServiceFixture : IServiceFixture
// ("Could not obtain exclusive lock on database 'model'"), so creation is serialized per test session.
private const int ModelLockErrorNumber = 1807;

private const int MaxAttempts = 5;

private static readonly SemaphoreSlim CreateDatabaseLock = new(1, 1);

// Set once connecting has failed MaxAttempts times. Creation is serialized, so without it every remaining fixture
// would wait through the same connect timeouts against an unreachable server.
private static Exception? _serverUnreachable;

public async Task InitializeAsync()
{
await CreateDatabaseLock.WaitAsync().ConfigureAwait(false);
try
{
// Bounded retry in case 'model' is briefly held by SQL Server itself (e.g. right after startup).
for (var attempt = 1; attempt < 5; attempt++)
if (_serverUnreachable is not null)
{
try
{
await CreateDatabaseAsync().ConfigureAwait(false);
return;
}
catch (SqlException ex) when (ex.Number == ModelLockErrorNumber)
{
await Task.Delay(TimeSpan.FromMilliseconds(500 * attempt)).ConfigureAwait(false);
}
throw new InvalidOperationException(
$"Failed to create SQL Server test database '{DatabaseName}': SQL Server was unreachable earlier in this test session.",
_serverUnreachable
);
}

await CreateDatabaseAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to create SQL Server test database '{DatabaseName}'.", ex);
try
{
await CreateDatabaseAsync(Container.ConnectionString, DatabaseName).ConfigureAwait(false);
}
catch (Exception ex)
{
var diagnostics = await Container.GetDiagnosticsAsync().ConfigureAwait(false);
throw new InvalidOperationException(
$"Failed to create SQL Server test database '{DatabaseName}'.{Environment.NewLine}{diagnostics}",
ex
);
}
}
finally
{
_ = CreateDatabaseLock.Release();
}
}

private async Task CreateDatabaseAsync()
private static async Task CreateDatabaseAsync(string connectionString, string databaseName)
{
var con = new SqlConnection(Container.ConnectionString);
await using (con.ConfigureAwait(false))
// Bounded retry: the connect fails transiently (managed SNI error 35 on Linux, which SqlClient does not treat
// as transient, see dotnet/SqlClient#4665), or 'model' is briefly held by SQL Server itself (e.g. right after
// startup).
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
await con.OpenAsync().ConfigureAwait(false);

var cmd = con.CreateCommand();
await using (cmd.ConfigureAwait(false))
var con = new SqlConnection(connectionString);
await using (con.ConfigureAwait(false))
{
try
{
await con.OpenAsync().ConfigureAwait(false);
}
catch (SqlException ex)
{
if (attempt == MaxAttempts)
{
_serverUnreachable = ex;
throw;
}

await Task.Delay(TimeSpan.FromSeconds(attempt)).ConfigureAwait(false);
continue;
}

try
{
var cmd = con.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
#pragma warning disable CA2100, S2077 // Review SQL queries for security vulnerabilities; DatabaseName is test-controlled, not user input
cmd.CommandText = $"CREATE DATABASE [{DatabaseName}]";
cmd.CommandText = $"CREATE DATABASE [{databaseName}]";
#pragma warning restore CA2100, S2077

_ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
_ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
return;
}
}
catch (SqlException ex) when (ex.Number == ModelLockErrorNumber && attempt < MaxAttempts)
{
await Task.Delay(TimeSpan.FromMilliseconds(500 * attempt)).ConfigureAwait(false);
}
}
}
}
Expand Down
Loading