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
*/