diff --git a/src/EfClassicLocalDb/EfClassicLocalDb.csproj b/src/EfClassicLocalDb/EfClassicLocalDb.csproj index 0d2a9d84..c235526b 100644 --- a/src/EfClassicLocalDb/EfClassicLocalDb.csproj +++ b/src/EfClassicLocalDb/EfClassicLocalDb.csproj @@ -24,6 +24,7 @@ + diff --git a/src/EfLocalDb/EfLocalDb.csproj b/src/EfLocalDb/EfLocalDb.csproj index e864bd2f..9df161a6 100644 --- a/src/EfLocalDb/EfLocalDb.csproj +++ b/src/EfLocalDb/EfLocalDb.csproj @@ -22,6 +22,7 @@ + diff --git a/src/LocalDb.Tests/StartRetryTests.cs b/src/LocalDb.Tests/StartRetryTests.cs new file mode 100644 index 00000000..cc0af24c --- /dev/null +++ b/src/LocalDb.Tests/StartRetryTests.cs @@ -0,0 +1,86 @@ +[TestFixture] +public class StartRetryTests +{ + [Test] + public void SucceedsFirstAttempt() + { + var starts = 0; + var recovers = 0; + StartRetry.Run( + "Instance", + () => + { + starts++; + return 0; + }, + () => false, + () => recovers++, + _ => { }); + AreEqual(1, starts); + AreEqual(0, recovers); + } + + [Test] + public void RecoversAndRetries() + { + var starts = 0; + var recovers = 0; + var sleeps = new List(); + StartRetry.Run( + "Instance", + () => + { + starts++; + return starts < 3 ? StartRetry.SqlServerStartupFailed : 0; + }, + () => false, + () => recovers++, + sleeps.Add); + AreEqual(3, starts); + AreEqual(2, recovers); + AreEqual(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2) }, sleeps); + } + + [Test] + public void FailureButRunningIsSuccess() + { + var recovers = 0; + StartRetry.Run( + "Instance", + () => StartRetry.SqlServerStartupFailed, + () => true, + () => recovers++, + _ => { }); + AreEqual(0, recovers); + } + + [Test] + public Task ThrowsAfterMaxAttempts() + { + var starts = 0; + var exception = Throws( + () => StartRetry.Run( + "Instance", + () => + { + starts++; + return StartRetry.SqlServerStartupFailed; + }, + () => false, + () => { }, + _ => { }))!; + AreEqual(StartRetry.MaxAttempts, starts); + return Verify(exception.Message) + .Snapshot( + """ + Failed to start LocalDB instance after 3 attempts. + name: Instance + HRESULT: 0x89C5010A + + To cleanup perform the following actions: + * Execute 'sqllocaldb stop Instance -k' + * Execute 'sqllocaldb delete Instance' + * Delete the instance data directory + """); + } +} diff --git a/src/LocalDb/ExceptionBuilder.cs b/src/LocalDb/ExceptionBuilder.cs index eddab486..41ff1484 100644 --- a/src/LocalDb/ExceptionBuilder.cs +++ b/src/LocalDb/ExceptionBuilder.cs @@ -14,4 +14,33 @@ Failed to setup a LocalDB instance. """; return new(message, exception); } + + public static Exception StartFailure(string name, int result, int attempts) + { + var message = $""" + Failed to start LocalDB instance after {attempts} attempts. + {nameof(name)}: {name} + HRESULT: {StartRetry.ToHex(result)} + + To cleanup perform the following actions: + * Execute 'sqllocaldb stop {name} -k' + * Execute 'sqllocaldb delete {name}' + * Delete the instance data directory + """; + return new(message); + } + + public static Exception CreateFailure(string name, int result) + { + var message = $""" + Failed to create LocalDB instance. + {nameof(name)}: {name} + HRESULT: {StartRetry.ToHex(result)} + + To cleanup perform the following actions: + * Execute 'sqllocaldb delete {name}' + * Delete the instance data directory + """; + return new(message); + } } \ No newline at end of file diff --git a/src/LocalDb/LocalDbApi.cs b/src/LocalDb/LocalDbApi.cs index 297377d5..1fb88278 100644 --- a/src/LocalDb/LocalDbApi.cs +++ b/src/LocalDb/LocalDbApi.cs @@ -166,7 +166,13 @@ public static void StopAndDelete(string instanceName, ShutdownMode mode, TimeSpa public static void CreateInstance(string instanceName) { - createInstance(ApiVersion, instanceName, 0); + // Creating an instance that already exists (with the same version) succeeds + var result = createInstance(ApiVersion, instanceName, 0); + if (result < 0) + { + throw ExceptionBuilder.CreateFailure(instanceName, result); + } + InstanceMarker.Mark(instanceName); } @@ -177,7 +183,7 @@ public static State CreateAndStart(string instance) if (!localDbInstanceInfo.Exists) { CreateInstance(instance); - StartInstance(instance); + StartInstance(instance, recreateOnFailure: true); return State.NotExists; } @@ -189,12 +195,30 @@ public static State CreateAndStart(string instance) return State.Running; } - public static void StartInstance(string instanceName) + // recreateOnFailure deletes and recreates the instance between attempts. Only pass true when + // the instance was just created, so no existing template is registered with it + public static void StartInstance(string instanceName, bool recreateOnFailure = false) { - var connection = new StringBuilder(MaxPath); - var size = connection.Capacity; - - startInstance(instanceName, 0, connection, ref size); + StartRetry.Run( + instanceName, + () => + { + // Starting an instance that is already running succeeds + var connection = new StringBuilder(MaxPath); + var size = connection.Capacity; + return startInstance(instanceName, 0, connection, ref size); + }, + () => GetInstance(instanceName).IsRunning, + () => + { + StopInstance(instanceName, ShutdownMode.KillProcess); + if (recreateOnFailure) + { + DeleteInstance(instanceName); + CreateInstance(instanceName); + } + }, + Thread.Sleep); // also marked here, not just on create, so instances created before marking existed // are picked up the next time they are used InstanceMarker.Mark(instanceName); diff --git a/src/LocalDb/StartRetry.cs b/src/LocalDb/StartRetry.cs new file mode 100644 index 00000000..201c5fab --- /dev/null +++ b/src/LocalDb/StartRetry.cs @@ -0,0 +1,48 @@ +static class StartRetry +{ + public const int MaxAttempts = 3; + + // LOCALDB_ERROR_SQL_SERVER_STARTUP_FAILED. Seen on CI runners where the sqlservr process + // intermittently fails to start + public const int SqlServerStartupFailed = unchecked((int)0x89C5010A); + + // Retries a failing LocalDBStartInstance. Between attempts recover is called so the caller can + // kill any half started process (and optionally recreate the instance). If another process + // started the instance in the meantime, that is treated as success. + public static void Run( + string instanceName, + Func start, + Func isRunning, + Action recover, + Action sleep) + { + var result = 0; + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + result = start(); + if (result >= 0) + { + return; + } + + if (isRunning()) + { + return; + } + + LocalDbLogging.Log($"Failed to start LocalDB instance `{instanceName}` (attempt {attempt} of {MaxAttempts}). HRESULT: {ToHex(result)}"); + + if (attempt == MaxAttempts) + { + break; + } + + recover(); + sleep(TimeSpan.FromSeconds(attempt)); + } + + throw ExceptionBuilder.StartFailure(instanceName, result, MaxAttempts); + } + + public static string ToHex(int result) => $"0x{result:X8}"; +} diff --git a/src/LocalDb/Wrapper.cs b/src/LocalDb/Wrapper.cs index 258b86a8..8dc7abae 100644 --- a/src/LocalDb/Wrapper.cs +++ b/src/LocalDb/Wrapper.cs @@ -358,7 +358,8 @@ void CleanStart() { FileExtensions.CleanDirectory(Directory); LocalDbApi.CreateInstance(instance); - LocalDbApi.StartInstance(instance); + // The directory was just cleaned, so recreating the instance on a failed start loses nothing + LocalDbApi.StartInstance(instance, recreateOnFailure: true); startupTask = CreateAndDetachTemplate( timestamp, buildTemplate,