sqlinsight-pro/src/SQLInsight.Collectors/Performance/PlanAnalysisCollector.cs
Søren Eggert Lundsteen Olsen 25b8e95611 SQL Server 2005: fix agent jobs and plan analysis, guard against 2008+ syntax
Seen on a customer's SQL Server 2005 SP4 (legacy ODBC):
- agent-jobs declared @since with an initial value (2008+): "Incorrect syntax
  near CONVERT" and SQLI-JOB-001/002 never ran. Now DECLARE; SET.
- plan-analysis used query_hash (2008+) and failed every 30 minutes for
  SQLI-PLAN-001/002/003. On 2005 a statement is now identified by
  sql_handle + offsets, as top SQL does. It needs sys.dm_exec_text_query_plan,
  so the new capability TextQueryPlan (2005 SP2, 9.0.3042) is required.
- COM-DB-001 used OBJECT_SCHEMA_NAME (2005 SP2+); now joins sys.objects and
  sys.schemas.

Audit of all collector SQL and community rules without minimumVersion 10.0:
the rest is 2005 syntax or behind a version/capability check.

Sql2005CompatibilityTests (2005 cannot run in CI): every collector that can
run on 2005 is scanned for 2008+ syntax in its SQL (inline DECLARE, +=,
MERGE, CONCAT/IIF/FORMAT/TRY_*, OFFSET/FETCH, date/time types, multi-row
VALUES), version-branched files listed explicitly; the same for community
rules; and the 2005 variants of plan analysis and top SQL must not use
query_hash. Verified to fail on the old agent-jobs SQL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:51:39 +02:00

73 lines
3.8 KiB
C#

using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
/// <summary>
/// The heaviest statements (CPU, last 24 hours) from the plan cache, with their cached plans analyzed.
/// Plans are fetched as text with <c>sys.dm_exec_text_query_plan</c>: <c>sys.dm_exec_query_plan</c> returns NULL
/// for plans nested deeper than the xml type allows. TOP is applied before any plan is fetched.
/// </summary>
public sealed class PlanAnalysisCollector : Collector<PlanAnalysisSnapshot>
{
public const int TopStatements = 50;
public override string Name => "plan-analysis";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews, Capability.TextQueryPlan];
protected override async Task<PlanAnalysisSnapshot> CollectAsync(CollectorContext context, PlanAnalysisSnapshot? previous)
{
var memory = context.Capabilities.Has(Capability.QueryStatsMemoryColumns);
var queryHash = context.Instance.Version.Major >= 10;
var statements = await Db.QueryAsync(context.Connection, BuildSql(memory, queryHash), r => new AnalyzedStatement
{
QueryHash = r.String(0),
DatabaseName = r.NullableString(1),
StatementText = r.NullableString(2),
Executions = r.Long(3),
TotalWorkerTimeUs = r.Long(4),
TotalElapsedTimeUs = r.Long(5),
TotalLogicalReads = r.Long(6),
TotalSpills = r.NullableLong(7),
TotalGrantKb = r.NullableLong(8),
TotalUsedGrantKb = r.NullableLong(9),
LastExecution = r.NullableDateTime(10),
Warnings = ShowplanAnalyzer.Analyze(r.NullableString(11)),
}, context.CancellationToken).ConfigureAwait(false);
return new PlanAnalysisSnapshot { CapturedAt = context.Now, Statements = statements };
}
/// <param name="queryHash">query_hash exists from SQL Server 2008; on 2005 a statement is its batch and offsets.</param>
internal static string BuildSql(bool memoryColumns, bool queryHash = true)
{
var key = queryHash
? "CONVERT(varchar(20), qs.query_hash, 1)"
: "sys.fn_varbintohexstr(qs.sql_handle) + ':' + CAST(qs.statement_start_offset AS varchar(12)) + ':' + CAST(qs.statement_end_offset AS varchar(12))";
var memory = memoryColumns
? "qs.total_spills, qs.total_grant_kb, qs.total_used_grant_kb"
: "CAST(NULL AS bigint), CAST(NULL AS bigint), CAST(NULL AS bigint)";
return $"""
WITH top_statements AS (
SELECT TOP ({TopStatements}) qs.*
FROM sys.dm_exec_query_stats qs
WHERE qs.last_execution_time >= DATEADD(hour, -24, GETDATE())
ORDER BY qs.total_worker_time DESC
)
SELECT {key},
DB_NAME(CONVERT(int, pa.value)),
LEFT(SUBSTRING(t.text, qs.statement_start_offset / 2 + 1,
(CASE WHEN qs.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1), 4000),
qs.execution_count, qs.total_worker_time, qs.total_elapsed_time, qs.total_logical_reads,
{memory},
qs.last_execution_time,
tp.query_plan
FROM top_statements qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) tp
OUTER APPLY (SELECT value FROM sys.dm_exec_plan_attributes(qs.plan_handle) WHERE attribute = N'dbid') pa
ORDER BY qs.total_worker_time DESC;
""";
}
}