diff --git a/deprecated/Installer.Tests/XeShredGateSqlTests.cs b/deprecated/Installer.Tests/XeShredGateSqlTests.cs new file mode 100644 index 0000000000..e8e0441446 --- /dev/null +++ b/deprecated/Installer.Tests/XeShredGateSqlTests.cs @@ -0,0 +1,370 @@ +namespace Installer.Tests; + +/// +/// Source guard for the #4213 execution_count gate in the deprecated Dashboard's blocked-process and +/// deadlock collectors. +/// +/// #4200 found that install/22_collect_blocked_processes.sql and +/// install/24_collect_deadlock_xml.sql converted the whole Extended Events ring buffer to XML and +/// shredded it on every run, even when nothing new had arrived. #4212 fixed the same defect in Darling and +/// Lite by reading the session target's own execution_count before the cast and skipping the +/// conversion when it matches the value stored from the prior cycle. #4213 is that same gate, in T-SQL, +/// for the deprecated Dashboard's collectors — the only ones #4212 could not reach. +/// +/// This is a SOURCE guard for the same reason QueryStoreSliceAggregationSqlTests is: the gated +/// query lives inside a dynamically assembled @sql string, so the compiler proves nothing about it, +/// the sql-validation workflow only proves the batch installs and runs, and the DB-touching test +/// classes that would execute this collector against a live server are exactly the ones CI filters out. +/// A guard that reads the file is what would have caught either collector shipping without the gate, or +/// shipping it wired to the wrong table, the wrong direction, or the wrong session's counter. +/// +public class XeShredGateSqlTests +{ + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.sln"))) + { + dir = dir.Parent; + } + + Assert.True(dir is not null, "could not locate the repository root from " + AppContext.BaseDirectory); + + return dir!.FullName; + } + + private static string CollectorSql(string fileName) + { + var path = Path.Combine(RepoRoot(), "install", fileName); + Assert.True(File.Exists(path), "expected the collector script at " + path); + return File.ReadAllText(path); + } + + /// + /// Both collectors carry a durable place to remember the last count they saw. Without + /// config.xe_shred_state, every run would have no @last_execution_count to compare + /// against and the gate below would always fall through to "shred" — the pre-#4213 behavior, just + /// with extra code around it. + /// + [Fact] + public void ConfigTablesScriptCreatesTheShredStateTable() + { + var sql = CollectorSql("03_create_config_tables.sql"); + + Assert.Contains("config.xe_shred_state", sql, StringComparison.Ordinal); + Assert.Contains("IF OBJECT_ID(N'config.xe_shred_state', N'U') IS NULL", sql, StringComparison.Ordinal); + Assert.Contains("last_execution_count bigint NOT NULL,", sql, StringComparison.Ordinal); + Assert.Contains("CONSTRAINT PK_xe_shred_state PRIMARY KEY CLUSTERED (collector_name)", sql, StringComparison.Ordinal); + } + + /// + /// The blocked-process collector reads execution_count before the cast, on both the Azure SQL + /// DB and server-scoped paths, and only shreds when the count is unavailable, no prior count is + /// stored, or the count has changed. On the OLD file (before #4213) neither + /// xet.execution_count nor @shred_needed appears at all — the INSERT into the ring + /// buffer table variable runs unconditionally on every execution, which is exactly the defect #4213 + /// closes. This assertion fails against that old file. + /// + [Fact] + public void BlockedProcessCollectorReadsExecutionCountBeforeShredding() + { + var sql = CollectorSql("22_collect_blocked_processes.sql"); + + Assert.Contains("@execution_count = xet.execution_count", sql, StringComparison.Ordinal); + Assert.Contains("@shred_needed =", sql, StringComparison.Ordinal); + Assert.Contains("IF @shred_needed = 1", sql, StringComparison.Ordinal); + Assert.Contains("config.xe_shred_state", sql, StringComparison.Ordinal); + + /* The gate must be evaluated on both the Azure SQL DB branch and the server-scoped branch — one + instance per branch, so the shred is skippable regardless of platform. */ + var gateInstances = System.Text.RegularExpressions.Regex.Matches(sql, "IF @shred_needed = 1").Count; + Assert.Equal(2, gateInstances); + + /* A MISSING state row (no prior cycle recorded for this collector) must mean a full read, not + "nothing new" — the WHEN branch below is what makes the gate fail open on the first run and + after any row loss, instead of silently skipping real data. */ + Assert.Contains("WHEN @last_execution_count IS NULL THEN 1", sql, StringComparison.Ordinal); + } + + /// + /// The deadlock collector carries the same gate, on the same two platform branches. On the OLD file + /// the ring-buffer INSERT ran unconditionally in both AzureQueryText-equivalent and + /// server-scoped SQL text, with no execution_count read anywhere — this assertion fails + /// against that old file. + /// + [Fact] + public void DeadlockCollectorReadsExecutionCountBeforeShredding() + { + var sql = CollectorSql("24_collect_deadlock_xml.sql"); + + Assert.Contains("@execution_count = xet.execution_count", sql, StringComparison.Ordinal); + Assert.Contains("@shred_needed =", sql, StringComparison.Ordinal); + Assert.Contains("IF @shred_needed = 1", sql, StringComparison.Ordinal); + Assert.Contains("config.xe_shred_state", sql, StringComparison.Ordinal); + + var gateInstances = System.Text.RegularExpressions.Regex.Matches(sql, "IF @shred_needed = 1").Count; + Assert.Equal(2, gateInstances); + + /* Same fail-open requirement as the blocked-process collector: a missing row means "shred + everything", never "nothing new". */ + Assert.Contains("WHEN @last_execution_count IS NULL THEN 1", sql, StringComparison.Ordinal); + } + + /// + /// Both collectors persist THIS cycle's own execution_count after the read, regardless of which + /// branch the gate took, so the next cycle's comparison reflects reality. The old file never writes + /// to config.xe_shred_state at all — there was nothing to persist, since every run always + /// shredded. + /// + [Theory] + [InlineData("22_collect_blocked_processes.sql", "blocked_process_xml_collector")] + [InlineData("24_collect_deadlock_xml.sql", "deadlock_xml_collector")] + public void CollectorsPersistTheGateResultAfterEveryRun(string fileName, string collectorName) + { + var sql = CollectorSql(fileName); + + Assert.Contains("IF @execution_count IS NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains( + $"IF EXISTS (SELECT 1/0 FROM config.xe_shred_state WHERE collector_name = N'{collectorName}')", + sql, + StringComparison.Ordinal); + Assert.Contains("last_execution_count = @execution_count,", sql, StringComparison.Ordinal); + } + + /// + /// The stored rows themselves must stay the same shape as before the gate: both collectors still + /// insert into their payload table (collect.blocked_process_xml / collect.deadlock_xml) + /// with the same columns, only now guarded by the shred check rather than unconditional. + /// + [Fact] + public void BlockedProcessRowShapeIsUnchanged() + { + var sql = CollectorSql("22_collect_blocked_processes.sql"); + + Assert.Contains("collect.blocked_process_xml", sql, StringComparison.Ordinal); + Assert.Contains("event_time,", sql, StringComparison.Ordinal); + Assert.Contains("blocked_process_xml", sql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockRowShapeIsUnchanged() + { + var sql = CollectorSql("24_collect_deadlock_xml.sql"); + + Assert.Contains("collect.deadlock_xml", sql, StringComparison.Ordinal); + Assert.Contains("event_time,", sql, StringComparison.Ordinal); + Assert.Contains("deadlock_xml", sql, StringComparison.Ordinal); + } + + /// + /// Both collectors' payload INSERT, inside the gated dynamic SQL, excludes any event whose + /// event_time already exists in the target table -- so a changed execution_count that triggers a + /// full re-shred of the ring buffer does not re-insert events the table already holds. Without this + /// predicate a full re-shred (by design, on every counter change) would duplicate every row the + /// prior cycle already stored, not just the truly new ones. This assertion pins the predicate's + /// presence and its target: collect.blocked_process_xml / collect.deadlock_xml, + /// compared on event_time. + /// + [Theory] + [InlineData("22_collect_blocked_processes.sql", "blocked_process_xml", "bx")] + [InlineData("24_collect_deadlock_xml.sql", "deadlock_xml", "dx")] + public void CollectorsDedupeReShreddedEventsOnInsert(string fileName, string tableSuffix, string alias) + { + var sql = CollectorSql(fileName); + + Assert.Contains( + $"FROM collect.{tableSuffix} AS {alias}", + sql, + StringComparison.Ordinal); + Assert.Contains( + $"WHERE {alias}.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'')", + sql, + StringComparison.Ordinal); + + /* The NOT EXISTS wrapping that WHERE must appear once per platform branch (Azure SQL DB, + server-scoped), same as the shred gate itself -- one dedupe check per INSERT, not shared + across branches. */ + var dedupeInstances = System.Text.RegularExpressions.Regex.Matches( + sql, + $"WHERE {alias}\\.event_time = evt\\.value\\(''\\(@timestamp\\)\\[1\\]'', ''datetime2\\(7\\)''\\)").Count; + Assert.Equal(2, dedupeInstances); + } + + /// + /// The dynamic SQL strings assembled for sp_executesql are their own scope: a variable + /// referenced inside one that is neither DECLAREd inside that same string nor passed in as an + /// sp_executesql parameter does not exist at execution time and the batch throws "Must + /// declare the scalar variable" straight into the outer CATCH. #4213's own gate hit exactly this -- + /// @shred_needed was read and SET inside all four dynamic batches (Azure + server-scoped, both + /// collectors) but only ever DECLAREd in the OUTER procedure scope, which the dynamic string cannot + /// see. This pin parses each N'...' string passed to sp_executesql and asserts that + /// every @variable it references is covered by a local DECLARE or a parameter listed in that + /// same call's @params definition. + /// + /// The parser is deliberately simple: it strips /* ... */ comments and single-line + /// -- comments, then walks the T-SQL string literal using its own escaping rule (a doubled + /// '' is a literal quote, not a terminator) to find the matching close quote. It does not + /// understand nested dynamic SQL, does not evaluate control flow, and treats any @name token + /// as a variable reference even inside a string literal that happens to contain one (none of the + /// four batches here do). It is a source guard against exactly the defect class #4213 shipped with, + /// not a T-SQL parser. + /// + [Theory] + [InlineData("22_collect_blocked_processes.sql")] + [InlineData("24_collect_deadlock_xml.sql")] + public void DynamicSqlBatchesDeclareOrParameterizeEveryVariableTheyReference(string fileName) + { + var sql = CollectorSql(fileName); + var batches = ExtractSpExecuteSqlBatches(sql); + + Assert.True(batches.Count >= 4, $"expected at least 4 sp_executesql batches in {fileName}, found {batches.Count}"); + + foreach (var batch in batches) + { + var declared = DeclaredVariables(batch.SqlText); + var parameterized = ParameterNames(batch.ParamsText); + var referenced = ReferencedVariables(batch.SqlText); + + var uncovered = referenced + .Where(v => !declared.Contains(v) && !parameterized.Contains(v)) + .ToList(); + + Assert.True( + uncovered.Count == 0, + $"{fileName}: batch near offset {batch.Offset} references {string.Join(", ", uncovered)} " + + "without a local DECLARE or an sp_executesql parameter"); + } + } + + private readonly record struct SpExecuteSqlBatch(int Offset, string SqlText, string ParamsText); + + /// + /// Finds each EXECUTE sys.sp_executesql @sql, N'...params...', ... call, resolves the two + /// string literals it needs -- the SQL text built into @sql just above the call, and the + /// inline params string passed as the second argument -- and returns both as plain text with + /// T-SQL's doubled-quote escaping already collapsed. + /// + private static List ExtractSpExecuteSqlBatches(string sql) + { + var stripped = StripComments(sql); + var results = new List(); + + foreach (System.Text.RegularExpressions.Match setMatch in + System.Text.RegularExpressions.Regex.Matches(stripped, @"SET\s+@sql\s*=\s*N'")) + { + var sqlTextStart = setMatch.Index + setMatch.Length; + var sqlTextEnd = FindStringLiteralEnd(stripped, sqlTextStart); + var sqlText = UnescapeLiteral(stripped.Substring(sqlTextStart, sqlTextEnd - sqlTextStart)); + + var execMatch = System.Text.RegularExpressions.Regex.Match( + stripped.Substring(sqlTextEnd), + @"EXECUTE\s+sys\.sp_executesql\s*@sql\s*,\s*N'"); + + if (!execMatch.Success) + { + continue; + } + + var paramsTextStart = sqlTextEnd + execMatch.Index + execMatch.Length; + var paramsTextEnd = FindStringLiteralEnd(stripped, paramsTextStart); + var paramsText = UnescapeLiteral(stripped.Substring(paramsTextStart, paramsTextEnd - paramsTextStart)); + + results.Add(new SpExecuteSqlBatch(setMatch.Index, sqlText, paramsText)); + } + + return results; + } + + /// + /// From SET @sql = N'...' at (the position right after the opening + /// quote), walks forward respecting T-SQL's doubled single-quote escape ('' inside an N'...' + /// literal is a literal quote character, not the end of the string) until the true closing quote. + /// + private static int FindStringLiteralEnd(string text, int start) + { + var i = start; + while (i < text.Length) + { + if (text[i] == '\'') + { + if (i + 1 < text.Length && text[i + 1] == '\'') + { + i += 2; + continue; + } + + return i; + } + + i++; + } + + Assert.Fail("unterminated string literal while scanning sp_executesql batch"); + return -1; + } + + private static string UnescapeLiteral(string text) => text.Replace("''", "'", StringComparison.Ordinal); + + private static string StripComments(string sql) + { + var noBlockComments = System.Text.RegularExpressions.Regex.Replace( + sql, + @"/\*.*?\*/", + string.Empty, + System.Text.RegularExpressions.RegexOptions.Singleline); + + return System.Text.RegularExpressions.Regex.Replace( + noBlockComments, + @"--[^\n]*", + string.Empty); + } + + private static HashSet DeclaredVariables(string sqlText) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(sqlText, @"DECLARE\s+(@\w+)")) + { + names.Add(m.Groups[1].Value); + } + + return names; + } + + private static HashSet ParameterNames(string paramsText) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(paramsText, @"(@\w+)\s+\w")) + { + names.Add(m.Groups[1].Value); + } + + return names; + } + + /// + /// @name tokens inside the XQuery/XPath string literals these batches build (e.g. + /// ''(@timestamp)[1]'', event[@name="blocked_process_report"]) are XML attribute + /// references, not T-SQL variables, and must not be treated as ones. By the time this runs, the + /// doubled '' escaping has already been collapsed to plain ', so those XQuery + /// fragments are ordinary single-quoted string literals in the batch text -- stripping quoted + /// literals before scanning for @ tokens removes them along with any other literal text that + /// happens to contain an @. + /// + private static HashSet ReferencedVariables(string sqlText) + { + var withoutLiterals = System.Text.RegularExpressions.Regex.Replace(sqlText, @"'[^']*'", string.Empty); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(withoutLiterals, @"(@\w+)")) + { + names.Add(m.Groups[1].Value); + } + + return names; + } +} diff --git a/deprecated/Installer/README.md b/deprecated/Installer/README.md index 971787aea8..fc1e8feaac 100644 --- a/deprecated/Installer/README.md +++ b/deprecated/Installer/README.md @@ -1,6 +1,6 @@ # Performance Monitor CLI Installer (Full Edition) -> **The "Full" edition — a `PerformanceMonitor` database installed on the target SQL Server with T-SQL collectors running via SQL Agent — is deprecated.** It still ships and is supported for existing users, but new deployments should use **[Lite](../Lite/README.md)** (portable desktop app, nothing installed on the server) or **[Darling](../Darling/README.md)** (headless service + viewer). This CLI installer remains for those who still run the Full edition. See the [root README](../README.md) for the current editions. +> **The "Full" edition — a `PerformanceMonitor` database installed on the target SQL Server with T-SQL collectors running via SQL Agent — is deprecated.** As of v3.3.0 it is no longer included in release assets, but it is supported for existing users and remains buildable from the repo. New deployments should use **[Lite](../Lite/README.md)** (portable desktop app, nothing installed on the server) or **[Darling](../Darling/README.md)** (headless service + viewer). This CLI installer remains for those who still run the Full edition. See the [root README](../README.md) for the current editions. Self-contained console application that installs the `PerformanceMonitor` database, collector stored procedures, reporting views, and SQL Agent jobs on a target SQL Server instance. The executable bundles the .NET 10.0 runtime — nothing to install on the machine running it. diff --git a/install/03_create_config_tables.sql b/install/03_create_config_tables.sql index 12f96d34de..1038eb2990 100644 --- a/install/03_create_config_tables.sql +++ b/install/03_create_config_tables.sql @@ -91,6 +91,48 @@ BEGIN */ SET @log_to_table = 1; + /* + Create config.xe_shred_state (#4213) -- the deprecated Dashboard's own copy of the + execution_count gate #4212 added to Darling and Lite. One row per collector, storing the + ring_buffer target's execution_count as of the last cycle that read it. The collectors + (22_collect_blocked_processes.sql, 24_collect_deadlock_xml.sql) compare their current read + against this row: an exact match means nothing has arrived since, so the cast+shred of + target_data is skipped. No row, or a count that has gone down (session restart), means a + full read runs and this row is written. + */ + IF OBJECT_ID(N'config.xe_shred_state', N'U') IS NULL + BEGIN + IF @debug = 1 + BEGIN + RAISERROR(N'Creating config.xe_shred_state table', 0, 1) WITH NOWAIT; + END; + + CREATE TABLE + config.xe_shred_state + ( + collector_name sysname NOT NULL, + last_execution_count bigint NOT NULL, + last_checked_time datetime2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT PK_xe_shred_state PRIMARY KEY CLUSTERED (collector_name) WITH (DATA_COMPRESSION = PAGE) + ); + + SET @tables_created = @tables_created + 1; + + INSERT INTO + config.collection_log + ( + collector_name, + collection_status, + error_message + ) + VALUES + ( + N'ensure_config_tables', + N'TABLE_CREATED', + N'Created config.xe_shred_state table' + ); + END; + /* Create config.collection_schedule */ diff --git a/install/22_collect_blocked_processes.sql b/install/22_collect_blocked_processes.sql index a024140169..51a152a003 100644 --- a/install/22_collect_blocked_processes.sql +++ b/install/22_collect_blocked_processes.sql @@ -63,7 +63,15 @@ BEGIN @is_azure_sql_db bit = 0, @session_missing bit = 0, @ensure_error nvarchar(4000) = N'', - @sql nvarchar(max) = N''; + @sql nvarchar(max) = N'', + /* #4213: the deprecated Dashboard's own copy of #4212's gate. @execution_count is this + cycle's read of the ring_buffer target's own delivered-event counter; @last_execution_count + is the prior cycle's read, carried in config.xe_shred_state; @shred_needed says whether the + cast+shred further down runs. An exact match between the two counts means nothing has + arrived since the last run, so the conversion and node-shred are skipped entirely. */ + @execution_count bigint = NULL, + @last_execution_count bigint = NULL, + @shred_needed bit = 1; BEGIN TRY /* @@ -340,6 +348,16 @@ BEGIN END; END; + /* + #4213: the prior cycle's own read of the target's execution_count, per collector. NULL on a + first run, a store that lost the row, or a store that has never seen this collector -- all of + which the gate below treats as "shred". + */ + SELECT + @last_execution_count = xss.last_execution_count + FROM config.xe_shred_state AS xss + WHERE xss.collector_name = N'blocked_process_xml_collector'; + /* Collect raw blocked process XML from ring_buffer target Azure SQL DB uses database-scoped sessions (dm_xe_database_*) @@ -354,13 +372,15 @@ BEGIN ring_buffer xml NOT NULL ); - INSERT - @ring_buffer - ( - ring_buffer - ) + DECLARE + @shred_needed bit = 1; + + /* #4213: read BEFORE the cast -- no XML materialized, so this costs one integer whether + or not anything shreds below. Compared against @last_execution_count (this collector''s + prior cycle, from config.xe_shred_state): an exact match means nothing has arrived since + then, so the cast+shred is skipped. */ SELECT - ring_xml = TRY_CAST(xet.target_data AS xml) + @execution_count = xet.execution_count FROM sys.dm_xe_database_session_targets AS xet JOIN sys.dm_xe_database_sessions AS xes ON xes.address = xet.event_session_address @@ -368,33 +388,58 @@ BEGIN AND xet.target_name = N''ring_buffer'' OPTION(RECOMPILE); - INSERT INTO - collect.blocked_process_xml - ( - event_time, - blocked_process_xml - ) - SELECT TOP (1000) - event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), - blocked_process_xml = evt.query(''.'') - FROM - ( - SELECT - rb.ring_buffer - FROM @ring_buffer AS rb - ) AS rb - CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt) - WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time - AND NOT EXISTS - ( + SET @shred_needed = + CASE + WHEN @execution_count IS NULL THEN 1 + WHEN @last_execution_count IS NULL THEN 1 + WHEN @execution_count <> @last_execution_count THEN 1 + ELSE 0 + END; + + IF @shred_needed = 1 + BEGIN + INSERT + @ring_buffer + ( + ring_buffer + ) SELECT - 1/0 - FROM collect.blocked_process_xml AS bx - WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') - ) - ORDER BY - evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC - OPTION(RECOMPILE);'; + ring_xml = TRY_CAST(xet.target_data AS xml) + FROM sys.dm_xe_database_session_targets AS xet + JOIN sys.dm_xe_database_sessions AS xes + ON xes.address = xet.event_session_address + WHERE xes.name = @session_name + AND xet.target_name = N''ring_buffer'' + OPTION(RECOMPILE); + + INSERT INTO + collect.blocked_process_xml + ( + event_time, + blocked_process_xml + ) + SELECT TOP (1000) + event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), + blocked_process_xml = evt.query(''.'') + FROM + ( + SELECT + rb.ring_buffer + FROM @ring_buffer AS rb + ) AS rb + CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt) + WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time + AND NOT EXISTS + ( + SELECT + 1/0 + FROM collect.blocked_process_xml AS bx + WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') + ) + ORDER BY + evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC + OPTION(RECOMPILE); + END;'; END; ELSE BEGIN @@ -405,13 +450,12 @@ BEGIN ring_buffer xml NOT NULL ); - INSERT - @ring_buffer - ( - ring_buffer - ) + DECLARE + @shred_needed bit = 1; + + /* #4213: see the Azure branch''s twin comment above -- same gate, server-scoped session. */ SELECT - ring_xml = TRY_CAST(xet.target_data AS xml) + @execution_count = xet.execution_count FROM sys.dm_xe_session_targets AS xet JOIN sys.dm_xe_sessions AS xes ON xes.address = xet.event_session_address @@ -419,33 +463,58 @@ BEGIN AND xet.target_name = N''ring_buffer'' OPTION(RECOMPILE); - INSERT INTO - collect.blocked_process_xml - ( - event_time, - blocked_process_xml - ) - SELECT TOP (1000) - event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), - blocked_process_xml = evt.query(''.'') - FROM - ( - SELECT - rb.ring_buffer - FROM @ring_buffer AS rb - ) AS rb - CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt) - WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time - AND NOT EXISTS - ( + SET @shred_needed = + CASE + WHEN @execution_count IS NULL THEN 1 + WHEN @last_execution_count IS NULL THEN 1 + WHEN @execution_count <> @last_execution_count THEN 1 + ELSE 0 + END; + + IF @shred_needed = 1 + BEGIN + INSERT + @ring_buffer + ( + ring_buffer + ) SELECT - 1/0 - FROM collect.blocked_process_xml AS bx - WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') - ) - ORDER BY - evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC - OPTION(RECOMPILE);'; + ring_xml = TRY_CAST(xet.target_data AS xml) + FROM sys.dm_xe_session_targets AS xet + JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address + WHERE xes.name = @session_name + AND xet.target_name = N''ring_buffer'' + OPTION(RECOMPILE); + + INSERT INTO + collect.blocked_process_xml + ( + event_time, + blocked_process_xml + ) + SELECT TOP (1000) + event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), + blocked_process_xml = evt.query(''.'') + FROM + ( + SELECT + rb.ring_buffer + FROM @ring_buffer AS rb + ) AS rb + CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt) + WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time + AND NOT EXISTS + ( + SELECT + 1/0 + FROM collect.blocked_process_xml AS bx + WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') + ) + ORDER BY + evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC + OPTION(RECOMPILE); + END;'; END; BEGIN TRY @@ -459,9 +528,11 @@ BEGIN EXECUTE sys.sp_executesql @sql, - N'@session_name sysname, @cutoff_time datetime2(7)', + N'@session_name sysname, @cutoff_time datetime2(7), @execution_count bigint OUTPUT, @last_execution_count bigint', @session_name, - @cutoff_time; + @cutoff_time, + @execution_count OUTPUT, + @last_execution_count; SET @rows_collected = ROWCOUNT_BIG(); @@ -480,6 +551,39 @@ BEGIN THROW; END CATCH; + /* + #4213: persist THIS cycle's own read of execution_count regardless of whether it shredded, + so the next cycle's comparison is against what actually happened here. A NULL means the read + above never ran (e.g. the batch failed before reaching it) -- leave the stored row alone rather + than overwrite a good count with nothing. + */ + IF @execution_count IS NOT NULL + BEGIN + IF EXISTS (SELECT 1/0 FROM config.xe_shred_state WHERE collector_name = N'blocked_process_xml_collector') + BEGIN + UPDATE + config.xe_shred_state + SET + last_execution_count = @execution_count, + last_checked_time = SYSUTCDATETIME() + WHERE collector_name = N'blocked_process_xml_collector'; + END; + ELSE + BEGIN + INSERT INTO + config.xe_shred_state + ( + collector_name, + last_execution_count + ) + VALUES + ( + N'blocked_process_xml_collector', + @execution_count + ); + END; + END; + /* Log successful collection */ diff --git a/install/24_collect_deadlock_xml.sql b/install/24_collect_deadlock_xml.sql index 6f53837176..251e28616d 100644 --- a/install/24_collect_deadlock_xml.sql +++ b/install/24_collect_deadlock_xml.sql @@ -58,7 +58,15 @@ BEGIN @is_azure_sql_db bit = 0, @session_missing bit = 0, @ensure_error nvarchar(4000) = N'', - @sql nvarchar(max) = N''; + @sql nvarchar(max) = N'', + /* #4213: the deprecated Dashboard's own copy of #4212's gate. @execution_count is this + cycle's read of the ring_buffer target's own delivered-event counter; @last_execution_count + is the prior cycle's read, carried in config.xe_shred_state; @shred_needed says whether the + cast+shred further down runs. An exact match between the two counts means nothing has + arrived since the last run, so the conversion and node-shred are skipped entirely. */ + @execution_count bigint = NULL, + @last_execution_count bigint = NULL, + @shred_needed bit = 1; BEGIN TRY /* @@ -297,6 +305,16 @@ BEGIN END; END; + /* + #4213: the prior cycle's own read of the target's execution_count, per collector. NULL on a + first run, a store that lost the row, or a store that has never seen this collector -- all of + which the gate below treats as "shred". + */ + SELECT + @last_execution_count = xss.last_execution_count + FROM config.xe_shred_state AS xss + WHERE xss.collector_name = N'deadlock_xml_collector'; + /* Collect raw deadlock XML from ring_buffer target Azure SQL DB uses database-scoped sessions (dm_xe_database_*) @@ -311,13 +329,15 @@ BEGIN ring_buffer xml NOT NULL ); - INSERT - @ring_buffer - ( - ring_buffer - ) + DECLARE + @shred_needed bit = 1; + + /* #4213: read BEFORE the cast -- no XML materialized, so this costs one integer whether + or not anything shreds below. Compared against @last_execution_count (this collector''s + prior cycle, from config.xe_shred_state): an exact match means nothing has arrived since + then, so the cast+shred is skipped. */ SELECT - ring_xml = TRY_CAST(xet.target_data AS xml) + @execution_count = xet.execution_count FROM sys.dm_xe_database_session_targets AS xet JOIN sys.dm_xe_database_sessions AS xes ON xes.address = xet.event_session_address @@ -325,33 +345,58 @@ BEGIN AND xet.target_name = N''ring_buffer'' OPTION(RECOMPILE); - INSERT INTO - collect.deadlock_xml - ( - event_time, - deadlock_xml - ) - SELECT TOP (1000) - event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), - deadlock_xml = evt.query(''.'') - FROM - ( - SELECT - rb.ring_buffer - FROM @ring_buffer AS rb - ) AS rb - CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="database_xml_deadlock_report"]'') AS q(evt) - WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time - AND NOT EXISTS - ( + SET @shred_needed = + CASE + WHEN @execution_count IS NULL THEN 1 + WHEN @last_execution_count IS NULL THEN 1 + WHEN @execution_count <> @last_execution_count THEN 1 + ELSE 0 + END; + + IF @shred_needed = 1 + BEGIN + INSERT + @ring_buffer + ( + ring_buffer + ) SELECT - 1/0 - FROM collect.deadlock_xml AS dx - WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') - ) - ORDER BY - evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC - OPTION(RECOMPILE);'; + ring_xml = TRY_CAST(xet.target_data AS xml) + FROM sys.dm_xe_database_session_targets AS xet + JOIN sys.dm_xe_database_sessions AS xes + ON xes.address = xet.event_session_address + WHERE xes.name = @session_name + AND xet.target_name = N''ring_buffer'' + OPTION(RECOMPILE); + + INSERT INTO + collect.deadlock_xml + ( + event_time, + deadlock_xml + ) + SELECT TOP (1000) + event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), + deadlock_xml = evt.query(''.'') + FROM + ( + SELECT + rb.ring_buffer + FROM @ring_buffer AS rb + ) AS rb + CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="database_xml_deadlock_report"]'') AS q(evt) + WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time + AND NOT EXISTS + ( + SELECT + 1/0 + FROM collect.deadlock_xml AS dx + WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') + ) + ORDER BY + evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC + OPTION(RECOMPILE); + END;'; END; ELSE BEGIN @@ -362,13 +407,12 @@ BEGIN ring_buffer xml NOT NULL ); - INSERT - @ring_buffer - ( - ring_buffer - ) + DECLARE + @shred_needed bit = 1; + + /* #4213: see the Azure branch''s twin comment above -- same gate, server-scoped session. */ SELECT - ring_xml = TRY_CAST(xet.target_data AS xml) + @execution_count = xet.execution_count FROM sys.dm_xe_session_targets AS xet JOIN sys.dm_xe_sessions AS xes ON xes.address = xet.event_session_address @@ -376,33 +420,58 @@ BEGIN AND xet.target_name = N''ring_buffer'' OPTION(RECOMPILE); - INSERT INTO - collect.deadlock_xml - ( - event_time, - deadlock_xml - ) - SELECT TOP (1000) - event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), - deadlock_xml = evt.query(''.'') - FROM - ( - SELECT - rb.ring_buffer - FROM @ring_buffer AS rb - ) AS rb - CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt) - WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time - AND NOT EXISTS - ( + SET @shred_needed = + CASE + WHEN @execution_count IS NULL THEN 1 + WHEN @last_execution_count IS NULL THEN 1 + WHEN @execution_count <> @last_execution_count THEN 1 + ELSE 0 + END; + + IF @shred_needed = 1 + BEGIN + INSERT + @ring_buffer + ( + ring_buffer + ) SELECT - 1/0 - FROM collect.deadlock_xml AS dx - WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') - ) - ORDER BY - evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC - OPTION(RECOMPILE);'; + ring_xml = TRY_CAST(xet.target_data AS xml) + FROM sys.dm_xe_session_targets AS xet + JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address + WHERE xes.name = @session_name + AND xet.target_name = N''ring_buffer'' + OPTION(RECOMPILE); + + INSERT INTO + collect.deadlock_xml + ( + event_time, + deadlock_xml + ) + SELECT TOP (1000) + event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''), + deadlock_xml = evt.query(''.'') + FROM + ( + SELECT + rb.ring_buffer + FROM @ring_buffer AS rb + ) AS rb + CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt) + WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time + AND NOT EXISTS + ( + SELECT + 1/0 + FROM collect.deadlock_xml AS dx + WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'') + ) + ORDER BY + evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC + OPTION(RECOMPILE); + END;'; END; BEGIN TRY @@ -416,9 +485,11 @@ BEGIN EXECUTE sys.sp_executesql @sql, - N'@session_name sysname, @cutoff_time datetime2(7)', + N'@session_name sysname, @cutoff_time datetime2(7), @execution_count bigint OUTPUT, @last_execution_count bigint', @session_name, - @cutoff_time; + @cutoff_time, + @execution_count OUTPUT, + @last_execution_count; SET @rows_collected = ROWCOUNT_BIG(); @@ -437,6 +508,39 @@ BEGIN THROW; END CATCH; + /* + #4213: persist THIS cycle's own read of execution_count regardless of whether it shredded, + so the next cycle's comparison is against what actually happened here. A NULL means the read + above never ran (e.g. the batch failed before reaching it) -- leave the stored row alone rather + than overwrite a good count with nothing. + */ + IF @execution_count IS NOT NULL + BEGIN + IF EXISTS (SELECT 1/0 FROM config.xe_shred_state WHERE collector_name = N'deadlock_xml_collector') + BEGIN + UPDATE + config.xe_shred_state + SET + last_execution_count = @execution_count, + last_checked_time = SYSUTCDATETIME() + WHERE collector_name = N'deadlock_xml_collector'; + END; + ELSE + BEGIN + INSERT INTO + config.xe_shred_state + ( + collector_name, + last_execution_count + ) + VALUES + ( + N'deadlock_xml_collector', + @execution_count + ); + END; + END; + /* Log successful collection */