Review 1.3: database choice and discovery take turns (sp_getapplock)

The web's database choice and the agent's discovery both update many rows of an
instance's insight.ConfiguredDatabases in one transaction, in different orders,
which can deadlock. Both now take an application lock per instance first, as
the finding saves do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 12:39:28 +02:00
parent 29e1909e98
commit 01c04ac95a

View file

@ -240,6 +240,24 @@ public sealed partial class InsightRepository
return updated > 0 ? ConfigurationChangeOutcome.Changed : await WhyNotAsync(id, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// The web UI (a choice of databases) and the agent (a discovery) both update many rows of one instance's
/// insight.ConfiguredDatabases in one transaction, in different orders; without taking turns they could deadlock.
/// Like the finding saves, they take an application lock first (one per instance; sp_getapplock needs no grant).
/// </summary>
private static async Task LockDatabasesOfAsync(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction transaction, int id)
{
var result = await connection.ExecuteScalarAsync<int>("""
DECLARE @result int;
EXEC @result = sp_getapplock @Resource = @resource, @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;
SELECT @result;
""", new { resource = "SQLInsight:ConfiguredDatabases:" + id.ToString(System.Globalization.CultureInfo.InvariantCulture) }, transaction).ConfigureAwait(false);
if (result < 0)
{
throw new TimeoutException($"Kunne ikke få lås på databasevalget (sp_getapplock {result}).");
}
}
private async Task<ConfigurationChangeOutcome> WhyNotAsync(int id, CancellationToken cancellationToken) =>
await GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is { FromConfigFile: true }
? ConfigurationChangeOutcome.FromConfigFile
@ -264,6 +282,7 @@ public sealed partial class InsightRepository
ArgumentNullException.ThrowIfNull(choices);
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await LockDatabasesOfAsync(connection, transaction, id).ConfigureAwait(false);
var unknown = new List<string>();
foreach (var choice in choices)
{
@ -439,6 +458,7 @@ public sealed partial class InsightRepository
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var at = Utc(now);
await LockDatabasesOfAsync(connection, transaction, id).ConfigureAwait(false);
var monitorNew = await connection.ExecuteScalarAsync<bool?>(
"SELECT MonitorNewDatabases FROM insight.ConfiguredInstances WITH (UPDLOCK) WHERE ConfiguredInstanceId = @id;", new { id }, transaction).ConfigureAwait(false)
?? throw new InvalidOperationException($"Instans {id} findes ikke.");