Compare commits

...

3 commits

Author SHA1 Message Date
c2cdb58a27 Version 1.0.4: own deadlocks fixed, AX rules find bmssa tables
Some checks failed
deploy community / deploy (push) Successful in 16s
deploy hub / deploy (push) Successful in 18s
CI / Build og unit tests (push) Successful in 7s
CI / MSI-installer (push) Successful in 14s
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:40:20 +02:00
f362862b33 AX rules: find the AX system tables in any schema (AX 3.0 uses bmssa)
AX 3.0 owns its tables as the database user bmssa (bmssa.SQLDICTIONARY), so
looking only in dbo never recognised a real AX 3.0 database. Detection now
uses sys.tables by name in any schema; later AX versions in dbo still match.
Tested against mock AX 3.0 (bmssa, int RECID), AX 4+ (dbo, bigint RECID) and
a plain database.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:40:19 +02:00
25a67cb98b Stop SQLInsight deadlocking itself, and stop reporting its own deadlocks
At a customer whose repository sits on a monitored instance, the agent
reported 12 deadlocks that were its own: two instances' monitors save
findings in parallel, one transaction each, and while insight.Findings is
small SQL Server scans it, so the two lock each other's rows.

- SaveRuleResultsAsync takes an exclusive application lock for its
  transaction (the agent is the only writer; a save takes milliseconds)
  and retries if chosen as deadlock victim anyway
- repository connections are named "SQLInsight Pro (repository)"
- deadlocks where every participant is SQLInsight Pro are not reported
  (the read position still moves past them); any other session keeps it

The new integration test (two instances saving 25 rules x 40 rounds in
parallel) failed 2 of 2 without the fix and passed 5 of 5 with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 07:34:59 +02:00
7 changed files with 131 additions and 14 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.0.3</Version>
<Version>1.0.4</Version>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -2,8 +2,9 @@
//
// The agent has no data access (grant-least-privilege.sql), so these rules only read metadata and DMVs. They run in
// every user database, so an AX database is recognised without naming its tables directly (that would fail to compile
// elsewhere): OBJECT_ID() of the AX system tables SQLDICTIONARY, DATAAREA and SYSCONFIG. AX 3.0 is told apart from
// AX 4.0 and later by RECID being 32-bit (int) instead of 64-bit (bigint).
// elsewhere): the AX system tables SQLDICTIONARY, DATAAREA and SYSCONFIG in sys.tables, in ANY schema: AX 3.0 owns its
// tables as the database user bmssa (bmssa.SQLDICTIONARY), later versions use dbo. AX 3.0 is told apart from AX 4.0
// and later by RECID being 32-bit (int) instead of 64-bit (bigint).
//
// Everything is SQL Server 2005 syntax (no CONCAT, IIF or STRING_AGG): AX 3.0 typically runs on 2000 or 2005.
[
@ -12,7 +13,7 @@
"title": "Dynamics AX 3.0 er uden support fra Microsoft",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL AND COLUMNPROPERTY(OBJECT_ID(N'dbo.SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version] WHERE EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"subject": "database: {database}",
"findingTitle": "{database} er en Dynamics AX 3.0-database, og AX 3.0 er uden support",
"explanation": "Microsoft Dynamics AX 3.0 er for længst ude af Microsofts support, også den udvidede. Der kommer ingen sikkerhedsopdateringer eller rettelser til AX-kernen, klienten eller AOS, og nyere Windows- og SQL Server-versioner er aldrig testet med AX 3.0. AX 3.0 bruger desuden 32-bit RecId (int), som kan løbe tør på store installationer.",
@ -25,7 +26,7 @@
"title": "Dynamics AX 3.0 kører på en SQL Server-version, der ikke var understøttet til AX 3.0",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL AND COLUMNPROPERTY(OBJECT_ID(N'dbo.SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"query": "SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS [sql_version], d.compatibility_level AS [compatibility_level] FROM sys.databases d WHERE d.database_id = DB_ID() AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) > 9 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') AND COLUMNPROPERTY((SELECT TOP 1 object_id FROM sys.tables WHERE name = N'SQLDICTIONARY'), N'RECID', 'Precision') = 10",
"subject": "database: {database}",
"findingTitle": "AX 3.0-databasen {database} ligger på SQL Server {sql_version}",
"explanation": "AX 3.0 blev kun frigivet til SQL Server 2000 og 2005. På nyere versioner kan den virke, men optimizeren, låsning og udfasede funktioner opfører sig anderledes end det, AX-kernen blev bygget til, og hverken Microsoft eller en AX-partner kan give support. Fejl viser sig ofte først under belastning, fx ved månedsafslutning. Databasens compatibility level er {compatibility_level}.",
@ -38,7 +39,7 @@
"title": "Dynamics AX-database på en instans, hvor MAXDOP ikke er 1 (SQL Server 2005-2014)",
"severity": "Medium",
"scope": "Database",
"query": "SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13 AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL",
"query": "SELECT CAST(c.value_in_use AS int) AS [maxdop] FROM sys.configurations c WHERE c.name = 'max degree of parallelism' AND CAST(c.value_in_use AS int) <> 1 AND CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int) < 13 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"subject": "database: {database}",
"findingTitle": "MAXDOP er {maxdop} på instansen med Dynamics AX-databasen {database}",
"explanation": "Dynamics AX sender mange små, hyppige forespørgsler og cursorer fra AOS. Når de får parallelle planer, bruger de flere tråde end nødvendigt, venter på hinanden (CXPACKET) og holder låse længere, så brugerne oplever langsomme formularer og blokering. Microsofts vejledning for Dynamics AX er derfor max degree of parallelism = 1 på instansen.",
@ -55,7 +56,7 @@
"severity": "Medium",
"scope": "Database",
"minimumVersion": "13.0",
"query": "SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1 AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL",
"query": "SELECT CAST(c.value_in_use AS int) AS [server_maxdop], CAST(s.value AS int) AS [database_maxdop] FROM sys.configurations c CROSS JOIN sys.database_scoped_configurations s WHERE c.name = 'max degree of parallelism' AND s.name = 'MAXDOP' AND (CASE WHEN CAST(s.value AS int) = 0 THEN CAST(c.value_in_use AS int) ELSE CAST(s.value AS int) END) <> 1 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"subject": "database: {database}",
"findingTitle": "Dynamics AX-databasen {database} kører med MAXDOP større end 1",
"explanation": "Dynamics AX sender mange små, hyppige forespørgsler og cursorer fra AOS. Når de får parallelle planer, bruger de flere tråde end nødvendigt, venter på hinanden (CXPACKET) og holder låse længere. Microsofts vejledning for Dynamics AX er MAXDOP 1. Instansen har {server_maxdop}, og databasen har {database_maxdop} (0 = instansens værdi gælder).",
@ -71,7 +72,7 @@
"title": "Dynamics AX-tabeller uden unikt RecId-indeks",
"severity": "Medium",
"scope": "Database",
"query": "SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL HAVING COUNT(*) > 0",
"query": "SELECT COUNT(*) AS [tables], LEFT(STUFF((SELECT TOP 10 N', ' + t2.name FROM sys.tables t2 WHERE COLUMNPROPERTY(t2.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t2.object_id AND i.is_unique = 1 AND c.name = N'RECID') ORDER BY t2.name FOR XML PATH('')), 1, 2, ''), 400) AS [examples] FROM sys.tables t WHERE COLUMNPROPERTY(t.object_id, N'RECID', 'ColumnId') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id WHERE i.object_id = t.object_id AND i.is_unique = 1 AND c.name = N'RECID') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0",
"subject": "database: {database}",
"findingTitle": "{tables} AX-tabeller i {database} har intet unikt indeks på RECID",
"explanation": "AX identificerer hver post med RecId og opretter normalt et unikt indeks på den (I_<tabel-id>RECID) ved synkronisering. Mangler det, er en synkronisering typisk fejlet eller afbrudt, eller indekset er fjernet manuelt. Uden det bliver opslag på RecId (som AX laver hele tiden, fx ved opdatering og sletning af én post) til scanninger, og dubletter opdages ikke. Eksempler: {examples}.",
@ -84,7 +85,7 @@
"title": "Store Dynamics AX-tabeller uden clustered index (heaps)",
"severity": "Medium",
"scope": "Database",
"query": "SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL HAVING COUNT(*) > 0",
"query": "SELECT COUNT(*) AS [tables], SUM(h.row_count) AS [rows], LEFT(STUFF((SELECT TOP 10 N', ' + OBJECT_NAME(h2.object_id) + N' (' + CAST(h2.row_count AS nvarchar(20)) + N')' FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h2 WHERE h2.row_count >= 100000 ORDER BY h2.row_count DESC FOR XML PATH('')), 1, 2, ''), 400) AS [largest] FROM (SELECT p.object_id, SUM(p.rows) AS row_count FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id WHERE p.index_id = 0 GROUP BY p.object_id) h WHERE h.row_count >= 100000 AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG') HAVING COUNT(*) > 0",
"subject": "database: {database}",
"findingTitle": "{tables} store AX-tabeller i {database} har intet clustered index",
"explanation": "En tabel uden clustered index (en heap) får spredte data og 'forwarded records', når poster opdateres, og den kan ikke defragmenteres med en indeks-rebuild. I AX styres det af tabellens ClusterIndex-egenskab i AOT, som i AX 3.0 ofte ikke er sat på standard- og kundetabeller. På store tabeller giver det langsomme opslag og mere I/O. I alt {rows} rækker. Største: {largest}.",
@ -97,7 +98,7 @@
"title": "Automatiske statistikker er slået fra i en Dynamics AX-database",
"severity": "High",
"scope": "Database",
"query": "SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0) AND OBJECT_ID(N'dbo.SQLDICTIONARY', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.DATAAREA', N'U') IS NOT NULL AND OBJECT_ID(N'dbo.SYSCONFIG', N'U') IS NOT NULL",
"query": "SELECT CASE WHEN d.is_auto_create_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_create_statistics], CASE WHEN d.is_auto_update_stats_on = 1 THEN 'til' ELSE 'fra' END AS [auto_update_statistics] FROM sys.databases d WHERE d.database_id = DB_ID() AND (d.is_auto_create_stats_on = 0 OR d.is_auto_update_stats_on = 0) AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SQLDICTIONARY') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'DATAAREA') AND EXISTS (SELECT 1 FROM sys.tables WHERE name = N'SYSCONFIG')",
"subject": "database: {database}",
"findingTitle": "Automatiske statistikker er slået fra i AX-databasen {database}",
"explanation": "AX bygger sine forespørgsler dynamisk ud fra brugernes filtre, så optimizeren er helt afhængig af, at statistikkerne er der og er opdaterede. Uden automatiske statistikker vælger SQL Server ofte forkerte planer, især efter store kørsler som lageropgørelse og nummerserier, og formularer og batchjob kan pludselig blive meget langsomme. Auto create statistics: {auto_create_statistics}. Auto update statistics: {auto_update_statistics}.",

View file

@ -105,7 +105,8 @@ public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
return new DeadlockSnapshot
{
CapturedAt = context.Now,
NewDeadlocks = deadlocks,
// Our own deadlocks are skipped, but still move the read position past them.
NewDeadlocks = deadlocks.Where(d => !d.IsOwn).ToList(),
LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
LastFileName = file,
LastFileOffset = offset,
@ -130,7 +131,7 @@ public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
return new DeadlockSnapshot
{
CapturedAt = context.Now,
NewDeadlocks = deadlocks,
NewDeadlocks = deadlocks.Where(d => !d.IsOwn).ToList(),
LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
};
}

View file

@ -26,6 +26,16 @@ public sealed record DeadlockEvent
/// <summary>The raw deadlock graph (XDL), for download and viewing in SSMS.</summary>
public required string GraphXml { get; init; }
/// <summary>
/// Every participant is SQLInsight Pro itself (monitoring or its own repository), e.g. when the repository lives on
/// a monitored instance. Not the customer's problem, so not reported. A deadlock with any other session is kept.
/// </summary>
public bool IsOwn => Processes.Count > 0
&& Processes.All(p => p.ClientApp?.StartsWith(OwnApplicationName, StringComparison.Ordinal) == true);
/// <summary>Application Name prefix on all of SQLInsight Pro's connections ("SQLInsight Pro", "SQLInsight Pro (repository)").</summary>
public const string OwnApplicationName = "SQLInsight Pro";
}
public sealed record DeadlockProcess

View file

@ -14,6 +14,23 @@ namespace SQLInsight.Repository;
/// </summary>
public sealed partial class InsightRepository(string connectionString)
{
/// <summary>Application Name on repository connections, so SQLInsight's own sessions are recognisable (e.g. in deadlock graphs).</summary>
public const string ApplicationName = DeadlockEvent.OwnApplicationName + " (repository)";
private readonly string _connectionString = WithApplicationName(connectionString);
/// <summary>Sets <see cref="ApplicationName"/> unless the connection string names an application itself.</summary>
public static string WithApplicationName(string connectionString)
{
var builder = new SqlConnectionStringBuilder(connectionString);
if (builder.ApplicationName == new SqlConnectionStringBuilder().ApplicationName)
{
builder.ApplicationName = ApplicationName;
}
return builder.ConnectionString;
}
static InsightRepository()
{
// Dapper sends DateTime as the legacy datetime type (≈3 ms steps). Since SQL Server 2016, comparing
@ -26,11 +43,11 @@ public sealed partial class InsightRepository(string connectionString)
// PascalCase: OPENJSON ... WITH matches property names case-sensitively.
internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.General);
public Task<int> MigrateAsync(CancellationToken cancellationToken = default) => SchemaMigrator.MigrateAsync(connectionString, cancellationToken);
public Task<int> MigrateAsync(CancellationToken cancellationToken = default) => SchemaMigrator.MigrateAsync(_connectionString, cancellationToken);
private async Task<SqlConnection> OpenAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(connectionString);
var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
@ -101,12 +118,43 @@ public sealed partial class InsightRepository(string connectionString)
public async Task<FindingChanges> SaveRuleResultsAsync(int instanceId, IReadOnlyList<RuleResult> results, DateTimeOffset evaluatedAt, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(results);
for (var attempt = 1; ; attempt++)
{
try
{
return await SaveRuleResultsOnceAsync(instanceId, results, evaluatedAt, cancellationToken).ConfigureAwait(false);
}
catch (SqlException ex) when (ex.Number == DeadlockVictim && attempt < 3)
{
// The whole transaction was rolled back, so running it again is safe.
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false);
}
}
}
private const int DeadlockVictim = 1205;
private async Task<FindingChanges> SaveRuleResultsOnceAsync(int instanceId, IReadOnlyList<RuleResult> results, DateTimeOffset evaluatedAt, CancellationToken cancellationToken)
{
var now = Utc(evaluatedAt);
var opened = new List<(long, Finding)>();
var resolved = new List<ResolvedFinding>();
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Instances are monitored in parallel, and each saves all its rules in one transaction. While insight.Findings
// is small, SQL Server scans it, so two instances' transactions lock each other's rows in opposite order and
// deadlock. The agent is the only writer and a save takes milliseconds: take turns.
var lockResult = await connection.ExecuteScalarAsync<int>("""
DECLARE @result int;
EXEC @result = sp_getapplock @Resource = N'SQLInsight:Findings', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;
SELECT @result;
""", transaction: transaction).ConfigureAwait(false);
if (lockResult < 0)
{
throw new TimeoutException($"Kunne ikke få lås på fundene (sp_getapplock {lockResult}).");
}
foreach (var result in results)
{
await connection.ExecuteAsync("""

View file

@ -66,4 +66,21 @@ public class DeadlockParserTests
Assert.Null(DeadlockParser.ParseEvent(evt));
}
[Theory]
[InlineData("SQLInsight Pro (repository)", "SQLInsight Pro (repository)", true)]
[InlineData("SQLInsight Pro", "SQLInsight Pro (repository)", true)]
[InlineData("SQLInsight Pro (repository)", "Sales.Api", false)]
[InlineData("SQLInsight Pro (repository)", null, false)]
public void Deadlocks_between_sqlinsights_own_sessions_are_recognised(string first, string? second, bool own)
{
var deadlock = new SQLInsight.Core.Data.DeadlockEvent
{
Time = DateTimeOffset.UnixEpoch,
GraphXml = "<deadlock />",
Processes = [new() { Id = "p1", ClientApp = first }, new() { Id = "p2", ClientApp = second }],
};
Assert.Equal(own, deadlock.IsOwn);
}
}

View file

@ -66,6 +66,46 @@ public class RepositoryTests(SqlServerFixture sql) : IAsyncLifetime
Reason = outcome == RuleOutcome.NotMeasurable ? "Kræver Query Store" : null,
};
[Fact]
public async Task Parallel_saves_for_different_instances_do_not_deadlock()
{
// The agent monitors instances in parallel; each saves all its rules in one transaction. On a small Findings
// table this deadlocked (seen at a customer, where the repository sits on a monitored instance).
var a = await _repository.UpsertInstanceAsync("sql-a", Info, "SqlClient", false, Now);
var b = await _repository.UpsertInstanceAsync("sql-b", Info, "SqlClient", false, Now);
async Task RunAsync(int instanceId)
{
for (var round = 0; round < 40; round++)
{
string[] subjects = round % 2 == 0 ? ["x", "y"] : ["y", "z"];
var results = Enumerable.Range(1, 25).Select(r =>
{
var ruleId = $"SQLI-PAR-{r:000}";
return new RuleResult
{
RuleId = ruleId,
RuleTitle = "Parallel",
Outcome = RuleOutcome.Findings,
Findings = subjects.Select(s => Finding(s) with { RuleId = ruleId }).ToList(),
};
}).ToList();
await _repository.SaveRuleResultsAsync(instanceId, results, Now.AddMinutes(round));
}
}
await Task.WhenAll(RunAsync(a), RunAsync(b));
// Last round was odd: y and z active for 25 rules on both instances.
Assert.Equal(100, await ScalarAsync<int>("SELECT COUNT(*) FROM insight.Findings WHERE ResolvedAt IS NULL"));
}
[Theory]
[InlineData("Server=.;Database=SQLInsight;Integrated Security=true", "SQLInsight Pro (repository)")]
[InlineData("Server=.;Database=SQLInsight;Integrated Security=true;Application Name=Eget navn", "Eget navn")]
public void Repository_connections_are_named_unless_the_string_names_them(string connectionString, string expected) =>
Assert.Equal(expected, new SqlConnectionStringBuilder(InsightRepository.WithApplicationName(connectionString)).ApplicationName);
[Fact]
public async Task Findings_are_tracked_across_evaluations()
{